source: Klonkt/src/services/ActivityPubService.js@ 6089c53

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

Een C2S-post draagt zijn media, en een foto mag het hele bericht zijn

De composer-uitbreiding van de apps (Robins opdracht, 30-7) liep meteen op een
servergat: c2sCreatePost las alleen de content, dus een top-level post met
attachments kwam naakt aan, terwijl dezelfde attachments op replies en DM's
gewoon werkten.

Nu: de media wordt gevalideerd zoals bij deliverReply (alleen eigen
/media-uploads, image/audio/video, max 4), in de post-HTML gevouwen zodat het
web hem toont en afspeelt, en opgeslagen in posts.c2s_attachments zodat
buildNote hem federeert met zijn ECHTE mediaType: de extensiemap kent geen
audio en noemde een m4a anders een Image. Beelden staan ook inline in de
content; de dedup op URL houdt ze enkel.

En een media-only note is voortaan een post in plaats van een
empty_note-fout: een foto kan het hele bericht zijn.

Changed files:
src/services/ActivityPubService.js

  • c2sCreatePost: attachments valideren, in de HTML vouwen, opslaan
  • buildNote: c2s_attachments mee-federeren, opgeslagen mediaType wint
  • de empty-note-poort laat media-only door

src/config/database.js

  • kolom posts.c2s_attachments

New file:
test/c2s-compose.test.js

  • media in de web-content en als AS2-attachments, met dedup en het juiste type; een vreemde URL komt er niet in; media-only mag

remarks: 336 tests groen. De app-kant (panel-composer met pickers) volgt in
de app-repos.

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

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