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

main
Last change on this file since d56d471 was 6100ce9, checked in by Robin <roboburr@…>, 6 weeks ago

Een dichte poort mag zeggen dat hij dicht is

Robin tikte op een videokaart in de app en er gebeurde niets. Geen speler, geen
melding, geen reactie. De speler was niet stuk: de afspeel-poort staat voor dat
account dicht, dus de server stuurt geen playerUrl mee en de kaart is niet
aantikbaar. Correct gedrag, maar het leest als kapot.

De kaart zegt nu dat er iets achter de poort zit. timelineEmbed voegt
shaer:playable toe wanneer er wel een speler zou zijn maar de poort dicht is.
Dat kost niets: de kaart toont al een videothumbnail, dus dat er video achter
zit is geen nieuws. Het is een mededeling, nooit een ingang.

Het verschil dat dit maakt: "deze app is stuk" wordt "dit is niet aan mij". Een
kaart die een tik opslokt leert geen van beide. En het staat de guardians
nergens in de weg: niets hieraan zet iets aan.

Changed files:
src/services/ActivityPubService.js

  • timelineEmbed: shaer:playable bij een dichte poort met een speler erachter

test/gated-settings.test.js

  • een dichte poort geeft geen speler maar geeft dat wel toe
  • een nieuwsartikel is geen dichte poort, die belooft niets

remarks: 333 tests groen. De clientkant (het regeltje op de kaart) zit in de
app-repos.

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

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