source: Klonkt/src/services/ActivityPubService.js@ 329873e

main
Last change on this file since 329873e was e27b8db, checked in by Robin Genis <roboburr@…>, 6 weeks ago

Afspelen in de app als tweede gated feature, en het gat in de gate

Bij het uitzoeken van de YouTube-vraag bleek de gate lek. De web-Krant bouwt de
speler uit de inhoud van de post via timelineEmbedHtml, en dat pad raakte
gateEmbeds nooit. Een ward wiens guardians niets hadden toegestaan kreeg dus de
volledige YouTube-speler op het web, terwijl de app niets liet zien: het zware
ding open, het lichte dicht. Precies omgekeerd.

Nu zijn het twee besluiten, want het zijn twee dingen. Zien dat er een filmpje
is, is niet hetzelfde als het scherm afstaan aan de motor van een derde partij,
compleet met eindscherm en volgende-video. shaer:externalEmbeds houdt de kaart,
shaer:externalPlayback de speler, allebei standaard uit voor een ward, en
afspelen vereist de kaart: je kunt niet spelen wat je niet mag zien.

En het antwoord op Robins vraag over de links: die vallen er ook onder. De gate
verborg tot nu toe alleen het plaatje terwijl de kale link eronder gewoon
aantikbaar bleef, dus de deur stond open met een doek eroverheen. Staat de gate
dicht, dan toont de kaart zich nog wel maar is hij geen deur meer.

De server bepaalt wat gespeeld mag worden, niet de client: hij levert
shaer:playerUrl mee, alleen bij een open gate en alleen in de privacy-variant
(youtube-nocookie met rel=0, of de eigen speler van de PeerTube-instance). De
app houdt zo geen lijst van hosts bij; hij speelt wat hij krijgt aangereikt.

Changed files:
src/config/database.js

  • kolom sites.external_playback

src/services/guardianship/notes.js

  • externalPlaybackAllowed naast externalEmbedsAllowed

src/services/guardianship/gated.js

  • shaer:externalPlayback in de feature-tabel

src/services/ActivityPubService.js

  • timelineEmbed voegt shaer:playerUrl toe als afspelen mag; playerUrlFor kent alleen privacy-varianten en weigert de rest

src/routes/activitypub.js

  • shaer:capabilities op de owner-only inbox-read: wat mag dit account
  • de embed draagt de speler-URL alleen bij een open playback-gate

src/routes/posts.js

  • het gat gedicht: de speler-iframe op de web-Krant valt nu onder de gate

src/routes/guardian.js

  • de voorstel-route is feature-bewust; het lokale pad stuurt nu ook door

src/assets/js/guardian.js

  • tweede knop in het paneel, alleen zichtbaar als de kaart al aan staat

src/services/i18n.js

  • de labels in nl, en, de

test/gated-settings.test.js

  • drie tests: de speler-URL rijdt alleen mee bij een open gate, een pagina die we niet framen blijft een thumbnail, en afspelen vereist de kaart

remarks: 280 tests groen. Niets geforceerd: beide gates staan standaard uit
voor een ward en twee van de drie guardians moeten nog steeds akkoord gaan.

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

  • Property mode set to 100644
