source: Klonkt/src/services/ActivityPubService.js@ 08ab8ad

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

De app las alleen de tijdlijn, dus een zwaai kwam nooit aan

Robin vroeg waarom zwaai-acties niet als bericht in Shaer verschijnen. De app
bouwt Berichten, gesprekken en de hulpvraag-escalaties uit een bron: de
C2S-inboxlees plus de eigen outbox. Die lees serveerde alleen ap_timeline, en
een directe note staat in ap_mentions. Dus kwam er niets binnen: geen zwaai,
geen DM, en sinds gisteren ook geen hulpvraag meer.

Tot d9ad6c5 lekten directe notes toevallig in de tijdlijn: de insert vroeg
alleen of het een top-level post was van iemand die je volgt. Dat hebben we
dichtgezet om hulpvragen uit de Krant te houden, en daarmee viel de enige weg
weg waarlangs de app ze binnenkreeg. Je eigen antwoord zag je nog wel, want dat
komt uit je outbox, en daardoor leek het half te werken.

Nu serveert de inboxlees allebei: posts en de directe notes die aan jou gericht
zijn. In dezelfde vorm, dus de client heeft er een parser voor. Met to:[jij] en
een Mention-tag, want zonder die adressering groepeert de app het niet tot een
gesprek, en met shaer:wave en shaer:helpRequest zodat een zwaai er als een
zwaai uitziet.

Onderweg gevonden: SQLite schrijft CURRENT_TIMESTAMP in UTC zonder zone, en
Date.parse leest dat als lokale tijd. Twee uur verschil is genoeg om een
gesprek in de verkeerde volgorde te zetten, dus dat gaat nu door isoStamp.

Changed files:
src/services/ActivityPubService.js

  • getDirectMessages(): de mentions die niet ook een tijdlijnrij zijn, want een publieke mention van iemand die je volgt staat in allebei
  • isoStamp(): een opgeslagen stempel als echt moment
  • stripLeadingMentions op de default export, die had de route nodig

src/routes/activitypub.js

  • de inboxlees serveert posts en berichten in een collectie, nieuwste eerst
  • adressering, wave-vlag, byline en de gated shaer:embed op elk bericht

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

  • de twee tabellen blijven gescheiden, de lees brengt ze samen
  • de zwaai zit erin, is gemarkeerd, en is aan jou gericht
  • een publieke mention komt een keer langs, als post

remarks: 333 tests groen. De clientkant zit in de app-repos.

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

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