source: Klonkt/src/services/ActivityPubService.js@ 2b4252c

main
Last change on this file since 2b4252c was 2b4252c, checked in by Robin <roboburr@…>, 7 weeks ago

Cross-instance follow-goedkeuring, gemodelleerd op de guardian-offer (§5.3)

Guardians die op een andere instance wonen kunnen nu een gated follow goedkeuren.
Zelfde gedistribueerde patroon als de adoptie-offer: de ward forwardt de follow
naar z'n guardians als een Offer(Follow); elke guardian houdt een kopie en ziet
'm in /guardian2; de guardian antwoordt met Accept/Reject naar de ward; de ward
telt quorum (bestaande follows.decide) en stuurt de gewone Accept(Follow) naar de
volger. Lokale guardians blijven zoals ze waren (push + lokale beslissing, geen
forwarding). Alles achter de shaer:followApproval-marker, dus normaal verkeer
onaangeroerd.

Changed files:
src/services/ActivityPubService.js

  • gate: remote guardian krijgt Offer(Follow) (leg 1); lokale guardian push
  • handleFollowApprovalInbox: Offer(Follow) -> review (leg 2), Accept/Reject -> decide+commit (leg 4)
  • sendFollowDecision: guardian stuurt beslissing naar de ward (leg 3)
  • dispatch-tak op shaer:followApproval vóór de handshake-dispatch

src/services/guardianship/follows.js

  • guardian-side review-store (recordReview/getReview/listReviews/removeReview)

src/config/database.js

  • ap_follow_reviews (PK slug,id): de guardian-kopie van een remote-ward-follow

src/routes/guardian2.js

  • follow-requests toont ook reviews (remote); approve op een review = sendFollowDecision

test/follow-gating.test.js

  • review-store test erbij