File size: 225.5 KB
Line 
1/**
2 * ActivityPubService — Klonkt as a real ActivityPub actor (fediverse bridge).
3 *
4 * Phase 1 (this file): the PUBLISH/discoverable side.
5 * - per-site RSA keypair (Mastodon-compatible HTTP Signatures; separate from
6 * the Ed25519 keys used by the lighter Cirkels v1)
7 * - builders for the Actor document, Note objects and the Outbox collection
8 * - apWants(): HTTP content-negotiation helper (activity+json vs HTML)
9 *
10 * The interactive side (inbox: Follow/Accept, signature verify, delivery to
11 * followers) lands in the next step and is tested live against Mastodon.
12 *
13 * AP actor URLs live under /ap/* so they never clash with the human pages:
14 * actor = <base>/ap/users/<slug>
15 * inbox = <actor>/inbox outbox = <actor>/outbox
16 * note = <base>/ap/notes/<postId>
17 */
18import crypto from 'crypto';
19import dns from 'dns';
20import net from 'net';
21import db from '../config/database.js';
22import HtmlSanitizerService from './HtmlSanitizerService.js';
23import AudioEmbedService from './AudioEmbedService.js';
24import 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) {
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 const verified = await verifyRequest(req).catch(() => null);
1355
1356 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
1357 // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
1358 // forged replies/likes/follows/timeline posts). GET/discovery stays open.
1359 const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
1360 // Blocked actor/domain → silently drop (202, don't reveal the block).
1361 if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
1362 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer'];
1363 if (GATED.includes(type)) {
1364 if (!verified || !claimedActor || verified.id !== claimedActor) {
1365 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
1366 return 401;
1367 }
1368 // One answer restores everything (FEP-633c 3.6): any VERIFIED activity
1369 // from an actor that guards someone here restores it to active for those
1370 // wards and cancels any lapse running against it, before the activity is
1371 // even looked at. Signature-gated on purpose: an unverified claim of
1372 // being gran must not wake gran up.
1373 try {
1374 const ev = Guardianship.availability.oneAnswer(claimedActor, Date.now());
1375 if (ev.restored.length) console.log('[AP] guardian restored (one answer, 3.6):', claimedActor, '→', ev.restored.join(', '));
1376 for (const c of ev.cancelledLapses) console.log('[AP] lapse cancelled by an answer from its target:', c.id);
1377 } catch { /* availability is never load-bearing for delivery */ }
1378 }
1379
1380 // FEP-633c §5.3 (modelled on the adoption offer): a gated follow forwarded to
1381 // the guardians as an Offer(Follow), their Accept/Reject back to the ward.
1382 if ((type === 'Offer' || type === 'Accept' || type === 'Reject') && act['shaer:followApproval'] === true) {
1383 if (await handleFollowApprovalInbox(act, slugParam)) { console.log('[AP] follow-approval', type, 'from', claimedActor); return 202; }
1384 }
1385
1386 // FEP-633c: the adoption handshake. An Offer lands at the local ward; an
1387 // Accept/Reject answers an offer a local guardian sent. Anything the
1388 // guardianship module does not recognize falls through to the old paths.
1389 // An Undo of the guardianship Relationship (§3.2) is handled here too, and it
1390 // must be seen BEFORE the generic Undo branch below, which only knows about
1391 // Follow/Like/Announce and would swallow it with a 202.
1392 if (type === 'Offer' || type === 'Accept' || type === 'Reject' || (type === 'Undo' && Guardianship.parseUndoRelationship(act))) {
1393 // Every LOCAL party this activity is addressed to gets its own copy of the
1394 // handshake (a ward and a co-guardian may both live here). Gather candidate
1395 // local slugs from the inbox owner, the `to` list, and the ward.
1396 const cand = new Set();
1397 if (slugParam) cand.add(slugParam);
1398 for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) {
1399 if (typeof t === 'string') { const s = slugFromActorUrl(t); if (s) cand.add(s); }
1400 }
1401 if (type === 'Offer' || type === 'Undo') {
1402 const rel = type === 'Undo' ? Guardianship.parseUndoRelationship(act) : Guardianship.parseRelationship(act.object);
1403 if (rel) { const s = slugFromActorUrl(rel.ward); if (s) cand.add(s); }
1404 }
1405 let consumed = false;
1406 for (const slug of cand) {
1407 const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
1408 if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) consumed = true;
1409 }
1410 if (consumed) { console.log('[AP] guardianship', type, 'from', claimedActor); return 202; }
1411 }
1412
1413 // A moderation report (Flag) about our content — store it for the targeted site's owner
1414 // (each Klonkt site is moderated by its own owner). Signature is enforced (GATED).
1415 if (type === 'Flag') {
1416 const objs = Array.isArray(act.object) ? act.object : (act.object ? [act.object] : []);
1417 const objectUris = objs.map((o) => (typeof o === 'string' ? o : (o && o.id))).filter(Boolean);
1418 let targetSlug = null;
1419 const noteIds = [];
1420 for (const u of objectUris) {
1421 const s = slugFromActorUrl(u); // one of our actors?
1422 if (s) { targetSlug = targetSlug || s; continue; }
1423 const pid = postIdFromNoteUrl(u, base); // one of our notes?
1424 if (pid) noteIds.push(pid);
1425 }
1426 if (!targetSlug && noteIds.length) {
1427 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 */ }
1428 }
1429 if (!targetSlug) return 202; // not about us / can't tell → drop
1430 // Flag is GATED, so `verified` is the signer's (reporter's) actor doc already.
1431 const ai = actorInfo(verified || null, claimedActor);
1432 try {
1433 db.prepare('INSERT INTO ap_reports (slug, actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
1434 .run(targetSlug, claimedActor || null, ai.name, ai.handle, ai.icon, HtmlSanitizerService.toPlainText(act.content || '').slice(0, 3000), JSON.stringify(objectUris.slice(0, 20)));
1435 console.log('[AP] report received for', targetSlug, 'from', claimedActor);
1436 } catch { /* ignore */ }
1437 return 202;
1438 }
1439
1440 if (type === 'Follow') {
1441 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
1442 const slug = slugParam || slugFromActorUrl(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
1443 if (!who || !slug) return 400;
1444 const remote = await fetchActor(who);
1445 if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
1446 const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
1447 const fi = actorInfo(remote, who); // cache display for the friends list (shaer-aa3)
1448 // FEP-633c §5.3: if the followed actor is a WARD (has guardians), the
1449 // follow is gated. A committed guardian's own Follow is auto-accepted
1450 // (it needs no gate); anyone else is held pending for guardian approval.
1451 // Free actors / normal sites have no guardians → fall through, unchanged.
1452 const wardGuardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
1453 if (wardGuardians.length && !wardGuardians.includes(who)) {
1454 const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
1455 Guardianship.follows.recordPending(slug, {
1456 id: followId, follower: who, inbox: remote.inbox, sharedInbox,
1457 name: fi.name, handle: fi.handle, icon: fi.icon, activity: act,
1458 });
1459 // FEP-633c §5.3, modelled on the guardian offer: the ward forwards the
1460 // gated follow to its guardians for approval. A LOCAL guardian gets a
1461 // push and reads /guardian directly; a REMOTE guardian gets an
1462 // Offer(Follow) delivered so its instance stores a copy (same distributed
1463 // pattern as the adoption offer). On quorum the ward returns Accept(Follow).
1464 const wardActor = actorId(base, slug);
1465 const wardKeys = getOrCreateKeys(slug);
1466 const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor };
1467 // Dormancy evidence (FEP-633c 3.6.2): this decision directly addresses
1468 // every guardian. The ONLY admissible evidence is a request like this
1469 // one going unanswered; recordRequest itself skips a declared absence.
1470 for (const g of wardGuardians) {
1471 try { Guardianship.availability.recordRequest(slug, g, followId, Date.now()); } catch { /* never load-bearing */ }
1472 }
1473 for (const g of wardGuardians) {
1474 // Local ONLY when the guardian lives on THIS instance: slugFromActorUrl
1475 // ignores the host (an /ap/users/x path on a remote host is someone
1476 // else's actor), so also require our base + an existing local site.
1477 const gslug = g.startsWith(`${base}/`) ? slugFromActorUrl(g) : null;
1478 const isLocal = gslug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(gslug);
1479 if (isLocal) {
1480 const L = pushLang(gslug);
1481 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` });
1482 } else {
1483 fetchActor(g).then((ga) => {
1484 const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);
1485 if (!inbox) return;
1486 const offer = { '@context': AP_CONTEXT, id: `${wardActor}#followoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true };
1487 deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
1488 }).catch(() => {});
1489 }
1490 }
1491 console.log('[AP] Follow', who, '→ ward', slug, '(gated, awaiting guardians)');
1492 return 202;
1493 }
1494 fStmts().ins.run(slug, who, remote.inbox, sharedInbox, fi.name, fi.handle, fi.icon);
1495 try { _updFDisp.run(fi.name, fi.handle, fi.icon, slug, who); } catch { /* best effort */ }
1496 { 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` }); }
1497 const me = actorId(base, slug);
1498 const keys = getOrCreateKeys(slug);
1499 const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
1500 deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
1501 // Auto-backfill: send our recent posts as Create so the instance has our history
1502 // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —
1503 // Mastodon dedupes notes per-instance, so re-filling an instance that already has
1504 // a follower of ours is wasted work (and won't re-populate the new follower's
1505 // timeline anyway). Deliver to the shared inbox (instance-level) when present.
1506 // Sync insert+check (no await between) → no interleave race with concurrent Follows.
1507 const instanceFilled = sharedInbox &&
1508 db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')
1509 .get(slug, sharedInbox, who);
1510 if (!instanceFilled) {
1511 backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });
1512 }
1513 console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
1514 return 202;
1515 }
1516 if (type === 'Undo' && act.object) {
1517 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
1518 const ot = act.object.type;
1519 if (ot === 'Follow') {
1520 const obj = act.object.object;
1521 const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
1522 if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
1523 return 202;
1524 }
1525 if (ot === 'Like' || ot === 'Announce') {
1526 const tgt = act.object.object;
1527 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
1528 if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
1529 return 202;
1530 }
1531 return 202;
1532 }
1533
1534 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
1535 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
1536 // Activities from our OWN actors are already stored via ap_outbox — don't re-store.
1537 const isLocalActor = !!(base && actorUri && actorUri.startsWith(`${base}/ap/users/`));
1538
1539 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
1540 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
1541 const o = act.object;
1542 // A poll ballot: a Note carrying a `name` (the chosen option) inReplyTo one of OUR poll
1543 // posts. Record it (deduped per actor) BEFORE the reply logic so a vote is never stored
1544 // as a comment. recordPollBallot returns handled=false only if the target isn't a poll.
1545 if (o.name && o.inReplyTo && actorUri && !isLocalActor) {
1546 const seg = postIdFromNoteUrl(o.inReplyTo, base);
1547 if (seg && localPostExists(seg)) {
1548 const rec = recordPollBallot(seg, actorUri, o.name);
1549 if (rec.handled) { console.log('[AP] poll vote', actorUri, '→', seg); return 202; }
1550 }
1551 }
1552 const tgt = findThreadTarget(o.inReplyTo, base);
1553 if (tgt && actorUri && !isLocalActor) {
1554 const ai = actorInfo(await resolveActor(actorUri), actorUri);
1555 const html = HtmlSanitizerService.sanitize(o.content || '');
1556 if (isRejectedObject(o.id)) { console.log('[AP] reply skipped (tombstoned)', o.id); return 202; }
1557 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));
1558 console.log('[AP] reply', actorUri, '→', tgt.post_id);
1559 // A reply is a post too: Berichten renders it the way de Krant renders a
1560 // timeline row, so it needs the same media and the same quote/preview card.
1561 {
1562 const where = 'kind = ? AND post_id = ? AND actor_uri = ? AND object_uri = ?';
1563 const key = ['reply', tgt.post_id, actorUri, o.id || ''];
1564 const mj = mediaFromNote(o);
1565 if (mj && mj !== '[]') { try { db.prepare(`UPDATE ap_interactions SET media_json = ? WHERE ${where}`).run(mj, ...key); } catch { /* ignore */ } }
1566 resolveCard(o).then((c) => {
1567 if (!c) return;
1568 const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json'; // never a value from the wire
1569 try { db.prepare(`UPDATE ap_interactions SET ${col} = ? WHERE ${where}`).run(c.json, ...key); } catch { /* ignore */ }
1570 }).catch(() => { /* best-effort */ });
1571 }
1572 {
1573 // Private (followers/direct) replies push as a DM ping WITHOUT content
1574 // (the push service should never carry private text, design decision);
1575 // public replies carry a short snippet.
1576 const ctx = pushPostCtx(tgt.post_id);
1577 const vis = noteVisibility(o);
1578 const priv = vis === 'direct' || vis === 'followers';
1579 if (ctx) {
1580 const L = pushLang(ctx.site);
1581 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
1582 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` });
1583 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 });
1584 }
1585 }
1586 return 202;
1587 }
1588 // Home timeline (client): a top-level post from an account we follow.
1589 if (actorUri && !isLocalActor && belongsInTimeline(o)) {
1590 let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
1591 if (subs.length) {
1592 const ai = actorInfo(await resolveActor(actorUri), actorUri);
1593 const html = HtmlSanitizerService.sanitize(o.content || '');
1594 const _atts = (Array.isArray(o.attachment) ? o.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
1595 // Fallback cover: a Note's `image` (set when the attachment was suppressed
1596 // for a player-card post, e.g. hosted-audio posts).
1597 if (!_atts.some((m) => !m.type || /image/i.test(m.type)) && o.image) {
1598 const _im = Array.isArray(o.image) ? o.image[0] : o.image;
1599 const _iu = safeUrl(typeof _im === 'string' ? _im : (_im && _im.url));
1600 if (_iu) _atts.push({ url: _iu, type: (_im && _im.mediaType) || 'image/jpeg' });
1601 }
1602 const media = JSON.stringify(_atts);
1603 const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts
1604 // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
1605 // incoming posts to the fediverse — that flooded followers. Boosting to the
1606 // fediverse is only ever a deliberate, manual per-post action (the 🔁 on
1607 // the timeline).
1608 for (const s of subs) {
1609 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);
1610 // FEP-633c §2.2: register the ward hint on the stored object (no action yet).
1611 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 */ } }
1612 // FEP-9098: keep the note's custom-emoji tags so the C2S inbox read can serve them.
1613 { 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 */ } } }
1614 storeAuthorEmoji(o.id, s.slug, ai); // custom-emoji display name for the byline
1615
1616 // FEP-e232 + FEP-044f: keep the note's object-link/quote tags for the same read.
1617 { 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 */ } } }
1618 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 */ } }
1619 }
1620 // FEP-044f embedded quote card: resolve the quoted post out of band so
1621 // the inbox response is not blocked on a remote fetch. Best-effort.
1622 if (quoteHrefOf(o)) {
1623 const slugs = subs.map((s) => s.slug);
1624 resolveQuote(o).then((qj) => {
1625 if (!qj) return;
1626 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 */ } }
1627 }).catch(() => { /* best-effort */ });
1628 } else {
1629 // No fediverse quote: try an EXTERNAL embed (oEmbed / known provider),
1630 // thumbnail-only. Also out of band, and stored for everyone; the gate
1631 // that decides who may SEE it is applied at serve time (§5.3-style
1632 // gated feature, see the inbox read).
1633 const slugs = subs.map((s) => s.slug);
1634 resolveExternalEmbed(o.content).then((ej) => {
1635 if (!ej) return;
1636 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 */ } }
1637 }).catch(() => { /* best-effort */ });
1638 }
1639 console.log('[AP] timeline +', actorUri, 'x' + subs.length);
1640 }
1641 }
1642 // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
1643 // above): store a mention notification for each of our actors named in the Mention tags.
1644 // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
1645 // someone else's actor, not ours.
1646 if (actorUri && !isLocalActor && o.id) {
1647 const slugs = localMentionSlugs(o.tag, base);
1648 if (slugs.length) {
1649 const ai = actorInfo(await resolveActor(actorUri), actorUri);
1650 const html = HtmlSanitizerService.sanitize(o.content || '');
1651 // FEP-633c 5.2.1: a ward's call for help rides a direct mention; the
1652 // flag is stored so the Guardian PWA's message centre can list it.
1653 const help = Guardianship.isHelpRequest(o);
1654 const wave = Guardianship.isWave(o);
1655 const hasG = Guardianship.objectHasGuardians(o); // §2.2 hint, register-only
1656 // FEP-633c 3.6.1: a guardian declares itself away to its ward, on the
1657 // same direct note the mention below stores (so the kid also reads it
1658 // as an ordinary message). Recorded only from an actual guardian of
1659 // the addressed ward, and only with an end: an absence without an end
1660 // is logged and dropped, never guessed.
1661 if (Guardianship.availability.isAway(o)) {
1662 const until = Guardianship.availability.parseEndTime(o.endTime);
1663 for (const slug of slugs) {
1664 const isG = (() => { try { return Guardianship.listGuardians(slug).some((g) => g.other_uri === actorUri); } catch { return false; } })();
1665 if (!isG) continue;
1666 if (!until || until <= Date.now()) { console.warn('[AP] away without a (future) end ignored (3.6.1):', actorUri, '→', slug); continue; }
1667 Guardianship.availability.declareAway(slug, actorUri, until);
1668 console.log('[AP] guardian declared away (3.6.1):', actorUri, '→', slug, 'until', new Date(until).toISOString());
1669 }
1670 }
1671 for (const slug of slugs) {
1672 try {
1673 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)
1674 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
1675 .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,
1676 extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o));
1677 if (r.changes) {
1678 // The quote / link-preview card resolves out of band (a remote
1679 // fetch), exactly as it does for a timeline post, so the inbox
1680 // answer is never blocked on it.
1681 resolveCard(o).then((c) => {
1682 if (!c) return;
1683 const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json'; // never a value from the wire
1684 try { db.prepare(`UPDATE ap_mentions SET ${col} = ? WHERE slug = ? AND object_uri = ?`).run(c.json, slug, o.id); } catch { /* ignore */ }
1685 }).catch(() => { /* best-effort */ });
1686 console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
1687 const vis = noteVisibility(o);
1688 const priv = vis === 'direct' || vis === 'followers';
1689 const L = pushLang(slug);
1690 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
1691 // Same privacy rule as replies: private mentions push without content.
1692 // A help request pushes as its own alert type, aimed at the
1693 // Guardian PWA's message centre.
1694 if (help) pushEvent(slug, { type: 'help', title: i18nT(L, 'push.n_help_t'), body: i18nT(L, 'push.n_help_b', { who }), url: '/guardian' });
1695 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` });
1696 else pushEvent(slug, { type: 'reply', title: i18nT(L, 'push.n_mention_t'), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
1697 }
1698 } catch { /* ignore */ }
1699 }
1700 }
1701 }
1702 return 202;
1703 }
1704 // A remote post we cached was edited upstream → refresh our cached copy. This is the
1705 // push-based edit-sync that keeps the Cirkel/timeline fresh without polling (selfHeal
1706 // does it on a version bump; this does it live). Scope to the SIGNING actor so B can't
1707 // edit A's note (the signature gate guarantees claimedActor == the verified signer).
1708 if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
1709 const o = act.object;
1710 if (o.id && claimedActor) {
1711 const html = HtmlSanitizerService.sanitize(o.content || '');
1712 const media = mediaFromNote(o);
1713 try {
1714 // Refresh url too (COALESCE keeps the old one if the Update omits it): a remote slug
1715 // rename keeps the same AP id but changes the human url, so without this the cached
1716 // post would keep linking to the old, now-dead URL.
1717 const r = db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE id = ? AND author_uri = ?')
1718 .run(html, media, o.sensitive ? 1 : 0, o.summary || null, o.url || null, o.id, claimedActor);
1719 if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);
1720 // A poll's Update carries the fresh vote counts / closed state. Refresh per-row so each
1721 // site keeps its own `voted` state while the counts/closed update to the new totals.
1722 const poll = parsePoll(o);
1723 if (poll) {
1724 const rows = db.prepare('SELECT rowid AS rid, poll_json FROM ap_timeline WHERE id = ? AND author_uri = ?').all(o.id, claimedActor);
1725 const upd = db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE rowid = ?');
1726 for (const rw of rows) {
1727 let voted = null; try { voted = rw.poll_json ? (JSON.parse(rw.poll_json).voted || null) : null; } catch { /* ignore */ }
1728 upd.run(JSON.stringify({ ...poll, voted }), rw.rid);
1729 }
1730 }
1731 } catch { /* ignore */ }
1732 // If this note is a cached fediverse reply on one of our posts, refresh its text too.
1733 try { db.prepare('UPDATE ap_interactions SET content = ? WHERE object_uri = ? AND actor_uri = ?').run(html, o.id, claimedActor); } catch { /* ignore */ }
1734 }
1735 return 202;
1736 }
1737 if (type === 'Like' || type === 'Announce') {
1738 const tgt = act.object;
1739 const objUrl = typeof tgt === 'string' ? tgt : (tgt && tgt.id);
1740 const pid = postIdFromNoteUrl(objUrl, base);
1741 if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
1742 // A boost/like of a non-public post is dropped, not stored: nobody
1743 // outside the audience should even hold it (shaer-tqc hardening).
1744 const vp = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(pid);
1745 if (vp && (vp.fan_only || vp.ap_visibility === 'direct' || vp.ap_visibility === 'friends')) {
1746 console.log('[AP] dropped', type, 'on non-public post', pid);
1747 return;
1748 }
1749 const ai = actorInfo(await resolveActor(actorUri), actorUri);
1750 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act), null, emojiJsonOf(ai.emojis));
1751 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
1752 {
1753 const ctx = pushPostCtx(pid);
1754 if (ctx) {
1755 const L = pushLang(ctx.site);
1756 const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
1757 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 });
1758 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 });
1759 }
1760 }
1761 } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
1762 // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.
1763 // We only STORE it for display; we NEVER auto-Announce it onward (anti-feedback-loop:
1764 // re-announcing an incoming Announce would cascade boosts across the network).
1765 let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist */ }
1766 if (subs.length) {
1767 const bn = await fetchNoteAP(objUrl);
1768 if (bn && bn !== 404 && (bn.type === 'Note' || bn.type === 'Article') && bn.id) {
1769 const origUri = actorUriOf(bn.attributedTo);
1770 // Block completeness: even if you follow the booster, drop a boost whose ORIGINAL
1771 // author is blocked — otherwise a block is bypassed via someone else's boost.
1772 if (origUri && isBlockedAny(origUri)) { console.log('[AP] timeline boost dropped (blocked origin)', origUri, 'via', actorUri); return 202; }
1773 const oai = actorInfo(await resolveActor(origUri), origUri);
1774 const html = HtmlSanitizerService.sanitize(bn.content || '');
1775 const media = mediaFromNote(bn);
1776 const booster = actorInfo(await resolveActor(actorUri), actorUri);
1777 for (const s of subs) {
1778 // published = now → the boost shows as fresh activity at the top (Mastodon shows
1779 // reblogs at reblog-time, not the original's date). INSERT OR IGNORE: if we already
1780 // have the note (e.g. we also follow the author), keep it and DON'T relabel it.
1781 let inserted = false;
1782 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 */ }
1783 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 */ } }
1784 storeAuthorEmoji(bn.id, s.slug, oai); // custom-emoji display name for the byline
1785 // A boost carries the same renderable tags as a Create: capture the
1786 // note's content emojis (FEP-9098) and object links / quote (FEP-e232/
1787 // 044f) so boosted posts render like any other, not as raw shortcodes.
1788 { 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 */ } } }
1789 { 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 */ } } }
1790 }
1791 // FEP-044f: resolve the embedded quote card for a boosted post too
1792 // (out of band, best-effort, so it does not block the inbox response).
1793 if (quoteHrefOf(bn)) {
1794 const slugs = subs.map((s) => s.slug);
1795 resolveQuote(bn).then((qj) => {
1796 if (!qj) return;
1797 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 */ } }
1798 }).catch(() => { /* best-effort */ });
1799 }
1800 console.log('[AP] timeline boost +', actorUri, 'x' + subs.length);
1801 }
1802 }
1803 }
1804 return 202;
1805 }
1806 if (type === 'Delete') {
1807 // A remote note was deleted upstream → drop it from replies AND the timeline.
1808 // Scope to the SIGNING actor so actor B can't delete actor A's content (the
1809 // signature gate guarantees claimedActor == the verified signer here).
1810 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
1811 if (oid && claimedActor) {
1812 try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
1813 try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
1814 // Also clear a boost/like YOU made of this now-deleted remote post (the interact-page
1815 // ap_my_reactions state), so it can't stay stuck as "boosted" on a post that's gone.
1816 // Guard: only when the deleter owns the note's domain (B mustn't clear your reactions
1817 // to A's posts).
1818 try {
1819 let sameHost = false;
1820 try { sameHost = new URL(oid).host === new URL(claimedActor).host; } catch { sameHost = false; }
1821 if (sameHost) db.prepare('DELETE FROM ap_my_reactions WHERE target_uri = ?').run(oid);
1822 } catch { /* ignore */ }
1823 }
1824 return 202;
1825 }
1826 // Accept/Reject of a Follow WE sent (client side).
1827 if (type === 'Accept' && act.object) {
1828 const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
1829 if (fid) { try { fwStmts().acc.run(fid); } catch { /* ignore */ } }
1830 console.log('[AP] follow accepted', actorUri);
1831 return 202;
1832 }
1833 if (type === 'Reject' && act.object) {
1834 const who = actorUri;
1835 if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
1836 return 202;
1837 }
1838
1839 console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', 'from', ip, '(ignored)');
1840 return 202;
1841}
1842
1843// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
1844// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
1845export async function deliverCreate(site, post) {
1846 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1847 if (!base || !site || !site.slug) return;
1848 // Resolve inline @user@host mentions → link them in the note + collect their inboxes, so a
1849 // mentioned person is notified even if they don't follow us (Mastodon-standard mention).
1850 const mres = await resolveMentionsInText(base, post.content || '');
1851 let post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
1852 // FEP-044f: does this post quote a fediverse object? Resolve it once, here,
1853 // and remember it on the post, so buildNote (sync, also used by the outbox)
1854 // never has to fetch. The quoted author's inbox joins the delivery set: that
1855 // IS the notification.
1856 const quoteInboxes = [];
1857 if (post2.quote_uri === undefined || post2.quote_uri === null) {
1858 const q = await resolveOwnQuote(post2.content || '');
1859 if (q) {
1860 try { db.prepare('UPDATE posts SET quote_uri = ?, quote_actor = ? WHERE id = ?').run(q.uri, q.actor || null, post.id); } catch { /* ignore */ }
1861 post2 = { ...post2, quote_uri: q.uri, quote_actor: q.actor || null };
1862 }
1863 }
1864 if (post2.quote_actor) {
1865 const a = await fetchActor(post2.quote_actor).catch(() => null);
1866 const inbox = a && ((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
1867 if (inbox) quoteInboxes.push(inbox);
1868 }
1869 const followers = fStmts().list.all(site.slug);
1870 const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes, ...quoteInboxes].filter(Boolean))];
1871 if (!inboxes.length) return; // no followers, no one mentioned, no one quoted
1872 const keys = getOrCreateKeys(site.slug);
1873 const keyId = `${actorId(base, site.slug)}#main-key`;
1874 const create = buildCreate(base, site, post2);
1875 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, create, keyId, keys.private_pem);
1876}
1877
1878// On a new Follow, send that follower our most recent posts as Create so their
1879// timeline shows our history (Mastodon does not backfill on follow). Oldest-first
1880// so they sort into the follower's timeline at their original dates.
1881async function backfillNewFollower(base, slug, inbox) {
1882 if (!base || !slug || !inbox) return;
1883 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
1884 if (!site) return;
1885 const recent = db.prepare(
1886 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, published_at, created_at
1887 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
1888 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
1889 ).all(site.id).reverse();
1890 if (!recent.length) return;
1891 const keys = getOrCreateKeys(slug);
1892 const keyId = `${actorId(base, slug)}#main-key`;
1893 for (const p of recent) {
1894 try { await deliver(inbox, buildCreate(base, site, p), keyId, keys.private_pem); } catch { /* best-effort */ }
1895 await new Promise((r) => setTimeout(r, 150));
1896 }
1897 console.log('[AP] backfilled', recent.length, 'posts to new follower of', slug);
1898}
1899
1900// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
1901export async function deliverDelete(site, post) {
1902 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1903 if (!base || !site || !site.slug || !post || !post.id) return;
1904 const followers = fStmts().list.all(site.slug);
1905 if (!followers.length) return;
1906 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
1907 const keys = getOrCreateKeys(site.slug);
1908 const me = actorId(base, site.slug);
1909 const nid = noteId(base, post.id);
1910 const del = {
1911 '@context': AP_CONTEXT,
1912 id: `${nid}#delete-${Date.now()}-${rid()}`,
1913 type: 'Delete',
1914 actor: me,
1915 to: [PUBLIC],
1916 object: { id: nid, type: 'Tombstone' },
1917 };
1918 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem);
1919}
1920
1921// Tell followers an already-published post changed (Update + edited Note) so
1922// Mastodon refreshes the cached copy (e.g. after fixing content).
1923export async function deliverUpdate(site, post) {
1924 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1925 if (!base || !site || !site.slug || !post || !post.id) return;
1926 const mres = await resolveMentionsInText(base, post.content || ''); // link mentions + collect inboxes
1927 const post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
1928 const followers = fStmts().list.all(site.slug);
1929 const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes].filter(Boolean))];
1930 if (!inboxes.length) return;
1931 const keys = getOrCreateKeys(site.slug);
1932 const me = actorId(base, site.slug);
1933 const note = buildNote(base, site, post2);
1934 note.updated = new Date().toISOString();
1935 const update = {
1936 '@context': AP_CONTEXT,
1937 id: `${noteId(base, post.id)}#update-${Date.now()}-${rid()}`,
1938 type: 'Update', actor: me, to: [PUBLIC], cc: note.cc,
1939 object: note,
1940 };
1941 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
1942}
1943
1944// Tell followers the ACTOR changed (Update + Person) so Mastodon re-processes the
1945// account AND re-fetches the featured (pinned) collection — there is no standard
1946// "featured changed" activity, so this is how a pin/unpin propagates promptly.
1947export async function deliverActorUpdate(site) {
1948 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1949 if (!base || !site || !site.slug) return;
1950 const followers = fStmts().list.all(site.slug);
1951 if (!followers.length) return;
1952 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
1953 const keys = getOrCreateKeys(site.slug);
1954 const me = actorId(base, site.slug);
1955 const update = {
1956 '@context': AP_CONTEXT,
1957 id: `${me}#update-${Date.now()}-${rid()}`,
1958 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
1959 object: buildActor(base, site),
1960 };
1961 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
1962}
1963
1964// Reliably set the pinned order on followers' instances via Add/Remove activities
1965// (how Mastodon itself federates pins) — pushed to the inbox + processed immediately,
1966// unlike the featured COLLECTION which Mastodon caches with sticky StatusPins.
1967// Mastodon's Add skips an already-pinned status, so we REMOVE every pin first, wait,
1968// then ADD in rank-DESCENDING order (rank 1 added LAST → newest StatusPin → shown first,
1969// because Mastodon displays pins newest-first). `alsoRemove` = ids to unpin too.
1970// Serialize pin-resyncs per site: two concurrent /save calls would otherwise interleave
1971// their Remove -> wait -> Add sequences and scramble the StatusPin order on Mastodon. A
1972// resync already in flight for a site coalesces later requests into ONE rerun after it
1973// finishes (accumulating their extra unpins), so rapid saves don't pile up N full resyncs.
1974const _pinResync = new Map(); // slug -> { promise, pending, pendingRemove:Set, site }
1975export function resyncFeaturedPins(site, alsoRemove = []) {
1976 if (!site || !site.slug) return Promise.resolve();
1977 const slug = site.slug;
1978 const running = _pinResync.get(slug);
1979 if (running) {
1980 running.pending = true;
1981 running.site = site; // use the latest site object on the rerun
1982 for (const id of alsoRemove) running.pendingRemove.add(id);
1983 return running.promise;
1984 }
1985 const state = { promise: null, pending: false, pendingRemove: new Set(), site };
1986 state.promise = (async () => {
1987 let extra = alsoRemove;
1988 for (;;) {
1989 try { await doResyncFeaturedPins(state.site, extra); }
1990 catch (e) { console.warn('[AP] pin resync failed:', e.message); }
1991 if (!state.pending) break;
1992 state.pending = false;
1993 extra = [...state.pendingRemove];
1994 state.pendingRemove = new Set();
1995 }
1996 _pinResync.delete(slug);
1997 })();
1998 _pinResync.set(slug, state);
1999 return state.promise;
2000}
2001
2002// The actual resync work — do NOT call directly; go through resyncFeaturedPins() above so
2003// it stays serialized per site.
2004async function doResyncFeaturedPins(site, alsoRemove = []) {
2005 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2006 if (!base || !site || !site.slug) return;
2007 const followers = fStmts().list.all(site.slug);
2008 if (!followers.length) return;
2009 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
2010 const keys = getOrCreateKeys(site.slug);
2011 const me = actorId(base, site.slug);
2012 const keyId = `${me}#main-key`;
2013 const featured = `${me}/featured`;
2014 const note = (id) => noteId(base, id);
2015 const pinned = db.prepare(
2016 `SELECT id FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
2017 AND pinned IS NOT NULL AND pinned > 0
2018 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
2019 ).all(site.id);
2020 const removeIds = [...new Set([...pinned.map((p) => p.id), ...alsoRemove])];
2021 // 1. Remove every current pin so Mastodon can recreate them in order.
2022 for (const id of removeIds) {
2023 const rm = { '@context': AP_CONTEXT, id: `${me}#rm-${id}-${Date.now()}-${rid()}`, type: 'Remove', actor: me, object: note(id), target: featured, to: [PUBLIC] };
2024 for (const inbox of inboxes) deliver(inbox, rm, keyId, keys.private_pem).catch(() => { /* best-effort */ });
2025 }
2026 if (!pinned.length) { console.log('[AP] unpinned all featured for', site.slug); return; }
2027 await new Promise((r) => setTimeout(r, 5000)); // let the Removes land first
2028 // 2. Add in rank-DESC order, gaps so each StatusPin gets an increasing created_at.
2029 for (const p of pinned) {
2030 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`] };
2031 for (const inbox of inboxes) deliver(inbox, add, keyId, keys.private_pem).catch(() => { /* best-effort */ });
2032 await new Promise((r) => setTimeout(r, 2000));
2033 }
2034 console.log('[AP] resynced', pinned.length, 'featured pins for', site.slug);
2035}
2036
2037// ── outbound replies (Klonkt → fediverse) ─────────────────────────
2038const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
2039const 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(); };
2040
2041// Build one of OUR outbound reply Notes from an ap_outbox row.
2042// Turn #hashtags in reply text into Mastodon-style hashtag links (clickable + federated).
2043function linkHashtags(base, html) {
2044 // Prefix: start / whitespace / '>' / opening bracket — "(#tag" is a tag too. NO quote
2045 // chars in this class: a quote precedes attribute values (alt="#…"), which must not match.
2046 return String(html || '').replace(/(^|[\s>([{])#([\p{L}\p{M}\p{N}_]+)/gu, (m, pre, tag) =>
2047 `${pre}<a href="${base}/tag/${encodeURIComponent(tag.toLowerCase())}" class="mention hashtag" rel="tag">#${tag}</a>`);
2048}
2049// Auto-link bare http(s) URLs in already-safe HTML (federated copies). Splits on existing
2050// <a>…</a> so a linked URL is never wrapped twice; requires start/whitespace/'>' before the
2051// URL so attribute values (src="https://…") never match. Trailing sentence punctuation stays
2052// outside the link (Mastodon-style).
2053function linkUrls(html) {
2054 const parts = String(html || '').split(/(<a\b[^>]*>[\s\S]*?<\/a>)/gi);
2055 for (let i = 0; i < parts.length; i++) {
2056 if (/^<a\b/i.test(parts[i])) continue; // already a link → leave as-is
2057 parts[i] = parts[i].replace(/(^|[\s>([{])(https?:\/\/[^\s<]+?)([.,;:!?)\]»]*)(?=$|[\s<])/g,
2058 (m, pre, url, trail) => `${pre}<a href="${url.replace(/"/g, '%22')}" rel="nofollow noopener" target="_blank">${url}</a>${trail}`);
2059 }
2060 return parts.join('');
2061}
2062// Linkify inline #hashtags and bare URLs in BODY html for on-site DISPLAY, using the
2063// EXACT same rules as the federated copy (linkHashtags/linkUrls), so the website and the
2064// Mastodon copy agree instead of the website showing raw text. Idempotent: existing
2065// <a>…</a> (editor links, embeds, shortcode buttons) are split out and left untouched, so
2066// nothing is double-wrapped. Pass base='' → root-relative /tag/<slug> links.
2067export function linkifyBody(base, html) {
2068 const withTags = String(html || '')
2069 .split(/(<a\b[^>]*>[\s\S]*?<\/a>)/gi)
2070 .map((seg) => (/^<a\b/i.test(seg) ? seg : linkHashtags(base, seg)))
2071 .join('');
2072 return linkUrls(withTags);
2073}
2074
2075// Bake a post's raw source into its display HTML (the ActivityPub `source` model): done ONCE
2076// at save and cached in posts.content_rendered, so page views serve it statically instead of
2077// re-linkifying every render. Step 1 = #hashtags + bare URLs (cheap, no network). Step 2 will
2078// resolve @mentions here too (webfinger once at save instead of per page view).
2079export function bakePostContent(source) {
2080 return linkifyBody('', source || '');
2081}
2082
2083// Step 2: the full bake, incl. @mention links. Resolves @user@host via webfinger ONCE (the
2084// same resolver the federated copy uses) and bakes the profile links into content_rendered,
2085// so page views never do a per-view lookup. Unresolvable handles stay plain text; on any
2086// failure it degrades to the sync #hashtag/URL bake. Async (webfinger) → callers run it off
2087// the save response so the request never blocks on a slow/dead remote server.
2088export async function bakePostContentWithMentions(source) {
2089 const withHashUrls = bakePostContent(source);
2090 try { const m = await resolveMentionsInText('', withHashUrls); return m.html; }
2091 catch { return withHashUrls; }
2092}
2093
2094// Extract the AP Hashtag tag objects from already-linked reply content.
2095function hashtagTags(base, content) {
2096 const tags = [], seen = new Set();
2097 const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([\p{L}\p{M}\p{N}_]+)</giu;
2098 let m;
2099 while ((m = re.exec(content || ''))) {
2100 const k = m[1].toLowerCase();
2101 if (seen.has(k)) continue; seen.add(k);
2102 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
2103 }
2104 return tags;
2105}
2106
2107// Normalise a post's tags field (array, JSON-string, or comma-string) to an array.
2108function normalizeTags(t) {
2109 if (Array.isArray(t)) return t;
2110 if (typeof t === 'string') {
2111 const s = t.trim(); if (!s) return [];
2112 if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* fall through */ } }
2113 return s.split(',').map((x) => x.trim()).filter(Boolean);
2114 }
2115 return [];
2116}
2117// A tag → { label, slug }. Multi-word tags become CamelCase (#LiveMusic) for the display
2118// name (Mastodon hashtags can't contain spaces; CamelCase is the accessibility norm); the
2119// slug/href stays lowercase ("livemusic").
2120function tagParts(raw) {
2121 const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^\p{L}\p{M}\p{N}]/gu, '')).filter(Boolean);
2122 if (!words.length) return null;
2123 const slug = words.join('').toLowerCase();
2124 if (!slug) return null;
2125 const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
2126 return { label, slug };
2127}
2128// Merge a post's tags field + the #hashtags linked inline in its body into one deduped
2129// Hashtag tag list (with hrefs to our /tag page).
2130function buildHashtagList(base, tagsField, content) {
2131 const out = [], seen = new Set();
2132 for (const t of normalizeTags(tagsField)) {
2133 const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
2134 out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
2135 }
2136 for (const h of hashtagTags(base, content)) {
2137 const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
2138 out.push(h);
2139 }
2140 return out;
2141}
2142
2143// Extract Mention tag objects from already-linked content (class="u-url mention").
2144function mentionTags(content) {
2145 const tags = [], seen = new Set();
2146 // The link href is the human profile URL; the actor URI (for the Mention tag) is in data-actor.
2147 const re = /<a href="[^"]*" class="u-url mention" data-actor="([^"]+)">@([^<]+)<\/a>/gi;
2148 let m;
2149 while ((m = re.exec(content || ''))) {
2150 const href = m[1];
2151 if (seen.has(href)) continue; seen.add(href);
2152 tags.push({ type: 'Mention', href, name: '@' + m[2] });
2153 }
2154 return tags;
2155}
2156// Resolve inline @user@domain mentions in reply/post text → link them (href = actor URI)
2157// and collect the mentioned actors' inboxes so they get notified. Best-effort per mention.
2158async function resolveMentionsInText(base, html) {
2159 const inboxes = [];
2160 const handles = new Set();
2161 // Prefix also allows opening brackets — "(@user@host + me)" is a mention too (real-world
2162 // miss: a bracketed mention federated as plain text and its target was never notified).
2163 const re = /(^|[\s>([{])@([\p{L}\p{M}\p{N}_.-]+@[\p{L}\p{M}\p{N}.-]+)/gu;
2164 let m;
2165 while ((m = re.exec(html || ''))) handles.add(m[2]);
2166 let out = String(html || '');
2167 for (const h of handles) {
2168 let actorUri = null;
2169 try { actorUri = await webfingerResolve('@' + h); } catch { actorUri = null; }
2170 if (!actorUri) continue;
2171 const actor = await fetchActor(actorUri).catch(() => null);
2172 const inbox = actor && ((actor.endpoints && actor.endpoints.sharedInbox) || actor.inbox);
2173 if (inbox) inboxes.push(inbox);
2174 const profileUrl = actorInfo(actor, actorUri).url || actorUri; // human profile page → the link href
2175 const esc = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2176 out = out.replace(new RegExp('(^|[\\s>([{])@' + esc + '(?![\\p{L}\\p{M}\\p{N}_.-])', 'gu'),
2177 (full, pre) => `${pre}<a href="${profileUrl}" class="u-url mention" data-actor="${actorUri}">@${h}</a>`);
2178 }
2179 return { html: out, inboxes };
2180}
2181
2182export function buildReplyNote(base, site, row) {
2183 // Thin delegate: replies are built by buildNote (the single Note entry point) in reply mode.
2184 return buildNote(base, site, row, { isReply: true });
2185}
2186
2187// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
2188export function getOutboxNote(base, id) {
2189 const row = iStmts().getO.get(id);
2190 if (!row) return null;
2191 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
2192 if (!site) return null;
2193 return buildReplyNote(base, site, row);
2194}
2195
2196// ── ActivityPub Client-to-Server: ingest an activity POSTed to the outbox ──
2197// The C2S counterpart of handleInbox: a native/web client (Shaer) posts an
2198// activity here and we translate it onto the SAME delivery machinery the web UI
2199// uses (deliverReply / sendInteraction / followActor / deliverCreate). Returns
2200// { status, id?, url?, error? }. Auth + site-ownership are checked by the route.
2201const c2sIdOf = (x) => (typeof x === 'string' ? x : (x && (x.id || x.href))) || null;
2202
2203export async function ingestOutboxActivity(site, user, activity) {
2204 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2205 if (!base || !site || !activity || typeof activity !== 'object') return { status: 400, error: 'invalid_activity' };
2206
2207 // AP §6: a client MAY POST a bare object; the server wraps it in a Create.
2208 let type = activity.type;
2209 let object = activity.object;
2210 if (type === 'Note' || type === 'Article') { object = activity; type = 'Create'; }
2211 if (Array.isArray(type)) type = type.find((t) => typeof t === 'string');
2212
2213 // FEP-633c: the adoption handshake (Offer/Accept/Reject on a guardianship
2214 // Relationship) belongs to the guardianship module; anything else falls
2215 // through to the switch below.
2216 if (type === 'Offer' || type === 'Accept' || type === 'Reject') {
2217 const g = await Guardianship.handleGuardianshipOutbox(site, activity).catch(() => null);
2218 if (g) return g;
2219 }
2220
2221 try {
2222 switch (type) {
2223 case 'Create': {
2224 if (!object || typeof object !== 'object') return { status: 400, error: 'missing_object' };
2225 // Client sends `source` (plain/markdown) + `content` (HTML). deliverReply
2226 // re-escapes, so it needs plain text; a top-level post keeps sanitized HTML.
2227 const plain = (object.source && object.source.content) || HtmlSanitizerService.toPlainText(object.content || '');
2228 if (!plain.trim() && !object.content) return { status: 400, error: 'empty_note' };
2229 // Direct (private mention, shaer-tqc): NOT a post. Delivered over the
2230 // outbox machinery to the addressed inboxes only; shows under Messages.
2231 if (c2sVisibility(object) === 'direct') {
2232 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
2233 const recipients = [...new Set([...arr(object.to), ...arr(object.cc)])]
2234 .filter((u) => /^https?:\/\//i.test(u) && !/\/followers\/?$/.test(u) && u !== PUBLIC);
2235 if (!recipients.length) return { status: 400, error: 'no_recipients' };
2236 // AS2 attachments (e.g. the help-buoy capture, uploaded via
2237 // uploadMedia): normalize our own absolute /media/ URLs to relative
2238 // so the deliverReply-style validation applies unchanged.
2239 const atts = (Array.isArray(object.attachment) ? object.attachment : [])
2240 .map((a) => a && typeof a === 'object' ? {
2241 url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
2242 mediaType: String(a.mediaType || ''),
2243 name: String(a.name || '').slice(0, 120),
2244 } : null)
2245 .filter(Boolean);
2246 const help = object['shaer:helpRequest'] === true || object.helpRequest === true;
2247 // FEP-633c 3.6.1: a guardian here declaring itself away to its
2248 // wards. An away without a (future) end fails loudly, exactly as
2249 // the daemon refuses it: stored quietly it would be a nominal
2250 // guardian holding a seat.
2251 let awayUntil = null;
2252 if (Guardianship.availability.isAway(object)) {
2253 awayUntil = Guardianship.availability.parseEndTime(object.endTime);
2254 if (!awayUntil || awayUntil <= Date.now()) return { status: 400, error: 'away_needs_an_end' };
2255 // A ward we host ourselves never receives its own delivery
2256 // (private ranges, loopback): apply locally, the way the
2257 // handshake commit does.
2258 const meUri = selfActorId(site.slug);
2259 for (const uri of recipients) {
2260 const wslug = uri.startsWith(`${base}/`) ? slugFromActorUrl(uri) : null;
2261 if (wslug && Guardianship.listGuardians(wslug).some((g) => g.other_uri === meUri)) {
2262 Guardianship.availability.declareAway(wslug, meUri, awayUntil);
2263 }
2264 }
2265 }
2266 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 });
2267 if (!r || !r.id) return { status: 502, error: 'direct_failed' };
2268 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
2269 }
2270 if (object.inReplyTo) {
2271 const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo)).catch(() => null);
2272 if (!parent) return { status: 502, error: 'cannot_resolve_inReplyTo' };
2273 // Honour the client's visibility for the reply: 'friends' (followers-
2274 // only, the Shaer detail-view Reply) drops Public; anything else stays
2275 // quiet-public. 'direct' was already handled above.
2276 const r = await deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text: plain, visibility: c2sVisibility(object) });
2277 if (!r || !r.id) return { status: 502, error: 'reply_failed' };
2278 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
2279 }
2280 return await c2sCreatePost(base, site, user, object);
2281 }
2282 case 'Like':
2283 case 'Announce': {
2284 const targetUri = c2sIdOf(object);
2285 if (!targetUri) return { status: 400, error: 'missing_object' };
2286 // A non-public local note cannot be boosted or liked into the open
2287 // (shaer-tqc hardening; the Mastodon 422 equivalent).
2288 const localPid = postIdFromNoteUrl(targetUri, base);
2289 if (localPid) {
2290 const p = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(localPid);
2291 if (p && (p.fan_only || p.ap_visibility === 'direct' || p.ap_visibility === 'friends')) {
2292 return { status: 403, error: 'not_public' };
2293 }
2294 }
2295 const note = await resolveRemoteNote(targetUri).catch(() => null);
2296 const objUri = (note && note.object_uri) || targetUri;
2297 const authorUri = note && note.actor_uri;
2298 const kind = type === 'Announce' ? 'boost' : 'like';
2299 await sendInteraction(site, kind, objUri, authorUri);
2300 setMyReaction(site.slug, targetUri, kind, true);
2301 if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } }
2302 return { status: 202, url: objUri };
2303 }
2304 case 'Follow': {
2305 const actorUri = c2sIdOf(object);
2306 if (!actorUri) return { status: 400, error: 'missing_object' };
2307 await followActor(site, actorUri);
2308 return { status: 202, url: actorUri };
2309 }
2310 // Shaer "in Orbit" = a real Block (FEP-c648 client side): lands in
2311 // ap_blocks, shows in the Block tab, and purges the actor's cached
2312 // content. Client-side filtering becomes a cache of this state.
2313 case 'Block': {
2314 const targetUri = c2sIdOf(object);
2315 if (!targetUri) return { status: 400, error: 'missing_object' };
2316 const r = await blockTarget(site, targetUri);
2317 if (r && r.error) return { status: 400, error: r.error };
2318 return { status: 202, url: targetUri };
2319 }
2320 case 'Undo': {
2321 const inner = object && typeof object === 'object' ? object : null;
2322 let innerType = inner && inner.type;
2323 if (Array.isArray(innerType)) innerType = innerType.find((t) => typeof t === 'string');
2324 const innerTarget = c2sIdOf(inner && inner.object);
2325 if (innerType === 'Follow') { await unfollowActor(site, innerTarget); return { status: 202, url: innerTarget }; }
2326 if (innerType === 'Block') {
2327 if (!innerTarget) return { status: 400, error: 'missing_object' };
2328 unblock(site, innerTarget); // release from Orbit
2329 return { status: 202, url: innerTarget };
2330 }
2331 if (innerType === 'Like' || innerType === 'Announce') {
2332 const kind = innerType === 'Announce' ? 'unboost' : 'unlike';
2333 const note = await resolveRemoteNote(innerTarget).catch(() => null);
2334 const objUri = (note && note.object_uri) || innerTarget;
2335 await sendInteraction(site, kind, objUri, note && note.actor_uri);
2336 setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);
2337 if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } }
2338 return { status: 202, url: objUri };
2339 }
2340 return { status: 400, error: 'unsupported_undo' };
2341 }
2342 // Delete/Update of arbitrary objects need the post-edit pipeline; tracked
2343 // separately (klonkt-demo-c2s-del). Reject clearly rather than half-doing it.
2344 default:
2345 return { status: 400, error: 'unsupported_type', detail: String(type || 'none') };
2346 }
2347 } catch (e) {
2348 console.warn('[AP] C2S ingest failed:', e && e.message);
2349 return { status: 500, error: 'ingest_error' };
2350 }
2351}
2352
2353// Create a top-level microblog post from a C2S Note and federate it. Minimal
2354// sibling of the /posts/create route: sanitized HTML content, no title/cover.
2355async function c2sCreatePost(base, site, user, object) {
2356 const html = HtmlSanitizerService.sanitize(object.content || (object.source && object.source.content) || '');
2357 if (!html.trim()) return { status: 400, error: 'empty_note' };
2358 const postId = crypto.randomUUID();
2359 const slug = 'n-' + postId.slice(0, 8);
2360 const now = new Date().toISOString();
2361 // Visibility from the note's addressing (shaer-60b): Public in `to` = loud
2362 // public, Public in `cc` = quiet public (unlisted), followers-only = friends
2363 // (rides the existing fan_only pipeline: followers-only AP delivery + web
2364 // gating), neither = participants-only (kept local until mention addressing
2365 // lands; still followers-gated on the web).
2366 const vis = c2sVisibility(object);
2367 const fanOnly = (vis === 'friends' || vis === 'direct') ? 1 : 0;
2368 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)
2369 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
2370 .run(postId, site.id, slug, user.id, '', html, '', 'published', 'post', object.language || 'nl', fanOnly, vis, now, now, now);
2371 try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html), postId); } catch { /* render fallback covers it */ }
2372 bakePostContentWithMentions(html).then((h) => { try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(h, postId); } catch { /* keep sync bake */ } }).catch(() => {});
2373 try { db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?,?,?,?)').run(HtmlSanitizerService.toPlainText(html), '', user.username || '', postId); } catch { /* FTS non-fatal */ }
2374 if (vis !== 'direct') {
2375 deliverCreate(site, { id: postId, slug, title: '', content: html, published_at: now, created_at: now, fan_only: fanOnly, ap_visibility: vis }).catch(() => { /* best-effort */ });
2376 }
2377 return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` };
2378}
2379
2380// The direct-note leg (ward call-for-help) lives in the guardianship module
2381// (src/services/guardianship/delivery.js); wired with our AP helpers at the
2382// bottom of this file. Re-exported so every existing caller keeps working.
2383export const c2sVisibility = Guardianship.c2sVisibility;
2384export const deliverDirectNote = Guardianship.deliverDirectNote;
2385
2386// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
2387// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
2388export async function deliverReply(site, { postId, postSlug, parent, text, html, language, attachments, mentions, visibility }) {
2389 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2390 // Rich replies: `html` is the reply editor's HTML (sanitized here); `text` is
2391 // the plain-text fallback (no-JS path, C2S `source`). Either may carry the reply.
2392 const richClean = html ? HtmlSanitizerService.sanitize(String(html)) : '';
2393 const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
2394 // Attachments: only OUR OWN uploads (/media/... paths, no remote URLs — the
2395 // upload route is the sole producer), image/audio/video only, max 4.
2396 const media = (Array.isArray(attachments) ? attachments : [])
2397 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
2398 && /^(image|audio|video)\//.test(String(a.mediaType || '')))
2399 .slice(0, 4)
2400 .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
2401 // A media-only reply (no text) is a valid reply.
2402 if (!base || !site || !site.slug || !parent || (!String(text || '').trim() && !rich && !media.length)) return null;
2403 const me = actorId(base, site.slug);
2404 // u02, the mentions bar: `mentions` undefined = legacy behavior (mention the
2405 // parent author). An ARRAY (possibly empty) = the kept conversation partners
2406 // exactly as the bar shows them; the mention prefix, the Mention tags (via
2407 // mentionTags over the content) and the delivery targets all follow it.
2408 const kept = Array.isArray(mentions)
2409 ? mentions
2410 .filter((m) => m && typeof m.uri === 'string' && /^https?:\/\//i.test(m.uri))
2411 .slice(0, 8)
2412 .map((m) => ({
2413 uri: m.uri,
2414 url: (typeof m.url === 'string' && /^https?:\/\//i.test(m.url)) ? m.url : m.uri,
2415 handle: String(m.handle || deriveHandle(m.uri)).slice(0, 120),
2416 }))
2417 : null;
2418 const mentionAnchor = (uri, url, h) => {
2419 const disp = h && h[0] === '@' ? h : '@' + (h || '');
2420 return `<a href="${escHtml(url || uri)}" class="u-url mention" data-actor="${escHtml(uri)}">${escHtml(disp)}</a> `;
2421 };
2422 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
2423 const mention = kept
2424 ? kept.map((k) => mentionAnchor(k.uri, k.url, k.handle)).join('')
2425 : (parent.actor_uri ? mentionAnchor(parent.actor_uri, parent.actor_url, handle) : '');
2426 // Who the stored reply is "to": the parent when kept, else the first kept chip.
2427 const parentKept = !kept || kept.some((k) => k.uri === parent.actor_uri);
2428 const toActorUri = parentKept ? (parent.actor_uri || null) : (kept[0] ? kept[0].uri : null);
2429 const toHandle = parentKept ? handle : (kept[0] ? kept[0].handle : null);
2430 let content;
2431 let mres;
2432 if (rich) {
2433 // Same enrichment pipeline as the plain path (mentions/hashtags/URLs), on
2434 // sanitized editor HTML. The parent mention goes inline into the first
2435 // paragraph (Mastodon convention), or becomes its own leading one.
2436 mres = await resolveMentionsInText(base, rich);
2437 const processed = linkUrls(linkHashtags(base, mres.html));
2438 if (processed.startsWith('<p>')) {
2439 content = processed.replace('<p>', `<p>${mention}`); // inline in the first paragraph
2440 } else if (/^<(blockquote|ul|ol|pre|h[1-6]|div|hr)\b/i.test(processed)) {
2441 content = `<p>${mention}</p>${processed}`; // block content: own leading paragraph
2442 } else {
2443 content = `<p>${mention}${processed}</p>`; // bare inline text: one paragraph together
2444 }
2445 } else {
2446 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
2447 mres = await resolveMentionsInText(base, body); // link inline @mentions + collect their inboxes
2448 content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
2449 }
2450 const replyLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
2451 // Dedup: skip if the exact same reply was already sent (double-submit guard).
2452 // Attachments count toward "the same": two media-only replies share content.
2453 const mediaJson = media.length ? JSON.stringify(media) : null;
2454 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')
2455 .get(site.slug, parent.object_uri || '', content, mediaJson);
2456 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
2457 const id = crypto.randomUUID();
2458 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, toActorUri, toHandle, content, replyLang, mediaJson);
2459 // Followers-only reply (shaer detail-view): mark the row so buildNote drops
2460 // Public from cc. Default (undefined/'public'/'quiet') stays quiet-public.
2461 if (visibility === 'friends') { try { db.prepare('UPDATE ap_outbox SET visibility = ? WHERE id = ?').run('friends', id); } catch { /* ignore */ } }
2462 const row = iStmts().getO.get(id);
2463 const note = buildReplyNote(base, site, row);
2464 const create = {
2465 '@context': AP_CONTEXT,
2466 id: note.id + '#create', type: 'Create', actor: me,
2467 published: note.published, to: note.to, cc: note.cc, object: note,
2468 };
2469 const keys = getOrCreateKeys(site.slug);
2470 const keyId = `${me}#main-key`;
2471 const inboxes = new Set();
2472 // Everyone the mentions bar kept gets pinged; legacy path = the parent only.
2473 const mentionTargets = kept ? kept.map((k) => k.uri) : (parent.actor_uri ? [parent.actor_uri] : []);
2474 for (const uri of mentionTargets) {
2475 const a = await fetchActor(uri).catch(() => null);
2476 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
2477 }
2478 if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
2479 (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
2480 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
2481 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the reply
2482 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
2483 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
2484 let delivered = 0;
2485 for (const inbox of [...inboxes].filter(Boolean)) {
2486 let ok = false;
2487 try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
2488 if (ok) delivered++;
2489 else enqueueDelivery(site.slug, inbox, create); // durable: retry a briefly-offline recipient (was silently dropped)
2490 }
2491 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
2492 return { id, content, delivered };
2493}
2494
2495// attributedTo may be a string, an object {id}, or an ARRAY — e.g. a PeerTube Video is
2496// attributed to [Person (account), Group (channel)]. Pick a usable actor URI (prefer Person).
2497function actorUriOf(att) {
2498 if (!att) return null;
2499 if (typeof att === 'string') return att;
2500 if (Array.isArray(att)) {
2501 const person = att.find((a) => a && typeof a === 'object' && a.type === 'Person' && a.id);
2502 if (person) return person.id;
2503 for (const a of att) { if (typeof a === 'string') return a; if (a && a.id) return a.id; }
2504 return null;
2505 }
2506 return att.id || null;
2507}
2508
2509// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
2510// Returns a parent-shaped object usable by deliverReply(), or null.
2511export async function resolveRemoteNote(url) {
2512 if (!/^https?:\/\//i.test(String(url || ''))) return null;
2513 const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
2514 if (!note || !note.id) return null;
2515 const att = note.attributedTo;
2516 const actorUri = actorUriOf(att);
2517 if (!actorUri) return null;
2518 const actor = await fetchActor(actorUri).catch(() => null);
2519 const ai = actorInfo(actor, actorUri);
2520 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
2521 // link our reply to that local post so it shows nested in the post thread.
2522 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
2523 // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
2524 // and collect every ancestor author's inbox, so each participant's server —
2525 // including the original post's author — receives + threads our reply.
2526 const threadInboxes = [];
2527 const seenInbox = new Set();
2528 let cursor = note.inReplyTo, guard = 0;
2529 while (cursor && guard++ < 6) {
2530 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
2531 if (!url) break;
2532 const pn = await fetchActor(url).catch(() => null);
2533 if (!pn) break;
2534 const pa = actorUriOf(pn.attributedTo);
2535 if (pa && pa !== actorUri) {
2536 const paDoc = await fetchActor(pa).catch(() => null);
2537 const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
2538 if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
2539 }
2540 cursor = pn.inReplyTo; // climb to the next ancestor
2541 }
2542 // For non-Note objects (PeerTube Video, Article, …) the meaningful label is `name` (the
2543 // title); prepend it so the reply page shows what you're replying to (sanitize cleans it).
2544 let rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
2545 if (note.name && note.type && note.type !== 'Note') rawHtml = `<p><strong>${note.name}</strong></p>` + rawHtml;
2546 const images = (Array.isArray(note.attachment) ? note.attachment : [])
2547 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
2548 .map((a) => safeUrl(a.url)).filter(Boolean);
2549 // A Klonkt hosted-audio post strips its cover from `attachment` (so Mastodon
2550 // shows the player card, not a loose image) and puts it in `image` instead.
2551 // Same fallback as mediaFromNote() so a boosted music post keeps its cover.
2552 if (!images.length && note.image) {
2553 const im = Array.isArray(note.image) ? note.image[0] : note.image;
2554 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
2555 if (iu) images.push(iu);
2556 }
2557 return {
2558 object_uri: safeUrl(note.id) || note.id,
2559 actor_uri: actorUri,
2560 actor_url: ai.url,
2561 actor_handle: ai.handle,
2562 actor_name: ai.name,
2563 actor_icon: ai.icon,
2564 url: note.url || url,
2565 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
2566 sensitive: !!note.sensitive, // remote CW → blur in the Cirkel
2567 cw: note.summary || '',
2568 images,
2569 // Full typed media (incl. video/mp4) for the timeline cache. `images` above is
2570 // image-only for the interact page preview; a boosted video-only post (Loops)
2571 // lost its media entirely because upsertBoostedNote only saw `images`.
2572 media: mediaFromNote(note),
2573 threadInboxes, // every ancestor author's inbox
2574 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
2575 poll: parsePoll(note), // a Question → its options/counts (else null)
2576 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
2577 };
2578}
2579
2580// List a site's own outbound fediverse replies (for the manage/delete view).
2581// The plain editable text of a stored reply (unwrap links → their text, <br> → newline)
2582// so the manage view can prefill an edit box; the mention is re-added on save.
2583function outboxEditableText(content) {
2584 return String(content || '')
2585 .replace(/<br\s*\/?>/gi, '\n')
2586 .replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
2587 .replace(/<[^>]+>/g, '')
2588 .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
2589 .trim();
2590}
2591export function listOutbox(siteSlug) {
2592 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')
2593 .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
2594}
2595
2596// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
2597export async function deliverOutboxDelete(site, outboxId) {
2598 const row = iStmts().getO.get(outboxId);
2599 if (!row || row.site_slug !== site.slug) return false;
2600 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2601 if (base) {
2602 const me = actorId(base, site.slug);
2603 const nid = noteId(base, row.id);
2604 const del = { '@context': AP_CONTEXT, id: `${nid}#delete-${Date.now()}-${rid()}`, type: 'Delete', actor: me, to: [PUBLIC], object: { id: nid, type: 'Tombstone' } };
2605 const keys = getOrCreateKeys(site.slug);
2606 const inboxes = new Set();
2607 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
2608 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
2609 for (const inbox of [...inboxes].filter(Boolean)) {
2610 try { const st = await deliver(inbox, del, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) continue; } catch { /* queue below */ }
2611 enqueueDelivery(site.slug, inbox, del); // durable: a failed comment-delete now retries (was silently dropped)
2612 }
2613 }
2614 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
2615 return true;
2616}
2617
2618// Edit one of our outbound replies: rewrite the stored content (mention re-added + #tags
2619// re-linked) and send an Update(Note) so recipients refresh their cached copy.
2620export async function deliverOutboxUpdate(site, outboxId, newText, opts = {}) {
2621 const row = iStmts().getO.get(outboxId);
2622 if (!row || row.site_slug !== site.slug) return false;
2623 const text = String(newText || '').trim();
2624 // Rich edit: same sanitize + enrichment pipeline as deliverReply.
2625 const richClean = opts.html ? HtmlSanitizerService.sanitize(String(opts.html)) : '';
2626 const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
2627 if (!text && !rich) return false;
2628 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
2629 if (!base) return false;
2630 const me = actorId(base, site.slug);
2631 const toActor = row.to_actor ? await fetchActor(row.to_actor).catch(() => null) : null;
2632 const toProfile = row.to_actor ? (actorInfo(toActor, row.to_actor).url || row.to_actor) : '';
2633 const _h = row.to_handle || deriveHandle(row.to_actor);
2634 const toHandle = _h && _h[0] === '@' ? _h : '@' + (_h || '');
2635 // An edit must not drop co-mentions (u02): reuse the OLD content's leading
2636 // mention anchors (the bar's kept list at send time) when present; only fall
2637 // back to rebuilding the single to_actor mention for legacy rows.
2638 const oldPrefix = (String(row.content || '')
2639 .match(/^\s*(?:<p[^>]*>)?\s*((?:<a\b[^>]*class="u-url mention"[^>]*>\s*@[^<]+<\/a>[\s ]*)+)/i) || [])[1] || '';
2640 const mention = oldPrefix || (row.to_actor
2641 ? `<a href="${escHtml(toProfile)}" class="u-url mention" data-actor="${escHtml(row.to_actor)}">${escHtml(toHandle)}</a> ` : '');
2642 let content;
2643 let mres;
2644 if (rich) {
2645 mres = await resolveMentionsInText(base, rich);
2646 const processed = linkUrls(linkHashtags(base, mres.html));
2647 if (processed.startsWith('<p>')) content = processed.replace('<p>', `<p>${mention}`);
2648 else if (/^<(blockquote|ul|ol|pre|h[1-6]|div|hr)\b/i.test(processed)) content = `<p>${mention}</p>${processed}`;
2649 else content = `<p>${mention}${processed}</p>`;
2650 } else {
2651 mres = await resolveMentionsInText(base, escHtml(text).replace(/\r?\n/g, '<br>'));
2652 content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
2653 }
2654 // Language may be updated with the edit; attachments always survive untouched.
2655 const newLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(opts.language || '')) ? opts.language : null;
2656 db.prepare('UPDATE ap_outbox SET content = ?, language = COALESCE(?, language) WHERE id = ?').run(content, newLang, outboxId);
2657 const note = buildReplyNote(base, site, iStmts().getO.get(outboxId));
2658 note.updated = new Date().toISOString();
2659 const update = {
2660 '@context': AP_CONTEXT,
2661 id: `${note.id}#update-${Date.now()}-${rid()}`, type: 'Update', actor: me,
2662 published: note.published, updated: note.updated, to: note.to, cc: note.cc, object: note,
2663 };
2664 const keys = getOrCreateKeys(site.slug);
2665 const inboxes = new Set();
2666 if (toActor) inboxes.add((toActor.endpoints && toActor.endpoints.sharedInbox) || toActor.inbox);
2667 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
2668 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the edit
2669 inboxes.delete(`${me}/inbox`); inboxes.delete(`${base}/ap/inbox`);
2670 let delivered = 0;
2671 for (const inbox of [...inboxes].filter(Boolean)) {
2672 let ok = false;
2673 try { const st = await deliver(inbox, update, `${me}#main-key`, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
2674 if (ok) delivered++;
2675 else enqueueDelivery(site.slug, inbox, update); // durable: retry the edit later (was silently dropped)
2676 }
2677 console.log('[AP] outreply edit', site.slug, 'delivered', delivered);
2678 return { ok: true, content, delivered };
2679}
2680
2681// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
2682// Resolve an @user@domain handle to its actor URL via WebFinger.
2683export async function webfingerResolve(handle) {
2684 const h = String(handle || '').trim().replace(/^@/, '');
2685 const parts = h.split('@');
2686 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
2687 const acct = `${parts[0]}@${parts[1]}`;
2688 try {
2689 const r = await safeFetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
2690 { headers: { Accept: 'application/jrd+json, application/json' } });
2691 if (!r.ok) return null;
2692 const jrd = await r.json();
2693 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
2694 return safeUrl(link ? link.href : '') || null;
2695 } catch { return null; }
2696}
2697
2698let _insFw, _delFw, _listFw, _accFw, _oneFw, _setAB;
2699function fwStmts() {
2700 if (!_insFw) {
2701 _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)');
2702 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
2703 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
2704 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
2705 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
2706 _setAB = db.prepare('UPDATE ap_following SET auto_boost = ? WHERE slug = ? AND actor_uri = ?');
2707 }
2708 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw, setAB: _setAB };
2709}
2710export function listFollowing(slug) { return fwStmts().list.all(slug); }
2711
2712// Toggle auto-boost ("feature") on an account we already follow.
2713export function setAutoBoost(slug, actorUri, on) {
2714 try { fwStmts().setAB.run(on ? 1 : 0, slug, actorUri); } catch { /* ignore */ }
2715 // Featuring an account → AP-native catch-up so the Cirkel isn't empty until they next
2716 // post (push doesn't backfill history-before-follow). Fire-and-forget pull, sends nothing.
2717 if (on) backfillFromOutbox(slug, actorUri).catch(() => {});
2718 return { ok: true };
2719}
2720
2721let _insTl, _listTl, _delTl;
2722function tlStmts() {
2723 if (!_insTl) {
2724 _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)');
2725 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ? OFFSET ?');
2726 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
2727 }
2728 return { ins: _insTl, list: _listTl, del: _delTl };
2729}
2730export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); }
2731
2732// Inbox C2S read: a timeline row's media_json ([{url, type}], written on the
2733// inbound Create) → AS2 `attachment` array, so a client (Shaer) can render a
2734// friend's images/audio/video natively, exactly like own outbox posts. The
2735// stored `type` is the mediaType and may be ''. Malformed JSON yields
2736// undefined and never blocks the item.
2737export function timelineAttachments(mediaJson) {
2738 try {
2739 const list = mediaJson ? JSON.parse(mediaJson) : [];
2740 const rows = (Array.isArray(list) ? list : [])
2741 .filter((m) => m && m.url)
2742 .map((m) => ({ type: 'Document', mediaType: m.type || undefined, url: m.url }));
2743 return rows.length ? rows : undefined;
2744 } catch { return undefined; }
2745}
2746
2747// FEP-9098 custom emojis. Inbound: keep the note's Emoji tags (as JSON) so we
2748// can serve them back. `extractEmojiTags` returns the JSON to store (or null);
2749// `timelineEmojis` turns the stored JSON back into an AS2 `tag` array for the
2750// C2S inbox read, so a client (Shaer) can render :shortcode: as an image.
2751export function extractEmojiTags(tag) {
2752 const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
2753 const emojis = arr.filter((t) => t && (Array.isArray(t.type) ? t.type[0] : t.type) === 'Emoji'
2754 && typeof t.name === 'string' && t.icon);
2755 return emojis.length ? JSON.stringify(emojis) : null;
2756}
2757export function timelineEmojis(emojiJson) {
2758 try { const arr = emojiJson ? JSON.parse(emojiJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
2759 catch { return undefined; }
2760}
2761
2762// FEP-e232 object links (quotes / inline references). Inbound: keep the note's
2763// Link tags whose mediaType marks an AP object (the AS2-profiled ld+json, or
2764// activity+json as its equivalent) as JSON, so the C2S inbox read can serve
2765// them back and a client (Shaer) can render the quote/reference. Mirrors
2766// extractEmojiTags. Plain hyperlinks (text/html) and Mentions are dropped.
2767export function extractObjectLinkTags(tag) {
2768 const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
2769 const links = arr.filter((t) => {
2770 if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link') return false;
2771 if (typeof t.href !== 'string' || !t.href) return false;
2772 const mt = String(t.mediaType || '').toLowerCase();
2773 return (mt.startsWith('application/ld+json') && mt.includes('activitystreams'))
2774 || mt.startsWith('application/activity+json');
2775 });
2776 return links.length ? JSON.stringify(links) : null;
2777}
2778export function timelineObjectLinks(linkJson) {
2779 try { const arr = linkJson ? JSON.parse(linkJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
2780 catch { return undefined; }
2781}
2782
2783// FEP-044f quote posts: a quote is usually NOT an FEP-e232 tag but an
2784// object-level property. FEP-044f §"how to recognise" lists them all:
2785// `quote` (the FEP property, a string or an embedded Link/object), and the
2786// de-facto `quoteUrl` (as:), `quoteUri` (fedibird), `_misskey_quote` (misskey).
2787// This returns the quoted object's URL from whichever is present.
2788export function extractQuoteUrl(note) {
2789 if (!note || typeof note !== 'object') return null;
2790 const q = note.quote ?? note.quoteUrl ?? note.quoteUri ?? note['_misskey_quote'];
2791 if (!q) return null;
2792 if (typeof q === 'string') return q || null;
2793 if (typeof q === 'object') return (typeof q.id === 'string' && q.id) || (typeof q.href === 'string' && q.href) || null;
2794 return null;
2795}
2796
2797// The note's object-link tags for storage: real FEP-e232 Link tags PLUS any
2798// FEP-044f object-level quote, normalised to one FEP-e232-shaped Link (rel
2799// _misskey_quote) so the client's single object-link path renders them all.
2800// Deduped by href. Returns the JSON to store (or null if the note has neither).
2801export function extractLinkJson(note) {
2802 const links = [];
2803 const fromTag = extractObjectLinkTags(note && note.tag);
2804 if (fromTag) { try { links.push(...JSON.parse(fromTag)); } catch { /* ignore */ } }
2805 const qUrl = extractQuoteUrl(note);
2806 if (qUrl && !links.some((l) => l && l.href === qUrl)) {
2807 links.push({ type: 'Link', mediaType: 'application/activity+json', href: qUrl,
2808 rel: ['https://misskey-hub.net/ns#_misskey_quote'], name: qUrl });
2809 }
2810 return links.length ? JSON.stringify(links) : null;
2811}
2812
2813// The URL of the quoted post, from either an object-level quote (FEP-044f) or a
2814// quote-rel FEP-e232 Link tag. Used to resolve the embedded quote card.
2815export function quoteHrefOf(note) {
2816 const direct = extractQuoteUrl(note);
2817 if (direct) return direct;
2818 const arr = Array.isArray(note && note.tag) ? note.tag : (note && note.tag ? [note.tag] : []);
2819 for (const t of arr) {
2820 if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link' || typeof t.href !== 'string') continue;
2821 const rel = Array.isArray(t.rel) ? t.rel : (t.rel ? [t.rel] : []);
2822 if (rel.some((r) => /quote/i.test(String(r)))) return t.href;
2823 }
2824 return null;
2825}
2826
2827// Turn the stored quote snapshot back into the object the C2S inbox read serves
2828// as `shaer:quote`, so the client can render the embedded quote card.
2829export function timelineQuote(quoteJson) {
2830 try { const q = quoteJson ? JSON.parse(quoteJson) : null; return (q && typeof q === 'object') ? q : undefined; }
2831 catch { return undefined; }
2832}
2833
2834// Store the author's display-name emoji map (from actorInfo().emojis) on a
2835// timeline row, so the byline can render a ":shortcode:" name. No-op when the
2836// name has no custom emoji (the common case).
2837function storeAuthorEmoji(id, slug, ai) {
2838 if (!ai || !ai.emojis || !Object.keys(ai.emojis).length) return;
2839 try { db.prepare('UPDATE ap_timeline SET author_emoji_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(ai.emojis), id, slug); } catch { /* ignore */ }
2840}
2841
2842// A display-name emoji map (actorInfo().emojis) → JSON to store, or null.
2843function emojiJsonOf(map) { return (map && Object.keys(map).length) ? JSON.stringify(map) : null; }
2844
2845// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
2846let _abCount, _cirkelPosts, _cirkelMembers;
2847export function autoBoostCount(slug) {
2848 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; }
2849}
2850export function getCirkelPosts(slug, limit, offset) {
2851 try {
2852 // Cirkel = posts from featured (auto_boost) accounts + posts you boosted
2853 // (t.boosted), mixed by date. One row per note in ap_timeline → no duplicates.
2854 if (!_cirkelPosts) _cirkelPosts = db.prepare(`
2855 SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
2856 t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
2857 FROM ap_timeline t
2858 LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
2859 WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
2860 ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
2861 LIMIT ? OFFSET ?`);
2862 return _cirkelPosts.all(slug, limit || 60, offset || 0);
2863 } catch { return []; }
2864}
2865export function getCirkelMembers(slug) {
2866 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 []; }
2867}
2868// Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
2869let _markBoost, _unmarkBoost, _boostedCount;
2870export function markBoosted(slug, noteId) {
2871 try { if (!_markBoost) _markBoost = db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?'); _markBoost.run(slug, noteId); } catch { /* ignore */ }
2872}
2873export function unmarkBoosted(slug, noteId) {
2874 try { if (!_unmarkBoost) _unmarkBoost = db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?'); _unmarkBoost.run(slug, noteId); } catch { /* ignore */ }
2875}
2876let _markLike, _unmarkLike;
2877export function markLiked(slug, noteId) {
2878 try { if (!_markLike) _markLike = db.prepare('UPDATE ap_timeline SET liked = 1 WHERE slug = ? AND id = ?'); _markLike.run(slug, noteId); } catch { /* ignore */ }
2879}
2880export function unmarkLiked(slug, noteId) {
2881 try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
2882}
2883export function getTimelineReaction(slug, noteId) {
2884 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 }; }
2885}
2886// Boost a REMOTE post that may not be in your timeline (you don't follow the author):
2887// store it in ap_timeline (INSERT OR IGNORE → no dup for followed posts) so it shows in
2888// the Cirkel with a Boost badge, then flag it boosted.
2889export function upsertBoostedNote(slug, note) {
2890 if (!slug || !note || !note.object_uri) return;
2891 const id = note.object_uri;
2892 // Prefer the full typed media (incl. video/mp4 — a Loops boost is video-only and
2893 // rendered a bare text tile); fall back to the image-only list for older callers.
2894 const media = (note.media && note.media !== '[]')
2895 ? note.media
2896 : JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
2897 try {
2898 const r = tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
2899 note.actor_icon || '', note.actor_url || '', note.content || '', note.url || null,
2900 new Date().toISOString(), media, note.sensitive ? 1 : 0, note.cw || null);
2901 if (!r.changes) {
2902 // Row already cached (INSERT OR IGNORE) → refresh it with the freshly
2903 // resolved note. Without this a row cached without its cover (or with
2904 // stale content) stayed stale forever — even boosting again didn't heal it.
2905 // Keep the CACHED media when the resolve yielded none: an empty re-resolve
2906 // used to clobber a good media_json (the followed copy had the video, the
2907 // boost wiped it to []).
2908 db.prepare(`UPDATE ap_timeline SET content = ?, media_json = CASE WHEN ? = '[]' THEN media_json ELSE ? END,
2909 nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE slug = ? AND id = ?`)
2910 .run(note.content || '', media, media, note.sensitive ? 1 : 0, note.cw || null, note.url || null, slug, id);
2911 }
2912 } catch { /* ignore */ }
2913 markBoosted(slug, id);
2914}
2915export function boostedCount(slug) {
2916 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; }
2917}
2918
2919// Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to
2920// /ap/users/<slug> (content negotiation; Location may be relative). Used by
2921// followActor for bare-domain follows.
2922// NB: the old auto-migration of legacy Cirkels (circle_links -> AP follows) was
2923// REMOVED on 2026-06-26 — it auto-sent Follows on boot, which violates "the code
2924// never throws anything into the fediverse automatically" (would surprise-Follow
2925// for some operators at scale). The dead circle_links table stays as harmless dead
2926// data; an operator restores an old cirkel by re-following in /following (their click).
2927async function resolveApActor(siteUrl) {
2928 try {
2929 const r = await fetch(siteUrl, { headers: { Accept: 'application/activity+json' }, redirect: 'manual' });
2930 if (r.status >= 300 && r.status < 400) { const loc = r.headers.get('location'); if (loc) return new URL(loc, siteUrl).href; }
2931 if (r.ok) return siteUrl;
2932 } catch { /* unreachable */ }
2933 return null;
2934}
2935
2936// ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ──
2937// Runs ONCE per SELFHEAL_VERSION bump — NOT on every boot. Re-fetches each cached
2938// note and refreshes content + media (recovers covers/edits that were delivered
2939// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
2940// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
2941const SELFHEAL_VERSION = 21; // v21: drop direct notes (🛟 help requests, waves) that were cached as timeline posts
2942async function fetchNoteAP(url) {
2943 try {
2944 const r = await fetch(url, { headers: { Accept: 'application/activity+json' } });
2945 if (r.status === 404 || r.status === 410) return 404;
2946 if (r.ok) return await r.json();
2947 } catch { /* unreachable */ }
2948 return null;
2949}
2950function mediaFromNote(note) {
2951 const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
2952 if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) {
2953 const im = Array.isArray(note.image) ? note.image[0] : note.image;
2954 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
2955 if (iu) atts.push({ url: iu, type: (im && im.mediaType) || 'image/jpeg' });
2956 }
2957 return JSON.stringify(atts);
2958}
2959
2960// FEP-044f, emit side. The mirror of extractQuoteUrl (ingest): when one of our
2961// own posts quotes a fediverse object, say so in the shapes the network really
2962// reads. `quote` is the FEP property; quoteUrl / _misskey_quote are the de-facto
2963// ones Mastodon and Misskey look at, and the FEP-e232 `Link` in `tag` is the
2964// third form. All three point at the same object, which is what every reader
2965// expects. The quoted author goes in `cc`, because being quoted without being
2966// told is exactly the rudeness this FEP is trying to design away.
2967export function applyQuoteProps(note, quoteUri, quoteActor) {
2968 if (!note || typeof quoteUri !== 'string' || !/^https?:\/\//i.test(quoteUri)) return note;
2969 note.quote = quoteUri;
2970 note.quoteUrl = quoteUri;
2971 note['_misskey_quote'] = quoteUri;
2972 note.tag = [...(note.tag || []), {
2973 type: 'Link',
2974 mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
2975 href: quoteUri,
2976 rel: ['https://misskey-hub.net/ns#_misskey_quote'],
2977 name: quoteUri,
2978 }];
2979 if (typeof quoteActor === 'string' && /^https?:\/\//i.test(quoteActor)) {
2980 note.cc = [...new Set([...(note.cc || []), quoteActor])];
2981 }
2982 return note;
2983}
2984
2985// The first external (non-fediverse) link in a note, resolved to the same card
2986// shape as a quote: THUMBNAIL ONLY, never the provider's iframe. An arbitrary
2987// third-party frame inside a kid-safe app is a hole you cannot close again, so
2988// the embed carries an image and a title and nothing executable.
2989// Returns the JSON to store, or null when there is nothing worth showing.
2990export async function resolveExternalEmbed(html) {
2991 const first = firstExternalUrl(html);
2992 if (!first) return null;
2993 const io = EmbedResolver.liveIO({
2994 safeFetch,
2995 detectProvider: (u) => AudioEmbedService.detectProvider(u),
2996 fetchActor,
2997 actorInfo,
2998 });
2999 const card = await EmbedResolver.resolveEmbed(first, io).catch(() => null);
3000 // 'ap' is handled by the quote path; a bare 'link' is not worth a card.
3001 if (!card || card.kind === 'ap' || card.kind === 'link') return null;
3002 const thumb = (card.media || []).find((m) => m && m.url);
3003 if (!thumb && !card.title) return null;
3004 // Title, provider and author name come from a third party. Store them as
3005 // PLAIN TEXT (tags stripped, length-capped), so no renderer downstream has to
3006 // be the one that remembers to escape. A card is a card, not an essay.
3007 const plain = (v) => (v ? HtmlSanitizerService.toPlainText(String(v)).trim().slice(0, 200) : null);
3008 return JSON.stringify({
3009 url: card.url,
3010 kind: card.kind, // 'provider' | 'oembed'
3011 provider: plain(card.provider),
3012 title: plain(card.title),
3013 author: card.author ? { ...card.author, name: plain(card.author.name), handle: plain(card.author.handle) } : null,
3014 media: thumb ? [thumb] : [], // thumbnail only, no html/iframe
3015 });
3016}
3017
3018/**
3019 * Does our own post link to a fediverse object? Returns { uri, actor } when the
3020 * first external link resolves to a quotable AP object, else null. Runs once at
3021 * publish time; the answer is stored on the post.
3022 */
3023export async function resolveOwnQuote(html) {
3024 const first = firstExternalUrl(html);
3025 if (!first) return null;
3026 const io = EmbedResolver.liveIO({ safeFetch, detectProvider: () => null, fetchActor, actorInfo });
3027 const card = await EmbedResolver.resolveEmbed(first, io).catch(() => null);
3028 if (!card || card.kind !== 'ap' || !card.id) return null;
3029 return { uri: card.id, actor: card.attributedTo || null };
3030}
3031
3032/** The first http(s) link in sanitized note HTML that is not a mention/hashtag. */
3033export function firstExternalUrl(html) {
3034 if (!html || typeof html !== 'string') return null;
3035 for (const m of html.matchAll(/<a\b[^>]*href=["']([^"']+)["'][^>]*>/gi)) {
3036 const tag = m[0];
3037 if (/\b(mention|hashtag|u-url)\b/i.test(tag) && /mention|hashtag/i.test(tag)) continue;
3038 const href = m[1];
3039 if (/^https?:\/\//i.test(href)) return href;
3040 }
3041 return null;
3042}
3043
3044/** The stored external-embed card, for the C2S read. */
3045export function timelineEmbed(embedJson, { playback = false } = {}) {
3046 try {
3047 const e = embedJson ? JSON.parse(embedJson) : null;
3048 if (!e || typeof e !== 'object' || !e.url) return undefined;
3049 // The player URL is served ONLY when the playback gate is open (FEP-633c
3050 // 5.6). Deciding it here keeps the provider knowledge in one place: the
3051 // client never needs a list of hosts, it just plays what it is handed.
3052 // Privacy-enhanced variants only: nocookie for YouTube, the instance's own
3053 // player for PeerTube. Without one the card stays a thumbnail.
3054 const player = playback ? playerUrlFor(e.url) : null;
3055 return player ? { ...e, 'shaer:playerUrl': player } : e;
3056 } catch { return undefined; }
3057}
3058
3059/** The embeddable player for a URL, or null when we will not frame it. */
3060export function playerUrlFor(url) {
3061 if (typeof url !== 'string') return null;
3062 let p = null;
3063 try { p = AudioEmbedService.detectProvider(url); } catch { p = null; }
3064 if (p && p.provider === 'youtube' && p.id) return `https://www.youtube-nocookie.com/embed/${p.id}?rel=0&modestbranding=1&playsinline=1`;
3065 if (p && p.provider === 'vimeo' && p.id) return `https://player.vimeo.com/video/${p.id}`;
3066 // PeerTube is decentralised, so it is matched by its watch-URL shape rather
3067 // than a provider list. Host chars are validated before it is inlined.
3068 const pt = url.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
3069 if (pt) return `https://${pt[1]}/videos/embed/${pt[2]}`;
3070 return null;
3071}
3072
3073// FEP-044f embedded quote card: resolve the quoted post to a compact, sanitised
3074// snapshot { url, author{name,handle,icon}, content, published, media } so the
3075// client can render it as a nested card instead of a bare link. Best-effort and
3076// SSRF-safe (apGetJson): returns null on any failure, and the client falls back
3077// to the object-link chip. The content goes through the same sanitiser as every
3078// other note, so the kid-safe guarantees hold.
3079async function resolveQuote(note) {
3080 const url = quoteHrefOf(note);
3081 if (!url) return null;
3082 const q = await apGetJson(url);
3083 if (!q || typeof q !== 'object') return null;
3084 const authorUri = typeof q.attributedTo === 'string' ? q.attributedTo
3085 : (q.attributedTo && typeof q.attributedTo.id === 'string' ? q.attributedTo.id : null);
3086 const ai = authorUri ? actorInfo(await fetchActor(authorUri), authorUri) : null;
3087 // The quoted post's own FEP-9098 emojis, so :shortcode: renders in the card.
3088 const emojis = {};
3089 try {
3090 for (const e of JSON.parse(extractEmojiTags(q.tag) || '[]')) {
3091 const u = e.icon && (e.icon.url || (Array.isArray(e.icon) && e.icon[0] && e.icon[0].url));
3092 if (typeof e.name === 'string' && u) emojis[e.name] = u;
3093 }
3094 } catch { /* ignore */ }
3095 let media = []; try { media = JSON.parse(mediaFromNote(q)); } catch { /* ignore */ }
3096 const snapshot = {
3097 url: safeUrl(q.url || q.id || url) || url,
3098 author: ai ? { name: ai.name, handle: ai.handle, icon: ai.icon } : null,
3099 content: HtmlSanitizerService.sanitize(q.content || ''),
3100 published: q.published || null,
3101 media,
3102 emojis: Object.keys(emojis).length ? emojis : undefined,
3103 };
3104 return JSON.stringify(snapshot);
3105}
3106
3107/**
3108 * The card under a post: a fediverse quote (FEP-044f) when the note has one,
3109 * otherwise an external link preview. Both render as the SAME card, so only one
3110 * of the two is ever stored. Returns {column, json} or null.
3111 *
3112 * Both halves reach out over the network, which is why every caller runs this
3113 * out of band: an inbox answer must never wait on a third party.
3114 */
3115async function resolveCard(o) {
3116 if (quoteHrefOf(o)) {
3117 const qj = await resolveQuote(o);
3118 return qj ? { column: 'quote_json', json: qj } : null;
3119 }
3120 const ej = await resolveExternalEmbed(o && o.content);
3121 return ej ? { column: 'embed_json', json: ej } : null;
3122}
3123
3124// A generic SSRF-safe AP GET (collections / pages).
3125async function apGetJson(url) {
3126 try {
3127 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
3128 if (!r.ok) return null;
3129 const len = Number(r.headers.get('content-length') || 0);
3130 if (len > 3_000_000) return null;
3131 return await r.json();
3132 } catch { return null; }
3133}
3134// AP-native catch-up: pull an actor's standard `outbox` collection and merge their recent
3135// top-level posts into the timeline for `slug`. Push (Create delivery) cannot backfill
3136// history-from-before-you-followed or a delivery that was missed while you were down;
3137// reading the outbox is the spec-conform way to catch up. PULL ONLY — sends nothing.
3138export async function backfillFromOutbox(slug, actorUri, limit = 20) {
3139 try {
3140 if (!slug || !actorUri) return 0;
3141 const actor = await fetchActor(actorUri);
3142 if (!actor || !actor.outbox) return 0;
3143 let page = await apGetJson(typeof actor.outbox === 'string' ? actor.outbox : actor.outbox.id);
3144 let items = (page && (page.orderedItems || page.items)) || [];
3145 if (!items.length && page && page.first) {
3146 page = await apGetJson(typeof page.first === 'string' ? page.first : page.first.id);
3147 items = (page && (page.orderedItems || page.items)) || [];
3148 }
3149 if (!Array.isArray(items) || !items.length) return 0;
3150 const ai = actorInfo(actor, actorUri);
3151 let added = 0;
3152 for (const it of items.slice(0, limit)) {
3153 // Each item is usually a Create wrapping a Note, or sometimes the Note itself.
3154 const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
3155 if (!o || !o.id) continue;
3156 if (o.type && o.type !== 'Note' && o.type !== 'Article' && o.type !== 'Question') continue; // skip boosts/other
3157 if (o.inReplyTo) continue; // top-level only
3158 const auth = actorUriOf(o.attributedTo);
3159 if (auth && auth !== actorUri) continue; // their OWN posts only
3160 const html = HtmlSanitizerService.sanitize(o.content || '');
3161 const poll = parsePoll(o); // a Question (poll) → carry its options/counts on backfill too
3162 try {
3163 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);
3164 if (r && r.changes > 0) added++;
3165 // FEP-9098: keep custom-emoji tags from backfilled posts too.
3166 { 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 */ } } }
3167 storeAuthorEmoji(o.id, slug, ai); // custom-emoji display name for the byline
3168 // FEP-e232 + FEP-044f: keep object-link/quote tags from backfilled posts too.
3169 { 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 */ } } }
3170 // FEP-044f: resolve the embedded quote card for backfilled posts too.
3171 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 */ } } }
3172 // Set poll_json if this is a poll and we don't already have it (COALESCE preserves a vote).
3173 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 */ } }
3174 } catch { /* ignore */ }
3175 }
3176 if (added) console.log('[AP] outbox backfill', actorUri, '→', slug, '+' + added);
3177 return added;
3178 } catch { return 0; }
3179}
3180
3181// ── Remote thread crawl (fill the gaps in a local post's conversation) ────────────
3182// Most replies reach us by delivery, but replies-to-replies that live on other servers and
3183// aren't addressed to us are missed. This pulls the AS2 `replies` collections of the replies
3184// we DO have, caching any newly-found ones in ap_interactions.
3185//
3186// Matches Mastodon's behaviour: ONE level per crawl (like its FetchRepliesService), not a deep
3187// recursive walk. Deeper levels fill in incrementally across crawls — once a fetched reply is
3188// cached it becomes a seed itself, so its own replies are pulled on a later view (Mastodon's
3189// per-status cascade). Bounded + polite (serial), PULL only, and stale-while-revalidate: it
3190// never runs in a page request — the view renders from cache; a stale post kicks off a
3191// background refresh for the NEXT view.
3192const THREAD_TTL_MS = 15 * 60 * 1000; // don't re-crawl a post more than ~4×/hour
3193const THREAD_MAX_DEPTH = 1; // one hop per crawl (like Mastodon); deeper fills in over crawls
3194const THREAD_MAX_FETCHES = 30; // hard cap on remote GETs per crawl (be a good peer)
3195const _crawlingThreads = new Set(); // per-post in-flight lock (no stampede across views)
3196
3197function threadCrawlTs(postId) {
3198 try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('thread_crawl:' + postId); return r ? (Number(r.value) || 0) : 0; }
3199 catch { return 0; }
3200}
3201function setThreadCrawlTs(postId, ts) {
3202 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)); }
3203 catch { /* ignore */ }
3204}
3205
3206// Read a note's `replies` (string ref / Collection with `first` / paged CollectionPages) →
3207// child note URIs. Every remote GET goes through `budget` so the whole crawl stays capped.
3208async function collectReplyItems(repliesRef, maxPages, budget) {
3209 const uris = [];
3210 let node = typeof repliesRef === 'string' ? await budget.get(repliesRef) : repliesRef;
3211 if (node && node.first) node = typeof node.first === 'string' ? await budget.get(node.first) : node.first;
3212 let pages = 0;
3213 while (node && pages++ < maxPages) {
3214 for (const it of (node.items || node.orderedItems || [])) {
3215 const u = typeof it === 'string' ? it : (it && it.id);
3216 if (u && /^https?:\/\//i.test(u)) uris.push(u);
3217 }
3218 if (!node.next) break;
3219 node = typeof node.next === 'string' ? await budget.get(node.next) : node.next;
3220 }
3221 return uris;
3222}
3223
3224async function crawlThread(postId) {
3225 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3226 if (!base) return;
3227 // Seed frontier = the remote reply note URIs we already have; also the dedup set.
3228 let known;
3229 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)); }
3230 catch { return; }
3231 const seeds = [...known].filter((u) => /^https?:\/\//i.test(u));
3232 if (!seeds.length) return; // nothing remote to expand
3233 // Owner-removed replies (tombstones) join the dedup set AFTER seeding, so the
3234 // crawler never re-adds them via thread-filling (they're gone from the seeds
3235 // already because rejectInteraction deleted their ap_interactions row).
3236 try { for (const r of db.prepare('SELECT object_uri FROM ap_rejected_objects WHERE post_id = ?').all(postId)) known.add(r.object_uri); }
3237 catch { /* table always exists after boot migration */ }
3238
3239 let fetches = 0;
3240 const budget = { get: async (u) => { if (fetches >= THREAD_MAX_FETCHES) return null; fetches++; return apGetJson(u); } };
3241 const visited = new Set(); // notes whose replies collection we've already expanded
3242 let frontier = seeds.slice();
3243 let added = 0;
3244
3245 for (let depth = 0; depth < THREAD_MAX_DEPTH && frontier.length && fetches < THREAD_MAX_FETCHES; depth++) {
3246 const nextFrontier = [];
3247 for (const noteUri of frontier) {
3248 if (visited.has(noteUri) || fetches >= THREAD_MAX_FETCHES) continue;
3249 visited.add(noteUri);
3250 const note = await budget.get(noteUri);
3251 if (!note || !note.replies) continue;
3252 const childUris = await collectReplyItems(note.replies, 2, budget);
3253 for (const cu of childUris) {
3254 if (known.has(cu) || fetches >= THREAD_MAX_FETCHES) continue;
3255 known.add(cu);
3256 const child = await budget.get(cu);
3257 if (!child || !child.id || (child.type !== 'Note' && child.type !== 'Article')) continue;
3258 if (isRejectedObject(child.id)) continue; // note id can differ from the collection URI (redirects)
3259 const actorUri = actorUriOf(child.attributedTo);
3260 if (!actorUri || isBlockedAny(actorUri)) continue; // skip blocked authors
3261 const actor = await budget.get(actorUri); // may be null if budget spent → fallback handle
3262 const ai = actorInfo(actor, actorUri);
3263 const html = HtmlSanitizerService.sanitize(child.content || '');
3264 // The child replies to `note` by construction (it's in note's replies collection).
3265 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 */ }
3266 nextFrontier.push(child.id); // expand this reply's own replies next depth
3267 }
3268 }
3269 frontier = nextFrontier;
3270 }
3271 if (added) console.log('[AP] thread crawl', postId, '+' + added, 'remote replies (' + fetches + ' fetches)');
3272}
3273
3274// Stale-while-revalidate entry point: call from the post view. Renders nothing, blocks nothing —
3275// fires a background crawl only if this post hasn't been crawled within the TTL.
3276export function maybeCrawlThread(postId) {
3277 if (!postId || _crawlingThreads.has(postId)) return;
3278 if (Date.now() - threadCrawlTs(postId) < THREAD_TTL_MS) return;
3279 _crawlingThreads.add(postId);
3280 setThreadCrawlTs(postId, Date.now()); // optimistic mark so concurrent/next views don't re-fire
3281 crawlThread(postId).catch((e) => console.warn('[AP] thread crawl failed:', e && e.message)).finally(() => _crawlingThreads.delete(postId));
3282}
3283
3284let _selfHealing = false;
3285export async function selfHealTimeline() {
3286 if (_selfHealing) return; _selfHealing = true;
3287 try {
3288 let cur = 0;
3289 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; }
3290 if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
3291 // v21: direct notes used to land in the timeline as if they were posts, so a
3292 // ward's 🛟 help request showed up in the guardian's Krant. The insert now
3293 // refuses them; drop the ones already cached. Scoped to the two kinds we can
3294 // still recognise afterwards (help request, wave) — a plain public mention
3295 // from someone you follow IS a timeline post and must stay.
3296 try {
3297 const r = db.prepare(`DELETE FROM ap_timeline WHERE EXISTS (
3298 SELECT 1 FROM ap_mentions m
3299 WHERE m.object_uri = ap_timeline.id AND m.slug = ap_timeline.slug
3300 AND (m.help_request = 1 OR m.wave = 1))`).run();
3301 if (r.changes) console.log(`[AP] self-heal v21: ${r.changes} direct note(s) removed from the timeline`);
3302 } catch { /* table may predate the columns */ }
3303 let rows = [];
3304 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 */ }
3305 let healed = 0, failed = 0;
3306 for (const r of rows) {
3307 // Link previews first, and deliberately BEFORE the note re-fetch. A
3308 // preview is resolved from the content we already hold, so hanging it
3309 // behind a remote fetch meant one unreachable origin skipped the whole
3310 // row (`continue` below) and the card never appeared. It needs nothing
3311 // from the origin, so it must not depend on it.
3312 if (!r.quote_json && !r.embed_json) {
3313 try {
3314 const ej = await resolveExternalEmbed(r.content);
3315 if (ej) db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ?').run(ej, r.id);
3316 } catch { /* best-effort, never blocks the heal */ }
3317 }
3318 try {
3319 const note = await fetchNoteAP(r.id);
3320 if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
3321 if (!note || typeof note !== 'object') { failed++; continue; } // origin unreachable right now
3322 const html = HtmlSanitizerService.sanitize(note.content || '');
3323 const media = mediaFromNote(note);
3324 const nsfw = note.sensitive ? 1 : 0; // re-sync NSFW/sensitive + CW onto already-cached posts
3325 const cw = note.summary || null;
3326 const url = note.url || null; // re-sync the human url (catches a remote slug rename)
3327 const emoji = extractEmojiTags(note.tag); // FEP-9098: re-capture custom-emoji tags (v8)
3328 const link = extractLinkJson(note); // FEP-e232 + FEP-044f: re-capture object-link/quote tags (v9)
3329 // FEP-044f: resolve the embedded quote card (v11). COALESCE-style: keep a
3330 // cached snapshot if the quoted post is momentarily unreachable now.
3331 const quote = quoteHrefOf(note) ? (await resolveQuote(note)) || r.quote_json || null : null;
3332 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 || '')) {
3333 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);
3334 healed++;
3335 }
3336 // v13: a custom-emoji display name needs the author's emoji map. Fetch
3337 // the actor once, only for rows whose name has a shortcode and no map yet.
3338 if (/:[A-Za-z0-9_+-]+:/.test(r.author_name || '') && !r.author_emoji_json && r.author_uri) {
3339 const ai = actorInfo(await fetchActor(r.author_uri), r.author_uri);
3340 if (ai.emojis) { try { db.prepare('UPDATE ap_timeline SET author_emoji_json = ? WHERE id = ?').run(JSON.stringify(ai.emojis), r.id); } catch { /* ignore */ } }
3341 }
3342 // v14: same for the booster's display name ("X boosted"). The row stores
3343 // no booster URI, so resolve it from the handle via webfinger. Scoped to
3344 // this exact row (slug) since a note can be boosted by different people.
3345 if (/:[A-Za-z0-9_+-]+:/.test(r.reblog_name || '') && !r.reblog_emoji_json && r.reblog_handle) {
3346 const bUri = await webfingerResolve(r.reblog_handle);
3347 const em = bUri ? actorNameEmojis(await fetchActor(bUri)) : undefined;
3348 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 */ } }
3349 }
3350 } catch { failed++; /* per-note best-effort */ }
3351 }
3352 // Only mark this version DONE after a clean pass. Some origins are briefly
3353 // offline exactly when we heal (phone-hosted instances!): skipping them and
3354 // consuming the version would leave those rows stale forever. Instead retry
3355 // on the next boots, giving up after a few attempts (permanently-dead
3356 // origins answer 404/410 and are deleted above, so they don't loop).
3357 const setSetting = (k, v) => { try { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(k, String(v)); } catch { /* ignore */ } };
3358 let attempts = 0;
3359 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 */ }
3360 if (failed === 0 || attempts >= 4) {
3361 setSetting('selfheal_version', SELFHEAL_VERSION);
3362 setSetting('selfheal_attempts', 0);
3363 } else {
3364 setSetting('selfheal_attempts', attempts + 1);
3365 }
3366 if (rows.length) console.log(`[AP] self-heal v${SELFHEAL_VERSION}: ${healed}/${rows.length} timeline notes${failed ? ` (${failed} unreachable — will retry next boot)` : ''}`);
3367 } catch { /* never block boot */ } finally { _selfHealing = false; }
3368}
3369
3370// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
3371export async function followActor(site, handle, autoBoost = false) {
3372 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3373 if (!base || !site || !site.slug) return { error: 'config' };
3374 // Accept any of: a profile/actor URL, an @user@host handle (WebFinger), or a
3375 // bare site domain (site.com) — for a single-actor site (Klonkt etc.) the root
3376 // resolves to its AP actor, so you can follow a site by just its domain.
3377 const s = String(handle || '').trim();
3378 let actorUrl;
3379 if (/^https?:\/\//i.test(s)) actorUrl = safeUrl(s) || null;
3380 else if (s.includes('@')) actorUrl = await webfingerResolve(s);
3381 else if (/^[a-z0-9.-]+\.[a-z]{2,}/i.test(s)) actorUrl = await resolveApActor('https://' + s.replace(/^\/+|\/+$/g, ''));
3382 else actorUrl = null;
3383 if (!actorUrl) return { error: 'not_found' };
3384 const actor = await fetchActor(actorUrl).catch(() => null);
3385 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
3386 const ai = actorInfo(actor, actor.id);
3387 const me = actorId(base, site.slug);
3388 const keys = getOrCreateKeys(site.slug);
3389 const followId = `${me}#follow-${Date.now()}-${rid()}`;
3390 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending', autoBoost ? 1 : 0);
3391 const follow = { '@context': AP_CONTEXT, id: followId, type: 'Follow', actor: me, object: actor.id };
3392 // Deliver via the retry queue: a Follow that fails the first attempt (peer down,
3393 // timeout, transient 5xx) is retried with backoff instead of staying stuck on
3394 // 'pending' forever — the Accept can only come back once the Follow lands.
3395 await deliverWithRetry(site.slug, actor.inbox, follow, `${me}#main-key`, keys.private_pem);
3396 console.log('[AP] follow', site.slug, '→', actor.id);
3397 // Follow + feature in one step → backfill their recent posts into the Cirkel right away.
3398 if (autoBoost) backfillFromOutbox(site.slug, actor.id).catch(() => {});
3399 return { ok: true, name: ai.name, handle: ai.handle, actor: actor.id };
3400}
3401
3402// Resolve a profile URL or @handle to a followable remote actor (for the
3403// authorize_interaction "Follow" flow). Returns display fields + inbox, or null
3404// when it isn't a reachable actor (e.g. the input was a post, not a profile).
3405export async function resolveRemoteActor(input) {
3406 const s = String(input || '').trim();
3407 const actorUrl = /^https?:\/\//i.test(s) ? (safeUrl(s) || null) : await webfingerResolve(s);
3408 if (!actorUrl) return null;
3409 const actor = await fetchActor(actorUrl).catch(() => null);
3410 if (!actor || !actor.id || !actor.inbox) return null;
3411 const ai = actorInfo(actor, actor.id);
3412 return { actor_uri: actor.id, actor_name: ai.name, actor_handle: ai.handle, actor_url: ai.url, actor_icon: ai.icon, inbox: actor.inbox };
3413}
3414
3415export async function unfollowActor(site, actorUri) {
3416 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3417 const me = actorId(base, site.slug);
3418 const keys = getOrCreateKeys(site.slug);
3419 const row = fwStmts().one.get(site.slug, actorUri);
3420 // Undo(Follow) MUST reference the original Follow's real id so the remote can correlate it
3421 // and drop the follow. The old `${me}#follow` fallback never matched anything → the unfollow
3422 // silently failed on the remote. With no stored follow id (legacy row), skip the network Undo
3423 // rather than send an unmatchable one. Deliver durably via the retry queue.
3424 if (row && row.inbox && row.follow_id) {
3425 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 } };
3426 deliverWithRetry(site.slug, row.inbox, undo, `${me}#main-key`, keys.private_pem);
3427 } else if (row && row.inbox) {
3428 console.warn('[AP] unfollow', site.slug, '→', actorUri, '— no stored follow id; removed locally only (legacy follow, remote may keep it)');
3429 }
3430 fwStmts().del.run(site.slug, actorUri);
3431 return { ok: true };
3432}
3433
3434// FEP-633c §5.3 note (authorized fetch): true when `actorUri` is a committed
3435// guardian of the local ward `wardSlug` — so a signed GET from it may read the
3436// ward's non-public history without the guardian appearing as a follower.
3437export function isWardGuardian(wardSlug, actorUri) {
3438 try { return !!Guardianship.getRelation(wardSlug, 'ward', actorUri); } catch { return false; }
3439}
3440
3441// FEP-633c §5.3: the guardians approved a gated follow of their ward. Send the
3442// Accept to the follower and record them, so delivery (incl. followers-only)
3443// begins. `pending` is a row from ap_pending_follows.
3444export async function acceptGatedFollow(pending) {
3445 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3446 const slug = pending.ward_slug;
3447 const me = actorId(base, slug);
3448 const keys = getOrCreateKeys(slug);
3449 fStmts().ins.run(slug, pending.follower_uri, pending.follower_inbox, pending.follower_shared_inbox, pending.follower_name, pending.follower_handle, pending.follower_icon);
3450 const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
3451 const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: original };
3452 await deliverWithRetry(slug, pending.follower_inbox, accept, `${me}#main-key`, keys.private_pem);
3453 const filled = pending.follower_shared_inbox &&
3454 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);
3455 if (!filled) backfillNewFollower(base, slug, pending.follower_shared_inbox || pending.follower_inbox).catch(() => {});
3456 console.log('[AP] gated Follow accepted', pending.follower_uri, '→ ward', slug);
3457 return { ok: true };
3458}
3459
3460// The guardians denied the follow: send a Reject so the follower's server clears
3461// its pending state, then the caller drops the record.
3462export async function rejectGatedFollow(pending) {
3463 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3464 const slug = pending.ward_slug;
3465 const me = actorId(base, slug);
3466 const keys = getOrCreateKeys(slug);
3467 const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
3468 const reject = { '@context': AP_CONTEXT, id: `${me}#reject-${Date.now()}-${rid()}`, type: 'Reject', actor: me, object: original };
3469 if (pending.follower_inbox) await deliverWithRetry(slug, pending.follower_inbox, reject, `${me}#main-key`, keys.private_pem).catch(() => {});
3470 console.log('[AP] gated Follow rejected', pending.follower_uri, '→ ward', slug);
3471 return { ok: true };
3472}
3473
3474// ── Cross-instance follow-approval (FEP-633c §5.3, modelled on the guardian
3475// offer). Inbound: an Offer(Follow) forwarded by a ward to a guardian (leg
3476// 2), or a guardian's Accept/Reject coming back to the ward (leg 4). ──────
3477async function handleFollowApprovalInbox(act, slugParam) {
3478 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3479 const type = Array.isArray(act.type) ? act.type[0] : act.type;
3480 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
3481
3482 // Leg 2: I am a guardian; the object is the Follow to approve. The Offer is
3483 // signed by the ward, so act.actor is the ward.
3484 if (type === 'Offer') {
3485 const fo = (act.object && typeof act.object === 'object') ? act.object : null;
3486 const foType = fo && (Array.isArray(fo.type) ? fo.type[0] : fo.type);
3487 if (!fo || foType !== 'Follow') return false;
3488 const followId = fo.id;
3489 const follower = typeof fo.actor === 'string' ? fo.actor : (fo.actor && fo.actor.id);
3490 const wardUri = actorUri;
3491 if (!followId || !follower || !wardUri) return false;
3492 const recips = (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : [])).filter((x) => typeof x === 'string');
3493 if (slugParam) recips.push(actorId(base, slugParam));
3494 let stored = false;
3495 for (const r of new Set(recips)) {
3496 const gslug = slugFromActorUrl(r);
3497 if (!gslug) continue;
3498 if (!Guardianship.getRelation(gslug, 'guardian', wardUri)) continue; // must actually guard this ward
3499 const wardDoc = await fetchActor(wardUri).catch(() => null);
3500 const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
3501 Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) });
3502 const L = pushLang(gslug);
3503 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` });
3504 stored = true;
3505 }
3506 return stored;
3507 }
3508
3509 // Leg 4: I am the ward; a guardian decided. object is the Follow (id).
3510 const fo = act.object;
3511 const followId = typeof fo === 'string' ? fo : (fo && fo.id);
3512 if (!followId) return false;
3513 const pending = Guardianship.follows.getPending(followId);
3514 if (!pending) return false;
3515 const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
3516 if (!allGuardians.includes(actorUri)) return false; // only a real guardian of this ward decides
3517 const decision = type === 'Reject' ? 'reject' : 'approve';
3518 // §3.5: the quorum runs over the AVAILABLE set. The voter itself was
3519 // restored by the one-answer rule when its activity arrived, so answering
3520 // is exactly what counts a guardian back in.
3521 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
3522 const r = Guardianship.follows.decide(followId, actorUri, decision, guardians);
3523 try {
3524 if (r.outcome === 'approved') { await acceptGatedFollow(r.follow); Guardianship.follows.remove(followId); }
3525 else if (r.outcome === 'rejected') { await rejectGatedFollow(r.follow); Guardianship.follows.remove(followId); }
3526 } catch { /* delivery is retried */ }
3527 return true;
3528}
3529
3530// Leg 3: a guardian in /guardian decides on a forwarded follow; send the
3531// Accept/Reject back to the ward's inbox (signed by the guardian).
3532export async function sendFollowDecision(guardianSite, review, decision) {
3533 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3534 const me = actorId(base, guardianSite.slug);
3535 const keys = getOrCreateKeys(guardianSite.slug);
3536 const fo = review.follow_json ? JSON.parse(review.follow_json) : { id: review.id, type: 'Follow', actor: review.follower_uri, object: review.ward_uri };
3537 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 };
3538 if (review.ward_inbox) await deliverWithRetry(guardianSite.slug, review.ward_inbox, activity, `${me}#main-key`, keys.private_pem);
3539 return { ok: true };
3540}
3541
3542// Send a Like or Announce (boost) on a remote note FROM this site.
3543export async function sendInteraction(site, kind, targetNoteId, authorUri) {
3544 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3545 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
3546 const me = actorId(base, site.slug);
3547 const keys = getOrCreateKeys(site.slug);
3548 // 'unboost' = Undo(Announce): retracts a boost so followers' servers remove the
3549 // reblog (matched on actor+object — no record of the original Announce needed).
3550 const fanout = (kind === 'boost' || kind === 'unboost'); // also goes to our followers
3551 const followersCol = `${me}/followers`;
3552 // Address the original author in cc so their server (Mastodon, WordPress/ActivityPub, …)
3553 // attributes the boost to their post and notifies them — without this, a shared-inbox
3554 // receiver has nothing to route the Announce to. Non-fragment activity ids + a `published`
3555 // stamp keep us aligned with what Mastodon emits.
3556 const audience = authorUri ? [followersCol, authorUri] : [followersCol];
3557 let act;
3558 if (kind === 'unboost' || kind === 'unlike') {
3559 // Undo(Announce) retracts a boost; Undo(Like) un-favourites (matched on actor+object,
3560 // no record of the original activity needed — Mastodon honours both).
3561 const inner = kind === 'unboost' ? 'Announce' : 'Like';
3562 act = {
3563 '@context': AP_CONTEXT,
3564 id: `${me}/undo/${Date.now()}-${rid()}`, type: 'Undo', actor: me,
3565 object: { id: `${me}/${inner.toLowerCase()}/${Date.now()}-${rid()}`, type: inner, actor: me, object: targetNoteId },
3566 };
3567 if (kind === 'unboost') { act.to = [PUBLIC]; act.cc = audience; }
3568 } else {
3569 const type = kind === 'boost' ? 'Announce' : 'Like';
3570 act = {
3571 '@context': AP_CONTEXT,
3572 id: `${me}/${type.toLowerCase()}/${Date.now()}-${rid()}`,
3573 type, actor: me, object: targetNoteId,
3574 };
3575 if (type === 'Announce') { act.published = new Date().toISOString(); act.to = [PUBLIC]; act.cc = audience; }
3576 }
3577 const inboxes = new Set();
3578 // Author first, via their PERSONAL inbox (not the shared one) so a multi-user receiver
3579 // routes the Announce/Like to the right post unambiguously.
3580 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add(a.inbox || (a.endpoints && a.endpoints.sharedInbox)); }
3581 if (fanout) { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
3582 // Queue each delivery (immediate attempt + backoff retries on failure via ap_delivery)
3583 // instead of a single fire-and-forget POST, so a transient hiccup at the receiver doesn't
3584 // silently lose the boost — same durability a new post (deliverCreate) already gets.
3585 let queued = 0;
3586 for (const inbox of [...inboxes].filter(Boolean)) { deliverWithRetry(site.slug, inbox, act, `${me}#main-key`, keys.private_pem); queued++; }
3587 console.log('[AP]', kind, site.slug, '→', targetNoteId, 'queued', queued, 'inbox(es)');
3588 return { ok: true, delivered: queued };
3589}
3590
3591// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
3592export function getNotifications(slug, limit) {
3593 // Per-source cap scales with the requested limit so Messages can page deep
3594 // (Load more). Bounded so a huge offset can't ask for unbounded rows.
3595 const L = Math.min(1000, Math.max(80, limit || 60));
3596 const out = [];
3597 try {
3598 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)) {
3599 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
3600 }
3601 } catch { /* ignore */ }
3602 try {
3603 const rows = db.prepare(`
3604 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
3605 i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
3606 p.slug AS post_slug, p.title AS post_title
3607 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
3608 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
3609 ORDER BY i.created_at DESC LIMIT ?
3610 `).all(slug, L);
3611 for (const r of rows) out.push({
3612 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url, icon: r.actor_icon,
3613 content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
3614 // When the post was written, for display. created_at (when it reached us)
3615 // stays the sort key and the unread watermark: a note that federated late
3616 // is still new to you.
3617 published: r.published,
3618 emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, // FEP-9098 (messages render)
3619 media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json, // rendered like a Krant post
3620 // followers/direct = a private message to the owner (not on the public thread) → 🔒 in Messages
3621 visibility: r.visibility || 'public',
3622 });
3623 } catch { /* ignore */ }
3624 try {
3625 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)) {
3626 // The reported objects: our own notes resolve to post links so the owner
3627 // sees WHICH post the report is about; other URIs (e.g. the actor itself)
3628 // are skipped — the report row already names the account.
3629 const about = [];
3630 try {
3631 for (const u of JSON.parse(r.objects || '[]')) {
3632 const m = String(u).match(/\/ap\/notes\/([^/?#]+)/);
3633 if (!m) continue;
3634 const p = db.prepare('SELECT slug, title FROM posts WHERE id = ?').get(decodeURIComponent(m[1]));
3635 if (p) about.push({ slug: p.slug, title: p.title || p.slug });
3636 }
3637 } catch { /* malformed objects json → no links */ }
3638 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 });
3639 }
3640 } catch { /* ignore */ }
3641 try {
3642 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,
3643 emoji_json, actor_emoji_json, media_json, quote_json, embed_json
3644 FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?`).all(slug, L)) {
3645 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,
3646 // Same trimmings a Krant row has, so Berichten renders the post identically.
3647 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 });
3648 }
3649 } catch { /* ignore */ }
3650 // Your own polls that have closed → a "results are in" item, derived read-time
3651 // from poll_json (Scheduler marks closed=1) with the tally via ownPollView.
3652 try {
3653 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
3654 if (site) {
3655 const polls = db.prepare(`
3656 SELECT id, slug, title, poll_json FROM posts
3657 WHERE site_id = ? AND poll_json IS NOT NULL
3658 AND json_extract(poll_json, '$.closed') = 1
3659 AND json_extract(poll_json, '$.endTime') IS NOT NULL
3660 ORDER BY json_extract(poll_json, '$.endTime') DESC LIMIT 20`).all(site.id);
3661 for (const p of polls) {
3662 const view = ownPollView(p);
3663 if (!view) continue;
3664 let endTime = null; try { endTime = JSON.parse(p.poll_json).endTime; } catch { /* keep null */ }
3665 out.push({ type: 'poll_done', post_slug: p.slug, post_title: p.title, poll: view, created_at: endTime || null });
3666 }
3667 }
3668 } catch { /* ignore */ }
3669 // NaN-safe sort: one row with a missing/garbled created_at would otherwise make the
3670 // comparator return NaN and scramble the WHOLE ordering (seen live: follow rows landing
3671 // between likes, which also broke Messages' like-grouping).
3672 out.sort((a, b) => _msgTs(b) - _msgTs(a));
3673 return out.slice(0, limit || 60);
3674}
3675function _msgTs(x) { const t = Date.parse((x && x.created_at) || ''); return Number.isFinite(t) ? t : 0; }
3676
3677// ── Blocking / defederation ───────────────────────────────────────
3678// Extracted to BlocklistService (shared: Klonkt's Block tab + Shaer's "in
3679// Orbit"). Thin delegations keep every existing caller working.
3680export function listBlocks(slug) { return Blocklist.listBlocks(slug); }
3681
3682// True if an actor (or its whole domain) is blocked anywhere on this instance.
3683// Vote on a remote fediverse poll (a cached Question). A ballot = a Create(Note) carrying only a
3684// `name` (the chosen option) + inReplyTo the Question, addressed to the poll's author — the
3685// Mastodon-standard vote. Records our choice locally + optimistically bumps the counts; the
3686// author's Update(Question) refreshes the authoritative totals when it arrives.
3687export async function voteOnPoll(site, questionId, choices) {
3688 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3689 if (!base || !site || !site.slug || !questionId) return { error: 'config' };
3690 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 */ }
3691 if (!row || !row.poll_json) return { error: 'not_found' };
3692 let poll; try { poll = JSON.parse(row.poll_json); } catch { return { error: 'not_found' }; }
3693 if (poll.closed) return { error: 'closed' };
3694 if (poll.voted) return { error: 'already' };
3695 const valid = new Set(poll.options.map((o) => o.name));
3696 const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
3697 if (!picks.length) return { error: 'invalid' };
3698 const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
3699 const me = actorId(base, site.slug);
3700 const keys = getOrCreateKeys(site.slug);
3701 const authorUri = row.author_uri || null;
3702 const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
3703 const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
3704 if (!inbox) return { error: 'unreachable' };
3705 for (const name of chosen) {
3706 const nid = `${me}/votes/${Date.now()}-${rid()}`;
3707 const note = { id: nid, type: 'Note', attributedTo: me, to: authorUri ? [authorUri] : [], name, inReplyTo: questionId, published: new Date().toISOString() };
3708 const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
3709 deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
3710 }
3711 // Local optimistic update (authoritative counts arrive via the author's Update(Question)).
3712 poll.voted = poll.multiple ? chosen : chosen[0];
3713 for (const o of poll.options) if (chosen.includes(o.name)) o.count = (o.count || 0) + 1;
3714 if (poll.voters != null) poll.voters += 1;
3715 try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), questionId, site.slug); } catch { /* ignore */ }
3716 return { ok: true };
3717}
3718
3719// Vote on ANY fediverse poll by URL (the interact page) — no timeline cache needed. Fetches
3720// the Question fresh, validates the choice(s), and casts the Mastodon-standard ballot (a
3721// Create(Note) with `name` + inReplyTo) straight to the poll's author. Used for polls you find
3722// by URL, not just ones from accounts you follow (which go through voteOnPoll via /news).
3723export async function voteOnRemotePoll(site, questionUrl, choices) {
3724 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3725 if (!base || !site || !site.slug || !/^https?:\/\//i.test(String(questionUrl || ''))) return { error: 'config' };
3726 const q = await fetchActor(questionUrl).catch(() => null); // AP GET (SSRF-guarded)
3727 if (!q || q.type !== 'Question' || !q.id) return { error: 'not_found' };
3728 const poll = parsePoll(q);
3729 if (!poll) return { error: 'not_found' };
3730 if (poll.closed) return { error: 'closed' };
3731 const valid = new Set(poll.options.map((o) => o.name));
3732 const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
3733 if (!picks.length) return { error: 'invalid' };
3734 const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
3735 const authorUri = actorUriOf(q.attributedTo);
3736 const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
3737 const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
3738 if (!inbox) return { error: 'unreachable' };
3739 const me = actorId(base, site.slug);
3740 const keys = getOrCreateKeys(site.slug);
3741 for (const name of chosen) {
3742 const nid = `${me}/votes/${Date.now()}-${rid()}`;
3743 const note = { id: nid, type: 'Note', attributedTo: me, to: [authorUri], name, inReplyTo: q.id, published: new Date().toISOString() };
3744 const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
3745 deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
3746 }
3747 return { ok: true };
3748}
3749
3750// Report a remote post or account to its home instance (moderation). Sends the Mastodon-standard
3751// AS2 `Flag`: object = [reported account, reported status?], content = the reason, delivered to the
3752// reported account's inbox so their instance's moderators receive it. objectUri = a post URL (its
3753// author is resolved + included) OR pass actorUri to report an account directly.
3754export async function sendReport(site, { objectUri, actorUri, reason }) {
3755 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3756 if (!base || !site || !site.slug) return { error: 'config' };
3757 let targetActor = actorUri || null;
3758 let noteUri = null;
3759 if (objectUri && /^https?:\/\//i.test(objectUri)) {
3760 const note = await apGetJson(objectUri).catch(() => null);
3761 if (note && note.id) { noteUri = note.id; if (!targetActor) targetActor = actorUriOf(note.attributedTo); }
3762 else if (!targetActor) return { error: 'not_found' };
3763 }
3764 if (!targetActor || !/^https?:\/\//i.test(targetActor)) return { error: 'not_found' };
3765 const actor = await fetchActor(targetActor).catch(() => null);
3766 const inbox = actor && (actor.inbox || (actor.endpoints && actor.endpoints.sharedInbox)); // personal inbox → their moderators
3767 if (!inbox) return { error: 'unreachable' };
3768 const me = actorId(base, site.slug);
3769 const keys = getOrCreateKeys(site.slug);
3770 const object = [targetActor];
3771 if (noteUri && noteUri !== targetActor) object.push(noteUri);
3772 const flag = {
3773 '@context': AP_CONTEXT,
3774 id: `${me}#report-${Date.now()}-${rid()}`,
3775 type: 'Flag',
3776 actor: me,
3777 content: String(reason == null ? '' : reason).slice(0, 3000),
3778 object, // [account, status?] — Mastodon's Flag shape
3779 to: [targetActor],
3780 };
3781 deliverWithRetry(site.slug, inbox, flag, `${me}#main-key`, keys.private_pem);
3782 return { ok: true };
3783}
3784
3785export function isBlockedAny(actorUri) { return Blocklist.isBlockedAny(actorUri); }
3786
3787// Block an actor (@handle or actor URL) or a whole domain; purges their content.
3788// The handle resolver is ours; the storage/purge lives in BlocklistService.
3789export async function blockTarget(site, input) { return Blocklist.blockTarget(site, input, webfingerResolve); }
3790
3791export function unblock(site, target) { return Blocklist.unblock(site, target); }
3792
3793// ── Guardianship module wiring (src/services/guardianship/) ────────
3794// The module owns FEP-633c (context, relations, handshake, queues, the
3795// direct-note leg); we hand it our AP helpers ONCE and delegate. It never
3796// imports us back.
3797function selfActorId(slug) {
3798 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3799 return actorId(base, slug);
3800}
3801// Deliver one activity to one actor's inbox, signed; queued + retried on any
3802// hiccup so a slow or briefly-down ward server never loses the offer. Returns
3803// { delivered, inbox }: delivered=false means the account could not be
3804// resolved at all (a bad handle) — the offer stays recorded regardless.
3805export async function deliverToActor(site, actorUri, activity) {
3806 const me = selfActorId(site.slug);
3807 const keys = getOrCreateKeys(site.slug);
3808 const payload = { '@context': AP_CONTEXT, ...activity };
3809 const a = await fetchActor(actorUri).catch(() => null);
3810 const inbox = a && (a.inbox || (a.endpoints && a.endpoints.sharedInbox));
3811 if (!inbox) {
3812 console.warn('[AP] guardianship: could not resolve an inbox for', actorUri, '(offer recorded, not sent)');
3813 return { delivered: false, inbox: null };
3814 }
3815 try {
3816 const st = await deliver(inbox, payload, `${me}#main-key`, keys.private_pem);
3817 if (st >= 200 && st < 300) { console.log('[AP] guardianship', activity.type, 'delivered →', inbox, st); return { delivered: true, inbox }; }
3818 console.warn('[AP] guardianship', activity.type, 'got', st, 'from', inbox, '→ queued for retry');
3819 } catch (e) { console.warn('[AP] guardianship', activity.type, 'to', inbox, 'failed:', e.message, '→ queued for retry'); }
3820 enqueueDelivery(site.slug, inbox, payload);
3821 return { delivered: true, inbox }; // queued: the retry worker gets it there
3822}
3823Guardianship.wireDelivery({
3824 actorId, fetchActor, deriveHandle, escHtml, linkUrls, linkHashtags,
3825 getOutboxRow: (id) => iStmts().getO.get(id),
3826 buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
3827});
3828// Which local site (if any) hosts this actor URI — used by the handshake to
3829// apply the local side of a commit and to derive a ward's existing guardians.
3830function localSlugOf(actorUri) {
3831 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3832 if (!actorUri || !actorUri.startsWith(`${base}/ap/users/`)) return null;
3833 const slug = slugFromActorUrl(actorUri);
3834 if (!slug) return null;
3835 try { return db.prepare('SELECT slug FROM sites WHERE slug = ?').get(slug) ? slug : null; }
3836 catch { return null; }
3837}
3838Guardianship.wireHandshake({
3839 selfId: selfActorId,
3840 localSlug: localSlugOf,
3841 deliverTo: deliverToActor,
3842 deriveHandle,
3843 fetchActor,
3844 // Guardian PWA / Berichten push. The kid answers an incoming offer in its
3845 // own Berichten; an existing guardian and a commit land in the PWA.
3846 onEvent: (slug, ev) => {
3847 const L = pushLang(slug);
3848 const texts = {
3849 offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'], // I am the ward
3850 offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'], // I co-guard this ward
3851 committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
3852 // §3.2: a guardian ended the relation. The ward hears that someone who
3853 // was looking after them has gone; a co-guardian hears they are one fewer.
3854 guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
3855 coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
3856 }[ev.kind];
3857 if (!texts) return;
3858 const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
3859 const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
3860 pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url });
3861 },
3862});
3863
3864// The notification duty of FEP-633c 3.6.2, wired once for every place a
3865// dormancy promotion can happen (queue reads, fan-outs, tallies): marking a
3866// guardian dormant MUST notify it, in protocol AND over the §6 handle. The
3867// one-answer rule is worthless to someone who does not know an answer is
3868// wanted. The handle of a committed guardian is its inbox (§6 minimum), which
3869// is the same door this delivery knocks on; both attempts are logged.
3870Guardianship.wireAvailability({
3871 onDormant: (wardSlug, guardianUri) => {
3872 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
3873 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(wardSlug);
3874 if (!base || !site) return;
3875 const me = selfActorId(wardSlug);
3876 const note = {
3877 id: `${me}/dormant/${Date.now().toString(36)}${rid()}`,
3878 type: 'Note', attributedTo: me, to: [guardianUri],
3879 'shaer:dormant': true,
3880 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>',
3881 };
3882 deliverToActor(site, guardianUri, { id: `${note.id}#create`, type: 'Create', actor: me, to: [guardianUri], object: note })
3883 .catch(() => { /* retried by the queue */ });
3884 console.log('[AP] guardian observed dormant (3.6.2):', guardianUri, 'ward', wardSlug, '(notified in protocol; the §6 handle is the same inbox)');
3885 },
3886});
3887
3888export default {
3889 AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId,
3890 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
3891 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
3892 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
3893 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
3894 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
3895 acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision,
3896 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
3897 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
3898 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
3899 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
3900 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
3901 linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
3902 noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget,
3903 getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched,
3904};
Note: See TracBrowser for help on using the repository browser.