source: Klonkt/src/services/ActivityPubService.js@ 7d01696

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

Posterframes voor video, ffmpeg-optioneel

shaer-zowq, de serverhelft. Bij een video-upload trekt ffmpeg een frame op
1 seconde naar <naam>.poster.jpg, best-effort en buiten de responspad: op een
machine zonder ffmpeg gebeurt er niets en breekt er niets (de clients halen
dan zelf een frame, native). Vanaf daar reist de poster naar drie plekken:

  • poster= op de gevouwen video-tag, dus het web toont een stilstaand beeld
  • opgeslagen op de c2s_attachments-entry
  • als AS2 icon op het gefedereerde Video-attachment, dus apps en andere servers krijgen de thumbnail-URL cadeau

Inkomend geldt het spiegelbeeld: een remote video-attachment met een icon
houdt hem als poster in media_json, en de C2S-inboxlees serveert hem terug.

Changed files:
src/routes/activitypub.js

  • uploadMedia: ffmpeg-posterframe voor video, best-effort

src/services/ActivityPubService.js

  • c2sCreatePost: poster op entry en tag; buildNote: icon op het attachment; mediaFromNote/timelineAttachments: inkomende posters door

test/c2s-compose.test.js

  • de poster bereikt tag, opslag en attachment; zonder posterbestand wordt er niets gegokt

remarks: 338 tests groen. ffmpeg staat NIET op de VPS; installeren is een
sudo-actie die ik niet mag doen: sudo apt-get install -y ffmpeg. Zonder dat
werkt alles, alleen maken de clients hun thumbnails zelf.

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

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