remarks: npm test 171/171. Spec-clausule §5.3 toegevoegd (forwarding gemodelleerd
op §3). Lokale/co-located flow ongewijzigd.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 192.5 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';
24import Push from './PushService.js';
25import { getTenancy } from './SettingsService.js';
26import { t as i18nT } from './i18n.js';
27import Blocklist from './BlocklistService.js';
28import * as Guardianship from './guardianship/index.js';
29
30const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
31// Full JSON-LD context for every AP object we emit: AS2 core + security (publicKey) + the
32// extension terms we actually use (Mastodon/toot + schema.org), each with a term definition
33// so a strict JSON-LD processor resolves them instead of dropping them → valid AS2/JSON-LD.
34// This is the same context shape Mastodon publishes, so Mastodon sees no change.
35const AP_CONTEXT = [
36 'https://www.w3.org/ns/activitystreams',
37 'https://w3id.org/security/v1',
38 {
39 toot: 'http://joinmastodon.org/ns#',
40 schema: 'http://schema.org#',
41 sensitive: 'as:sensitive',
42 Hashtag: 'as:Hashtag',
43 manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
44 discoverable: 'toot:discoverable',
45 featured: { '@id': 'toot:featured', '@type': '@id' },
46 PropertyValue: 'schema:PropertyValue',
47 value: 'schema:value',
48 embedUrl: { '@id': 'schema:embedUrl', '@type': '@id' },
49 // Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
50 // per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
51 // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
52 votersCount: 'toot:votersCount',
53 // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
54 // module (src/services/guardianship/).
55 ...Guardianship.SHAER_CONTEXT,
56 },
57];
58
59// Short random suffix so two activity ids minted in the same millisecond (e.g.
60// parallel saves) don't collide and get deduped by a receiver.
61const rid = () => crypto.randomBytes(4).toString('hex');
62
63// Keep only http(s) URLs — drops javascript:/data:/etc so a remote actor can't
64// smuggle a dangerous scheme into a stored href/src (rendered in owner-only views).
65const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
66
67// ── SSRF guard for outbound fetches ───────────────────────────────
68// Remote URLs (actor/keyId/webfinger/inbox/inReplyTo) are attacker-controlled, so
69// every outbound fetch must refuse hosts that resolve to private/loopback ranges
70// (cloud metadata, internal services) — on the initial host AND each redirect hop.
71function isBlockedIp(ip) {
72 if (!ip) return true;
73 const v = net.isIP(ip);
74 if (v === 4) {
75 const o = ip.split('.').map(Number);
76 return o[0] === 127 || o[0] === 10 || o[0] === 0
77 || (o[0] === 172 && o[1] >= 16 && o[1] <= 31)
78 || (o[0] === 192 && o[1] === 168)
79 || (o[0] === 169 && o[1] === 254)
80 || (o[0] === 100 && o[1] >= 64 && o[1] <= 127); // CGNAT
81 }
82 if (v === 6) {
83 const s = ip.toLowerCase().replace(/^\[|\]$/g, '');
84 return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')
85 || s.startsWith('::ffff:127.') || s.startsWith('::ffff:10.') || s.startsWith('::ffff:192.168.')
86 || s.startsWith('::ffff:169.254.') || s.startsWith('::ffff:172.');
87 }
88 return true; // not an IP literal we recognise → refuse
89}
90async function assertPublicHost(hostname) {
91 if (net.isIP(hostname)) { if (isBlockedIp(hostname)) throw new Error('ssrf-blocked-ip'); return; }
92 const addrs = await dns.promises.lookup(hostname, { all: true });
93 if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');
94}
95export async function safeFetch(url, opts = {}, maxRedirects = 3) {
96 let target = url;
97 for (let hop = 0; ; hop++) {
98 const u = new URL(target); // throws on malformed → caller's catch
99 if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('ssrf-bad-scheme');
100 await assertPublicHost(u.hostname);
101 const r = await fetch(target, { ...opts, redirect: 'manual', signal: AbortSignal.timeout(8000) });
102 const loc = (r.status >= 300 && r.status < 400) ? r.headers.get('location') : null;
103 if (loc && hop < maxRedirects) { target = new URL(loc, target).toString(); continue; }
104 return r;
105 }
106}
107const MAX_OUTBOX = 20;
108// Cache-buster for the music listen-link → forces Mastodon to re-crawl a FRESH
109// (square) player card. Bump this whenever the twitter:player card dimensions change.
110const FEDI_CARD_VER = '2';
111
112// ── RSA keys per actor (lazy, cached in DB) ───────────────────────
113// Prepared lazily (NOT at module load) — the ap_keys table is created in
114// initializeDatabase(), which runs after this module is imported.
115let _sel, _ins;
116function keyStmts() {
117 if (!_sel) {
118 _sel = db.prepare('SELECT public_pem, private_pem FROM ap_keys WHERE slug = ?');
119 _ins = db.prepare('INSERT OR IGNORE INTO ap_keys (slug, public_pem, private_pem, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)');
120 }
121 return { sel: _sel, ins: _ins };
122}
123
124export function getOrCreateKeys(slug) {
125 const { sel, ins } = keyStmts();
126 const row = sel.get(slug);
127 if (row) return row;
128 const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
129 modulusLength: 2048,
130 publicKeyEncoding: { type: 'spki', format: 'pem' },
131 privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
132 });
133 ins.run(slug, publicKey, privateKey);
134 return sel.get(slug) || { public_pem: publicKey, private_pem: privateKey };
135}
136
137// ── content negotiation ───────────────────────────────────────────
138// True when the caller wants ActivityPub JSON rather than the HTML page.
139export function apWants(req) {
140 const a = String(req.headers.accept || '').toLowerCase();
141 return a.includes('application/activity+json') ||
142 (a.includes('application/ld+json') && a.includes('activitystreams'));
143}
144
145const AP_CONTENT_TYPE = 'application/activity+json; charset=utf-8';
146export function sendAP(res, obj, cacheControl) {
147 res.type(AP_CONTENT_TYPE);
148 // A per-caller (e.g. guardian-widened) view must not be publicly cached.
149 res.set('Cache-Control', cacheControl || 'public, max-age=120');
150 res.send(JSON.stringify(obj));
151}
152
153// ── document builders ─────────────────────────────────────────────
154export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
155export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
156
157export function buildActor(base, site) {
158 const id = actorId(base, site.slug);
159 const keys = getOrCreateKeys(site.slug);
160 const actor = {
161 '@context': AP_CONTEXT,
162 id,
163 type: 'Person',
164 preferredUsername: site.slug,
165 name: site.title || site.slug,
166 summary: site.tagline || site.description || '',
167 url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
168 manuallyApprovesFollowers: false,
169 discoverable: true,
170 inbox: `${id}/inbox`,
171 outbox: `${id}/outbox`,
172 followers: `${id}/followers`,
173 following: `${id}/following`,
174 featured: `${id}/featured`,
175 // AP §5.6: the private blocked collection (owner-only GET). The server
176 // list is the source of truth for Shaer's "in Orbit"; clients keep no
177 // separate state.
178 blocked: `${id}/blocked`,
179 // FEP-633c §2: shaer:guardians / shaer:isGuardian / shaer:queues
180 // (guardianship module owns these).
181 ...Guardianship.guardianshipActorProps(id, site.slug),
182 // C2S clients (Shaer apps) discover auth + upload here — no hardcoded paths.
183 // All four are ActivityPub-spec `endpoints` terms. Dynamic client registration
184 // (RFC 7591) is discovered via /.well-known/oauth-authorization-server, not here.
185 endpoints: {
186 sharedInbox: `${base}/ap/inbox`,
187 oauthAuthorizationEndpoint: `${base}/oauth/authorize`,
188 oauthTokenEndpoint: `${base}/oauth/token`,
189 uploadMedia: `${id}/uploadMedia`,
190 },
191 publicKey: {
192 id: `${id}#main-key`,
193 owner: id,
194 publicKeyPem: keys.public_pem,
195 },
196 };
197 if (site.profile_photo) {
198 const u = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
199 actor.icon = { type: 'Image', url: u };
200 }
201 // Account creation date — shown by Mastodon + read by indexers (additive, standard AS2).
202 if (site.created_at) { try { actor.published = new Date(site.created_at).toISOString(); } catch { /* skip bad date */ } }
203 // Profile links → PropertyValue rows: Mastodon/PeerTube/WordPress-ActivityPub render these as
204 // profile metadata (rel=me enables link-back verification). Additive; ignored by simpler receivers.
205 try {
206 const links = JSON.parse(site.profile_links || '[]');
207 if (Array.isArray(links) && links.length) {
208 const esc = (s) => String(s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
209 const rows = links
210 .filter((l) => l && l.url && /^https?:/i.test(l.url))
211 .map((l) => ({
212 type: 'PropertyValue',
213 name: esc(l.platform || 'Link'),
214 value: `<a href="${esc(l.url).replace(/"/g, '&quot;')}" rel="me nofollow noopener" target="_blank">${esc(String(l.url).replace(/^https?:\/\//, ''))}</a>`,
215 }));
216 if (rows.length) actor.attachment = rows;
217 }
218 } catch { /* skip malformed profile_links */ }
219 return actor;
220}
221
222// Does a post's audio shortcodes reference at least one PLAYABLE (file-backed)
223// track? Link-only tracks (external Spotify/YouTube, media_id NULL) don't count —
224// they have no Klonkt-hosted audio to embed, so no player card / cover-suppression.
225export function hasPlayableAudio(content, siteId) {
226 if (!content || !/\[\[(track|album|playlist):/i.test(content)) return false;
227 try {
228 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; }
229 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; }
230 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; }
231 } catch { /* non-fatal */ }
232 return false;
233}
234
235// A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
236export function buildNote(base, site, post, opts = {}) {
237 // Replies are Notes too. buildNote is the single entry point for ALL Notes; a reply is
238 // (for now) the simple flavor: pre-baked content, no title/cover/image/audio/embed
239 // machinery, addressed to the parent actor + thread. This early branch keeps that output
240 // byte-identical to the old buildReplyNote. When rich replies land (images/audio/embeds),
241 // this branch collapses and replies flow through the full post pipeline below. `post` here
242 // is the ap_outbox reply row (id, in_reply_to, content, post_slug, created_at, to_actor).
243 if (opts.isReply) {
244 const meR = actorId(base, site.slug);
245 // Rich replies: attachments column (JSON [{url, mediaType, name}]) → AS2
246 // attachment array with absolute URLs and the matching object type.
247 let replyAtt;
248 try {
249 const list = post.attachments ? JSON.parse(post.attachments) : [];
250 if (Array.isArray(list) && list.length) {
251 replyAtt = list.map((a) => ({
252 type: a.mediaType.startsWith('image/') ? 'Image' : a.mediaType.startsWith('audio/') ? 'Audio' : 'Video',
253 mediaType: a.mediaType,
254 url: /^https?:/i.test(a.url) ? a.url : `${base}${a.url}`,
255 name: a.name || undefined,
256 }));
257 }
258 } catch { /* malformed attachments never block the Note */ }
259 return {
260 id: noteId(base, post.id),
261 type: 'Note',
262 attributedTo: meR,
263 inReplyTo: post.in_reply_to || undefined,
264 content: post.content,
265 // Reply language (rich replies): the AS2 language map next to `content`.
266 contentMap: post.language ? { [post.language]: post.content } : undefined,
267 attachment: replyAtt,
268 url: post.post_slug ? `${base}/${encodeURIComponent(post.post_slug)}` : undefined,
269 published: toISO(post.created_at),
270 // A direct note (private mention, shaer-tqc) addresses ONLY its
271 // recipients: no Public anywhere, so it cannot be boosted and never
272 // shows in public timelines (the Mastodon DM model).
273 to: post.visibility === 'direct'
274 ? (JSON.parse(post.to_actors || '[]'))
275 : (post.to_actor ? [post.to_actor] : [PUBLIC]),
276 cc: post.visibility === 'direct' ? [] : [PUBLIC, `${meR}/followers`],
277 // FEP-633c 5.2.1: a ward's call for help. Only ever on direct notes.
278 ...Guardianship.helpRequestProps(post),
279 ...Guardianship.waveProps(post),
280 // FEP-633c §2.2: object hint that the author is a ward.
281 ...Guardianship.hasGuardiansProps(site.slug),
282 tag: [
283 ...mentionTags(post.content),
284 ...hashtagTags(base, post.content),
285 ],
286 };
287 }
288 const id = noteId(base, post.id);
289 const aId = actorId(base, site.slug);
290 const human = `${base}/${encodeURIComponent(post.slug)}`;
291 // Mastodon ignores a Note's `name`, so put the title INTO the content (bold
292 // first line) — the standard blog→fediverse convention. post.content is
293 // already sanitized HTML; the title is plain text, so escape it.
294 const escTitle = String(post.title || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
295 const titleHtml = post.title ? `<p><strong>${escTitle}</strong></p>` : '';
296
297 // Paid post (klonkt-demo-aki): federate a PUBLIC teaser + link, never the full
298 // content, so nothing leaks past the paywall. No media attachments either.
299 if (post.paid) {
300 const esc = (x) => String(x || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
301 const _firstP = (String(post.content || '').match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, ''])[1] || '';
302 const rawTeaser = String(post.excerpt || '').trim()
303 || _firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 280);
304 return {
305 '@context': AP_CONTEXT,
306 id,
307 type: 'Note',
308 attributedTo: aId,
309 content: `${titleHtml}<p>${esc(rawTeaser)}${rawTeaser ? '…' : ''}</p><p><a href="${human}">Lees de volledige post (supporters)</a></p>`,
310 url: human,
311 published: toISO(post.published_at || post.created_at || Date.now()),
312 to: [PUBLIC],
313 cc: [`${aId}/followers`],
314 tag: [...hashtagTags(base, post.content)],
315 replies: `${id}/replies`,
316 ...Guardianship.hasGuardiansProps(site.slug),
317 };
318 }
319
320 // Images travel as AP `attachment` (Mastodon strips <img> from content). Collect
321 // the cover + any inline <img>, make absolute, then strip <img> from the content
322 // to avoid duplicate rendering on clients that DO keep them.
323 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
324 const mediaType = (u) => {
325 const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
326 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';
327 };
328 const hadAudio = /\[\[(track|album|playlist):/i.test(post.content || '');
329 const playable = hasPlayableAudio(post.content || '', site && site.id);
330 // A post with an external embed (Spotify/YouTube/SoundCloud/Vimeo/Bandcamp/Apple) should let
331 // Mastodon render the embed's player CARD. Mastodon shows EITHER media attachments OR a link
332 // card, never both — so when the post has an embed link we skip the image attachments so the
333 // card wins. (On Klonkt nothing changes: the cover + the embed player still render.)
334 const hasEmbed = (() => {
335 const c = post.content || '';
336 if (/\[\[embed:/i.test(c)) return true;
337 for (const m of c.matchAll(/https?:\/\/[^\s"'<>]+/gi)) if (AudioEmbedService.detectProvider(m[0])) return true;
338 return false;
339 })();
340 // Link-only tracks (external Spotify/YouTube/SoundCloud, no hosted file): collect their links
341 // so we federate them — Mastodon cards the first (its player), the rest show as clickable links
342 // — instead of a bare "listen on site" link, and we suppress the cover so the card can show.
343 const trackEmbedLinks = (() => {
344 if (playable) return [];
345 const out = [];
346 try {
347 for (const m of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
348 const r = db.prepare('SELECT media_id, link_spotify, link_youtube, link_soundcloud FROM audio_tracks WHERE id = ?').get(m[1]);
349 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);
350 }
351 } catch { /* non-fatal */ }
352 return [...new Set(out)].slice(0, 6);
353 })();
354 const noImages = playable || hasEmbed || trackEmbedLinks.length > 0; // suppress images → let the player/embed card show
355 const urls = [];
356 // Posts with PLAYABLE hosted audio suppress image attachments so Mastodon renders
357 // the player CARD (twitter:player) instead of the cover — media attachment and
358 // link/player card are mutually exclusive on Mastodon. Link-only audio (external)
359 // keeps its cover (no player card to show).
360 // An animated cover federates as the muted loop MP4 (→ a Video attachment): animated WebP is
361 // unreliable on Mastodon and its iOS apps; the MP4 plays everywhere. Else the still cover image.
362 // Each entry carries the media URL + its alt text (federated as the AS2 attachment `name`, for a11y).
363 if (post.cover_video_url && !noImages) urls.push({ url: abs(post.cover_video_url), name: post.cover_alt || '' });
364 else if (post.cover_image_url && !noImages) urls.push({ url: abs(post.cover_image_url), name: post.cover_alt || '' });
365 let body = post.content || '';
366 // Only federate inline images we can actually serve: absolute http(s) URLs, or our own
367 // /media/ uploads. A relative path we don't host (e.g. a stale /images/... ref) would 404
368 // and show up as a black tile in Mastodon's attachment grid. Carry the <img alt="…"> through
369 // as the attachment description.
370 if (!noImages) for (const m of body.matchAll(/<img\b[^>]*>/gi)) {
371 const tag = m[0];
372 const src = (tag.match(/\bsrc="([^"]+)"/i) || [])[1];
373 if (!src || !(/^https?:\/\//i.test(src) || src.startsWith('/media/'))) continue;
374 const alt = (tag.match(/\balt="([^"]*)"/i) || [])[1] || '';
375 urls.push({ url: abs(src), name: alt });
376 }
377 body = body.replace(/<img\b[^>]*>/gi, '');
378 // Audio shortcodes: do NOT federate the raw audio file — Klonkt deliberately
379 // gates audio (the /audio/stream URL has friction), and shipping it as an AP
380 // audio attachment would hand Mastodon a plain, downloadable mp3 URL. Instead,
381 // replace the shortcodes with a "🎵 listen on the site" link so the post invites
382 // a click-through to the protected player (discovery without leaking the file).
383 const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
384 const audioLabels = [];
385 try {
386 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); }
387 for (const m of body.matchAll(/\[\[album:([^\]]+)\]\]/g)) audioLabels.push(m[1].trim());
388 } catch { /* non-fatal */ }
389 // fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
390 // EVERY client incl. the Mastodon apps plays them inline natively. Gated tracks (default)
391 // stay link/card-only — the file is never exposed for them. Resolve from post.content so a
392 // later body mutation can't affect it.
393 const openAudio = [];
394 if (hadAudio) {
395 const seenA = new Set();
396 const addRow = (r) => {
397 const fn = r.filename || (r.storage_path || '').split('/').pop();
398 if (!fn || seenA.has(fn)) return; seenA.add(fn);
399 const a = { type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' };
400 // Cover art on the Audio attachment (AS2 `icon`): track cover, else the post cover.
401 // Mastodon renders it as the artwork thumbnail on its native audio player.
402 const art = abs(r.cover_url || post.cover_image_url || null);
403 if (art) a.icon = { type: 'Image', mediaType: mediaType(art), url: art };
404 openAudio.push(a);
405 };
406 const SEL = 'SELECT t.title, t.cover_url, 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 ';
407 try {
408 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); }
409 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);
410 for (const mm of (post.content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.title, t.cover_url, 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);
411 } catch { /* non-fatal */ }
412 }
413 body = body.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
414 // External embeds ([[embed:url]]) → emit the bare URL as a link so Mastodon
415 // renders its OWN preview/player card (YouTube/Spotify/SoundCloud/etc) instead
416 // of federating the raw shortcode text.
417 body = body.replace(/\[\[embed:([^\]]+)\]\]/gi, (mm, raw) => {
418 const u = esc(raw.trim().replace(/&amp;/g, '&'));
419 return `<p><a href="${u}">${u}</a></p>`;
420 });
421 if (hadAudio) {
422 const lbl = audioLabels.length ? esc(audioLabels.slice(0, 4).join(', ')) : '';
423 if (trackEmbedLinks.length) {
424 // Link-only track(s): emit the external link(s). Mastodon cards the first (Spotify → its
425 // player), the rest render as clickable links — the fediverse-native "embed + links".
426 body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong>` : ''}</p>`;
427 for (const u of trackEmbedLinks) { const eu = esc(u); body += `<p><a href="${eu}">${eu}</a></p>`; }
428 } else {
429 // For playable posts, append a version param to the listen-link so Mastodon
430 // sees a NEW card URL and re-crawls it (fresh SQUARE player card) instead of
431 // reusing the cached landscape one. Invisible: the link TEXT stays clean, the
432 // page ignores the param. Bump FEDI_CARD_VER when the card dimensions change.
433 const listenHref = playable ? `${human}?fc=${FEDI_CARD_VER}` : human;
434 body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong> — ` : ''}<a href="${listenHref}">listen on ${esc(site.title || 'the site')}</a></p>`;
435 }
436 }
437 // Klonkt renders post content with white-space:pre-wrap, so raw newlines ARE line
438 // breaks on the site. Mastodon (plain HTML) collapses whitespace and would drop them,
439 // so convert newlines to <br> for the federated copy (content already made with
440 // shift+enter uses <br> and has no \n → this is a no-op there).
441 body = body.replace(/\r?\n/g, '<br>');
442 body = linkHashtags(base, body); // link inline #hashtags in the post body too
443 body = linkUrls(body); // bare URLs → clickable links on the federated copy
444 // Append the tags-field hashtags to the content so Mastodon renders them as clickable
445 // hashtags (a Hashtag that's only in the `tag` array isn't shown inline). CamelCase
446 // multi-word tags; skip any already present inline in the body.
447 {
448 const inlineTags = new Set(hashtagTags(base, body).map((h) => h.name.slice(1).toLowerCase()));
449 const addSeen = new Set();
450 const tagLinks = normalizeTags(post.tags).map(tagParts).filter(Boolean)
451 .filter((p) => !inlineTags.has(p.slug) && !addSeen.has(p.slug) && addSeen.add(p.slug))
452 .map((p) => `<a href="${base}/tag/${encodeURIComponent(p.slug)}" class="mention hashtag" rel="tag">#${p.label}</a>`);
453 if (tagLinks.length) body += `<p>${tagLinks.join(' ')}</p>`;
454 }
455 const seen = new Set();
456 const attachment = urls.filter((x) => x && x.url)
457 .filter((x) => { if (seen.has(x.url)) return false; seen.add(x.url); return true; })
458 .map((x) => { const mt = mediaType(x.url); // specific AS2 subtype (Image/Audio/Video) over generic Document
459 const ty = /^image\//i.test(mt) ? 'Image' : /^video\//i.test(mt) ? 'Video' : /^audio\//i.test(mt) ? 'Audio' : 'Document';
460 const a = { type: ty, mediaType: mt, url: x.url };
461 if (x.name) a.name = String(x.name).slice(0, 1500); // alt text / description (AS2 `name`)
462 return a; });
463 for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players
464
465 // Inline @user@host mentions: the Mention tag objects + the mentioned actor URIs. Only
466 // present when the content was already mention-linked (deliverCreate/Update resolve them
467 // at send time); a plain buildNote (outbox/notes) yields none.
468 const _mentionTags = mentionTags(body);
469 const _mentionCc = _mentionTags.map((t) => t.href);
470
471 const note = {
472 id,
473 type: 'Note',
474 attributedTo: aId,
475 content: titleHtml + body,
476 url: human,
477 published: new Date(post.published_at || post.created_at || Date.now()).toISOString(),
478 // fan_only = "fans only" → followers-only visibility (delivered to your followers
479 // but not addressed to Public, so Mastodon shows it only to them and can't boost it).
480 to: (post.fan_only || post.ap_visibility === 'quiet') ? [`${aId}/followers`] : [PUBLIC],
481 // Mentioned actors (from inline @user@host links the caller resolved) are addressed in cc
482 // so Mastodon notifies them; empty unless the content was mention-linked (delivery time).
483 cc: [...new Set([
484 ...(post.ap_visibility === 'quiet' ? [PUBLIC] : []), // quiet public: Public in cc, not to
485 ...((post.fan_only || post.ap_visibility === 'quiet') ? [] : [`${aId}/followers`]),
486 ..._mentionCc])],
487 tag: [...buildHashtagList(base, post.tags, body), ..._mentionTags],
488 replies: `${id}/replies`,
489 // NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler
490 // (hides the whole post behind a "Gevoelige inhoud" button until the reader opens it).
491 sensitive: !!post.nsfw,
492 };
493 // FEP-633c §2.2: object hint that the author is a ward (safely ignorable).
494 Object.assign(note, Guardianship.hasGuardiansProps(site.slug));
495 if (post.nsfw) note.summary = post.content_warning || 'Gevoelige inhoud';
496 if (attachment.length) note.attachment = attachment;
497 // When the cover attachment is suppressed (hosted audio OR an external embed/link-only track →
498 // so Mastodon shows the player/link card, not media), still expose the cover via AS2 `image` so
499 // card/grid consumers (the Klonkt Cirkel/News feed) can show it. Mastodon ignores a Note's
500 // `image`, so its card is unaffected — but a Klonkt receiver reads it (handleInbox o.image).
501 if (post.cover_image_url && noImages) {
502 const cov = abs(post.cover_image_url);
503 if (cov) { note.image = { type: 'Image', mediaType: mediaType(cov), url: cov }; if (post.cover_alt) note.image.name = String(post.cover_alt).slice(0, 1500); }
504 }
505 // Experiment (mirrors PeerTube / schema.org `embedUrl`): point at the GATED player page
506 // (/embed) so a client that honours embedUrl can show an inline player WITHOUT ever
507 // getting the audio file — the anti-steal posture is untouched. `embedUrl` is a real
508 // standard field name (not a Klonkt invention); if Mastodon's apps honour it on a Note we
509 // make it JSON-LD-clean with a context term, otherwise it degrades to the player card.
510 if (playable) note.embedUrl = `${base}/embed?post=${encodeURIComponent(post.slug)}`;
511 // Content language → AS2 contentMap (a BCP-47-keyed copy of the content). Mastodon reads the
512 // language from its key for the timeline language filter + the translate button. Emitted
513 // alongside `content` (Mastodon sends both); a plain receiver just uses `content`.
514 if (post.language && /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(post.language)) note.contentMap = { [post.language]: note.content };
515 // A hosted poll → federate as an AS2 Question (options + live tally). Do this last so it
516 // reuses the note's content/addressing/tags, then swaps the type and strips media.
517 const ownPoll = parseOwnPoll(post.poll_json);
518 if (ownPoll) applyPollToNote(note, post.id, ownPoll);
519 return note;
520}
521
522// All reply note URIs on a local post (inbound fediverse replies + our own
523// outbound replies) — backs the Note's `replies` Collection so remote servers
524// can fetch the whole thread.
525export function getReplyUris(base, postId) {
526 const out = [];
527 try {
528 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);
529 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}`);
530 } catch { /* non-fatal */ }
531 return out;
532}
533
534// Notifications "seen" tracking → a real bell badge. Stored per site in app_settings.
535export function markNotificationsSeen(slug) {
536 try {
537 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")
538 .run(`fedi_notif_seen:${slug}`, new Date().toISOString());
539 } catch { /* non-fatal */ }
540}
541export function countUnseenNotifications(slug) {
542 try {
543 const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
544 const seen = row ? Date.parse(row.value) : 0;
545 let n = 0;
546 for (const it of getNotifications(slug, 50)) { if (Date.parse(it.created_at) > seen) n++; }
547 return n;
548 } catch { return 0; }
549}
550// The seen-watermark itself (ms epoch, 0 = never marked) — the Messages page reads it
551// BEFORE marking seen, so it can render unread dots on the items newer than last visit.
552export function notificationsSeenAt(slug) {
553 try {
554 const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
555 return row ? (Date.parse(row.value) || 0) : 0;
556 } catch { return 0; }
557}
558
559// Messages = the unified inbox (Reacties + Meldingen merged, decision Robin+Bart 2026-07-16):
560// every notification PLUS your own outbound replies ('sent', with edit/delete via their
561// outboxId), sorted as one stream. Consecutive likes/boosts on the same post collapse into
562// one grouped item (actors list + count) so activity doesn't drown out conversations.
563export function getMessages(slug, limit, offset) {
564 const off = Math.max(0, offset || 0);
565 const lim = limit || 60;
566 // The stream is grouped (consecutive likes/boosts collapse), so paging is done by
567 // recomputing the whole stream top-down and slicing [off, off+lim] — stable across
568 // pages. Fetch a buffer past off+lim so grouping-shrinkage can't hide a full page.
569 const need = off + lim + 100;
570 const items = getNotifications(slug, need);
571 try {
572 for (const m of listOutbox(slug).slice(0, need)) {
573 items.push({
574 type: 'sent', outboxId: m.id, to_handle: m.to_handle, in_reply_to: m.in_reply_to,
575 content: m.content, editable: m.editable, language: m.language, created_at: m.created_at,
576 });
577 }
578 } catch { /* ignore */ }
579 items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications)
580 const out = [];
581 for (const it of items) {
582 const prev = out[out.length - 1];
583 if ((it.type === 'like' || it.type === 'announce') && prev && prev.type === it.type
584 && prev.post_slug === it.post_slug) {
585 prev.actors = prev.actors || [prev.name || prev.handle || '?'];
586 prev.actors.push(it.name || it.handle || '?');
587 prev.count = (prev.count || 1) + 1;
588 continue;
589 }
590 out.push(it);
591 }
592 return out.slice(off, off + lim);
593}
594
595export function buildCreate(base, site, post) {
596 const note = buildNote(base, site, post);
597 return {
598 '@context': AP_CONTEXT,
599 id: note.id + '#create',
600 type: 'Create',
601 actor: actorId(base, site.slug),
602 published: note.published,
603 to: note.to,
604 cc: note.cc,
605 object: note,
606 };
607}
608
609export function buildOutbox(base, site, posts) {
610 const id = `${actorId(base, site.slug)}/outbox`;
611 const items = (posts || []).slice(0, MAX_OUTBOX).map((p) => buildCreate(base, site, p));
612 return {
613 '@context': AP_CONTEXT,
614 id,
615 type: 'OrderedCollection',
616 totalItems: items.length,
617 orderedItems: items,
618 };
619}
620
621// Public callers get a count-only collection (privacy). The authenticated
622// account owner (a C2S bearer scoped to this site) gets the real actor URIs via
623// `items`, so their own client can build a friends list.
624export function buildFollowers(base, site, count, items = null) {
625 const id = `${actorId(base, site.slug)}/followers`;
626 return {
627 '@context': AP_CONTEXT,
628 id,
629 type: 'OrderedCollection',
630 totalItems: items ? items.length : (count || 0),
631 orderedItems: items || [], // count-only for the public; full for the owner
632 };
633}
634
635// The accounts this site follows — count only, mirroring buildFollowers. The spec lists
636// `following` as a standard actor property; Hubzilla/Friendica + crawlers expect it.
637export function buildFollowing(base, site, count, items = null) {
638 const id = `${actorId(base, site.slug)}/following`;
639 return {
640 '@context': AP_CONTEXT,
641 id,
642 type: 'OrderedCollection',
643 totalItems: items ? items.length : (count || 0),
644 orderedItems: items || [], // count-only for the public; full for the owner
645 };
646}
647
648// Pinned posts → the actor's `featured` collection. Mastodon reads this and shows
649// these as the "Featured" tab (pinned to the profile). Posts come ordered by pin
650// rank; embedded as full Notes so a remote server doesn't need extra fetches.
651export function buildFeatured(base, site, posts) {
652 const id = `${actorId(base, site.slug)}/featured`;
653 const items = (posts || []).map((p) => buildNote(base, site, p));
654 return {
655 '@context': AP_CONTEXT,
656 id,
657 type: 'OrderedCollection',
658 totalItems: items.length,
659 orderedItems: items,
660 };
661}
662
663// ── followers store (lazy stmts) ──────────────────────────────────
664let _insF, _updFDisp, _delF, _listF, _cntF;
665function fStmts() {
666 if (!_insF) {
667 _insF = db.prepare('INSERT OR IGNORE INTO ap_followers (slug, actor_uri, inbox, shared_inbox, name, handle, icon, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
668 _updFDisp = db.prepare('UPDATE ap_followers SET name = COALESCE(?, name), handle = COALESCE(?, handle), icon = COALESCE(?, icon) WHERE slug = ? AND actor_uri = ?');
669 _delF = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND actor_uri = ?');
670 _listF = db.prepare('SELECT inbox, shared_inbox FROM ap_followers WHERE slug = ?');
671 _cntF = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?');
672 }
673 return { ins: _insF, del: _delF, list: _listF, cnt: _cntF };
674}
675export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
676
677// Followers with delivery health, for the management list. Never-delivered accounts
678// first, then oldest successful delivery first — i.e. the cleanup candidates on top.
679export function listFollowers(slug) {
680 return db.prepare(
681 `SELECT id, actor_uri, inbox, shared_inbox, created_at, last_delivery_at, last_error_at
682 FROM ap_followers WHERE slug = ?
683 ORDER BY (last_delivery_at IS NULL) DESC, last_delivery_at ASC, created_at ASC`
684 ).all(slug);
685}
686// Manually drop a follower after a check (a still-live account would have to re-follow).
687export function removeFollower(slug, id) {
688 const info = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND id = ?').run(slug, id);
689 return info.changes > 0;
690}
691
692// Best cached display for an actor URI, across the caches Klonkt already fills:
693// followers (now with name/icon), following, interactions, timeline, mentions.
694// Falls back to a handle derived from the URI. Display info is not sensitive.
695export function actorDisplay(slug, uri) {
696 const ok = (r) => r && (r.name || r.icon);
697 try {
698 let r = db.prepare('SELECT name, handle, icon FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, uri);
699 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
700 r = db.prepare('SELECT name, handle, icon FROM ap_following WHERE slug = ? AND actor_uri = ?').get(slug, uri);
701 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
702 r = db.prepare('SELECT actor_name AS name, actor_handle AS handle, actor_icon AS icon FROM ap_interactions WHERE actor_uri = ? AND (actor_name IS NOT NULL OR actor_icon IS NOT NULL) ORDER BY created_at DESC LIMIT 1').get(uri);
703 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
704 r = db.prepare('SELECT author_name AS name, author_handle AS handle, author_icon AS icon FROM ap_timeline WHERE author_uri = ? AND (author_name IS NOT NULL OR author_icon IS NOT NULL) LIMIT 1').get(uri);
705 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
706 r = db.prepare('SELECT actor_name AS name, actor_handle AS handle, actor_icon AS icon FROM ap_mentions WHERE actor_uri = ? AND (actor_name IS NOT NULL OR actor_icon IS NOT NULL) ORDER BY created_at DESC LIMIT 1').get(uri);
707 if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
708 } catch { /* ignore */ }
709 return { name: null, handle: deriveHandle(uri), icon: null };
710}
711
712// FEP-9876: does this `Prefer` header ask for enriched (embedded) members?
713// Pure and testable; the route sets the response headers around it.
714export function prefersEnriched(preferHeader) {
715 return /(^|[,;\s])return=representation($|[,;\s])/i.test(String(preferHeader || ''));
716}
717
718// AS2 actor reference with display, for the owner C2S followers/following view.
719// preferredUsername = the local part of the handle; name = the set display name.
720export function buildActorRef(slug, uri) {
721 const d = actorDisplay(slug, uri);
722 const user = d.handle && d.handle[0] === '@' ? d.handle.slice(1).split('@')[0] : null;
723 const out = { id: uri, type: 'Person' };
724 if (d.name) out.name = d.name;
725 if (user) out.preferredUsername = user;
726 if (d.icon) out.icon = { type: 'Image', url: d.icon };
727 return out;
728}
729
730// Merge who-you-follow (ap_following, rich display) with who-follows-you (ap_followers,
731// delivery health) into ONE connections list, keyed by actor_uri. Each entry gets a
732// direction (following →, follower ←, mutual ↔) and, for accounts we deliver to, an
733// `unreachable` flag (never delivered, or last attempt failed after the last success) so
734// the view can split dead connections into their own section. Powers the Connect page.
735export function listConnections(slug) {
736 const byUri = new Map();
737 for (const f of listFollowing(slug)) {
738 byUri.set(f.actor_uri, {
739 actor_uri: f.actor_uri, name: f.name || null, handle: f.handle || null,
740 icon: f.icon || null, url: f.url || null, auto_boost: f.auto_boost ? 1 : 0,
741 status: f.status || null, following: true, follower: false,
742 last_delivery_at: null, last_error_at: null, follower_id: null,
743 });
744 }
745 for (const fo of listFollowers(slug)) {
746 const e = byUri.get(fo.actor_uri);
747 if (e) { e.follower = true; e.last_delivery_at = fo.last_delivery_at; e.last_error_at = fo.last_error_at; e.follower_id = fo.id; }
748 else byUri.set(fo.actor_uri, {
749 actor_uri: fo.actor_uri, name: null, handle: null, icon: null, url: null,
750 auto_boost: 0, status: null, following: false, follower: true,
751 last_delivery_at: fo.last_delivery_at, last_error_at: fo.last_error_at, follower_id: fo.id,
752 });
753 }
754 return [...byUri.values()].map((e) => {
755 e.direction = (e.following && e.follower) ? 'mutual' : (e.following ? 'following' : 'follower');
756 e.unreachable = e.follower && (!e.last_delivery_at || (!!e.last_error_at && (!e.last_delivery_at || e.last_error_at > e.last_delivery_at)));
757 return e;
758 });
759}
760
761// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
762let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
763// ── moderation tombstones (ap_rejected_objects) ───────────────────
764// A reply the owner removed stays removed: its object URI is tombstoned and
765// checked at ingest AND by the thread-crawler (else thread-filling would
766// re-fetch it). Owner moderation acts on the LOCAL copy, so it also works for
767// private notes that authorize_interaction can't fetch (401/404).
768let _insRj, _hasRj;
769function rjStmts() {
770 if (!_insRj) {
771 _insRj = db.prepare('INSERT OR IGNORE INTO ap_rejected_objects (object_uri, post_id, reason) VALUES (?,?,?)');
772 _hasRj = db.prepare('SELECT 1 FROM ap_rejected_objects WHERE object_uri = ?');
773 }
774 return { ins: _insRj, has: _hasRj };
775}
776export function isRejectedObject(uri) {
777 if (!uri) return false;
778 try { return !!rjStmts().has.get(String(uri)); } catch { return false; }
779}
780// Owner removes an incoming reply: tombstone + delete. Tenancy-scoped: the
781// interaction's post must belong to the caller's site.
782export function rejectInteraction(site, interactionId, reason) {
783 if (!site || !site.slug) return { error: 'forbidden' };
784 const row = iStmts().getI.get(interactionId);
785 if (!row) return { error: 'not_found' };
786 const owns = db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = (SELECT id FROM sites WHERE slug = ?)')
787 .get(row.post_id, site.slug);
788 if (!owns) return { error: 'forbidden' };
789 if (row.object_uri) { try { rjStmts().ins.run(row.object_uri, row.post_id, reason || 'removed by site owner'); } catch { /* non-fatal */ } }
790 db.prepare('DELETE FROM ap_interactions WHERE id = ?').run(interactionId);
791 console.log('[AP] interaction removed by owner', site.slug, row.object_uri || row.actor_uri);
792 return { ok: true, object_uri: row.object_uri || null, actor_uri: row.actor_uri || null };
793}
794// Stored URIs of an interaction (tenancy-scoped) → feed sendReport for flagging
795// from the local copy (works for private notes; no remote fetch needed to target).
796export function interactionReportTarget(site, interactionId) {
797 if (!site || !site.slug) return null;
798 const row = iStmts().getI.get(interactionId);
799 if (!row) return null;
800 const owns = db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = (SELECT id FROM sites WHERE slug = ?)')
801 .get(row.post_id, site.slug);
802 if (!owns) return null;
803 return { objectUri: row.object_uri || null, actorUri: row.actor_uri || null };
804}
805
806// AP addressing → visibility: 'public' | 'unlisted' | 'followers' | 'direct'.
807// Mastodon-conventie: Public in `to` = public, Public in `cc` = unlisted, een
808// followers-collectie zonder Public = followers-only, anders direct (DM). Public
809// kan als volledige URI, 'as:Public' of 'Public' voorkomen (JSON-LD shorthands).
810export function noteVisibility(o) {
811 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : []));
812 const isPub = (u) => u === PUBLIC || u === 'as:Public' || u === 'Public';
813 const to = arr(o && o.to).map(String);
814 const cc = arr(o && o.cc).map(String);
815 if (to.some(isPub)) return 'public';
816 if (cc.some(isPub)) return 'unlisted';
817 if ([...to, ...cc].some((u) => /\/followers\/?$/.test(u))) return 'followers';
818 return 'direct';
819}
820
821function iStmts() {
822 if (!_insI) {
823 _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, visibility, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
824 _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
825 _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
826 _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, visibility FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
827 _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
828 _insO = db.prepare('INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
829 _listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
830 _getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
831 }
832 return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI, getI: _getI, insO: _insO, listO: _listO, getO: _getO };
833}
834
835export function getInteractionById(id) { return iStmts().getI.get(id); }
836export function setInteractionBoosted(id, on) {
837 db.prepare('UPDATE ap_interactions SET acted_boost = ? WHERE id = ?').run(on ? 1 : 0, id);
838}
839export function setInteractionLiked(id, on) {
840 db.prepare('UPDATE ap_interactions SET acted_like = ? WHERE id = ?').run(on ? 1 : 0, id);
841}
842// Your like/boost state on a REMOTE post (interact page toggles).
843export function setMyReaction(slug, uri, kind, on) {
844 if (on) db.prepare('INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind) VALUES (?,?,?)').run(slug, uri, kind);
845 else db.prepare('DELETE FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ? AND kind = ?').run(slug, uri, kind);
846}
847export function getMyReactions(slug, uri) {
848 const rows = (slug && uri) ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, uri) : [];
849 return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
850}
851
852const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
853// Extract our local post id from a note URL, but only if it's ours (base match).
854function postIdFromNoteUrl(url, base) {
855 const s = String(url || '');
856 if (base && !s.startsWith(base)) return null;
857 const m = s.match(/\/ap\/notes\/([^/?#]+)/);
858 return m ? decodeURIComponent(m[1]) : null;
859}
860function deriveHandle(actorUri) {
861 try { const u = new URL(actorUri); const seg = u.pathname.split('/').filter(Boolean).pop() || ''; return `@${seg}@${u.host}`; } catch { return String(actorUri || ''); }
862}
863function actorInfo(doc, actorUri) {
864 let host = ''; try { host = new URL(actorUri).host; } catch { /* keep empty */ }
865 const handle = doc && doc.preferredUsername ? `@${doc.preferredUsername}@${host}` : deriveHandle(actorUri);
866 const icon = doc && doc.icon ? (doc.icon.url || (Array.isArray(doc.icon) && doc.icon[0] && doc.icon[0].url)) : null;
867 return {
868 name: (doc && (doc.name || doc.preferredUsername)) || handle,
869 handle,
870 url: safeUrl((doc && (doc.url || doc.id)) || actorUri) || null,
871 icon: safeUrl(icon) || null,
872 };
873}
874
875// Given an inReplyTo note URL, find which local post the thread belongs to + the
876// note being replied to (parent), so a reply-to-a-comment can be nested.
877function findThreadTarget(inReplyTo, base) {
878 if (!inReplyTo) return null;
879 const seg = postIdFromNoteUrl(inReplyTo, base); // our /ap/notes/<id> segment (if ours)
880 if (seg && localPostExists(seg)) return { post_id: seg, parent_uri: inReplyTo };
881 if (seg) {
882 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 */ }
883 }
884 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 */ }
885 return null;
886}
887
888// Drop the leading @mention(s) a federated reply carries (the person being replied to),
889// so a comment reads "dope tekening ouwe" instead of "@jason@jasonhacky.nl dope …".
890// Keeps a leading <p> wrapper; handles mention <a> links and plain-text @user@domain.
891export function stripLeadingMentions(html) {
892 if (!html) return html;
893 let s = String(html);
894 s = s.replace(/^(\s*<p[^>]*>)?\s*(?:<a\b[^>]*>\s*@[^<]+<\/a>[  ]*)+/i, (m, p) => p || '');
895 s = s.replace(/^(\s*<p[^>]*>)?\s*(?:@[\w.-]+(?:@[\w.-]+)?[  ]+)+/i, (m, p) => p || '');
896 return s;
897}
898
899// View-ready threaded view of a post's fediverse activity (inbound replies +
900// our outbound replies, nested), plus like/boost counts.
901export function getInteractions(postId, base, site) {
902 const s = iStmts();
903 // Privacy: a followers-only or direct (DM) reply is addressed to people, not to the
904 // public web, so it must NOT render in the public thread. It still reaches the owner
905 // via notifications (post context + reference included there). Legacy rows without a
906 // visibility value are treated as public. Likes/boosts stay counted (count-only).
907 const rows = s.list.all(postId).filter((r) =>
908 r.kind !== 'reply' || !(r.visibility === 'followers' || r.visibility === 'direct'));
909 const baseClean = (base || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
910 const postNoteId = baseClean ? `${baseClean}/ap/notes/${postId}` : null;
911 // Our own (outbound) replies show the SITE identity for everyone (not "You").
912 let host = ''; try { host = new URL(baseClean).host; } catch { /* ignore */ }
913 const siteName = (site && (site.title || site.slug)) || '';
914 const siteHandle = (site && site.slug && host) ? `@${site.slug}@${host}` : '';
915 const siteUrl = baseClean ? `${baseClean}/` : '';
916 const siteIcon = (site && site.profile_photo) || null;
917
918 const nodes = [];
919 for (const r of rows) {
920 if (r.kind !== 'reply') continue;
921 nodes.push({
922 noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
923 actor_uri: r.actor_uri,
924 actor_name: r.actor_name, actor_handle: r.actor_handle, actor_url: r.actor_url,
925 actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
926 acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,
927 children: [],
928 });
929 }
930 for (const o of s.listO.all(postId)) {
931 nodes.push({
932 noteId: baseClean ? `${baseClean}/ap/notes/${o.id}` : o.id, parent: o.in_reply_to || null,
933 mine: true, outboxId: o.id, content: stripLeadingMentions(o.content), created_at: o.created_at,
934 media: (() => { try { return o.attachments ? JSON.parse(o.attachments) : []; } catch { return []; } })(),
935 actor_name: siteName, actor_handle: siteHandle, actor_url: siteUrl, actor_icon: siteIcon,
936 children: [],
937 });
938 }
939
940 const byId = new Map(nodes.map((n) => [n.noteId, n]));
941 // Conversation partners per node (u02, the reply editor's mentions bar): the
942 // node's author plus the ancestor authors up the chain. Our own nodes are
943 // skipped (we do not mention ourselves), deduped by actor, capped at 8.
944 for (const n of nodes) {
945 const seen = new Set();
946 const list = [];
947 let cur = n, guard = 0;
948 while (cur && guard++ < 12 && list.length < 8) {
949 if (!cur.mine && cur.actor_uri && !seen.has(cur.actor_uri)) {
950 seen.add(cur.actor_uri);
951 list.push({
952 uri: cur.actor_uri,
953 url: cur.actor_url || cur.actor_uri,
954 handle: cur.actor_handle || deriveHandle(cur.actor_uri),
955 });
956 }
957 cur = cur.parent ? byId.get(cur.parent) : null;
958 }
959 n.participants = list;
960 }
961 const isTop = (n) => !n.parent || n.parent === postNoteId || !byId.has(n.parent);
962 const tops = [];
963 for (const n of nodes) {
964 if (isTop(n)) { tops.push(n); continue; }
965 let anc = n, guard = 0;
966 while (!isTop(anc) && guard++ < 12) anc = byId.get(anc.parent);
967 anc.children.push(n);
968 }
969 const byTime = (a, b) => new Date(a.created_at) - new Date(b.created_at);
970 tops.sort(byTime).forEach((t) => t.children.sort(byTime));
971
972 return {
973 thread: tops,
974 likeCount: rows.filter((r) => r.kind === 'like').length,
975 announceCount: rows.filter((r) => r.kind === 'announce').length,
976 total: nodes.length,
977 };
978}
979
980// ── HTTP Signatures + delivery ────────────────────────────────────
981const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
982// Which of OUR sites are named in a note's Mention tags? Only hrefs on our own base count
983// (an /ap/users/<slug> path on a remote host is someone else's actor), and the slug must be
984// an existing site. Deduped.
985export function localMentionSlugs(tags, base) {
986 if (!base) return [];
987 const out = [], seen = new Set();
988 for (const t of (Array.isArray(tags) ? tags : (tags ? [tags] : []))) {
989 if (!t || t.type !== 'Mention' || typeof t.href !== 'string') continue;
990 if (!t.href.startsWith(base + '/ap/users/')) continue;
991 const slug = slugFromActorUrl(t.href);
992 if (!slug || seen.has(slug)) continue; seen.add(slug);
993 try { if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(slug)) out.push(slug); } catch { /* ignore */ }
994 }
995 return out;
996}
997
998// Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).
999export async function deliver(inboxUrl, bodyObj, keyId, privatePem) {
1000 const body = JSON.stringify(bodyObj);
1001 const u = new URL(inboxUrl);
1002 const date = new Date().toUTCString();
1003 const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
1004 const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
1005 const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
1006 const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
1007 const r = await safeFetch(inboxUrl, {
1008 method: 'POST',
1009 headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },
1010 body,
1011 });
1012 return r.status;
1013}
1014
1015export async function fetchActor(url) {
1016 try {
1017 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
1018 if (!r.ok) return null;
1019 const len = Number(r.headers.get('content-length') || 0);
1020 if (len > 2_000_000) return null; // refuse oversized actor docs
1021 return await r.json();
1022 } catch { return null; }
1023}
1024
1025// ── Delivery queue with retries ───────────────────────────────────
1026// Outbound deliveries are tried immediately; on failure (down server, timeout,
1027// non-2xx) they're queued and retried with backoff so a briefly-offline follower
1028// doesn't silently miss the post. The signing key is NOT stored — the worker
1029// re-derives it from the actor slug at send time.
1030const DELIVERY_MAX_ATTEMPTS = 6;
1031const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360];
1032let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv;
1033function deliveryStmts() {
1034 if (!_insDeliv) {
1035 _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, body, attempts, next_at) VALUES (?,?,?,0,CURRENT_TIMESTAMP)');
1036 _dueDeliv = db.prepare("SELECT * FROM ap_delivery WHERE datetime(next_at) <= datetime('now') ORDER BY next_at LIMIT 30");
1037 _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?');
1038 _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?');
1039 }
1040 return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv };
1041}
1042export function enqueueDelivery(slug, inbox, activity) {
1043 if (!slug || !inbox || !activity) return;
1044 try { deliveryStmts().ins.run(slug, inbox, JSON.stringify(activity)); } catch { /* ignore */ }
1045}
1046// Record delivery health per follower so the followers list can flag dead accounts.
1047// Keyed by inbox: a shared-inbox POST reaches every follower behind it, so all of them
1048// are marked. A non-follower inbox (inline @mention) simply matches 0 rows.
1049let _fDelivOk, _fDelivErr;
1050function markFollowerDelivery(slug, inbox, ok) {
1051 if (!slug || !inbox) return;
1052 try {
1053 if (!_fDelivOk) {
1054 _fDelivOk = db.prepare('UPDATE ap_followers SET last_delivery_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');
1055 _fDelivErr = db.prepare('UPDATE ap_followers SET last_error_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');
1056 }
1057 (ok ? _fDelivOk : _fDelivErr).run(slug, inbox, inbox);
1058 } catch { /* health tracking is non-fatal */ }
1059}
1060// Deliver now; queue for retry if it fails.
1061export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
1062 if (!inbox) return;
1063 try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) { markFollowerDelivery(slug, inbox, true); return; } } catch { /* queue below */ }
1064 enqueueDelivery(slug, inbox, activity);
1065}
1066let _processingDeliv = false;
1067export async function processDeliveryQueue() {
1068 if (_processingDeliv) return; // re-entrancy guard: 30 rows × 8s can exceed the 60s tick → no double-delivery
1069 _processingDeliv = true;
1070 try {
1071 let rows;
1072 try { rows = deliveryStmts().due.all(); } catch { return; }
1073 if (!rows || !rows.length) return;
1074 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1075 for (const row of rows) {
1076 let ok = false;
1077 try {
1078 const keys = getOrCreateKeys(row.slug);
1079 const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
1080 ok = st >= 200 && st < 300;
1081 } catch { ok = false; }
1082 if (ok) { markFollowerDelivery(row.slug, row.inbox, true); deliveryStmts().del.run(row.id); continue; }
1083 const attempts = row.attempts + 1;
1084 if (attempts >= DELIVERY_MAX_ATTEMPTS) { markFollowerDelivery(row.slug, row.inbox, false); deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }
1085 // Index the backoff on the CURRENT attempt count (row.attempts) so the first
1086 // retry uses the 1-min tier instead of skipping it.
1087 const mins = DELIVERY_BACKOFF_MIN[Math.min(row.attempts, DELIVERY_BACKOFF_MIN.length - 1)];
1088 deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id);
1089 }
1090 } finally { _processingDeliv = false; }
1091}
1092let _delivTimer = null;
1093export function startDeliveryWorker() {
1094 if (_delivTimer) return;
1095 _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000);
1096 if (_delivTimer.unref) _delivTimer.unref();
1097}
1098
1099// Best-effort verification of an incoming signed request. Returns the sender's
1100// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
1101// Max clock skew for the signed Date header (replay window). Generous default to tolerate
1102// federating servers with drifting clocks; an operator can widen it via env.
1103const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
1104export async function verifyRequest(req) {
1105 const sigH = req.headers['signature'];
1106 if (!sigH) return null;
1107 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
1108 if (!p.keyId || !p.signature) return null;
1109 const actor = await fetchActor(p.keyId.split('#')[0]);
1110 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
1111 if (!pem) return null;
1112 const hs = (p.headers || '(request-target) host date').split(/\s+/);
1113 // Behind a reverse proxy the raw Host header is the backend bind (e.g. localhost:3000, when
1114 // the proxy doesn't preserve it — Apache .htaccess [P] proxying), but the sender signed the
1115 // HTTP-Signature over the PUBLIC host. Try each candidate host (the configured PUBLIC_BASE_URL
1116 // host, the proxy's X-Forwarded-Host, and the raw Host) and accept if the signature verifies
1117 // against any. An attacker can't forge a match (no private key), so this only rescues the
1118 // legitimate proxied case. Also normalise a leading double-slash in the request-target.
1119 let _pubHost = null;
1120 if (process.env.PUBLIC_BASE_URL) { try { _pubHost = new URL(process.env.PUBLIC_BASE_URL).host; } catch { /* ignore */ } }
1121 const _hosts = [...new Set([_pubHost, req.headers['x-forwarded-host'], req.headers['host']].filter(Boolean))];
1122 const _target = `${req.method.toLowerCase()} ${String(req.originalUrl || '').replace(/^\/{2,}/, '/')}`;
1123 const _sig = Buffer.from(p.signature, 'base64');
1124 let ok = false;
1125 for (const _h of _hosts) {
1126 const line = hs.map((x) => x === '(request-target)'
1127 ? `(request-target): ${_target}`
1128 : x === 'host' ? `host: ${_h}`
1129 : `${x}: ${req.headers[x] || ''}`).join('\n');
1130 try { if (crypto.verify('sha256', Buffer.from(line), pem, _sig)) { ok = true; break; } } catch { /* try next host */ }
1131 }
1132 // Replay defence: the Date header must be signed and recent. A captured signed request
1133 // replayed later (or with a swapped body) is rejected.
1134 if (ok) {
1135 if (!hs.includes('date')) ok = false;
1136 else {
1137 const t = Date.parse(req.headers['date'] || '');
1138 if (isNaN(t) || Math.abs(Date.now() - t) > SIG_MAX_SKEW_MS) ok = false;
1139 }
1140 }
1141 // Digest is MANDATORY when the request carries a body: without a signed digest the body
1142 // isn't covered by the signature and could be swapped on a replay.
1143 if (ok && req.rawBody && req.rawBody.length) {
1144 if (!hs.includes('digest')) ok = false;
1145 else {
1146 const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
1147 if (req.headers['digest'] !== exp) ok = false;
1148 }
1149 }
1150 return ok ? actor : null;
1151}
1152
1153// Parse a fediverse poll (an ActivityStreams `Question` — the Mastodon-standard poll form)
1154// into our compact shape. `oneOf` = single choice, `anyOf` = multiple; each option is a Note
1155// with a `name` and a `replies` collection whose `totalItems` is that option's vote count.
1156function parsePoll(o) {
1157 if (!o || o.type !== 'Question') return null;
1158 const raw = Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null);
1159 if (!raw || !raw.length) return null;
1160 const options = raw.slice(0, 12).map((opt) => ({
1161 name: String((opt && opt.name) || '').slice(0, 300),
1162 count: Math.max(0, Number(opt && opt.replies && opt.replies.totalItems) || 0),
1163 })).filter((x) => x.name);
1164 if (!options.length) return null;
1165 const endTime = o.endTime || (typeof o.closed === 'string' ? o.closed : null);
1166 const closed = !!o.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
1167 return { multiple: Array.isArray(o.anyOf), options, endTime, closed, voters: Number(o.votersCount) || null, voted: null };
1168}
1169
1170// ── Polls WE host (a local post with a poll) ──────────────────────
1171// Parse the poll definition stored on our own post (posts.poll_json). Counts are
1172// NOT stored here — they're derived from the poll_votes ballots so a re-render always
1173// reflects the authoritative tally.
1174export function parseOwnPoll(pollJson) {
1175 if (!pollJson) return null;
1176 let d; try { d = typeof pollJson === 'string' ? JSON.parse(pollJson) : pollJson; } catch { return null; }
1177 if (!d || !Array.isArray(d.options)) return null;
1178 const options = d.options.map((o) => ({ name: String((o && o.name != null ? o.name : o) || '').slice(0, 300) })).filter((o) => o.name);
1179 if (options.length < 2) return null;
1180 const endTime = d.endTime || null;
1181 const closed = !!d.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
1182 return { multiple: !!d.multiple, options, endTime, closed };
1183}
1184
1185// Live tally of a hosted poll from its ballots: per-option counts + unique voters.
1186export function pollTally(postId) {
1187 const counts = {}; let voters = 0;
1188 try {
1189 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;
1190 voters = db.prepare('SELECT COUNT(DISTINCT actor_uri) AS n FROM poll_votes WHERE post_id = ?').get(postId).n || 0;
1191 } catch { /* table may not exist yet */ }
1192 return { counts, voters };
1193}
1194
1195// Render-ready view of a hosted poll (options with counts + percentages, totals, state).
1196// Voting is fediverse-only, so this is display-only on the site.
1197export function ownPollView(post) {
1198 const poll = parseOwnPoll(post && post.poll_json);
1199 if (!poll) return null;
1200 const { counts, voters } = pollTally(post.id);
1201 const total = Object.values(counts).reduce((a, b) => a + b, 0);
1202 const denom = poll.multiple ? voters : total; // multiple-choice %: share of voters (can sum >100%)
1203 const options = poll.options.map((o) => {
1204 const count = counts[o.name] || 0;
1205 return { name: o.name, count, pct: denom ? Math.round((count / denom) * 100) : 0 };
1206 });
1207 return { multiple: poll.multiple, options, total, voters, endTime: poll.endTime, closed: poll.closed };
1208}
1209
1210// Attach the AS2 Question shape to a note built for a hosted poll. Mastodon renders a
1211// status with either media OR a poll (never both), so a poll federates as content +
1212// options with no media attachment. oneOf = single choice, anyOf = multiple.
1213function applyPollToNote(note, postId, poll) {
1214 const { counts, voters } = pollTally(postId);
1215 const opts = poll.options.map((o) => ({
1216 type: 'Note',
1217 name: o.name,
1218 replies: { type: 'Collection', totalItems: counts[o.name] || 0 },
1219 }));
1220 note.type = 'Question';
1221 note[poll.multiple ? 'anyOf' : 'oneOf'] = opts;
1222 if (poll.endTime) note.endTime = new Date(poll.endTime).toISOString();
1223 // Once closed, Mastodon expects a `closed` timestamp (the effective end).
1224 if (poll.closed) note.closed = poll.endTime ? new Date(poll.endTime).toISOString() : new Date().toISOString();
1225 note.votersCount = voters;
1226 delete note.attachment; // media ATTACHMENTS + a poll are mutually exclusive on Mastodon
1227 // Keep note.image: it's the cover, which Mastodon ignores on a Question anyway
1228 // (same as on any Note) but Klonkt reads to show the cover in feeds/the Cirkel.
1229 // Deleting it stripped the cover off every boosted poll.
1230 return note;
1231}
1232
1233// Record an inbound ballot on one of OUR polls. A vote arrives as a Create(Note) whose
1234// `name` is the chosen option and `inReplyTo` is our poll note — the Mastodon-standard
1235// vote form. Returns { handled } — handled=true means it was addressed to a poll (so the
1236// caller must NOT also store it as a reply), false means "not a poll, fall through".
1237function recordPollBallot(postId, actorUri, rawChoice) {
1238 const choice = String(rawChoice == null ? '' : rawChoice).slice(0, 300);
1239 if (!choice) return { handled: false };
1240 let post; try { post = db.prepare('SELECT poll_json FROM posts WHERE id = ?').get(postId); } catch { return { handled: false }; }
1241 const poll = post && parseOwnPoll(post.poll_json);
1242 if (!poll) return { handled: false }; // not a poll → let the reply logic handle it
1243 if (poll.closed) return { handled: true }; // voting closed → drop
1244 if (!poll.options.some((o) => o.name === choice)) return { handled: true }; // unknown option → drop
1245 try {
1246 // Single choice = one ballot per actor: ignore a later/different vote. Multiple choice
1247 // allows one ballot per distinct option (the UNIQUE(post,actor,choice) dedupes repeats).
1248 if (!poll.multiple && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? AND actor_uri = ? LIMIT 1').get(postId, actorUri)) return { handled: true };
1249 db.prepare('INSERT OR IGNORE INTO poll_votes (post_id, actor_uri, choice) VALUES (?, ?, ?)').run(postId, actorUri, choice);
1250 } catch { return { handled: true }; }
1251 schedulePollUpdate(postId);
1252 return { handled: true };
1253}
1254
1255// Coalesce a burst of votes into ONE Update(Question) per poll: the first vote schedules a
1256// refresh ~15s out; further votes in that window ride the same pending update (which carries
1257// the accumulated tally). Non-follower voters re-fetch the Question (live tally) themselves.
1258const _pollUpdTimers = new Map();
1259function schedulePollUpdate(postId) {
1260 if (_pollUpdTimers.has(postId)) return;
1261 const t = setTimeout(() => { _pollUpdTimers.delete(postId); deliverPollUpdate(postId).catch(() => { /* best-effort */ }); }, 15000);
1262 if (t.unref) t.unref();
1263 _pollUpdTimers.set(postId, t);
1264}
1265
1266// Push the fresh poll tally (or closed state) to followers as Update(Question).
1267export async function deliverPollUpdate(postId) {
1268 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1269 if (!base || !postId) return;
1270 let post, site;
1271 try {
1272 post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
1273 if (!post || !post.poll_json) return;
1274 site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
1275 } catch { return; }
1276 if (site) await deliverUpdate(site, post);
1277}
1278
1279// ── Web push to the owner (docs/webpush-design.md, slice 3) ─────────
1280// Fire-and-forget: a notification must never block or break inbox processing.
1281function pushEvent(slug, event) {
1282 try { Push.notifySite(slug, event).catch(() => {}); } catch { /* never throw */ }
1283}
1284// Hub-aware path prefix for a site's pages ('' in solo).
1285function pushPrefix(slug) {
1286 try { return getTenancy() === 'hub' ? `/user/${slug}` : ''; } catch { return ''; }
1287}
1288// Notification language: the site's content language (fallback: instance default).
1289function pushLang(slug) {
1290 try { const r = db.prepare('SELECT language FROM sites WHERE slug = ?').get(slug); return (r && r.language) || process.env.KLONKT_DEFAULT_LANG || 'nl'; } catch { return 'nl'; }
1291}
1292// Site slug, target URL and title for a post-scoped notification.
1293function pushPostCtx(postId) {
1294 try {
1295 const r = db.prepare('SELECT p.slug AS post, p.title, s.slug AS site FROM posts p JOIN sites s ON s.id = p.site_id WHERE p.id = ?').get(postId);
1296 if (!r) return null;
1297 return { site: r.site, title: r.title || r.post, url: `${pushPrefix(r.site)}/${r.post}#fediverse` };
1298 } catch { return null; }
1299}
1300
1301// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
1302export async function handleInbox(req, slugParam) {
1303 const act = req.body || {};
1304 const type = act.type;
1305 // Real client IP (behind the proxy via `trust proxy`) — logged on dropped/rejected/
1306 // ignored inbox hits so an operator can see who is probing their fediverse inbox.
1307 const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';
1308 const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
1309 const verified = await verifyRequest(req).catch(() => null);
1310
1311 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
1312 // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
1313 // forged replies/likes/follows/timeline posts). GET/discovery stays open.
1314 const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
1315 // Blocked actor/domain → silently drop (202, don't reveal the block).
1316 if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
1317 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer'];
1318 if (GATED.includes(type)) {
1319 if (!verified || !claimedActor || verified.id !== claimedActor) {
1320 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
1321 return 401;
1322 }
1323 }
1324
1325 // FEP-633c §5.3 (modelled on the adoption offer): a gated follow forwarded to
1326 // the guardians as an Offer(Follow), their Accept/Reject back to the ward.
1327 if ((type === 'Offer' || type === 'Accept' || type === 'Reject') && act['shaer:followApproval'] === true) {
1328 if (await handleFollowApprovalInbox(act, slugParam)) { console.log('[AP] follow-approval', type, 'from', claimedActor); return 202; }
1329 }
1330
1331 // FEP-633c: the adoption handshake. An Offer lands at the local ward; an
1332 // Accept/Reject answers an offer a local guardian sent. Anything the
1333 // guardianship module does not recognize falls through to the old paths.
1334 if (type === 'Offer' || type === 'Accept' || type === 'Reject') {
1335 // Every LOCAL party this activity is addressed to gets its own copy of the
1336 // handshake (a ward and a co-guardian may both live here). Gather candidate
1337 // local slugs from the inbox owner, the `to` list, and the ward.
1338 const cand = new Set();
1339 if (slugParam) cand.add(slugParam);
1340 for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) {
1341 if (typeof t === 'string') { const s = slugFromActorUrl(t); if (s) cand.add(s); }
1342 }
1343 if (type === 'Offer') {
1344 const rel = Guardianship.parseRelationship(act.object);
1345 if (rel) { const s = slugFromActorUrl(rel.ward); if (s) cand.add(s); }
1346 }
1347 let consumed = false;
1348 for (const slug of cand) {
1349 const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
1350 if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) consumed = true;
1351 }
1352 if (consumed) { console.log('[AP] guardianship', type, 'from', claimedActor); return 202; }
1353 }
1354
1355 // A moderation report (Flag) about our content — store it for the targeted site's owner
1356 // (each Klonkt site is moderated by its own owner). Signature is enforced (GATED).
1357 if (type === 'Flag') {
1358 const objs = Array.isArray(act.object) ? act.object : (act.object ? [act.object] : []);
1359 const objectUris = objs.map((o) => (typeof o === 'string' ? o : (o && o.id))).filter(Boolean);
1360 let targetSlug = null;
1361 const noteIds = [];
1362 for (const u of objectUris) {
1363 const s = slugFromActorUrl(u); // one of our actors?
1364 if (s) { targetSlug = targetSlug || s; continue; }
1365 const pid = postIdFromNoteUrl(u, base); // one of our notes?
1366 if (pid) noteIds.push(pid);
1367 }
1368 if (!targetSlug && noteIds.length) {
1369 try { const r = db.prepare('SELECT s.slug FROM posts p JOIN sites s ON s.id = p.site_id WHERE p.id = ? LIMIT 1').get(noteIds[0]); if (r) targetSlug = r.slug; } catch { /* ignore */ }
1370 }
1371 if (!targetSlug) return 202; // not about us / can't tell → drop
1372 // Flag is GATED, so `verified` is the signer's (reporter's) actor doc already.
1373 const ai = actorInfo(verified || null, claimedActor);
1374 try {
1375 db.prepare('INSERT INTO ap_reports (slug, actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
1376 .run(targetSlug, claimedActor || null, ai.name, ai.handle, ai.icon, HtmlSanitizerService.toPlainText(act.content || '').slice(0, 3000), JSON.stringify(objectUris.slice(0, 20)));
1377 console.log('[AP] report received for', targetSlug, 'from', claimedActor);
1378 } catch { /* ignore */ }
1379 return 202;
1380 }
1381
1382 if (type === 'Follow') {
1383 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
1384 const slug = slugParam || slugFromActorUrl(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
1385 if (!who || !slug) return 400;
1386 const remote = await fetchActor(who);
1387 if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
1388 const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
1389 const fi = actorInfo(remote, who); // cache display for the friends list (shaer-aa3)
1390 // FEP-633c §5.3: if the followed actor is a WARD (has guardians), the
1391 // follow is gated. A committed guardian's own Follow is auto-accepted
1392 // (it needs no gate); anyone else is held pending for guardian approval.
1393 // Free actors / normal sites have no guardians → fall through, unchanged.
1394 const wardGuardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
1395 if (wardGuardians.length && !wardGuardians.includes(who)) {
1396 const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
1397 Guardianship.follows.recordPending(slug, {
1398 id: followId, follower: who, inbox: remote.inbox, sharedInbox,
1399 name: fi.name, handle: fi.handle, icon: fi.icon, activity: act,
1400 });
1401 // FEP-633c §5.3, modelled on the guardian offer: the ward forwards the
1402 // gated follow to its guardians for approval. A LOCAL guardian gets a
1403 // push and reads /guardian2 directly; a REMOTE guardian gets an
1404 // Offer(Follow) delivered so its instance stores a copy (same distributed
1405 // pattern as the adoption offer). On quorum the ward returns Accept(Follow).
1406 const wardActor = actorId(base, slug);
1407 const wardKeys = getOrCreateKeys(slug);
1408 const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor };
1409 for (const g of wardGuardians) {
1410 const gslug = slugFromActorUrl(g);
1411 if (gslug) {
1412 const L = pushLang(gslug);
1413 pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian2` });
1414 } else {
1415 fetchActor(g).then((ga) => {
1416 const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);
1417 if (!inbox) return;
1418 const offer = { '@context': AP_CONTEXT, id: `${wardActor}#followoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true };
1419 deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
1420 }).catch(() => {});
1421 }
1422 }
1423 console.log('[AP] Follow', who, '→ ward', slug, '(gated, awaiting guardians)');
1424 return 202;
1425 }
1426 fStmts().ins.run(slug, who, remote.inbox, sharedInbox, fi.name, fi.handle, fi.icon);
1427 try { _updFDisp.run(fi.name, fi.handle, fi.icon, slug, who); } catch { /* best effort */ }
1428 { const L = pushLang(slug); pushEvent(slug, { type: 'follow', title: i18nT(L, 'push.n_follow_t'), body: i18nT(L, 'push.n_follow_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(slug)}/connect` }); }
1429 const me = actorId(base, slug);
1430 const keys = getOrCreateKeys(slug);
1431 const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
1432 deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
1433 // Auto-backfill: send our recent posts as Create so the instance has our history
1434 // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —
1435 // Mastodon dedupes notes per-instance, so re-filling an instance that already has
1436 // a follower of ours is wasted work (and won't re-populate the new follower's
1437 // timeline anyway). Deliver to the shared inbox (instance-level) when present.
1438 // Sync insert+check (no await between) → no interleave race with concurrent Follows.
1439 const instanceFilled = sharedInbox &&
1440 db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')
1441 .get(slug, sharedInbox, who);
1442 if (!instanceFilled) {
1443 backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });
1444 }
1445 console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
1446 return 202;
1447 }
1448 if (type === 'Undo' && act.object) {
1449 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
1450 const ot = act.object.type;
1451 if (ot === 'Follow') {
1452 const obj = act.object.object;
1453 const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
1454 if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
1455 return 202;
1456 }
1457 if (ot === 'Like' || ot === 'Announce') {
1458 const tgt = act.object.object;
1459 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
1460 if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
1461 return 202;
1462 }
1463 return 202;
1464 }
1465
1466 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
1467 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
1468 // Activities from our OWN actors are already stored via ap_outbox — don't re-store.
1469 const isLocalActor = !!(base && actorUri && actorUri.startsWith(`${base}/ap/users/`));
1470
1471 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
1472 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
1473 const o = act.object;
1474 // A poll ballot: a Note carrying a `name` (the chosen option) inReplyTo one of OUR poll
1475 // posts. Record it (deduped per actor) BEFORE the reply logic so a vote is never stored
1476 // as a comment. recordPollBallot returns handled=false only if the target isn't a poll.
1477 if (o.name && o.inReplyTo && actorUri && !isLocalActor) {
1478 const seg = postIdFromNoteUrl(o.inReplyTo, base);
1479 if (seg && localPostExists(seg)) {
1480 const rec = recordPollBallot(seg, actorUri, o.name);
1481 if (rec.handled) { console.log('[AP] poll vote', actorUri, '→', seg); return 202; }
1482 }
1483 }
1484 const tgt = findThreadTarget(o.inReplyTo, base);
1485 if (tgt && actorUri && !isLocalActor) {
1486 const ai = actorInfo(await resolveActor(actorUri), actorUri);
1487 const html = HtmlSanitizerService.sanitize(o.content || '');
1488 if (isRejectedObject(o.id)) { console.log('[AP] reply skipped (tombstoned)', o.id); return 202; }
1489 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, noteVisibility(o));
1490 console.log('[AP] reply', actorUri, '→', tgt.post_id);
1491 {
1492 // Private (followers/direct) replies push as a DM ping WITHOUT content
1493 // (the push service should never carry private text, design decision);
1494 // public replies carry a short snippet.
1495 const ctx = pushPostCtx(tgt.post_id);
1496 const vis = noteVisibility(o);
1497 const priv = vis === 'direct' || vis === 'followers';
1498 if (ctx) {
1499 const L = pushLang(ctx.site);
1500 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
1501 if (priv) pushEvent(ctx.site, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(ctx.site)}/messages` });
1502 else pushEvent(ctx.site, { type: 'reply', title: i18nT(L, 'push.n_reply_t', { title: ctx.title }), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: ctx.url });
1503 }
1504 }
1505 return 202;
1506 }
1507 // Home timeline (client): a top-level post from an account we follow.
1508 if (actorUri && !isLocalActor && !o.inReplyTo && o.id) {
1509 let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
1510 if (subs.length) {
1511 const ai = actorInfo(await resolveActor(actorUri), actorUri);
1512 const html = HtmlSanitizerService.sanitize(o.content || '');
1513 const _atts = (Array.isArray(o.attachment) ? o.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
1514 // Fallback cover: a Note's `image` (set when the attachment was suppressed
1515 // for a player-card post, e.g. hosted-audio posts).
1516 if (!_atts.some((m) => !m.type || /image/i.test(m.type)) && o.image) {
1517 const _im = Array.isArray(o.image) ? o.image[0] : o.image;
1518 const _iu = safeUrl(typeof _im === 'string' ? _im : (_im && _im.url));
1519 if (_iu) _atts.push({ url: _iu, type: (_im && _im.mediaType) || 'image/jpeg' });
1520 }
1521 const media = JSON.stringify(_atts);
1522 const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts
1523 // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
1524 // incoming posts to the fediverse — that flooded followers. Boosting to the
1525 // fediverse is only ever a deliberate, manual per-post action (the 🔁 on
1526 // the timeline).
1527 for (const s of subs) {
1528 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);
1529 // FEP-633c §2.2: register the ward hint on the stored object (no action yet).
1530 if (Guardianship.objectHasGuardians(o)) { try { db.prepare('UPDATE ap_timeline SET has_guardians = 1 WHERE id = ? AND slug = ?').run(o.id, s.slug); } catch { /* ignore */ } }
1531 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 */ } }
1532 }
1533 console.log('[AP] timeline +', actorUri, 'x' + subs.length);
1534 }
1535 }
1536 // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
1537 // above): store a mention notification for each of our actors named in the Mention tags.
1538 // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
1539 // someone else's actor, not ours.
1540 if (actorUri && !isLocalActor && o.id) {
1541 const slugs = localMentionSlugs(o.tag, base);
1542 if (slugs.length) {
1543 const ai = actorInfo(await resolveActor(actorUri), actorUri);
1544 const html = HtmlSanitizerService.sanitize(o.content || '');
1545 // FEP-633c 5.2.1: a ward's call for help rides a direct mention; the
1546 // flag is stored so the Guardian PWA's message centre can list it.
1547 const help = Guardianship.isHelpRequest(o);
1548 const wave = Guardianship.isWave(o);
1549 const hasG = Guardianship.objectHasGuardians(o); // §2.2 hint, register-only
1550 for (const slug of slugs) {
1551 try {
1552 const r = db.prepare('INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, wave, has_guardians, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
1553 .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0);
1554 if (r.changes) {
1555 console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
1556 const vis = noteVisibility(o);
1557 const priv = vis === 'direct' || vis === 'followers';
1558 const L = pushLang(slug);
1559 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
1560 // Same privacy rule as replies: private mentions push without content.
1561 // A help request pushes as its own alert type, aimed at the
1562 // Guardian PWA's message centre.
1563 if (help) pushEvent(slug, { type: 'help', title: i18nT(L, 'push.n_help_t'), body: i18nT(L, 'push.n_help_b', { who }), url: '/guardian' });
1564 else if (priv) pushEvent(slug, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(slug)}/messages` });
1565 else pushEvent(slug, { type: 'reply', title: i18nT(L, 'push.n_mention_t'), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
1566 }
1567 } catch { /* ignore */ }
1568 }
1569 }
1570 }
1571 return 202;
1572 }
1573 // A remote post we cached was edited upstream → refresh our cached copy. This is the
1574 // push-based edit-sync that keeps the Cirkel/timeline fresh without polling (selfHeal
1575 // does it on a version bump; this does it live). Scope to the SIGNING actor so B can't
1576 // edit A's note (the signature gate guarantees claimedActor == the verified signer).
1577 if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
1578 const o = act.object;
1579 if (o.id && claimedActor) {
1580 const html = HtmlSanitizerService.sanitize(o.content || '');
1581 const media = mediaFromNote(o);
1582 try {
1583 // Refresh url too (COALESCE keeps the old one if the Update omits it): a remote slug
1584 // rename keeps the same AP id but changes the human url, so without this the cached
1585 // post would keep linking to the old, now-dead URL.
1586 const r = db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE id = ? AND author_uri = ?')
1587 .run(html, media, o.sensitive ? 1 : 0, o.summary || null, o.url || null, o.id, claimedActor);
1588 if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);
1589 // A poll's Update carries the fresh vote counts / closed state. Refresh per-row so each
1590 // site keeps its own `voted` state while the counts/closed update to the new totals.
1591 const poll = parsePoll(o);
1592 if (poll) {
1593 const rows = db.prepare('SELECT rowid AS rid, poll_json FROM ap_timeline WHERE id = ? AND author_uri = ?').all(o.id, claimedActor);
1594 const upd = db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE rowid = ?');
1595 for (const rw of rows) {
1596 let voted = null; try { voted = rw.poll_json ? (JSON.parse(rw.poll_json).voted || null) : null; } catch { /* ignore */ }
1597 upd.run(JSON.stringify({ ...poll, voted }), rw.rid);
1598 }
1599 }
1600 } catch { /* ignore */ }
1601 // If this note is a cached fediverse reply on one of our posts, refresh its text too.
1602 try { db.prepare('UPDATE ap_interactions SET content = ? WHERE object_uri = ? AND actor_uri = ?').run(html, o.id, claimedActor); } catch { /* ignore */ }
1603 }
1604 return 202;
1605 }
1606 if (type === 'Like' || type === 'Announce') {
1607 const tgt = act.object;
1608 const objUrl = typeof tgt === 'string' ? tgt : (tgt && tgt.id);
1609 const pid = postIdFromNoteUrl(objUrl, base);
1610 if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
1611 // A boost/like of a non-public post is dropped, not stored: nobody
1612 // outside the audience should even hold it (shaer-tqc hardening).
1613 const vp = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(pid);
1614 if (vp && (vp.fan_only || vp.ap_visibility === 'direct' || vp.ap_visibility === 'friends')) {
1615 console.log('[AP] dropped', type, 'on non-public post', pid);
1616 return;
1617 }
1618 const ai = actorInfo(await resolveActor(actorUri), actorUri);
1619 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act));
1620 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
1621 {
1622 const ctx = pushPostCtx(pid);
1623 if (ctx) {
1624 const L = pushLang(ctx.site);
1625 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
1626 if (type === 'Like') pushEvent(ctx.site, { type: 'like', title: i18nT(L, 'push.n_like_t'), body: i18nT(L, 'push.n_like_b', { who, title: ctx.title }), url: ctx.url });
1627 else pushEvent(ctx.site, { type: 'boost', title: i18nT(L, 'push.n_boost_t'), body: i18nT(L, 'push.n_boost_b', { who, title: ctx.title }), url: ctx.url });
1628 }
1629 }
1630 } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
1631 // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.
1632 // We only STORE it for display; we NEVER auto-Announce it onward (anti-feedback-loop:
1633 // re-announcing an incoming Announce would cascade boosts across the network).
1634 let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist */ }
1635 if (subs.length) {
1636 const bn = await fetchNoteAP(objUrl);
1637 if (bn && bn !== 404 && (bn.type === 'Note' || bn.type === 'Article') && bn.id) {
1638 const origUri = actorUriOf(bn.attributedTo);
1639 // Block completeness: even if you follow the booster, drop a boost whose ORIGINAL
1640 // author is blocked — otherwise a block is bypassed via someone else's boost.
1641 if (origUri && isBlockedAny(origUri)) { console.log('[AP] timeline boost dropped (blocked origin)', origUri, 'via', actorUri); return 202; }
1642 const oai = actorInfo(await resolveActor(origUri), origUri);
1643 const html = HtmlSanitizerService.sanitize(bn.content || '');
1644 const media = mediaFromNote(bn);
1645 const booster = actorInfo(await resolveActor(actorUri), actorUri);
1646 for (const s of subs) {
1647 // published = now → the boost shows as fresh activity at the top (Mastodon shows
1648 // reblogs at reblog-time, not the original's date). INSERT OR IGNORE: if we already
1649 // have the note (e.g. we also follow the author), keep it and DON'T relabel it.
1650 let inserted = false;
1651 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 */ }
1652 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 */ } }
1653 }
1654 console.log('[AP] timeline boost +', actorUri, 'x' + subs.length);
1655 }
1656 }
1657 }
1658 return 202;
1659 }
1660 if (type === 'Delete') {
1661 // A remote note was deleted upstream → drop it from replies AND the timeline.
1662 // Scope to the SIGNING actor so actor B can't delete actor A's content (the
1663 // signature gate guarantees claimedActor == the verified signer here).
1664 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
1665 if (oid && claimedActor) {
1666 try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
1667 try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
1668 // Also clear a boost/like YOU made of this now-deleted remote post (the interact-page
1669 // ap_my_reactions state), so it can't stay stuck as "boosted" on a post that's gone.
1670 // Guard: only when the deleter owns the note's domain (B mustn't clear your reactions
1671 // to A's posts).
1672 try {
1673 let sameHost = false;
1674 try { sameHost = new URL(oid).host === new URL(claimedActor).host; } catch { sameHost = false; }
1675 if (sameHost) db.prepare('DELETE FROM ap_my_reactions WHERE target_uri = ?').run(oid);
1676 } catch { /* ignore */ }
1677 }
1678 return 202;
1679 }
1680 // Accept/Reject of a Follow WE sent (client side).
1681 if (type === 'Accept' && act.object) {
1682 const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
1683 if (fid) { try { fwStmts().acc.run(fid); } catch { /* ignore */ } }
1684 console.log('[AP] follow accepted', actorUri);
1685 return 202;
1686 }
1687 if (type === 'Reject' && act.object) {
1688 const who = actorUri;
1689 if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
1690 return 202;
1691 }
1692
1693 console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', 'from', ip, '(ignored)');
1694 return 202;
1695}
1696
1697// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
1698// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
1699export async function deliverCreate(site, post) {
1700 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1701 if (!base || !site || !site.slug) return;
1702 // Resolve inline @user@host mentions → link them in the note + collect their inboxes, so a
1703 // mentioned person is notified even if they don't follow us (Mastodon-standard mention).
1704 const mres = await resolveMentionsInText(base, post.content || '');
1705 const post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
1706 const followers = fStmts().list.all(site.slug);
1707 const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes].filter(Boolean))];
1708 if (!inboxes.length) return; // no followers and no one mentioned
1709 const keys = getOrCreateKeys(site.slug);
1710 const keyId = `${actorId(base, site.slug)}#main-key`;
1711 const create = buildCreate(base, site, post2);
1712 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, create, keyId, keys.private_pem);
1713}
1714
1715// On a new Follow, send that follower our most recent posts as Create so their
1716// timeline shows our history (Mastodon does not backfill on follow). Oldest-first
1717// so they sort into the follower's timeline at their original dates.
1718async function backfillNewFollower(base, slug, inbox) {
1719 if (!base || !slug || !inbox) return;
1720 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
1721 if (!site) return;
1722 const recent = db.prepare(
1723 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, published_at, created_at
1724 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
1725 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
1726 ).all(site.id).reverse();
1727 if (!recent.length) return;
1728 const keys = getOrCreateKeys(slug);
1729 const keyId = `${actorId(base, slug)}#main-key`;
1730 for (const p of recent) {
1731 try { await deliver(inbox, buildCreate(base, site, p), keyId, keys.private_pem); } catch { /* best-effort */ }
1732 await new Promise((r) => setTimeout(r, 150));
1733 }
1734 console.log('[AP] backfilled', recent.length, 'posts to new follower of', slug);
1735}
1736
1737// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
1738export async function deliverDelete(site, post) {
1739 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1740 if (!base || !site || !site.slug || !post || !post.id) return;
1741 const followers = fStmts().list.all(site.slug);
1742 if (!followers.length) return;
1743 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
1744 const keys = getOrCreateKeys(site.slug);
1745 const me = actorId(base, site.slug);
1746 const nid = noteId(base, post.id);
1747 const del = {
1748 '@context': AP_CONTEXT,
1749 id: `${nid}#delete-${Date.now()}-${rid()}`,
1750 type: 'Delete',
1751 actor: me,
1752 to: [PUBLIC],
1753 object: { id: nid, type: 'Tombstone' },
1754 };
1755 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem);
1756}
1757
1758// Tell followers an already-published post changed (Update + edited Note) so
1759// Mastodon refreshes the cached copy (e.g. after fixing content).
1760export async function deliverUpdate(site, post) {
1761 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1762 if (!base || !site || !site.slug || !post || !post.id) return;
1763 const mres = await resolveMentionsInText(base, post.content || ''); // link mentions + collect inboxes
1764 const post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
1765 const followers = fStmts().list.all(site.slug);
1766 const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes].filter(Boolean))];
1767 if (!inboxes.length) return;
1768 const keys = getOrCreateKeys(site.slug);
1769 const me = actorId(base, site.slug);
1770 const note = buildNote(base, site, post2);
1771 note.updated = new Date().toISOString();
1772 const update = {
1773 '@context': AP_CONTEXT,
1774 id: `${noteId(base, post.id)}#update-${Date.now()}-${rid()}`,
1775 type: 'Update', actor: me, to: [PUBLIC], cc: note.cc,
1776 object: note,
1777 };
1778 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
1779}
1780
1781// Tell followers the ACTOR changed (Update + Person) so Mastodon re-processes the
1782// account AND re-fetches the featured (pinned) collection — there is no standard
1783// "featured changed" activity, so this is how a pin/unpin propagates promptly.
1784export async function deliverActorUpdate(site) {
1785 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1786 if (!base || !site || !site.slug) return;
1787 const followers = fStmts().list.all(site.slug);
1788 if (!followers.length) return;
1789 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
1790 const keys = getOrCreateKeys(site.slug);
1791 const me = actorId(base, site.slug);
1792 const update = {
1793 '@context': AP_CONTEXT,
1794 id: `${me}#update-${Date.now()}-${rid()}`,
1795 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
1796 object: buildActor(base, site),
1797 };
1798 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
1799}
1800
1801// Reliably set the pinned order on followers' instances via Add/Remove activities
1802// (how Mastodon itself federates pins) — pushed to the inbox + processed immediately,
1803// unlike the featured COLLECTION which Mastodon caches with sticky StatusPins.
1804// Mastodon's Add skips an already-pinned status, so we REMOVE every pin first, wait,
1805// then ADD in rank-DESCENDING order (rank 1 added LAST → newest StatusPin → shown first,
1806// because Mastodon displays pins newest-first). `alsoRemove` = ids to unpin too.
1807// Serialize pin-resyncs per site: two concurrent /save calls would otherwise interleave
1808// their Remove -> wait -> Add sequences and scramble the StatusPin order on Mastodon. A
1809// resync already in flight for a site coalesces later requests into ONE rerun after it
1810// finishes (accumulating their extra unpins), so rapid saves don't pile up N full resyncs.
1811const _pinResync = new Map(); // slug -> { promise, pending, pendingRemove:Set, site }
1812export function resyncFeaturedPins(site, alsoRemove = []) {
1813 if (!site || !site.slug) return Promise.resolve();
1814 const slug = site.slug;
1815 const running = _pinResync.get(slug);
1816 if (running) {
1817 running.pending = true;
1818 running.site = site; // use the latest site object on the rerun
1819 for (const id of alsoRemove) running.pendingRemove.add(id);
1820 return running.promise;
1821 }
1822 const state = { promise: null, pending: false, pendingRemove: new Set(), site };
1823 state.promise = (async () => {
1824 let extra = alsoRemove;
1825 for (;;) {
1826 try { await doResyncFeaturedPins(state.site, extra); }
1827 catch (e) { console.warn('[AP] pin resync failed:', e.message); }
1828 if (!state.pending) break;
1829 state.pending = false;
1830 extra = [...state.pendingRemove];
1831 state.pendingRemove = new Set();
1832 }
1833 _pinResync.delete(slug);
1834 })();
1835 _pinResync.set(slug, state);
1836 return state.promise;
1837}
1838
1839// The actual resync work — do NOT call directly; go through resyncFeaturedPins() above so
1840// it stays serialized per site.
1841async function doResyncFeaturedPins(site, alsoRemove = []) {
1842 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1843 if (!base || !site || !site.slug) return;
1844 const followers = fStmts().list.all(site.slug);
1845 if (!followers.length) return;
1846 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
1847 const keys = getOrCreateKeys(site.slug);
1848 const me = actorId(base, site.slug);
1849 const keyId = `${me}#main-key`;
1850 const featured = `${me}/featured`;
1851 const note = (id) => noteId(base, id);
1852 const pinned = db.prepare(
1853 `SELECT id FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
1854 AND pinned IS NOT NULL AND pinned > 0
1855 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
1856 ).all(site.id);
1857 const removeIds = [...new Set([...pinned.map((p) => p.id), ...alsoRemove])];
1858 // 1. Remove every current pin so Mastodon can recreate them in order.
1859 for (const id of removeIds) {
1860 const rm = { '@context': AP_CONTEXT, id: `${me}#rm-${id}-${Date.now()}-${rid()}`, type: 'Remove', actor: me, object: note(id), target: featured, to: [PUBLIC] };
1861 for (const inbox of inboxes) deliver(inbox, rm, keyId, keys.private_pem).catch(() => { /* best-effort */ });
1862 }
1863 if (!pinned.length) { console.log('[AP] unpinned all featured for', site.slug); return; }
1864 await new Promise((r) => setTimeout(r, 5000)); // let the Removes land first
1865 // 2. Add in rank-DESC order, gaps so each StatusPin gets an increasing created_at.
1866 for (const p of pinned) {
1867 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`] };
1868 for (const inbox of inboxes) deliver(inbox, add, keyId, keys.private_pem).catch(() => { /* best-effort */ });
1869 await new Promise((r) => setTimeout(r, 2000));
1870 }
1871 console.log('[AP] resynced', pinned.length, 'featured pins for', site.slug);
1872}
1873
1874// ── outbound replies (Klonkt → fediverse) ─────────────────────────
1875const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
1876const 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(); };
1877
1878// Build one of OUR outbound reply Notes from an ap_outbox row.
1879// Turn #hashtags in reply text into Mastodon-style hashtag links (clickable + federated).
1880function linkHashtags(base, html) {
1881 // Prefix: start / whitespace / '>' / opening bracket — "(#tag" is a tag too. NO quote
1882 // chars in this class: a quote precedes attribute values (alt="#…"), which must not match.
1883 return String(html || '').replace(/(^|[\s>([{])#([\p{L}\p{M}\p{N}_]+)/gu, (m, pre, tag) =>
1884 `${pre}<a href="${base}/tag/${encodeURIComponent(tag.toLowerCase())}" class="mention hashtag" rel="tag">#${tag}</a>`);
1885}
1886// Auto-link bare http(s) URLs in already-safe HTML (federated copies). Splits on existing
1887// <a>…</a> so a linked URL is never wrapped twice; requires start/whitespace/'>' before the
1888// URL so attribute values (src="https://…") never match. Trailing sentence punctuation stays
1889// outside the link (Mastodon-style).
1890function linkUrls(html) {
1891 const parts = String(html || '').split(/(<a\b[^>]*>[\s\S]*?<\/a>)/gi);
1892 for (let i = 0; i < parts.length; i++) {
1893 if (/^<a\b/i.test(parts[i])) continue; // already a link → leave as-is
1894 parts[i] = parts[i].replace(/(^|[\s>([{])(https?:\/\/[^\s<]+?)([.,;:!?)\]»]*)(?=$|[\s<])/g,
1895 (m, pre, url, trail) => `${pre}<a href="${url.replace(/"/g, '%22')}" rel="nofollow noopener" target="_blank">${url}</a>${trail}`);
1896 }
1897 return parts.join('');
1898}
1899// Linkify inline #hashtags and bare URLs in BODY html for on-site DISPLAY, using the
1900// EXACT same rules as the federated copy (linkHashtags/linkUrls), so the website and the
1901// Mastodon copy agree instead of the website showing raw text. Idempotent: existing
1902// <a>…</a> (editor links, embeds, shortcode buttons) are split out and left untouched, so
1903// nothing is double-wrapped. Pass base='' → root-relative /tag/<slug> links.
1904export function linkifyBody(base, html) {
1905 const withTags = String(html || '')
1906 .split(/(<a\b[^>]*>[\s\S]*?<\/a>)/gi)
1907 .map((seg) => (/^<a\b/i.test(seg) ? seg : linkHashtags(base, seg)))
1908 .join('');
1909 return linkUrls(withTags);
1910}
1911
1912// Bake a post's raw source into its display HTML (the ActivityPub `source` model): done ONCE
1913// at save and cached in posts.content_rendered, so page views serve it statically instead of
1914// re-linkifying every render. Step 1 = #hashtags + bare URLs (cheap, no network). Step 2 will
1915// resolve @mentions here too (webfinger once at save instead of per page view).
1916export function bakePostContent(source) {
1917 return linkifyBody('', source || '');
1918}
1919
1920// Step 2: the full bake, incl. @mention links. Resolves @user@host via webfinger ONCE (the
1921// same resolver the federated copy uses) and bakes the profile links into content_rendered,
1922// so page views never do a per-view lookup. Unresolvable handles stay plain text; on any
1923// failure it degrades to the sync #hashtag/URL bake. Async (webfinger) → callers run it off
1924// the save response so the request never blocks on a slow/dead remote server.
1925export async function bakePostContentWithMentions(source) {
1926 const withHashUrls = bakePostContent(source);
1927 try { const m = await resolveMentionsInText('', withHashUrls); return m.html; }
1928 catch { return withHashUrls; }
1929}
1930
1931// Extract the AP Hashtag tag objects from already-linked reply content.
1932function hashtagTags(base, content) {
1933 const tags = [], seen = new Set();
1934 const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([\p{L}\p{M}\p{N}_]+)</giu;
1935 let m;
1936 while ((m = re.exec(content || ''))) {
1937 const k = m[1].toLowerCase();
1938 if (seen.has(k)) continue; seen.add(k);
1939 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
1940 }
1941 return tags;
1942}
1943
1944// Normalise a post's tags field (array, JSON-string, or comma-string) to an array.
1945function normalizeTags(t) {
1946 if (Array.isArray(t)) return t;
1947 if (typeof t === 'string') {
1948 const s = t.trim(); if (!s) return [];
1949 if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* fall through */ } }
1950 return s.split(',').map((x) => x.trim()).filter(Boolean);
1951 }
1952 return [];
1953}
1954// A tag → { label, slug }. Multi-word tags become CamelCase (#LiveMusic) for the display
1955// name (Mastodon hashtags can't contain spaces; CamelCase is the accessibility norm); the
1956// slug/href stays lowercase ("livemusic").
1957function tagParts(raw) {
1958 const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^\p{L}\p{M}\p{N}]/gu, '')).filter(Boolean);
1959 if (!words.length) return null;
1960 const slug = words.join('').toLowerCase();
1961 if (!slug) return null;
1962 const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
1963 return { label, slug };
1964}
1965// Merge a post's tags field + the #hashtags linked inline in its body into one deduped
1966// Hashtag tag list (with hrefs to our /tag page).
1967function buildHashtagList(base, tagsField, content) {
1968 const out = [], seen = new Set();
1969 for (const t of normalizeTags(tagsField)) {
1970 const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
1971 out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
1972 }
1973 for (const h of hashtagTags(base, content)) {
1974 const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
1975 out.push(h);
1976 }
1977 return out;
1978}
1979
1980// Extract Mention tag objects from already-linked content (class="u-url mention").
1981function mentionTags(content) {
1982 const tags = [], seen = new Set();
1983 // The link href is the human profile URL; the actor URI (for the Mention tag) is in data-actor.
1984 const re = /<a href="[^"]*" class="u-url mention" data-actor="([^"]+)">@([^<]+)<\/a>/gi;
1985 let m;
1986 while ((m = re.exec(content || ''))) {
1987 const href = m[1];
1988 if (seen.has(href)) continue; seen.add(href);
1989 tags.push({ type: 'Mention', href, name: '@' + m[2] });
1990 }
1991 return tags;
1992}
1993// Resolve inline @user@domain mentions in reply/post text → link them (href = actor URI)
1994// and collect the mentioned actors' inboxes so they get notified. Best-effort per mention.
1995async function resolveMentionsInText(base, html) {
1996 const inboxes = [];
1997 const handles = new Set();
1998 // Prefix also allows opening brackets — "(@user@host + me)" is a mention too (real-world
1999 // miss: a bracketed mention federated as plain text and its target was never notified).
2000 const re = /(^|[\s>([{])@([\p{L}\p{M}\p{N}_.-]+@[\p{L}\p{M}\p{N}.-]+)/gu;
2001 let m;
2002 while ((m = re.exec(html || ''))) handles.add(m[2]);
2003 let out = String(html || '');
2004 for (const h of handles) {
2005 let actorUri = null;
2006 try { actorUri = await webfingerResolve('@' + h); } catch { actorUri = null; }
2007 if (!actorUri) continue;
2008 const actor = await fetchActor(actorUri).catch(() => null);
2009 const inbox = actor && ((actor.endpoints && actor.endpoints.sharedInbox) || actor.inbox);
2010 if (inbox) inboxes.push(inbox);
2011 const profileUrl = actorInfo(actor, actorUri).url || actorUri; // human profile page → the link href
2012 const esc = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2013 out = out.replace(new RegExp('(^|[\\s>([{])@' + esc + '(?![\\p{L}\\p{M}\\p{N}_.-])', 'gu'),
2014 (full, pre) => `${pre}<a href="${profileUrl}" class="u-url mention" data-actor="${actorUri}">@${h}</a>`);
2015 }
2016 return { html: out, inboxes };
2017}
2018
2019export function buildReplyNote(base, site, row) {
2020 // Thin delegate: replies are built by buildNote (the single Note entry point) in reply mode.
2021 return buildNote(base, site, row, { isReply: true });
2022}
2023
2024// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
2025export function getOutboxNote(base, id) {
2026 const row = iStmts().getO.get(id);
2027 if (!row) return null;
2028 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
2029 if (!site) return null;
2030 return buildReplyNote(base, site, row);
2031}
2032
2033// ── ActivityPub Client-to-Server: ingest an activity POSTed to the outbox ──
2034// The C2S counterpart of handleInbox: a native/web client (Shaer) posts an
2035// activity here and we translate it onto the SAME delivery machinery the web UI
2036// uses (deliverReply / sendInteraction / followActor / deliverCreate). Returns
2037// { status, id?, url?, error? }. Auth + site-ownership are checked by the route.
2038const c2sIdOf = (x) => (typeof x === 'string' ? x : (x && (x.id || x.href))) || null;
2039
2040export async function ingestOutboxActivity(site, user, activity) {
2041 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2042 if (!base || !site || !activity || typeof activity !== 'object') return { status: 400, error: 'invalid_activity' };
2043
2044 // AP §6: a client MAY POST a bare object; the server wraps it in a Create.
2045 let type = activity.type;
2046 let object = activity.object;
2047 if (type === 'Note' || type === 'Article') { object = activity; type = 'Create'; }
2048 if (Array.isArray(type)) type = type.find((t) => typeof t === 'string');
2049
2050 // FEP-633c: the adoption handshake (Offer/Accept/Reject on a guardianship
2051 // Relationship) belongs to the guardianship module; anything else falls
2052 // through to the switch below.
2053 if (type === 'Offer' || type === 'Accept' || type === 'Reject') {
2054 const g = await Guardianship.handleGuardianshipOutbox(site, activity).catch(() => null);
2055 if (g) return g;
2056 }
2057
2058 try {
2059 switch (type) {
2060 case 'Create': {
2061 if (!object || typeof object !== 'object') return { status: 400, error: 'missing_object' };
2062 // Client sends `source` (plain/markdown) + `content` (HTML). deliverReply
2063 // re-escapes, so it needs plain text; a top-level post keeps sanitized HTML.
2064 const plain = (object.source && object.source.content) || HtmlSanitizerService.toPlainText(object.content || '');
2065 if (!plain.trim() && !object.content) return { status: 400, error: 'empty_note' };
2066 // Direct (private mention, shaer-tqc): NOT a post. Delivered over the
2067 // outbox machinery to the addressed inboxes only; shows under Messages.
2068 if (c2sVisibility(object) === 'direct') {
2069 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
2070 const recipients = [...new Set([...arr(object.to), ...arr(object.cc)])]
2071 .filter((u) => /^https?:\/\//i.test(u) && !/\/followers\/?$/.test(u) && u !== PUBLIC);
2072 if (!recipients.length) return { status: 400, error: 'no_recipients' };
2073 // AS2 attachments (e.g. the help-buoy capture, uploaded via
2074 // uploadMedia): normalize our own absolute /media/ URLs to relative
2075 // so the deliverReply-style validation applies unchanged.
2076 const atts = (Array.isArray(object.attachment) ? object.attachment : [])
2077 .map((a) => a && typeof a === 'object' ? {
2078 url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
2079 mediaType: String(a.mediaType || ''),
2080 name: String(a.name || '').slice(0, 120),
2081 } : null)
2082 .filter(Boolean);
2083 const help = object['shaer:helpRequest'] === true || object.helpRequest === true;
2084 const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null, attachments: atts, helpRequest: help });
2085 if (!r || !r.id) return { status: 502, error: 'direct_failed' };
2086 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
2087 }
2088 if (object.inReplyTo) {
2089 const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo)).catch(() => null);
2090 if (!parent) return { status: 502, error: 'cannot_resolve_inReplyTo' };
2091 const r = await deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text: plain });
2092 if (!r || !r.id) return { status: 502, error: 'reply_failed' };
2093 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
2094 }
2095 return await c2sCreatePost(base, site, user, object);
2096 }
2097 case 'Like':
2098 case 'Announce': {
2099 const targetUri = c2sIdOf(object);
2100 if (!targetUri) return { status: 400, error: 'missing_object' };
2101 // A non-public local note cannot be boosted or liked into the open
2102 // (shaer-tqc hardening; the Mastodon 422 equivalent).
2103 const localPid = postIdFromNoteUrl(targetUri, base);
2104 if (localPid) {
2105 const p = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(localPid);
2106 if (p && (p.fan_only || p.ap_visibility === 'direct' || p.ap_visibility === 'friends')) {
2107 return { status: 403, error: 'not_public' };
2108 }
2109 }
2110 const note = await resolveRemoteNote(targetUri).catch(() => null);
2111 const objUri = (note && note.object_uri) || targetUri;
2112 const authorUri = note && note.actor_uri;
2113 const kind = type === 'Announce' ? 'boost' : 'like';
2114 await sendInteraction(site, kind, objUri, authorUri);
2115 setMyReaction(site.slug, targetUri, kind, true);
2116 if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } }
2117 return { status: 202, url: objUri };
2118 }
2119 case 'Follow': {
2120 const actorUri = c2sIdOf(object);
2121 if (!actorUri) return { status: 400, error: 'missing_object' };
2122 await followActor(site, actorUri);
2123 return { status: 202, url: actorUri };
2124 }
2125 // Shaer "in Orbit" = a real Block (FEP-c648 client side): lands in
2126 // ap_blocks, shows in the Block tab, and purges the actor's cached
2127 // content. Client-side filtering becomes a cache of this state.
2128 case 'Block': {
2129 const targetUri = c2sIdOf(object);
2130 if (!targetUri) return { status: 400, error: 'missing_object' };
2131 const r = await blockTarget(site, targetUri);
2132 if (r && r.error) return { status: 400, error: r.error };
2133 return { status: 202, url: targetUri };
2134 }
2135 case 'Undo': {
2136 const inner = object && typeof object === 'object' ? object : null;
2137 let innerType = inner && inner.type;
2138 if (Array.isArray(innerType)) innerType = innerType.find((t) => typeof t === 'string');
2139 const innerTarget = c2sIdOf(inner && inner.object);
2140 if (innerType === 'Follow') { await unfollowActor(site, innerTarget); return { status: 202, url: innerTarget }; }
2141 if (innerType === 'Block') {
2142 if (!innerTarget) return { status: 400, error: 'missing_object' };
2143 unblock(site, innerTarget); // release from Orbit
2144 return { status: 202, url: innerTarget };
2145 }
2146 if (innerType === 'Like' || innerType === 'Announce') {
2147 const kind = innerType === 'Announce' ? 'unboost' : 'unlike';
2148 const note = await resolveRemoteNote(innerTarget).catch(() => null);
2149 const objUri = (note && note.object_uri) || innerTarget;
2150 await sendInteraction(site, kind, objUri, note && note.actor_uri);
2151 setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);
2152 if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } }
2153 return { status: 202, url: objUri };
2154 }
2155 return { status: 400, error: 'unsupported_undo' };
2156 }
2157 // Delete/Update of arbitrary objects need the post-edit pipeline; tracked
2158 // separately (klonkt-demo-c2s-del). Reject clearly rather than half-doing it.
2159 default:
2160 return { status: 400, error: 'unsupported_type', detail: String(type || 'none') };
2161 }
2162 } catch (e) {
2163 console.warn('[AP] C2S ingest failed:', e && e.message);
2164 return { status: 500, error: 'ingest_error' };
2165 }
2166}
2167
2168// Create a top-level microblog post from a C2S Note and federate it. Minimal
2169// sibling of the /posts/create route: sanitized HTML content, no title/cover.
2170async function c2sCreatePost(base, site, user, object) {
2171 const html = HtmlSanitizerService.sanitize(object.content || (object.source && object.source.content) || '');
2172 if (!html.trim()) return { status: 400, error: 'empty_note' };
2173 const postId = crypto.randomUUID();
2174 const slug = 'n-' + postId.slice(0, 8);
2175 const now = new Date().toISOString();
2176 // Visibility from the note's addressing (shaer-60b): Public in `to` = loud
2177 // public, Public in `cc` = quiet public (unlisted), followers-only = friends
2178 // (rides the existing fan_only pipeline: followers-only AP delivery + web
2179 // gating), neither = participants-only (kept local until mention addressing
2180 // lands; still followers-gated on the web).
2181 const vis = c2sVisibility(object);
2182 const fanOnly = (vis === 'friends' || vis === 'direct') ? 1 : 0;
2183 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, type, language, fan_only, ap_visibility, created_at, updated_at, published_at)
2184 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
2185 .run(postId, site.id, slug, user.id, '', html, '', 'published', 'post', object.language || 'nl', fanOnly, vis, now, now, now);
2186 try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html), postId); } catch { /* render fallback covers it */ }
2187 bakePostContentWithMentions(html).then((h) => { try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(h, postId); } catch { /* keep sync bake */ } }).catch(() => {});
2188 try { db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?,?,?,?)').run(HtmlSanitizerService.toPlainText(html), '', user.username || '', postId); } catch { /* FTS non-fatal */ }
2189 if (vis !== 'direct') {
2190 deliverCreate(site, { id: postId, slug, title: '', content: html, published_at: now, created_at: now, fan_only: fanOnly, ap_visibility: vis }).catch(() => { /* best-effort */ });
2191 }
2192 return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` };
2193}
2194
2195// The direct-note leg (ward call-for-help) lives in the guardianship module
2196// (src/services/guardianship/delivery.js); wired with our AP helpers at the
2197// bottom of this file. Re-exported so every existing caller keeps working.
2198export const c2sVisibility = Guardianship.c2sVisibility;
2199export const deliverDirectNote = Guardianship.deliverDirectNote;
2200
2201// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
2202// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
2203export async function deliverReply(site, { postId, postSlug, parent, text, html, language, attachments, mentions }) {
2204 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2205 // Rich replies: `html` is the reply editor's HTML (sanitized here); `text` is
2206 // the plain-text fallback (no-JS path, C2S `source`). Either may carry the reply.
2207 const richClean = html ? HtmlSanitizerService.sanitize(String(html)) : '';
2208 const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
2209 // Attachments: only OUR OWN uploads (/media/... paths, no remote URLs — the
2210 // upload route is the sole producer), image/audio/video only, max 4.
2211 const media = (Array.isArray(attachments) ? attachments : [])
2212 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
2213 && /^(image|audio|video)\//.test(String(a.mediaType || '')))
2214 .slice(0, 4)
2215 .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
2216 // A media-only reply (no text) is a valid reply.
2217 if (!base || !site || !site.slug || !parent || (!String(text || '').trim() && !rich && !media.length)) return null;
2218 const me = actorId(base, site.slug);
2219 // u02, the mentions bar: `mentions` undefined = legacy behavior (mention the
2220 // parent author). An ARRAY (possibly empty) = the kept conversation partners
2221 // exactly as the bar shows them; the mention prefix, the Mention tags (via
2222 // mentionTags over the content) and the delivery targets all follow it.
2223 const kept = Array.isArray(mentions)
2224 ? mentions
2225 .filter((m) => m && typeof m.uri === 'string' && /^https?:\/\//i.test(m.uri))
2226 .slice(0, 8)
2227 .map((m) => ({
2228 uri: m.uri,
2229 url: (typeof m.url === 'string' && /^https?:\/\//i.test(m.url)) ? m.url : m.uri,
2230 handle: String(m.handle || deriveHandle(m.uri)).slice(0, 120),
2231 }))
2232 : null;
2233 const mentionAnchor = (uri, url, h) => {
2234 const disp = h && h[0] === '@' ? h : '@' + (h || '');
2235 return `<a href="${escHtml(url || uri)}" class="u-url mention" data-actor="${escHtml(uri)}">${escHtml(disp)}</a> `;
2236 };
2237 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
2238 const mention = kept
2239 ? kept.map((k) => mentionAnchor(k.uri, k.url, k.handle)).join('')
2240 : (parent.actor_uri ? mentionAnchor(parent.actor_uri, parent.actor_url, handle) : '');
2241 // Who the stored reply is "to": the parent when kept, else the first kept chip.
2242 const parentKept = !kept || kept.some((k) => k.uri === parent.actor_uri);
2243 const toActorUri = parentKept ? (parent.actor_uri || null) : (kept[0] ? kept[0].uri : null);
2244 const toHandle = parentKept ? handle : (kept[0] ? kept[0].handle : null);
2245 let content;
2246 let mres;
2247 if (rich) {
2248 // Same enrichment pipeline as the plain path (mentions/hashtags/URLs), on
2249 // sanitized editor HTML. The parent mention goes inline into the first
2250 // paragraph (Mastodon convention), or becomes its own leading one.
2251 mres = await resolveMentionsInText(base, rich);
2252 const processed = linkUrls(linkHashtags(base, mres.html));
2253 if (processed.startsWith('<p>')) {
2254 content = processed.replace('<p>', `<p>${mention}`); // inline in the first paragraph
2255 } else if (/^<(blockquote|ul|ol|pre|h[1-6]|div|hr)\b/i.test(processed)) {
2256 content = `<p>${mention}</p>${processed}`; // block content: own leading paragraph
2257 } else {
2258 content = `<p>${mention}${processed}</p>`; // bare inline text: one paragraph together
2259 }
2260 } else {
2261 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
2262 mres = await resolveMentionsInText(base, body); // link inline @mentions + collect their inboxes
2263 content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
2264 }
2265 const replyLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
2266 // Dedup: skip if the exact same reply was already sent (double-submit guard).
2267 // Attachments count toward "the same": two media-only replies share content.
2268 const mediaJson = media.length ? JSON.stringify(media) : null;
2269 const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? AND IFNULL(attachments, \'\') = IFNULL(?, \'\') LIMIT 1')
2270 .get(site.slug, parent.object_uri || '', content, mediaJson);
2271 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
2272 const id = crypto.randomUUID();
2273 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, toActorUri, toHandle, content, replyLang, mediaJson);
2274 const row = iStmts().getO.get(id);
2275 const note = buildReplyNote(base, site, row);
2276 const create = {
2277 '@context': AP_CONTEXT,
2278 id: note.id + '#create', type: 'Create', actor: me,
2279 published: note.published, to: note.to, cc: note.cc, object: note,
2280 };
2281 const keys = getOrCreateKeys(site.slug);
2282 const keyId = `${me}#main-key`;
2283 const inboxes = new Set();
2284 // Everyone the mentions bar kept gets pinged; legacy path = the parent only.
2285 const mentionTargets = kept ? kept.map((k) => k.uri) : (parent.actor_uri ? [parent.actor_uri] : []);
2286 for (const uri of mentionTargets) {
2287 const a = await fetchActor(uri).catch(() => null);
2288 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
2289 }
2290 if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
2291 (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
2292 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
2293 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the reply
2294 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
2295 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
2296 let delivered = 0;
2297 for (const inbox of [...inboxes].filter(Boolean)) {
2298 let ok = false;
2299 try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
2300 if (ok) delivered++;
2301 else enqueueDelivery(site.slug, inbox, create); // durable: retry a briefly-offline recipient (was silently dropped)
2302 }
2303 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
2304 return { id, content, delivered };
2305}
2306
2307// attributedTo may be a string, an object {id}, or an ARRAY — e.g. a PeerTube Video is
2308// attributed to [Person (account), Group (channel)]. Pick a usable actor URI (prefer Person).
2309function actorUriOf(att) {
2310 if (!att) return null;
2311 if (typeof att === 'string') return att;
2312 if (Array.isArray(att)) {
2313 const person = att.find((a) => a && typeof a === 'object' && a.type === 'Person' && a.id);
2314 if (person) return person.id;
2315 for (const a of att) { if (typeof a === 'string') return a; if (a && a.id) return a.id; }
2316 return null;
2317 }
2318 return att.id || null;
2319}
2320
2321// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
2322// Returns a parent-shaped object usable by deliverReply(), or null.
2323export async function resolveRemoteNote(url) {
2324 if (!/^https?:\/\//i.test(String(url || ''))) return null;
2325 const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
2326 if (!note || !note.id) return null;
2327 const att = note.attributedTo;
2328 const actorUri = actorUriOf(att);
2329 if (!actorUri) return null;
2330 const actor = await fetchActor(actorUri).catch(() => null);
2331 const ai = actorInfo(actor, actorUri);
2332 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
2333 // link our reply to that local post so it shows nested in the post thread.
2334 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
2335 // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
2336 // and collect every ancestor author's inbox, so each participant's server —
2337 // including the original post's author — receives + threads our reply.
2338 const threadInboxes = [];
2339 const seenInbox = new Set();
2340 let cursor = note.inReplyTo, guard = 0;
2341 while (cursor && guard++ < 6) {
2342 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
2343 if (!url) break;
2344 const pn = await fetchActor(url).catch(() => null);
2345 if (!pn) break;
2346 const pa = actorUriOf(pn.attributedTo);
2347 if (pa && pa !== actorUri) {
2348 const paDoc = await fetchActor(pa).catch(() => null);
2349 const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
2350 if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
2351 }
2352 cursor = pn.inReplyTo; // climb to the next ancestor
2353 }
2354 // For non-Note objects (PeerTube Video, Article, …) the meaningful label is `name` (the
2355 // title); prepend it so the reply page shows what you're replying to (sanitize cleans it).
2356 let rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
2357 if (note.name && note.type && note.type !== 'Note') rawHtml = `<p><strong>${note.name}</strong></p>` + rawHtml;
2358 const images = (Array.isArray(note.attachment) ? note.attachment : [])
2359 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
2360 .map((a) => safeUrl(a.url)).filter(Boolean);
2361 // A Klonkt hosted-audio post strips its cover from `attachment` (so Mastodon
2362 // shows the player card, not a loose image) and puts it in `image` instead.
2363 // Same fallback as mediaFromNote() so a boosted music post keeps its cover.
2364 if (!images.length && note.image) {
2365 const im = Array.isArray(note.image) ? note.image[0] : note.image;
2366 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
2367 if (iu) images.push(iu);
2368 }
2369 return {
2370 object_uri: safeUrl(note.id) || note.id,
2371 actor_uri: actorUri,
2372 actor_url: ai.url,
2373 actor_handle: ai.handle,
2374 actor_name: ai.name,
2375 actor_icon: ai.icon,
2376 url: note.url || url,
2377 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
2378 sensitive: !!note.sensitive, // remote CW → blur in the Cirkel
2379 cw: note.summary || '',
2380 images,
2381 // Full typed media (incl. video/mp4) for the timeline cache. `images` above is
2382 // image-only for the interact page preview; a boosted video-only post (Loops)
2383 // lost its media entirely because upsertBoostedNote only saw `images`.
2384 media: mediaFromNote(note),
2385 threadInboxes, // every ancestor author's inbox
2386 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
2387 poll: parsePoll(note), // a Question → its options/counts (else null)
2388 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
2389 };
2390}
2391
2392// List a site's own outbound fediverse replies (for the manage/delete view).
2393// The plain editable text of a stored reply (unwrap links → their text, <br> → newline)
2394// so the manage view can prefill an edit box; the mention is re-added on save.
2395function outboxEditableText(content) {
2396 return String(content || '')
2397 .replace(/<br\s*\/?>/gi, '\n')
2398 .replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
2399 .replace(/<[^>]+>/g, '')
2400 .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
2401 .trim();
2402}
2403export function listOutbox(siteSlug) {
2404 return db.prepare('SELECT id, content, to_handle, in_reply_to, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
2405 .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
2406}
2407
2408// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
2409export async function deliverOutboxDelete(site, outboxId) {
2410 const row = iStmts().getO.get(outboxId);
2411 if (!row || row.site_slug !== site.slug) return false;
2412 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2413 if (base) {
2414 const me = actorId(base, site.slug);
2415 const nid = noteId(base, row.id);
2416 const del = { '@context': AP_CONTEXT, id: `${nid}#delete-${Date.now()}-${rid()}`, type: 'Delete', actor: me, to: [PUBLIC], object: { id: nid, type: 'Tombstone' } };
2417 const keys = getOrCreateKeys(site.slug);
2418 const inboxes = new Set();
2419 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
2420 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
2421 for (const inbox of [...inboxes].filter(Boolean)) {
2422 try { const st = await deliver(inbox, del, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) continue; } catch { /* queue below */ }
2423 enqueueDelivery(site.slug, inbox, del); // durable: a failed comment-delete now retries (was silently dropped)
2424 }
2425 }
2426 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
2427 return true;
2428}
2429
2430// Edit one of our outbound replies: rewrite the stored content (mention re-added + #tags
2431// re-linked) and send an Update(Note) so recipients refresh their cached copy.
2432export async function deliverOutboxUpdate(site, outboxId, newText, opts = {}) {
2433 const row = iStmts().getO.get(outboxId);
2434 if (!row || row.site_slug !== site.slug) return false;
2435 const text = String(newText || '').trim();
2436 // Rich edit: same sanitize + enrichment pipeline as deliverReply.
2437 const richClean = opts.html ? HtmlSanitizerService.sanitize(String(opts.html)) : '';
2438 const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
2439 if (!text && !rich) return false;
2440 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2441 if (!base) return false;
2442 const me = actorId(base, site.slug);
2443 const toActor = row.to_actor ? await fetchActor(row.to_actor).catch(() => null) : null;
2444 const toProfile = row.to_actor ? (actorInfo(toActor, row.to_actor).url || row.to_actor) : '';
2445 const _h = row.to_handle || deriveHandle(row.to_actor);
2446 const toHandle = _h && _h[0] === '@' ? _h : '@' + (_h || '');
2447 // An edit must not drop co-mentions (u02): reuse the OLD content's leading
2448 // mention anchors (the bar's kept list at send time) when present; only fall
2449 // back to rebuilding the single to_actor mention for legacy rows.
2450 const oldPrefix = (String(row.content || '')
2451 .match(/^\s*(?:<p[^>]*>)?\s*((?:<a\b[^>]*class="u-url mention"[^>]*>\s*@[^<]+<\/a>[\s ]*)+)/i) || [])[1] || '';
2452 const mention = oldPrefix || (row.to_actor
2453 ? `<a href="${escHtml(toProfile)}" class="u-url mention" data-actor="${escHtml(row.to_actor)}">${escHtml(toHandle)}</a> ` : '');
2454 let content;
2455 let mres;
2456 if (rich) {
2457 mres = await resolveMentionsInText(base, rich);
2458 const processed = linkUrls(linkHashtags(base, mres.html));
2459 if (processed.startsWith('<p>')) content = processed.replace('<p>', `<p>${mention}`);
2460 else if (/^<(blockquote|ul|ol|pre|h[1-6]|div|hr)\b/i.test(processed)) content = `<p>${mention}</p>${processed}`;
2461 else content = `<p>${mention}${processed}</p>`;
2462 } else {
2463 mres = await resolveMentionsInText(base, escHtml(text).replace(/\r?\n/g, '<br>'));
2464 content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
2465 }
2466 // Language may be updated with the edit; attachments always survive untouched.
2467 const newLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(opts.language || '')) ? opts.language : null;
2468 db.prepare('UPDATE ap_outbox SET content = ?, language = COALESCE(?, language) WHERE id = ?').run(content, newLang, outboxId);
2469 const note = buildReplyNote(base, site, iStmts().getO.get(outboxId));
2470 note.updated = new Date().toISOString();
2471 const update = {
2472 '@context': AP_CONTEXT,
2473 id: `${note.id}#update-${Date.now()}-${rid()}`, type: 'Update', actor: me,
2474 published: note.published, updated: note.updated, to: note.to, cc: note.cc, object: note,
2475 };
2476 const keys = getOrCreateKeys(site.slug);
2477 const inboxes = new Set();
2478 if (toActor) inboxes.add((toActor.endpoints && toActor.endpoints.sharedInbox) || toActor.inbox);
2479 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
2480 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the edit
2481 inboxes.delete(`${me}/inbox`); inboxes.delete(`${base}/ap/inbox`);
2482 let delivered = 0;
2483 for (const inbox of [...inboxes].filter(Boolean)) {
2484 let ok = false;
2485 try { const st = await deliver(inbox, update, `${me}#main-key`, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
2486 if (ok) delivered++;
2487 else enqueueDelivery(site.slug, inbox, update); // durable: retry the edit later (was silently dropped)
2488 }
2489 console.log('[AP] outreply edit', site.slug, 'delivered', delivered);
2490 return { ok: true, content, delivered };
2491}
2492
2493// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
2494// Resolve an @user@domain handle to its actor URL via WebFinger.
2495export async function webfingerResolve(handle) {
2496 const h = String(handle || '').trim().replace(/^@/, '');
2497 const parts = h.split('@');
2498 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
2499 const acct = `${parts[0]}@${parts[1]}`;
2500 try {
2501 const r = await safeFetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
2502 { headers: { Accept: 'application/jrd+json, application/json' } });
2503 if (!r.ok) return null;
2504 const jrd = await r.json();
2505 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
2506 return safeUrl(link ? link.href : '') || null;
2507 } catch { return null; }
2508}
2509
2510let _insFw, _delFw, _listFw, _accFw, _oneFw, _setAB;
2511function fwStmts() {
2512 if (!_insFw) {
2513 _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)');
2514 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
2515 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
2516 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
2517 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
2518 _setAB = db.prepare('UPDATE ap_following SET auto_boost = ? WHERE slug = ? AND actor_uri = ?');
2519 }
2520 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw, setAB: _setAB };
2521}
2522export function listFollowing(slug) { return fwStmts().list.all(slug); }
2523
2524// Toggle auto-boost ("feature") on an account we already follow.
2525export function setAutoBoost(slug, actorUri, on) {
2526 try { fwStmts().setAB.run(on ? 1 : 0, slug, actorUri); } catch { /* ignore */ }
2527 // Featuring an account → AP-native catch-up so the Cirkel isn't empty until they next
2528 // post (push doesn't backfill history-before-follow). Fire-and-forget pull, sends nothing.
2529 if (on) backfillFromOutbox(slug, actorUri).catch(() => {});
2530 return { ok: true };
2531}
2532
2533let _insTl, _listTl, _delTl;
2534function tlStmts() {
2535 if (!_insTl) {
2536 _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)');
2537 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ? OFFSET ?');
2538 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
2539 }
2540 return { ins: _insTl, list: _listTl, del: _delTl };
2541}
2542export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); }
2543
2544// Inbox C2S read: a timeline row's media_json ([{url, type}], written on the
2545// inbound Create) → AS2 `attachment` array, so a client (Shaer) can render a
2546// friend's images/audio/video natively, exactly like own outbox posts. The
2547// stored `type` is the mediaType and may be ''. Malformed JSON yields
2548// undefined and never blocks the item.
2549export function timelineAttachments(mediaJson) {
2550 try {
2551 const list = mediaJson ? JSON.parse(mediaJson) : [];
2552 const rows = (Array.isArray(list) ? list : [])
2553 .filter((m) => m && m.url)
2554 .map((m) => ({ type: 'Document', mediaType: m.type || undefined, url: m.url }));
2555 return rows.length ? rows : undefined;
2556 } catch { return undefined; }
2557}
2558
2559// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
2560let _abCount, _cirkelPosts, _cirkelMembers;
2561export function autoBoostCount(slug) {
2562 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; }
2563}
2564export function getCirkelPosts(slug, limit, offset) {
2565 try {
2566 // Cirkel = posts from featured (auto_boost) accounts + posts you boosted
2567 // (t.boosted), mixed by date. One row per note in ap_timeline → no duplicates.
2568 if (!_cirkelPosts) _cirkelPosts = db.prepare(`
2569 SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
2570 t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
2571 FROM ap_timeline t
2572 LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
2573 WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
2574 ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
2575 LIMIT ? OFFSET ?`);
2576 return _cirkelPosts.all(slug, limit || 60, offset || 0);
2577 } catch { return []; }
2578}
2579export function getCirkelMembers(slug) {
2580 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 []; }
2581}
2582// Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
2583let _markBoost, _unmarkBoost, _boostedCount;
2584export function markBoosted(slug, noteId) {
2585 try { if (!_markBoost) _markBoost = db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?'); _markBoost.run(slug, noteId); } catch { /* ignore */ }
2586}
2587export function unmarkBoosted(slug, noteId) {
2588 try { if (!_unmarkBoost) _unmarkBoost = db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?'); _unmarkBoost.run(slug, noteId); } catch { /* ignore */ }
2589}
2590let _markLike, _unmarkLike;
2591export function markLiked(slug, noteId) {
2592 try { if (!_markLike) _markLike = db.prepare('UPDATE ap_timeline SET liked = 1 WHERE slug = ? AND id = ?'); _markLike.run(slug, noteId); } catch { /* ignore */ }
2593}
2594export function unmarkLiked(slug, noteId) {
2595 try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
2596}
2597export function getTimelineReaction(slug, noteId) {
2598 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 }; }
2599}
2600// Boost a REMOTE post that may not be in your timeline (you don't follow the author):
2601// store it in ap_timeline (INSERT OR IGNORE → no dup for followed posts) so it shows in
2602// the Cirkel with a Boost badge, then flag it boosted.
2603export function upsertBoostedNote(slug, note) {
2604 if (!slug || !note || !note.object_uri) return;
2605 const id = note.object_uri;
2606 // Prefer the full typed media (incl. video/mp4 — a Loops boost is video-only and
2607 // rendered a bare text tile); fall back to the image-only list for older callers.
2608 const media = (note.media && note.media !== '[]')
2609 ? note.media
2610 : JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
2611 try {
2612 const r = tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
2613 note.actor_icon || '', note.actor_url || '', note.content || '', note.url || null,
2614 new Date().toISOString(), media, note.sensitive ? 1 : 0, note.cw || null);
2615 if (!r.changes) {
2616 // Row already cached (INSERT OR IGNORE) → refresh it with the freshly
2617 // resolved note. Without this a row cached without its cover (or with
2618 // stale content) stayed stale forever — even boosting again didn't heal it.
2619 // Keep the CACHED media when the resolve yielded none: an empty re-resolve
2620 // used to clobber a good media_json (the followed copy had the video, the
2621 // boost wiped it to []).
2622 db.prepare(`UPDATE ap_timeline SET content = ?, media_json = CASE WHEN ? = '[]' THEN media_json ELSE ? END,
2623 nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE slug = ? AND id = ?`)
2624 .run(note.content || '', media, media, note.sensitive ? 1 : 0, note.cw || null, note.url || null, slug, id);
2625 }
2626 } catch { /* ignore */ }
2627 markBoosted(slug, id);
2628}
2629export function boostedCount(slug) {
2630 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; }
2631}
2632
2633// Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to
2634// /ap/users/<slug> (content negotiation; Location may be relative). Used by
2635// followActor for bare-domain follows.
2636// NB: the old auto-migration of legacy Cirkels (circle_links -> AP follows) was
2637// REMOVED on 2026-06-26 — it auto-sent Follows on boot, which violates "the code
2638// never throws anything into the fediverse automatically" (would surprise-Follow
2639// for some operators at scale). The dead circle_links table stays as harmless dead
2640// data; an operator restores an old cirkel by re-following in /following (their click).
2641async function resolveApActor(siteUrl) {
2642 try {
2643 const r = await fetch(siteUrl, { headers: { Accept: 'application/activity+json' }, redirect: 'manual' });
2644 if (r.status >= 300 && r.status < 400) { const loc = r.headers.get('location'); if (loc) return new URL(loc, siteUrl).href; }
2645 if (r.ok) return siteUrl;
2646 } catch { /* unreachable */ }
2647 return null;
2648}
2649
2650// ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ──
2651// Runs ONCE per SELFHEAL_VERSION bump — NOT on every boot. Re-fetches each cached
2652// note and refreshes content + media (recovers covers/edits that were delivered
2653// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
2654// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
2655const SELFHEAL_VERSION = 7; // v7: rerun v6 with retry-until-clean semantics (origins briefly offline no longer stay stale forever)
2656async function fetchNoteAP(url) {
2657 try {
2658 const r = await fetch(url, { headers: { Accept: 'application/activity+json' } });
2659 if (r.status === 404 || r.status === 410) return 404;
2660 if (r.ok) return await r.json();
2661 } catch { /* unreachable */ }
2662 return null;
2663}
2664function mediaFromNote(note) {
2665 const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
2666 if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) {
2667 const im = Array.isArray(note.image) ? note.image[0] : note.image;
2668 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
2669 if (iu) atts.push({ url: iu, type: (im && im.mediaType) || 'image/jpeg' });
2670 }
2671 return JSON.stringify(atts);
2672}
2673// A generic SSRF-safe AP GET (collections / pages).
2674async function apGetJson(url) {
2675 try {
2676 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
2677 if (!r.ok) return null;
2678 const len = Number(r.headers.get('content-length') || 0);
2679 if (len > 3_000_000) return null;
2680 return await r.json();
2681 } catch { return null; }
2682}
2683// AP-native catch-up: pull an actor's standard `outbox` collection and merge their recent
2684// top-level posts into the timeline for `slug`. Push (Create delivery) cannot backfill
2685// history-from-before-you-followed or a delivery that was missed while you were down;
2686// reading the outbox is the spec-conform way to catch up. PULL ONLY — sends nothing.
2687export async function backfillFromOutbox(slug, actorUri, limit = 20) {
2688 try {
2689 if (!slug || !actorUri) return 0;
2690 const actor = await fetchActor(actorUri);
2691 if (!actor || !actor.outbox) return 0;
2692 let page = await apGetJson(typeof actor.outbox === 'string' ? actor.outbox : actor.outbox.id);
2693 let items = (page && (page.orderedItems || page.items)) || [];
2694 if (!items.length && page && page.first) {
2695 page = await apGetJson(typeof page.first === 'string' ? page.first : page.first.id);
2696 items = (page && (page.orderedItems || page.items)) || [];
2697 }
2698 if (!Array.isArray(items) || !items.length) return 0;
2699 const ai = actorInfo(actor, actorUri);
2700 let added = 0;
2701 for (const it of items.slice(0, limit)) {
2702 // Each item is usually a Create wrapping a Note, or sometimes the Note itself.
2703 const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
2704 if (!o || !o.id) continue;
2705 if (o.type && o.type !== 'Note' && o.type !== 'Article' && o.type !== 'Question') continue; // skip boosts/other
2706 if (o.inReplyTo) continue; // top-level only
2707 const auth = actorUriOf(o.attributedTo);
2708 if (auth && auth !== actorUri) continue; // their OWN posts only
2709 const html = HtmlSanitizerService.sanitize(o.content || '');
2710 const poll = parsePoll(o); // a Question (poll) → carry its options/counts on backfill too
2711 try {
2712 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);
2713 if (r && r.changes > 0) added++;
2714 // Set poll_json if this is a poll and we don't already have it (COALESCE preserves a vote).
2715 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 */ } }
2716 } catch { /* ignore */ }
2717 }
2718 if (added) console.log('[AP] outbox backfill', actorUri, '→', slug, '+' + added);
2719 return added;
2720 } catch { return 0; }
2721}
2722
2723// ── Remote thread crawl (fill the gaps in a local post's conversation) ────────────
2724// Most replies reach us by delivery, but replies-to-replies that live on other servers and
2725// aren't addressed to us are missed. This pulls the AS2 `replies` collections of the replies
2726// we DO have, caching any newly-found ones in ap_interactions.
2727//
2728// Matches Mastodon's behaviour: ONE level per crawl (like its FetchRepliesService), not a deep
2729// recursive walk. Deeper levels fill in incrementally across crawls — once a fetched reply is
2730// cached it becomes a seed itself, so its own replies are pulled on a later view (Mastodon's
2731// per-status cascade). Bounded + polite (serial), PULL only, and stale-while-revalidate: it
2732// never runs in a page request — the view renders from cache; a stale post kicks off a
2733// background refresh for the NEXT view.
2734const THREAD_TTL_MS = 15 * 60 * 1000; // don't re-crawl a post more than ~4×/hour
2735const THREAD_MAX_DEPTH = 1; // one hop per crawl (like Mastodon); deeper fills in over crawls
2736const THREAD_MAX_FETCHES = 30; // hard cap on remote GETs per crawl (be a good peer)
2737const _crawlingThreads = new Set(); // per-post in-flight lock (no stampede across views)
2738
2739function threadCrawlTs(postId) {
2740 try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('thread_crawl:' + postId); return r ? (Number(r.value) || 0) : 0; }
2741 catch { return 0; }
2742}
2743function setThreadCrawlTs(postId, ts) {
2744 try { db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run('thread_crawl:' + postId, String(ts)); }
2745 catch { /* ignore */ }
2746}
2747
2748// Read a note's `replies` (string ref / Collection with `first` / paged CollectionPages) →
2749// child note URIs. Every remote GET goes through `budget` so the whole crawl stays capped.
2750async function collectReplyItems(repliesRef, maxPages, budget) {
2751 const uris = [];
2752 let node = typeof repliesRef === 'string' ? await budget.get(repliesRef) : repliesRef;
2753 if (node && node.first) node = typeof node.first === 'string' ? await budget.get(node.first) : node.first;
2754 let pages = 0;
2755 while (node && pages++ < maxPages) {
2756 for (const it of (node.items || node.orderedItems || [])) {
2757 const u = typeof it === 'string' ? it : (it && it.id);
2758 if (u && /^https?:\/\//i.test(u)) uris.push(u);
2759 }
2760 if (!node.next) break;
2761 node = typeof node.next === 'string' ? await budget.get(node.next) : node.next;
2762 }
2763 return uris;
2764}
2765
2766async function crawlThread(postId) {
2767 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2768 if (!base) return;
2769 // Seed frontier = the remote reply note URIs we already have; also the dedup set.
2770 let known;
2771 try { known = new Set(db.prepare("SELECT object_uri FROM ap_interactions WHERE post_id = ? AND kind = 'reply' AND object_uri != ''").all(postId).map((r) => r.object_uri)); }
2772 catch { return; }
2773 const seeds = [...known].filter((u) => /^https?:\/\//i.test(u));
2774 if (!seeds.length) return; // nothing remote to expand
2775 // Owner-removed replies (tombstones) join the dedup set AFTER seeding, so the
2776 // crawler never re-adds them via thread-filling (they're gone from the seeds
2777 // already because rejectInteraction deleted their ap_interactions row).
2778 try { for (const r of db.prepare('SELECT object_uri FROM ap_rejected_objects WHERE post_id = ?').all(postId)) known.add(r.object_uri); }
2779 catch { /* table always exists after boot migration */ }
2780
2781 let fetches = 0;
2782 const budget = { get: async (u) => { if (fetches >= THREAD_MAX_FETCHES) return null; fetches++; return apGetJson(u); } };
2783 const visited = new Set(); // notes whose replies collection we've already expanded
2784 let frontier = seeds.slice();
2785 let added = 0;
2786
2787 for (let depth = 0; depth < THREAD_MAX_DEPTH && frontier.length && fetches < THREAD_MAX_FETCHES; depth++) {
2788 const nextFrontier = [];
2789 for (const noteUri of frontier) {
2790 if (visited.has(noteUri) || fetches >= THREAD_MAX_FETCHES) continue;
2791 visited.add(noteUri);
2792 const note = await budget.get(noteUri);
2793 if (!note || !note.replies) continue;
2794 const childUris = await collectReplyItems(note.replies, 2, budget);
2795 for (const cu of childUris) {
2796 if (known.has(cu) || fetches >= THREAD_MAX_FETCHES) continue;
2797 known.add(cu);
2798 const child = await budget.get(cu);
2799 if (!child || !child.id || (child.type !== 'Note' && child.type !== 'Article')) continue;
2800 if (isRejectedObject(child.id)) continue; // note id can differ from the collection URI (redirects)
2801 const actorUri = actorUriOf(child.attributedTo);
2802 if (!actorUri || isBlockedAny(actorUri)) continue; // skip blocked authors
2803 const actor = await budget.get(actorUri); // may be null if budget spent → fallback handle
2804 const ai = actorInfo(actor, actorUri);
2805 const html = HtmlSanitizerService.sanitize(child.content || '');
2806 // The child replies to `note` by construction (it's in note's replies collection).
2807 try { iStmts().ins.run('reply', postId, child.id, actorUri, ai.name, ai.handle, ai.url, ai.icon, html, child.published || null, note.id || noteUri, noteVisibility(child)); added++; } catch { /* ignore */ }
2808 nextFrontier.push(child.id); // expand this reply's own replies next depth
2809 }
2810 }
2811 frontier = nextFrontier;
2812 }
2813 if (added) console.log('[AP] thread crawl', postId, '+' + added, 'remote replies (' + fetches + ' fetches)');
2814}
2815
2816// Stale-while-revalidate entry point: call from the post view. Renders nothing, blocks nothing —
2817// fires a background crawl only if this post hasn't been crawled within the TTL.
2818export function maybeCrawlThread(postId) {
2819 if (!postId || _crawlingThreads.has(postId)) return;
2820 if (Date.now() - threadCrawlTs(postId) < THREAD_TTL_MS) return;
2821 _crawlingThreads.add(postId);
2822 setThreadCrawlTs(postId, Date.now()); // optimistic mark so concurrent/next views don't re-fire
2823 crawlThread(postId).catch((e) => console.warn('[AP] thread crawl failed:', e && e.message)).finally(() => _crawlingThreads.delete(postId));
2824}
2825
2826let _selfHealing = false;
2827export async function selfHealTimeline() {
2828 if (_selfHealing) return; _selfHealing = true;
2829 try {
2830 let cur = 0;
2831 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; }
2832 if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
2833 let rows = [];
2834 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 */ }
2835 let healed = 0, failed = 0;
2836 for (const r of rows) {
2837 try {
2838 const note = await fetchNoteAP(r.id);
2839 if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
2840 if (!note || typeof note !== 'object') { failed++; continue; } // origin unreachable right now
2841 const html = HtmlSanitizerService.sanitize(note.content || '');
2842 const media = mediaFromNote(note);
2843 const nsfw = note.sensitive ? 1 : 0; // re-sync NSFW/sensitive + CW onto already-cached posts
2844 const cw = note.summary || null;
2845 const url = note.url || null; // re-sync the human url (catches a remote slug rename)
2846 if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '') || (url && url !== r.url)) {
2847 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);
2848 healed++;
2849 }
2850 } catch { failed++; /* per-note best-effort */ }
2851 }
2852 // Only mark this version DONE after a clean pass. Some origins are briefly
2853 // offline exactly when we heal (phone-hosted instances!): skipping them and
2854 // consuming the version would leave those rows stale forever. Instead retry
2855 // on the next boots, giving up after a few attempts (permanently-dead
2856 // origins answer 404/410 and are deleted above, so they don't loop).
2857 const setSetting = (k, v) => { try { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(k, String(v)); } catch { /* ignore */ } };
2858 let attempts = 0;
2859 try { const a = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('selfheal_attempts'); attempts = a ? (parseInt(a.value, 10) || 0) : 0; } catch { /* ignore */ }
2860 if (failed === 0 || attempts >= 4) {
2861 setSetting('selfheal_version', SELFHEAL_VERSION);
2862 setSetting('selfheal_attempts', 0);
2863 } else {
2864 setSetting('selfheal_attempts', attempts + 1);
2865 }
2866 if (rows.length) console.log(`[AP] self-heal v${SELFHEAL_VERSION}: ${healed}/${rows.length} timeline notes${failed ? ` (${failed} unreachable — will retry next boot)` : ''}`);
2867 } catch { /* never block boot */ } finally { _selfHealing = false; }
2868}
2869
2870// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
2871export async function followActor(site, handle, autoBoost = false) {
2872 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2873 if (!base || !site || !site.slug) return { error: 'config' };
2874 // Accept any of: a profile/actor URL, an @user@host handle (WebFinger), or a
2875 // bare site domain (site.com) — for a single-actor site (Klonkt etc.) the root
2876 // resolves to its AP actor, so you can follow a site by just its domain.
2877 const s = String(handle || '').trim();
2878 let actorUrl;
2879 if (/^https?:\/\//i.test(s)) actorUrl = safeUrl(s) || null;
2880 else if (s.includes('@')) actorUrl = await webfingerResolve(s);
2881 else if (/^[a-z0-9.-]+\.[a-z]{2,}/i.test(s)) actorUrl = await resolveApActor('https://' + s.replace(/^\/+|\/+$/g, ''));
2882 else actorUrl = null;
2883 if (!actorUrl) return { error: 'not_found' };
2884 const actor = await fetchActor(actorUrl).catch(() => null);
2885 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
2886 const ai = actorInfo(actor, actor.id);
2887 const me = actorId(base, site.slug);
2888 const keys = getOrCreateKeys(site.slug);
2889 const followId = `${me}#follow-${Date.now()}-${rid()}`;
2890 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending', autoBoost ? 1 : 0);
2891 const follow = { '@context': AP_CONTEXT, id: followId, type: 'Follow', actor: me, object: actor.id };
2892 // Deliver via the retry queue: a Follow that fails the first attempt (peer down,
2893 // timeout, transient 5xx) is retried with backoff instead of staying stuck on
2894 // 'pending' forever — the Accept can only come back once the Follow lands.
2895 await deliverWithRetry(site.slug, actor.inbox, follow, `${me}#main-key`, keys.private_pem);
2896 console.log('[AP] follow', site.slug, '→', actor.id);
2897 // Follow + feature in one step → backfill their recent posts into the Cirkel right away.
2898 if (autoBoost) backfillFromOutbox(site.slug, actor.id).catch(() => {});
2899 return { ok: true, name: ai.name, handle: ai.handle, actor: actor.id };
2900}
2901
2902// Resolve a profile URL or @handle to a followable remote actor (for the
2903// authorize_interaction "Follow" flow). Returns display fields + inbox, or null
2904// when it isn't a reachable actor (e.g. the input was a post, not a profile).
2905export async function resolveRemoteActor(input) {
2906 const s = String(input || '').trim();
2907 const actorUrl = /^https?:\/\//i.test(s) ? (safeUrl(s) || null) : await webfingerResolve(s);
2908 if (!actorUrl) return null;
2909 const actor = await fetchActor(actorUrl).catch(() => null);
2910 if (!actor || !actor.id || !actor.inbox) return null;
2911 const ai = actorInfo(actor, actor.id);
2912 return { actor_uri: actor.id, actor_name: ai.name, actor_handle: ai.handle, actor_url: ai.url, actor_icon: ai.icon, inbox: actor.inbox };
2913}
2914
2915export async function unfollowActor(site, actorUri) {
2916 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2917 const me = actorId(base, site.slug);
2918 const keys = getOrCreateKeys(site.slug);
2919 const row = fwStmts().one.get(site.slug, actorUri);
2920 // Undo(Follow) MUST reference the original Follow's real id so the remote can correlate it
2921 // and drop the follow. The old `${me}#follow` fallback never matched anything → the unfollow
2922 // silently failed on the remote. With no stored follow id (legacy row), skip the network Undo
2923 // rather than send an unmatchable one. Deliver durably via the retry queue.
2924 if (row && row.inbox && row.follow_id) {
2925 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 } };
2926 deliverWithRetry(site.slug, row.inbox, undo, `${me}#main-key`, keys.private_pem);
2927 } else if (row && row.inbox) {
2928 console.warn('[AP] unfollow', site.slug, '→', actorUri, '— no stored follow id; removed locally only (legacy follow, remote may keep it)');
2929 }
2930 fwStmts().del.run(site.slug, actorUri);
2931 return { ok: true };
2932}
2933
2934// FEP-633c §5.3 note (authorized fetch): true when `actorUri` is a committed
2935// guardian of the local ward `wardSlug` — so a signed GET from it may read the
2936// ward's non-public history without the guardian appearing as a follower.
2937export function isWardGuardian(wardSlug, actorUri) {
2938 try { return !!Guardianship.getRelation(wardSlug, 'ward', actorUri); } catch { return false; }
2939}
2940
2941// FEP-633c §5.3: the guardians approved a gated follow of their ward. Send the
2942// Accept to the follower and record them, so delivery (incl. followers-only)
2943// begins. `pending` is a row from ap_pending_follows.
2944export async function acceptGatedFollow(pending) {
2945 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2946 const slug = pending.ward_slug;
2947 const me = actorId(base, slug);
2948 const keys = getOrCreateKeys(slug);
2949 fStmts().ins.run(slug, pending.follower_uri, pending.follower_inbox, pending.follower_shared_inbox, pending.follower_name, pending.follower_handle, pending.follower_icon);
2950 const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
2951 const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: original };
2952 await deliverWithRetry(slug, pending.follower_inbox, accept, `${me}#main-key`, keys.private_pem);
2953 const filled = pending.follower_shared_inbox &&
2954 db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1').get(slug, pending.follower_shared_inbox, pending.follower_uri);
2955 if (!filled) backfillNewFollower(base, slug, pending.follower_shared_inbox || pending.follower_inbox).catch(() => {});
2956 console.log('[AP] gated Follow accepted', pending.follower_uri, '→ ward', slug);
2957 return { ok: true };
2958}
2959
2960// The guardians denied the follow: send a Reject so the follower's server clears
2961// its pending state, then the caller drops the record.
2962export async function rejectGatedFollow(pending) {
2963 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2964 const slug = pending.ward_slug;
2965 const me = actorId(base, slug);
2966 const keys = getOrCreateKeys(slug);
2967 const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
2968 const reject = { '@context': AP_CONTEXT, id: `${me}#reject-${Date.now()}-${rid()}`, type: 'Reject', actor: me, object: original };
2969 if (pending.follower_inbox) await deliverWithRetry(slug, pending.follower_inbox, reject, `${me}#main-key`, keys.private_pem).catch(() => {});
2970 console.log('[AP] gated Follow rejected', pending.follower_uri, '→ ward', slug);
2971 return { ok: true };
2972}
2973
2974// ── Cross-instance follow-approval (FEP-633c §5.3, modelled on the guardian
2975// offer). Inbound: an Offer(Follow) forwarded by a ward to a guardian (leg
2976// 2), or a guardian's Accept/Reject coming back to the ward (leg 4). ──────
2977async function handleFollowApprovalInbox(act, slugParam) {
2978 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2979 const type = Array.isArray(act.type) ? act.type[0] : act.type;
2980 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
2981
2982 // Leg 2: I am a guardian; the object is the Follow to approve. The Offer is
2983 // signed by the ward, so act.actor is the ward.
2984 if (type === 'Offer') {
2985 const fo = (act.object && typeof act.object === 'object') ? act.object : null;
2986 const foType = fo && (Array.isArray(fo.type) ? fo.type[0] : fo.type);
2987 if (!fo || foType !== 'Follow') return false;
2988 const followId = fo.id;
2989 const follower = typeof fo.actor === 'string' ? fo.actor : (fo.actor && fo.actor.id);
2990 const wardUri = actorUri;
2991 if (!followId || !follower || !wardUri) return false;
2992 const recips = (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : [])).filter((x) => typeof x === 'string');
2993 if (slugParam) recips.push(actorId(base, slugParam));
2994 let stored = false;
2995 for (const r of new Set(recips)) {
2996 const gslug = slugFromActorUrl(r);
2997 if (!gslug) continue;
2998 if (!Guardianship.getRelation(gslug, 'guardian', wardUri)) continue; // must actually guard this ward
2999 const wardDoc = await fetchActor(wardUri).catch(() => null);
3000 const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
3001 Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) });
3002 const L = pushLang(gslug);
3003 pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fai.name || fai.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian2` });
3004 stored = true;
3005 }
3006 return stored;
3007 }
3008
3009 // Leg 4: I am the ward; a guardian decided. object is the Follow (id).
3010 const fo = act.object;
3011 const followId = typeof fo === 'string' ? fo : (fo && fo.id);
3012 if (!followId) return false;
3013 const pending = Guardianship.follows.getPending(followId);
3014 if (!pending) return false;
3015 const guardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
3016 if (!guardians.includes(actorUri)) return false; // only a real guardian of this ward decides
3017 const decision = type === 'Reject' ? 'reject' : 'approve';
3018 const r = Guardianship.follows.decide(followId, actorUri, decision, guardians);
3019 try {
3020 if (r.outcome === 'approved') { await acceptGatedFollow(r.follow); Guardianship.follows.remove(followId); }
3021 else if (r.outcome === 'rejected') { await rejectGatedFollow(r.follow); Guardianship.follows.remove(followId); }
3022 } catch { /* delivery is retried */ }
3023 return true;
3024}
3025
3026// Leg 3: a guardian in /guardian2 decides on a forwarded follow; send the
3027// Accept/Reject back to the ward's inbox (signed by the guardian).
3028export async function sendFollowDecision(guardianSite, review, decision) {
3029 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3030 const me = actorId(base, guardianSite.slug);
3031 const keys = getOrCreateKeys(guardianSite.slug);
3032 const fo = review.follow_json ? JSON.parse(review.follow_json) : { id: review.id, type: 'Follow', actor: review.follower_uri, object: review.ward_uri };
3033 const activity = { '@context': AP_CONTEXT, id: `${me}#followdec-${Date.now()}-${rid()}`, type: decision === 'reject' ? 'Reject' : 'Accept', actor: me, to: [review.ward_uri], object: fo, 'shaer:followApproval': true };
3034 if (review.ward_inbox) await deliverWithRetry(guardianSite.slug, review.ward_inbox, activity, `${me}#main-key`, keys.private_pem);
3035 return { ok: true };
3036}
3037
3038// Send a Like or Announce (boost) on a remote note FROM this site.
3039export async function sendInteraction(site, kind, targetNoteId, authorUri) {
3040 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3041 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
3042 const me = actorId(base, site.slug);
3043 const keys = getOrCreateKeys(site.slug);
3044 // 'unboost' = Undo(Announce): retracts a boost so followers' servers remove the
3045 // reblog (matched on actor+object — no record of the original Announce needed).
3046 const fanout = (kind === 'boost' || kind === 'unboost'); // also goes to our followers
3047 const followersCol = `${me}/followers`;
3048 // Address the original author in cc so their server (Mastodon, WordPress/ActivityPub, …)
3049 // attributes the boost to their post and notifies them — without this, a shared-inbox
3050 // receiver has nothing to route the Announce to. Non-fragment activity ids + a `published`
3051 // stamp keep us aligned with what Mastodon emits.
3052 const audience = authorUri ? [followersCol, authorUri] : [followersCol];
3053 let act;
3054 if (kind === 'unboost' || kind === 'unlike') {
3055 // Undo(Announce) retracts a boost; Undo(Like) un-favourites (matched on actor+object,
3056 // no record of the original activity needed — Mastodon honours both).
3057 const inner = kind === 'unboost' ? 'Announce' : 'Like';
3058 act = {
3059 '@context': AP_CONTEXT,
3060 id: `${me}/undo/${Date.now()}-${rid()}`, type: 'Undo', actor: me,
3061 object: { id: `${me}/${inner.toLowerCase()}/${Date.now()}-${rid()}`, type: inner, actor: me, object: targetNoteId },
3062 };
3063 if (kind === 'unboost') { act.to = [PUBLIC]; act.cc = audience; }
3064 } else {
3065 const type = kind === 'boost' ? 'Announce' : 'Like';
3066 act = {
3067 '@context': AP_CONTEXT,
3068 id: `${me}/${type.toLowerCase()}/${Date.now()}-${rid()}`,
3069 type, actor: me, object: targetNoteId,
3070 };
3071 if (type === 'Announce') { act.published = new Date().toISOString(); act.to = [PUBLIC]; act.cc = audience; }
3072 }
3073 const inboxes = new Set();
3074 // Author first, via their PERSONAL inbox (not the shared one) so a multi-user receiver
3075 // routes the Announce/Like to the right post unambiguously.
3076 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add(a.inbox || (a.endpoints && a.endpoints.sharedInbox)); }
3077 if (fanout) { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
3078 // Queue each delivery (immediate attempt + backoff retries on failure via ap_delivery)
3079 // instead of a single fire-and-forget POST, so a transient hiccup at the receiver doesn't
3080 // silently lose the boost — same durability a new post (deliverCreate) already gets.
3081 let queued = 0;
3082 for (const inbox of [...inboxes].filter(Boolean)) { deliverWithRetry(site.slug, inbox, act, `${me}#main-key`, keys.private_pem); queued++; }
3083 console.log('[AP]', kind, site.slug, '→', targetNoteId, 'queued', queued, 'inbox(es)');
3084 return { ok: true, delivered: queued };
3085}
3086
3087// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
3088export function getNotifications(slug, limit) {
3089 // Per-source cap scales with the requested limit so Messages can page deep
3090 // (Load more). Bounded so a huge offset can't ask for unbounded rows.
3091 const L = Math.min(1000, Math.max(80, limit || 60));
3092 const out = [];
3093 try {
3094 for (const f of db.prepare('SELECT actor_uri, created_at FROM ap_followers WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) {
3095 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
3096 }
3097 } catch { /* ignore */ }
3098 try {
3099 const rows = db.prepare(`
3100 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.visibility,
3101 p.slug AS post_slug, p.title AS post_title
3102 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
3103 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
3104 ORDER BY i.created_at DESC LIMIT ?
3105 `).all(slug, L);
3106 for (const r of rows) out.push({
3107 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url, icon: r.actor_icon,
3108 content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
3109 // followers/direct = a private message to the owner (not on the public thread) → 🔒 in Messages
3110 visibility: r.visibility || 'public',
3111 });
3112 } catch { /* ignore */ }
3113 try {
3114 for (const r of db.prepare('SELECT actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at FROM ap_reports WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) {
3115 // The reported objects: our own notes resolve to post links so the owner
3116 // sees WHICH post the report is about; other URIs (e.g. the actor itself)
3117 // are skipped — the report row already names the account.
3118 const about = [];
3119 try {
3120 for (const u of JSON.parse(r.objects || '[]')) {
3121 const m = String(u).match(/\/ap\/notes\/([^/?#]+)/);
3122 if (!m) continue;
3123 const p = db.prepare('SELECT slug, title FROM posts WHERE id = ?').get(decodeURIComponent(m[1]));
3124 if (p) about.push({ slug: p.slug, title: p.title || p.slug });
3125 }
3126 } catch { /* malformed objects json → no links */ }
3127 out.push({ type: 'report', name: r.actor_name, handle: r.actor_handle, url: r.actor_uri, icon: r.actor_icon, content: r.content, objects: about, created_at: r.created_at });
3128 }
3129 } catch { /* ignore */ }
3130 try {
3131 for (const r of db.prepare('SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, created_at FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) {
3132 out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, wave: r.wave ? 1 : 0, actorUri: r.actor_uri, created_at: r.created_at });
3133 }
3134 } catch { /* ignore */ }
3135 // Your own polls that have closed → a "results are in" item, derived read-time
3136 // from poll_json (Scheduler marks closed=1) with the tally via ownPollView.
3137 try {
3138 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
3139 if (site) {
3140 const polls = db.prepare(`
3141 SELECT id, slug, title, poll_json FROM posts
3142 WHERE site_id = ? AND poll_json IS NOT NULL
3143 AND json_extract(poll_json, '$.closed') = 1
3144 AND json_extract(poll_json, '$.endTime') IS NOT NULL
3145 ORDER BY json_extract(poll_json, '$.endTime') DESC LIMIT 20`).all(site.id);
3146 for (const p of polls) {
3147 const view = ownPollView(p);
3148 if (!view) continue;
3149 let endTime = null; try { endTime = JSON.parse(p.poll_json).endTime; } catch { /* keep null */ }
3150 out.push({ type: 'poll_done', post_slug: p.slug, post_title: p.title, poll: view, created_at: endTime || null });
3151 }
3152 }
3153 } catch { /* ignore */ }
3154 // NaN-safe sort: one row with a missing/garbled created_at would otherwise make the
3155 // comparator return NaN and scramble the WHOLE ordering (seen live: follow rows landing
3156 // between likes, which also broke Messages' like-grouping).
3157 out.sort((a, b) => _msgTs(b) - _msgTs(a));
3158 return out.slice(0, limit || 60);
3159}
3160function _msgTs(x) { const t = Date.parse((x && x.created_at) || ''); return Number.isFinite(t) ? t : 0; }
3161
3162// ── Blocking / defederation ───────────────────────────────────────
3163// Extracted to BlocklistService (shared: Klonkt's Block tab + Shaer's "in
3164// Orbit"). Thin delegations keep every existing caller working.
3165export function listBlocks(slug) { return Blocklist.listBlocks(slug); }
3166
3167// True if an actor (or its whole domain) is blocked anywhere on this instance.
3168// Vote on a remote fediverse poll (a cached Question). A ballot = a Create(Note) carrying only a
3169// `name` (the chosen option) + inReplyTo the Question, addressed to the poll's author — the
3170// Mastodon-standard vote. Records our choice locally + optimistically bumps the counts; the
3171// author's Update(Question) refreshes the authoritative totals when it arrives.
3172export async function voteOnPoll(site, questionId, choices) {
3173 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3174 if (!base || !site || !site.slug || !questionId) return { error: 'config' };
3175 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 */ }
3176 if (!row || !row.poll_json) return { error: 'not_found' };
3177 let poll; try { poll = JSON.parse(row.poll_json); } catch { return { error: 'not_found' }; }
3178 if (poll.closed) return { error: 'closed' };
3179 if (poll.voted) return { error: 'already' };
3180 const valid = new Set(poll.options.map((o) => o.name));
3181 const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
3182 if (!picks.length) return { error: 'invalid' };
3183 const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
3184 const me = actorId(base, site.slug);
3185 const keys = getOrCreateKeys(site.slug);
3186 const authorUri = row.author_uri || null;
3187 const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
3188 const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
3189 if (!inbox) return { error: 'unreachable' };
3190 for (const name of chosen) {
3191 const nid = `${me}/votes/${Date.now()}-${rid()}`;
3192 const note = { id: nid, type: 'Note', attributedTo: me, to: authorUri ? [authorUri] : [], name, inReplyTo: questionId, published: new Date().toISOString() };
3193 const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
3194 deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
3195 }
3196 // Local optimistic update (authoritative counts arrive via the author's Update(Question)).
3197 poll.voted = poll.multiple ? chosen : chosen[0];
3198 for (const o of poll.options) if (chosen.includes(o.name)) o.count = (o.count || 0) + 1;
3199 if (poll.voters != null) poll.voters += 1;
3200 try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), questionId, site.slug); } catch { /* ignore */ }
3201 return { ok: true };
3202}
3203
3204// Vote on ANY fediverse poll by URL (the interact page) — no timeline cache needed. Fetches
3205// the Question fresh, validates the choice(s), and casts the Mastodon-standard ballot (a
3206// Create(Note) with `name` + inReplyTo) straight to the poll's author. Used for polls you find
3207// by URL, not just ones from accounts you follow (which go through voteOnPoll via /news).
3208export async function voteOnRemotePoll(site, questionUrl, choices) {
3209 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3210 if (!base || !site || !site.slug || !/^https?:\/\//i.test(String(questionUrl || ''))) return { error: 'config' };
3211 const q = await fetchActor(questionUrl).catch(() => null); // AP GET (SSRF-guarded)
3212 if (!q || q.type !== 'Question' || !q.id) return { error: 'not_found' };
3213 const poll = parsePoll(q);
3214 if (!poll) return { error: 'not_found' };
3215 if (poll.closed) return { error: 'closed' };
3216 const valid = new Set(poll.options.map((o) => o.name));
3217 const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
3218 if (!picks.length) return { error: 'invalid' };
3219 const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
3220 const authorUri = actorUriOf(q.attributedTo);
3221 const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
3222 const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
3223 if (!inbox) return { error: 'unreachable' };
3224 const me = actorId(base, site.slug);
3225 const keys = getOrCreateKeys(site.slug);
3226 for (const name of chosen) {
3227 const nid = `${me}/votes/${Date.now()}-${rid()}`;
3228 const note = { id: nid, type: 'Note', attributedTo: me, to: [authorUri], name, inReplyTo: q.id, published: new Date().toISOString() };
3229 const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
3230 deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
3231 }
3232 return { ok: true };
3233}
3234
3235// Report a remote post or account to its home instance (moderation). Sends the Mastodon-standard
3236// AS2 `Flag`: object = [reported account, reported status?], content = the reason, delivered to the
3237// reported account's inbox so their instance's moderators receive it. objectUri = a post URL (its
3238// author is resolved + included) OR pass actorUri to report an account directly.
3239export async function sendReport(site, { objectUri, actorUri, reason }) {
3240 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3241 if (!base || !site || !site.slug) return { error: 'config' };
3242 let targetActor = actorUri || null;
3243 let noteUri = null;
3244 if (objectUri && /^https?:\/\//i.test(objectUri)) {
3245 const note = await apGetJson(objectUri).catch(() => null);
3246 if (note && note.id) { noteUri = note.id; if (!targetActor) targetActor = actorUriOf(note.attributedTo); }
3247 else if (!targetActor) return { error: 'not_found' };
3248 }
3249 if (!targetActor || !/^https?:\/\//i.test(targetActor)) return { error: 'not_found' };
3250 const actor = await fetchActor(targetActor).catch(() => null);
3251 const inbox = actor && (actor.inbox || (actor.endpoints && actor.endpoints.sharedInbox)); // personal inbox → their moderators
3252 if (!inbox) return { error: 'unreachable' };
3253 const me = actorId(base, site.slug);
3254 const keys = getOrCreateKeys(site.slug);
3255 const object = [targetActor];
3256 if (noteUri && noteUri !== targetActor) object.push(noteUri);
3257 const flag = {
3258 '@context': AP_CONTEXT,
3259 id: `${me}#report-${Date.now()}-${rid()}`,
3260 type: 'Flag',
3261 actor: me,
3262 content: String(reason == null ? '' : reason).slice(0, 3000),
3263 object, // [account, status?] — Mastodon's Flag shape
3264 to: [targetActor],
3265 };
3266 deliverWithRetry(site.slug, inbox, flag, `${me}#main-key`, keys.private_pem);
3267 return { ok: true };
3268}
3269
3270export function isBlockedAny(actorUri) { return Blocklist.isBlockedAny(actorUri); }
3271
3272// Block an actor (@handle or actor URL) or a whole domain; purges their content.
3273// The handle resolver is ours; the storage/purge lives in BlocklistService.
3274export async function blockTarget(site, input) { return Blocklist.blockTarget(site, input, webfingerResolve); }
3275
3276export function unblock(site, target) { return Blocklist.unblock(site, target); }
3277
3278// ── Guardianship module wiring (src/services/guardianship/) ────────
3279// The module owns FEP-633c (context, relations, handshake, queues, the
3280// direct-note leg); we hand it our AP helpers ONCE and delegate. It never
3281// imports us back.
3282function selfActorId(slug) {
3283 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3284 return actorId(base, slug);
3285}
3286// Deliver one activity to one actor's inbox, signed; queued + retried on any
3287// hiccup so a slow or briefly-down ward server never loses the offer. Returns
3288// { delivered, inbox }: delivered=false means the account could not be
3289// resolved at all (a bad handle) — the offer stays recorded regardless.
3290async function deliverToActor(site, actorUri, activity) {
3291 const me = selfActorId(site.slug);
3292 const keys = getOrCreateKeys(site.slug);
3293 const payload = { '@context': AP_CONTEXT, ...activity };
3294 const a = await fetchActor(actorUri).catch(() => null);
3295 const inbox = a && (a.inbox || (a.endpoints && a.endpoints.sharedInbox));
3296 if (!inbox) {
3297 console.warn('[AP] guardianship: could not resolve an inbox for', actorUri, '(offer recorded, not sent)');
3298 return { delivered: false, inbox: null };
3299 }
3300 try {
3301 const st = await deliver(inbox, payload, `${me}#main-key`, keys.private_pem);
3302 if (st >= 200 && st < 300) { console.log('[AP] guardianship', activity.type, 'delivered →', inbox, st); return { delivered: true, inbox }; }
3303 console.warn('[AP] guardianship', activity.type, 'got', st, 'from', inbox, '→ queued for retry');
3304 } catch (e) { console.warn('[AP] guardianship', activity.type, 'to', inbox, 'failed:', e.message, '→ queued for retry'); }
3305 enqueueDelivery(site.slug, inbox, payload);
3306 return { delivered: true, inbox }; // queued: the retry worker gets it there
3307}
3308Guardianship.wireDelivery({
3309 actorId, fetchActor, deriveHandle, escHtml, linkUrls, linkHashtags,
3310 getOutboxRow: (id) => iStmts().getO.get(id),
3311 buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
3312});
3313// Which local site (if any) hosts this actor URI — used by the handshake to
3314// apply the local side of a commit and to derive a ward's existing guardians.
3315function localSlugOf(actorUri) {
3316 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3317 if (!actorUri || !actorUri.startsWith(`${base}/ap/users/`)) return null;
3318 const slug = slugFromActorUrl(actorUri);
3319 if (!slug) return null;
3320 try { return db.prepare('SELECT slug FROM sites WHERE slug = ?').get(slug) ? slug : null; }
3321 catch { return null; }
3322}
3323Guardianship.wireHandshake({
3324 selfId: selfActorId,
3325 localSlug: localSlugOf,
3326 deliverTo: deliverToActor,
3327 deriveHandle,
3328 fetchActor,
3329 // Guardian PWA / Berichten push. The kid answers an incoming offer in its
3330 // own Berichten; an existing guardian and a commit land in the PWA.
3331 onEvent: (slug, ev) => {
3332 const L = pushLang(slug);
3333 const texts = {
3334 offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'], // I am the ward
3335 offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'], // I co-guard this ward
3336 committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
3337 }[ev.kind];
3338 if (!texts) return;
3339 const who = deriveHandle(ev.candidate || ev.ward || ev.guardian || '') || '?';
3340 const url = ev.kind === 'offer_received' ? `${pushPrefix(slug)}/messages` : '/guardian';
3341 pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url });
3342 },
3343});
3344
3345export default {
3346 AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId,
3347 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
3348 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
3349 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
3350 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
3351 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, sendInteraction, voteOnPoll, voteOnRemotePoll,
3352 acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision,
3353 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
3354 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
3355 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
3356 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
3357 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
3358 linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
3359 noteVisibility, isRejectedObject, rejectInteraction, interactionReportTarget,
3360 getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched,
3361};
Note: See TracBrowser for help on using the repository browser.