source: Klonkt/src/services/ActivityPubService.js@ 37a297d

main
Last change on this file since 37a297d was 2e62848, checked in by Robin Genis <roboburr@…>, 2 months ago

fix(fedi): post tags federate as visible hashtags (+ CamelCase for spaces)

  • services/ActivityPubService.js — buildNote now (a) normalises post.tags whether it arrives as an array, a JSON string, or a comma string (it was a JSON string at federation → the old Array.isArray check silently dropped the whole tags field), and (b) appends the tags-field hashtags to the Note content as rel="tag" links so Mastodon renders them (a Hashtag only in the tag array isn't shown inline). Multi-word tags become CamelCase (#LiveMusic) for display; the slug/href stays lowercase. Deduped against hashtags already inline in the body.
  • Property mode set to 100644
File size: 84.3 KB
Line 
1/**
2 * ActivityPubService — Klonkt as a real ActivityPub actor (fediverse bridge).
3 *
4 * Phase 1 (this file): the PUBLISH/discoverable side.
5 * - per-site RSA keypair (Mastodon-compatible HTTP Signatures; separate from
6 * the Ed25519 keys used by the lighter Cirkels v1)
7 * - builders for the Actor document, Note objects and the Outbox collection
8 * - apWants(): HTTP content-negotiation helper (activity+json vs HTML)
9 *
10 * The interactive side (inbox: Follow/Accept, signature verify, delivery to
11 * followers) lands in the next step and is tested live against Mastodon.
12 *
13 * AP actor URLs live under /ap/* so they never clash with the human pages:
14 * actor = <base>/ap/users/<slug>
15 * inbox = <actor>/inbox outbox = <actor>/outbox
16 * note = <base>/ap/notes/<postId>
17 */
18import crypto from 'crypto';
19import dns from 'dns';
20import net from 'net';
21import db from '../config/database.js';
22import HtmlSanitizerService from './HtmlSanitizerService.js';
23
24const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
25
26// Short random suffix so two activity ids minted in the same millisecond (e.g.
27// parallel saves) don't collide and get deduped by a receiver.
28const rid = () => crypto.randomBytes(4).toString('hex');
29
30// Keep only http(s) URLs — drops javascript:/data:/etc so a remote actor can't
31// smuggle a dangerous scheme into a stored href/src (rendered in owner-only views).
32const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
33
34// ── SSRF guard for outbound fetches ───────────────────────────────
35// Remote URLs (actor/keyId/webfinger/inbox/inReplyTo) are attacker-controlled, so
36// every outbound fetch must refuse hosts that resolve to private/loopback ranges
37// (cloud metadata, internal services) — on the initial host AND each redirect hop.
38function isBlockedIp(ip) {
39 if (!ip) return true;
40 const v = net.isIP(ip);
41 if (v === 4) {
42 const o = ip.split('.').map(Number);
43 return o[0] === 127 || o[0] === 10 || o[0] === 0
44 || (o[0] === 172 && o[1] >= 16 && o[1] <= 31)
45 || (o[0] === 192 && o[1] === 168)
46 || (o[0] === 169 && o[1] === 254)
47 || (o[0] === 100 && o[1] >= 64 && o[1] <= 127); // CGNAT
48 }
49 if (v === 6) {
50 const s = ip.toLowerCase().replace(/^\[|\]$/g, '');
51 return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')
52 || s.startsWith('::ffff:127.') || s.startsWith('::ffff:10.') || s.startsWith('::ffff:192.168.')
53 || s.startsWith('::ffff:169.254.') || s.startsWith('::ffff:172.');
54 }
55 return true; // not an IP literal we recognise → refuse
56}
57async function assertPublicHost(hostname) {
58 if (net.isIP(hostname)) { if (isBlockedIp(hostname)) throw new Error('ssrf-blocked-ip'); return; }
59 const addrs = await dns.promises.lookup(hostname, { all: true });
60 if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');
61}
62async function safeFetch(url, opts = {}, maxRedirects = 3) {
63 let target = url;
64 for (let hop = 0; ; hop++) {
65 const u = new URL(target); // throws on malformed → caller's catch
66 if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('ssrf-bad-scheme');
67 await assertPublicHost(u.hostname);
68 const r = await fetch(target, { ...opts, redirect: 'manual', signal: AbortSignal.timeout(8000) });
69 const loc = (r.status >= 300 && r.status < 400) ? r.headers.get('location') : null;
70 if (loc && hop < maxRedirects) { target = new URL(loc, target).toString(); continue; }
71 return r;
72 }
73}
74const MAX_OUTBOX = 20;
75// Cache-buster for the music listen-link → forces Mastodon to re-crawl a FRESH
76// (square) player card. Bump this whenever the twitter:player card dimensions change.
77const FEDI_CARD_VER = '2';
78
79// ── RSA keys per actor (lazy, cached in DB) ───────────────────────
80// Prepared lazily (NOT at module load) — the ap_keys table is created in
81// initializeDatabase(), which runs after this module is imported.
82let _sel, _ins;
83function keyStmts() {
84 if (!_sel) {
85 _sel = db.prepare('SELECT public_pem, private_pem FROM ap_keys WHERE slug = ?');
86 _ins = db.prepare('INSERT OR IGNORE INTO ap_keys (slug, public_pem, private_pem, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)');
87 }
88 return { sel: _sel, ins: _ins };
89}
90
91export function getOrCreateKeys(slug) {
92 const { sel, ins } = keyStmts();
93 const row = sel.get(slug);
94 if (row) return row;
95 const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
96 modulusLength: 2048,
97 publicKeyEncoding: { type: 'spki', format: 'pem' },
98 privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
99 });
100 ins.run(slug, publicKey, privateKey);
101 return sel.get(slug) || { public_pem: publicKey, private_pem: privateKey };
102}
103
104// ── content negotiation ───────────────────────────────────────────
105// True when the caller wants ActivityPub JSON rather than the HTML page.
106export function apWants(req) {
107 const a = String(req.headers.accept || '').toLowerCase();
108 return a.includes('application/activity+json') ||
109 (a.includes('application/ld+json') && a.includes('activitystreams'));
110}
111
112const AP_CONTENT_TYPE = 'application/activity+json; charset=utf-8';
113export function sendAP(res, obj) {
114 res.type(AP_CONTENT_TYPE);
115 res.set('Cache-Control', 'public, max-age=120');
116 res.send(JSON.stringify(obj));
117}
118
119// ── document builders ─────────────────────────────────────────────
120export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
121export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
122
123export function buildActor(base, site) {
124 const id = actorId(base, site.slug);
125 const keys = getOrCreateKeys(site.slug);
126 const actor = {
127 '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
128 id,
129 type: 'Person',
130 preferredUsername: site.slug,
131 name: site.title || site.slug,
132 summary: site.tagline || site.description || '',
133 url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
134 manuallyApprovesFollowers: false,
135 discoverable: true,
136 inbox: `${id}/inbox`,
137 outbox: `${id}/outbox`,
138 followers: `${id}/followers`,
139 featured: `${id}/featured`,
140 endpoints: { sharedInbox: `${base}/ap/inbox` },
141 publicKey: {
142 id: `${id}#main-key`,
143 owner: id,
144 publicKeyPem: keys.public_pem,
145 },
146 };
147 if (site.profile_photo) {
148 const u = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
149 actor.icon = { type: 'Image', url: u };
150 }
151 return actor;
152}
153
154// Does a post's audio shortcodes reference at least one PLAYABLE (file-backed)
155// track? Link-only tracks (external Spotify/YouTube, media_id NULL) don't count —
156// they have no Klonkt-hosted audio to embed, so no player card / cover-suppression.
157export function hasPlayableAudio(content, siteId) {
158 if (!content || !/\[\[(track|album|playlist):/i.test(content)) return false;
159 try {
160 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; }
161 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; }
162 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; }
163 } catch { /* non-fatal */ }
164 return false;
165}
166
167// A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
168export function buildNote(base, site, post) {
169 const id = noteId(base, post.id);
170 const aId = actorId(base, site.slug);
171 const human = `${base}/${encodeURIComponent(post.slug)}`;
172 // Mastodon ignores a Note's `name`, so put the title INTO the content (bold
173 // first line) — the standard blog→fediverse convention. post.content is
174 // already sanitized HTML; the title is plain text, so escape it.
175 const escTitle = String(post.title || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
176 const titleHtml = post.title ? `<p><strong>${escTitle}</strong></p>` : '';
177
178 // Images travel as AP `attachment` (Mastodon strips <img> from content). Collect
179 // the cover + any inline <img>, make absolute, then strip <img> from the content
180 // to avoid duplicate rendering on clients that DO keep them.
181 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
182 const mediaType = (u) => {
183 const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
184 return ({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif' })[(e || '').toLowerCase()] || 'image/jpeg';
185 };
186 const hadAudio = /\[\[(track|album|playlist):/i.test(post.content || '');
187 const playable = hasPlayableAudio(post.content || '', site && site.id);
188 const urls = [];
189 // Posts with PLAYABLE hosted audio suppress image attachments so Mastodon renders
190 // the player CARD (twitter:player) instead of the cover — media attachment and
191 // link/player card are mutually exclusive on Mastodon. Link-only audio (external)
192 // keeps its cover (no player card to show).
193 if (post.cover_image_url && !playable) urls.push(abs(post.cover_image_url));
194 let body = post.content || '';
195 if (!playable) for (const m of body.matchAll(/<img\b[^>]*\bsrc="([^"]+)"[^>]*>/gi)) urls.push(abs(m[1]));
196 body = body.replace(/<img\b[^>]*>/gi, '');
197 // Audio shortcodes: do NOT federate the raw audio file — Klonkt deliberately
198 // gates audio (the /audio/stream URL has friction), and shipping it as an AP
199 // audio attachment would hand Mastodon a plain, downloadable mp3 URL. Instead,
200 // replace the shortcodes with a "🎵 listen on the site" link so the post invites
201 // a click-through to the protected player (discovery without leaking the file).
202 const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
203 const audioLabels = [];
204 try {
205 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); }
206 for (const m of body.matchAll(/\[\[album:([^\]]+)\]\]/g)) audioLabels.push(m[1].trim());
207 } catch { /* non-fatal */ }
208 body = body.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
209 // External embeds ([[embed:url]]) → emit the bare URL as a link so Mastodon
210 // renders its OWN preview/player card (YouTube/Spotify/SoundCloud/etc) instead
211 // of federating the raw shortcode text.
212 body = body.replace(/\[\[embed:([^\]]+)\]\]/gi, (mm, raw) => {
213 const u = esc(raw.trim().replace(/&amp;/g, '&'));
214 return `<p><a href="${u}">${u}</a></p>`;
215 });
216 if (hadAudio) {
217 const lbl = audioLabels.length ? esc(audioLabels.slice(0, 4).join(', ')) : '';
218 // For playable posts, append a version param to the listen-link so Mastodon
219 // sees a NEW card URL and re-crawls it (fresh SQUARE player card) instead of
220 // reusing the cached landscape one. Invisible: the link TEXT stays clean, the
221 // page ignores the param. Bump FEDI_CARD_VER when the card dimensions change.
222 const listenHref = playable ? `${human}?fc=${FEDI_CARD_VER}` : human;
223 body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong> — ` : ''}<a href="${listenHref}">listen on ${esc(site.title || 'the site')}</a></p>`;
224 }
225 // Klonkt renders post content with white-space:pre-wrap, so raw newlines ARE line
226 // breaks on the site. Mastodon (plain HTML) collapses whitespace and would drop them,
227 // so convert newlines to <br> for the federated copy (content already made with
228 // shift+enter uses <br> and has no \n → this is a no-op there).
229 body = body.replace(/\r?\n/g, '<br>');
230 body = linkHashtags(base, body); // link inline #hashtags in the post body too
231 // Append the tags-field hashtags to the content so Mastodon renders them as clickable
232 // hashtags (a Hashtag that's only in the `tag` array isn't shown inline). CamelCase
233 // multi-word tags; skip any already present inline in the body.
234 {
235 const inlineTags = new Set(hashtagTags(base, body).map((h) => h.name.slice(1).toLowerCase()));
236 const addSeen = new Set();
237 const tagLinks = normalizeTags(post.tags).map(tagParts).filter(Boolean)
238 .filter((p) => !inlineTags.has(p.slug) && !addSeen.has(p.slug) && addSeen.add(p.slug))
239 .map((p) => `<a href="${base}/tag/${encodeURIComponent(p.slug)}" class="mention hashtag" rel="tag">#${p.label}</a>`);
240 if (tagLinks.length) body += `<p>${tagLinks.join(' ')}</p>`;
241 }
242 const seen = new Set();
243 const attachment = urls.filter(Boolean)
244 .filter((u) => { if (seen.has(u)) return false; seen.add(u); return true; })
245 .map((u) => ({ type: 'Document', mediaType: mediaType(u), url: u }));
246
247 const note = {
248 id,
249 type: 'Note',
250 attributedTo: aId,
251 content: titleHtml + body,
252 url: human,
253 published: new Date(post.published_at || post.created_at || Date.now()).toISOString(),
254 // fan_only = "fans only" → followers-only visibility (delivered to your followers
255 // but not addressed to Public, so Mastodon shows it only to them and can't boost it).
256 to: post.fan_only ? [`${aId}/followers`] : [PUBLIC],
257 cc: post.fan_only ? [] : [`${aId}/followers`],
258 tag: buildHashtagList(base, post.tags, body),
259 replies: `${id}/replies`,
260 // NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler
261 // (hides the whole post behind a "Gevoelige inhoud" button until the reader opens it).
262 sensitive: !!post.nsfw,
263 };
264 if (post.nsfw) note.summary = post.content_warning || 'Gevoelige inhoud';
265 if (attachment.length) note.attachment = attachment;
266 // Playable-audio posts suppress the cover attachment (player card). Still expose
267 // the cover via AS2 `image` so card/grid consumers (the Klonkt Cirkel) can show
268 // it — Mastodon ignores a Note's `image`, so the player card is unaffected.
269 if (post.cover_image_url && playable) {
270 const cov = abs(post.cover_image_url);
271 if (cov) note.image = { type: 'Image', mediaType: mediaType(cov), url: cov };
272 }
273 return note;
274}
275
276// All reply note URIs on a local post (inbound fediverse replies + our own
277// outbound replies) — backs the Note's `replies` Collection so remote servers
278// can fetch the whole thread.
279export function getReplyUris(base, postId) {
280 const out = [];
281 try {
282 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);
283 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}`);
284 } catch { /* non-fatal */ }
285 return out;
286}
287
288// Notifications "seen" tracking → a real bell badge. Stored per site in app_settings.
289export function markNotificationsSeen(slug) {
290 try {
291 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")
292 .run(`fedi_notif_seen:${slug}`, new Date().toISOString());
293 } catch { /* non-fatal */ }
294}
295export function countUnseenNotifications(slug) {
296 try {
297 const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
298 const seen = row ? Date.parse(row.value) : 0;
299 let n = 0;
300 for (const it of getNotifications(slug, 50)) { if (Date.parse(it.created_at) > seen) n++; }
301 return n;
302 } catch { return 0; }
303}
304
305export function buildCreate(base, site, post) {
306 const note = buildNote(base, site, post);
307 return {
308 '@context': 'https://www.w3.org/ns/activitystreams',
309 id: note.id + '#create',
310 type: 'Create',
311 actor: actorId(base, site.slug),
312 published: note.published,
313 to: note.to,
314 cc: note.cc,
315 object: note,
316 };
317}
318
319export function buildOutbox(base, site, posts) {
320 const id = `${actorId(base, site.slug)}/outbox`;
321 const items = (posts || []).slice(0, MAX_OUTBOX).map((p) => buildCreate(base, site, p));
322 return {
323 '@context': 'https://www.w3.org/ns/activitystreams',
324 id,
325 type: 'OrderedCollection',
326 totalItems: items.length,
327 orderedItems: items,
328 };
329}
330
331export function buildFollowers(base, site, count) {
332 const id = `${actorId(base, site.slug)}/followers`;
333 return {
334 '@context': 'https://www.w3.org/ns/activitystreams',
335 id,
336 type: 'OrderedCollection',
337 totalItems: count || 0,
338 orderedItems: [], // hidden for privacy; count only
339 };
340}
341
342// Pinned posts → the actor's `featured` collection. Mastodon reads this and shows
343// these as the "Featured" tab (pinned to the profile). Posts come ordered by pin
344// rank; embedded as full Notes so a remote server doesn't need extra fetches.
345export function buildFeatured(base, site, posts) {
346 const id = `${actorId(base, site.slug)}/featured`;
347 const items = (posts || []).map((p) => buildNote(base, site, p));
348 return {
349 '@context': 'https://www.w3.org/ns/activitystreams',
350 id,
351 type: 'OrderedCollection',
352 totalItems: items.length,
353 orderedItems: items,
354 };
355}
356
357// ── followers store (lazy stmts) ──────────────────────────────────
358let _insF, _delF, _listF, _cntF;
359function fStmts() {
360 if (!_insF) {
361 _insF = db.prepare('INSERT OR IGNORE INTO ap_followers (slug, actor_uri, inbox, shared_inbox, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
362 _delF = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND actor_uri = ?');
363 _listF = db.prepare('SELECT inbox, shared_inbox FROM ap_followers WHERE slug = ?');
364 _cntF = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?');
365 }
366 return { ins: _insF, del: _delF, list: _listF, cnt: _cntF };
367}
368export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
369
370// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
371let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
372function iStmts() {
373 if (!_insI) {
374 _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, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
375 _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
376 _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
377 _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 FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
378 _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
379 _insO = db.prepare('INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, created_at) VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
380 _listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
381 _getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
382 }
383 return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI, getI: _getI, insO: _insO, listO: _listO, getO: _getO };
384}
385
386export function getInteractionById(id) { return iStmts().getI.get(id); }
387export function setInteractionBoosted(id, on) {
388 db.prepare('UPDATE ap_interactions SET acted_boost = ? WHERE id = ?').run(on ? 1 : 0, id);
389}
390export function setInteractionLiked(id, on) {
391 db.prepare('UPDATE ap_interactions SET acted_like = ? WHERE id = ?').run(on ? 1 : 0, id);
392}
393// Your like/boost state on a REMOTE post (interact page toggles).
394export function setMyReaction(slug, uri, kind, on) {
395 if (on) db.prepare('INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind) VALUES (?,?,?)').run(slug, uri, kind);
396 else db.prepare('DELETE FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ? AND kind = ?').run(slug, uri, kind);
397}
398export function getMyReactions(slug, uri) {
399 const rows = (slug && uri) ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, uri) : [];
400 return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
401}
402
403const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
404// Extract our local post id from a note URL, but only if it's ours (base match).
405function postIdFromNoteUrl(url, base) {
406 const s = String(url || '');
407 if (base && !s.startsWith(base)) return null;
408 const m = s.match(/\/ap\/notes\/([^/?#]+)/);
409 return m ? decodeURIComponent(m[1]) : null;
410}
411function deriveHandle(actorUri) {
412 try { const u = new URL(actorUri); const seg = u.pathname.split('/').filter(Boolean).pop() || ''; return `@${seg}@${u.host}`; } catch { return String(actorUri || ''); }
413}
414function actorInfo(doc, actorUri) {
415 let host = ''; try { host = new URL(actorUri).host; } catch { /* keep empty */ }
416 const handle = doc && doc.preferredUsername ? `@${doc.preferredUsername}@${host}` : deriveHandle(actorUri);
417 const icon = doc && doc.icon ? (doc.icon.url || (Array.isArray(doc.icon) && doc.icon[0] && doc.icon[0].url)) : null;
418 return {
419 name: (doc && (doc.name || doc.preferredUsername)) || handle,
420 handle,
421 url: safeUrl((doc && (doc.url || doc.id)) || actorUri) || null,
422 icon: safeUrl(icon) || null,
423 };
424}
425
426// Given an inReplyTo note URL, find which local post the thread belongs to + the
427// note being replied to (parent), so a reply-to-a-comment can be nested.
428function findThreadTarget(inReplyTo, base) {
429 if (!inReplyTo) return null;
430 const seg = postIdFromNoteUrl(inReplyTo, base); // our /ap/notes/<id> segment (if ours)
431 if (seg && localPostExists(seg)) return { post_id: seg, parent_uri: inReplyTo };
432 if (seg) {
433 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 */ }
434 }
435 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 */ }
436 return null;
437}
438
439// Drop the leading @mention(s) a federated reply carries (the person being replied to),
440// so a comment reads "dope tekening ouwe" instead of "@jason@jasonhacky.nl dope …".
441// Keeps a leading <p> wrapper; handles mention <a> links and plain-text @user@domain.
442export function stripLeadingMentions(html) {
443 if (!html) return html;
444 let s = String(html);
445 s = s.replace(/^(\s*<p[^>]*>)?\s*(?:<a\b[^>]*>\s*@[^<]+<\/a>[  ]*)+/i, (m, p) => p || '');
446 s = s.replace(/^(\s*<p[^>]*>)?\s*(?:@[\w.-]+(?:@[\w.-]+)?[  ]+)+/i, (m, p) => p || '');
447 return s;
448}
449
450// View-ready threaded view of a post's fediverse activity (inbound replies +
451// our outbound replies, nested), plus like/boost counts.
452export function getInteractions(postId, base, site) {
453 const s = iStmts();
454 const rows = s.list.all(postId);
455 const baseClean = (base || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
456 const postNoteId = baseClean ? `${baseClean}/ap/notes/${postId}` : null;
457 // Our own (outbound) replies show the SITE identity for everyone (not "You").
458 let host = ''; try { host = new URL(baseClean).host; } catch { /* ignore */ }
459 const siteName = (site && (site.title || site.slug)) || '';
460 const siteHandle = (site && site.slug && host) ? `@${site.slug}@${host}` : '';
461 const siteUrl = baseClean ? `${baseClean}/` : '';
462 const siteIcon = (site && site.profile_photo) || null;
463
464 const nodes = [];
465 for (const r of rows) {
466 if (r.kind !== 'reply') continue;
467 nodes.push({
468 noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
469 actor_name: r.actor_name, actor_handle: r.actor_handle, actor_url: r.actor_url,
470 actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
471 acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,
472 children: [],
473 });
474 }
475 for (const o of s.listO.all(postId)) {
476 nodes.push({
477 noteId: baseClean ? `${baseClean}/ap/notes/${o.id}` : o.id, parent: o.in_reply_to || null,
478 mine: true, outboxId: o.id, content: stripLeadingMentions(o.content), created_at: o.created_at,
479 actor_name: siteName, actor_handle: siteHandle, actor_url: siteUrl, actor_icon: siteIcon,
480 children: [],
481 });
482 }
483
484 const byId = new Map(nodes.map((n) => [n.noteId, n]));
485 const isTop = (n) => !n.parent || n.parent === postNoteId || !byId.has(n.parent);
486 const tops = [];
487 for (const n of nodes) {
488 if (isTop(n)) { tops.push(n); continue; }
489 let anc = n, guard = 0;
490 while (!isTop(anc) && guard++ < 12) anc = byId.get(anc.parent);
491 anc.children.push(n);
492 }
493 const byTime = (a, b) => new Date(a.created_at) - new Date(b.created_at);
494 tops.sort(byTime).forEach((t) => t.children.sort(byTime));
495
496 return {
497 thread: tops,
498 likeCount: rows.filter((r) => r.kind === 'like').length,
499 announceCount: rows.filter((r) => r.kind === 'announce').length,
500 total: nodes.length,
501 };
502}
503
504// ── HTTP Signatures + delivery ────────────────────────────────────
505const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
506
507// Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).
508export async function deliver(inboxUrl, bodyObj, keyId, privatePem) {
509 const body = JSON.stringify(bodyObj);
510 const u = new URL(inboxUrl);
511 const date = new Date().toUTCString();
512 const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
513 const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
514 const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
515 const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
516 const r = await safeFetch(inboxUrl, {
517 method: 'POST',
518 headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },
519 body,
520 });
521 return r.status;
522}
523
524export async function fetchActor(url) {
525 try {
526 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
527 if (!r.ok) return null;
528 const len = Number(r.headers.get('content-length') || 0);
529 if (len > 2_000_000) return null; // refuse oversized actor docs
530 return await r.json();
531 } catch { return null; }
532}
533
534// ── Delivery queue with retries ───────────────────────────────────
535// Outbound deliveries are tried immediately; on failure (down server, timeout,
536// non-2xx) they're queued and retried with backoff so a briefly-offline follower
537// doesn't silently miss the post. The signing key is NOT stored — the worker
538// re-derives it from the actor slug at send time.
539const DELIVERY_MAX_ATTEMPTS = 6;
540const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360];
541let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv;
542function deliveryStmts() {
543 if (!_insDeliv) {
544 _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, body, attempts, next_at) VALUES (?,?,?,0,CURRENT_TIMESTAMP)');
545 _dueDeliv = db.prepare("SELECT * FROM ap_delivery WHERE datetime(next_at) <= datetime('now') ORDER BY next_at LIMIT 30");
546 _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?');
547 _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?');
548 }
549 return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv };
550}
551export function enqueueDelivery(slug, inbox, activity) {
552 if (!slug || !inbox || !activity) return;
553 try { deliveryStmts().ins.run(slug, inbox, JSON.stringify(activity)); } catch { /* ignore */ }
554}
555// Deliver now; queue for retry if it fails.
556export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
557 if (!inbox) return;
558 try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) return; } catch { /* queue below */ }
559 enqueueDelivery(slug, inbox, activity);
560}
561let _processingDeliv = false;
562export async function processDeliveryQueue() {
563 if (_processingDeliv) return; // re-entrancy guard: 30 rows × 8s can exceed the 60s tick → no double-delivery
564 _processingDeliv = true;
565 try {
566 let rows;
567 try { rows = deliveryStmts().due.all(); } catch { return; }
568 if (!rows || !rows.length) return;
569 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
570 for (const row of rows) {
571 let ok = false;
572 try {
573 const keys = getOrCreateKeys(row.slug);
574 const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
575 ok = st >= 200 && st < 300;
576 } catch { ok = false; }
577 if (ok) { deliveryStmts().del.run(row.id); continue; }
578 const attempts = row.attempts + 1;
579 if (attempts >= DELIVERY_MAX_ATTEMPTS) { deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }
580 // Index the backoff on the CURRENT attempt count (row.attempts) so the first
581 // retry uses the 1-min tier instead of skipping it.
582 const mins = DELIVERY_BACKOFF_MIN[Math.min(row.attempts, DELIVERY_BACKOFF_MIN.length - 1)];
583 deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id);
584 }
585 } finally { _processingDeliv = false; }
586}
587let _delivTimer = null;
588export function startDeliveryWorker() {
589 if (_delivTimer) return;
590 _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000);
591 if (_delivTimer.unref) _delivTimer.unref();
592}
593
594// Best-effort verification of an incoming signed request. Returns the sender's
595// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
596export async function verifyRequest(req) {
597 const sigH = req.headers['signature'];
598 if (!sigH) return null;
599 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
600 if (!p.keyId || !p.signature) return null;
601 const actor = await fetchActor(p.keyId.split('#')[0]);
602 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
603 if (!pem) return null;
604 const hs = (p.headers || '(request-target) host date').split(/\s+/);
605 const line = hs.map((h) => h === '(request-target)'
606 ? `(request-target): ${req.method.toLowerCase()} ${req.originalUrl}`
607 : `${h}: ${req.headers[h] || ''}`).join('\n');
608 let ok = false;
609 try { ok = crypto.verify('sha256', Buffer.from(line), pem, Buffer.from(p.signature, 'base64')); } catch { ok = false; }
610 if (ok && hs.includes('digest') && req.rawBody) {
611 const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
612 if (req.headers['digest'] !== exp) ok = false;
613 }
614 return ok ? actor : null;
615}
616
617// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
618export async function handleInbox(req, slugParam) {
619 const act = req.body || {};
620 const type = act.type;
621 // Real client IP (behind the proxy via `trust proxy`) — logged on dropped/rejected/
622 // ignored inbox hits so an operator can see who is probing their fediverse inbox.
623 const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';
624 const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
625 const verified = await verifyRequest(req).catch(() => null);
626
627 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
628 // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
629 // forged replies/likes/follows/timeline posts). GET/discovery stays open.
630 const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
631 // Blocked actor/domain → silently drop (202, don't reveal the block).
632 if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
633 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update'];
634 if (GATED.includes(type)) {
635 if (!verified || !claimedActor || verified.id !== claimedActor) {
636 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
637 return 401;
638 }
639 }
640
641 if (type === 'Follow') {
642 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
643 const slug = slugParam || slugFromActorUrl(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
644 if (!who || !slug) return 400;
645 const remote = await fetchActor(who);
646 if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
647 const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
648 fStmts().ins.run(slug, who, remote.inbox, sharedInbox);
649 const me = actorId(base, slug);
650 const keys = getOrCreateKeys(slug);
651 const accept = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
652 deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
653 // Auto-backfill: send our recent posts as Create so the instance has our history
654 // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —
655 // Mastodon dedupes notes per-instance, so re-filling an instance that already has
656 // a follower of ours is wasted work (and won't re-populate the new follower's
657 // timeline anyway). Deliver to the shared inbox (instance-level) when present.
658 // Sync insert+check (no await between) → no interleave race with concurrent Follows.
659 const instanceFilled = sharedInbox &&
660 db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')
661 .get(slug, sharedInbox, who);
662 if (!instanceFilled) {
663 backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });
664 }
665 console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
666 return 202;
667 }
668 if (type === 'Undo' && act.object) {
669 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
670 const ot = act.object.type;
671 if (ot === 'Follow') {
672 const obj = act.object.object;
673 const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
674 if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
675 return 202;
676 }
677 if (ot === 'Like' || ot === 'Announce') {
678 const tgt = act.object.object;
679 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
680 if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
681 return 202;
682 }
683 return 202;
684 }
685
686 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
687 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
688 // Activities from our OWN actors are already stored via ap_outbox — don't re-store.
689 const isLocalActor = !!(base && actorUri && actorUri.startsWith(`${base}/ap/users/`));
690
691 // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
692 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
693 const o = act.object;
694 const tgt = findThreadTarget(o.inReplyTo, base);
695 if (tgt && actorUri && !isLocalActor) {
696 const ai = actorInfo(await resolveActor(actorUri), actorUri);
697 const html = HtmlSanitizerService.sanitize(o.content || '');
698 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);
699 console.log('[AP] reply', actorUri, '→', tgt.post_id);
700 return 202;
701 }
702 // Home timeline (client): a top-level post from an account we follow.
703 if (actorUri && !isLocalActor && !o.inReplyTo && o.id) {
704 let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
705 if (subs.length) {
706 const ai = actorInfo(await resolveActor(actorUri), actorUri);
707 const html = HtmlSanitizerService.sanitize(o.content || '');
708 const _atts = (Array.isArray(o.attachment) ? o.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
709 // Fallback cover: a Note's `image` (set when the attachment was suppressed
710 // for a player-card post, e.g. hosted-audio posts).
711 if (!_atts.some((m) => !m.type || /image/i.test(m.type)) && o.image) {
712 const _im = Array.isArray(o.image) ? o.image[0] : o.image;
713 const _iu = safeUrl(typeof _im === 'string' ? _im : (_im && _im.url));
714 if (_iu) _atts.push({ url: _iu, type: (_im && _im.mediaType) || 'image/jpeg' });
715 }
716 const media = JSON.stringify(_atts);
717 // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
718 // incoming posts to the fediverse — that flooded followers. Boosting to the
719 // fediverse is only ever a deliberate, manual per-post action (the 🔁 on
720 // the timeline).
721 for (const s of subs) {
722 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);
723 }
724 console.log('[AP] timeline +', actorUri, 'x' + subs.length);
725 }
726 }
727 return 202;
728 }
729 if (type === 'Like' || type === 'Announce') {
730 const tgt = act.object;
731 const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
732 if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
733 const ai = actorInfo(await resolveActor(actorUri), actorUri);
734 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null);
735 console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
736 }
737 return 202;
738 }
739 if (type === 'Delete') {
740 // A remote note was deleted upstream → drop it from replies AND the timeline.
741 // Scope to the SIGNING actor so actor B can't delete actor A's content (the
742 // signature gate guarantees claimedActor == the verified signer here).
743 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
744 if (oid && claimedActor) {
745 try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
746 try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
747 }
748 return 202;
749 }
750 // Accept/Reject of a Follow WE sent (client side).
751 if (type === 'Accept' && act.object) {
752 const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
753 if (fid) { try { fwStmts().acc.run(fid); } catch { /* ignore */ } }
754 console.log('[AP] follow accepted', actorUri);
755 return 202;
756 }
757 if (type === 'Reject' && act.object) {
758 const who = actorUri;
759 if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
760 return 202;
761 }
762
763 console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', 'from', ip, '(ignored)');
764 return 202;
765}
766
767// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
768// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
769export async function deliverCreate(site, post) {
770 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
771 if (!base || !site || !site.slug) return;
772 const followers = fStmts().list.all(site.slug);
773 if (!followers.length) return;
774 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
775 const keys = getOrCreateKeys(site.slug);
776 const keyId = `${actorId(base, site.slug)}#main-key`;
777 const create = buildCreate(base, site, post);
778 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, create, keyId, keys.private_pem);
779}
780
781// On a new Follow, send that follower our most recent posts as Create so their
782// timeline shows our history (Mastodon does not backfill on follow). Oldest-first
783// so they sort into the follower's timeline at their original dates.
784async function backfillNewFollower(base, slug, inbox) {
785 if (!base || !slug || !inbox) return;
786 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
787 if (!site) return;
788 const recent = db.prepare(
789 `SELECT id, slug, title, content, cover_image_url, nsfw, content_warning, published_at, created_at
790 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
791 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
792 ).all(site.id).reverse();
793 if (!recent.length) return;
794 const keys = getOrCreateKeys(slug);
795 const keyId = `${actorId(base, slug)}#main-key`;
796 for (const p of recent) {
797 try { await deliver(inbox, buildCreate(base, site, p), keyId, keys.private_pem); } catch { /* best-effort */ }
798 await new Promise((r) => setTimeout(r, 150));
799 }
800 console.log('[AP] backfilled', recent.length, 'posts to new follower of', slug);
801}
802
803// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
804export async function deliverDelete(site, post) {
805 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
806 if (!base || !site || !site.slug || !post || !post.id) return;
807 const followers = fStmts().list.all(site.slug);
808 if (!followers.length) return;
809 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
810 const keys = getOrCreateKeys(site.slug);
811 const me = actorId(base, site.slug);
812 const nid = noteId(base, post.id);
813 const del = {
814 '@context': 'https://www.w3.org/ns/activitystreams',
815 id: `${nid}#delete-${Date.now()}-${rid()}`,
816 type: 'Delete',
817 actor: me,
818 to: [PUBLIC],
819 object: { id: nid, type: 'Tombstone' },
820 };
821 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem);
822}
823
824// Tell followers an already-published post changed (Update + edited Note) so
825// Mastodon refreshes the cached copy (e.g. after fixing content).
826export async function deliverUpdate(site, post) {
827 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
828 if (!base || !site || !site.slug || !post || !post.id) return;
829 const followers = fStmts().list.all(site.slug);
830 if (!followers.length) return;
831 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
832 const keys = getOrCreateKeys(site.slug);
833 const me = actorId(base, site.slug);
834 const note = buildNote(base, site, post);
835 note.updated = new Date().toISOString();
836 const update = {
837 '@context': 'https://www.w3.org/ns/activitystreams',
838 id: `${noteId(base, post.id)}#update-${Date.now()}-${rid()}`,
839 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
840 object: note,
841 };
842 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
843}
844
845// Tell followers the ACTOR changed (Update + Person) so Mastodon re-processes the
846// account AND re-fetches the featured (pinned) collection — there is no standard
847// "featured changed" activity, so this is how a pin/unpin propagates promptly.
848export async function deliverActorUpdate(site) {
849 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
850 if (!base || !site || !site.slug) return;
851 const followers = fStmts().list.all(site.slug);
852 if (!followers.length) return;
853 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
854 const keys = getOrCreateKeys(site.slug);
855 const me = actorId(base, site.slug);
856 const update = {
857 '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'],
858 id: `${me}#update-${Date.now()}-${rid()}`,
859 type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
860 object: buildActor(base, site),
861 };
862 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
863}
864
865// Reliably set the pinned order on followers' instances via Add/Remove activities
866// (how Mastodon itself federates pins) — pushed to the inbox + processed immediately,
867// unlike the featured COLLECTION which Mastodon caches with sticky StatusPins.
868// Mastodon's Add skips an already-pinned status, so we REMOVE every pin first, wait,
869// then ADD in rank-DESCENDING order (rank 1 added LAST → newest StatusPin → shown first,
870// because Mastodon displays pins newest-first). `alsoRemove` = ids to unpin too.
871export async function resyncFeaturedPins(site, alsoRemove = []) {
872 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
873 if (!base || !site || !site.slug) return;
874 const followers = fStmts().list.all(site.slug);
875 if (!followers.length) return;
876 const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
877 const keys = getOrCreateKeys(site.slug);
878 const me = actorId(base, site.slug);
879 const keyId = `${me}#main-key`;
880 const featured = `${me}/featured`;
881 const AS = 'https://www.w3.org/ns/activitystreams';
882 const note = (id) => noteId(base, id);
883 const pinned = db.prepare(
884 `SELECT id FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
885 AND pinned IS NOT NULL AND pinned > 0
886 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
887 ).all(site.id);
888 const removeIds = [...new Set([...pinned.map((p) => p.id), ...alsoRemove])];
889 // 1. Remove every current pin so Mastodon can recreate them in order.
890 for (const id of removeIds) {
891 const rm = { '@context': AS, id: `${me}#rm-${id}-${Date.now()}-${rid()}`, type: 'Remove', actor: me, object: note(id), target: featured, to: [PUBLIC] };
892 for (const inbox of inboxes) deliver(inbox, rm, keyId, keys.private_pem).catch(() => { /* best-effort */ });
893 }
894 if (!pinned.length) { console.log('[AP] unpinned all featured for', site.slug); return; }
895 await new Promise((r) => setTimeout(r, 5000)); // let the Removes land first
896 // 2. Add in rank-DESC order, gaps so each StatusPin gets an increasing created_at.
897 for (const p of pinned) {
898 const add = { '@context': AS, id: `${me}#add-${p.id}-${Date.now()}-${rid()}`, type: 'Add', actor: me, object: note(p.id), target: featured, to: [PUBLIC], cc: [`${me}/followers`] };
899 for (const inbox of inboxes) deliver(inbox, add, keyId, keys.private_pem).catch(() => { /* best-effort */ });
900 await new Promise((r) => setTimeout(r, 2000));
901 }
902 console.log('[AP] resynced', pinned.length, 'featured pins for', site.slug);
903}
904
905// ── outbound replies (Klonkt → fediverse) ─────────────────────────
906const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
907const 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(); };
908
909// Build one of OUR outbound reply Notes from an ap_outbox row.
910// Turn #hashtags in reply text into Mastodon-style hashtag links (clickable + federated).
911function linkHashtags(base, html) {
912 return String(html || '').replace(/(^|[\s>])#([A-Za-z0-9_]+)/g, (m, pre, tag) =>
913 `${pre}<a href="${base}/tag/${encodeURIComponent(tag.toLowerCase())}" class="mention hashtag" rel="tag">#${tag}</a>`);
914}
915// Extract the AP Hashtag tag objects from already-linked reply content.
916function hashtagTags(base, content) {
917 const tags = [], seen = new Set();
918 const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([A-Za-z0-9_]+)</gi;
919 let m;
920 while ((m = re.exec(content || ''))) {
921 const k = m[1].toLowerCase();
922 if (seen.has(k)) continue; seen.add(k);
923 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
924 }
925 return tags;
926}
927
928// Normalise a post's tags field (array, JSON-string, or comma-string) to an array.
929function normalizeTags(t) {
930 if (Array.isArray(t)) return t;
931 if (typeof t === 'string') {
932 const s = t.trim(); if (!s) return [];
933 if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* fall through */ } }
934 return s.split(',').map((x) => x.trim()).filter(Boolean);
935 }
936 return [];
937}
938// A tag → { label, slug }. Multi-word tags become CamelCase (#LiveMusic) for the display
939// name (Mastodon hashtags can't contain spaces; CamelCase is the accessibility norm); the
940// slug/href stays lowercase ("livemusic").
941function tagParts(raw) {
942 const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^A-Za-z0-9]/g, '')).filter(Boolean);
943 if (!words.length) return null;
944 const slug = words.join('').toLowerCase();
945 if (!slug) return null;
946 const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
947 return { label, slug };
948}
949// Merge a post's tags field + the #hashtags linked inline in its body into one deduped
950// Hashtag tag list (with hrefs to our /tag page).
951function buildHashtagList(base, tagsField, content) {
952 const out = [], seen = new Set();
953 for (const t of normalizeTags(tagsField)) {
954 const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
955 out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
956 }
957 for (const h of hashtagTags(base, content)) {
958 const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
959 out.push(h);
960 }
961 return out;
962}
963
964// Extract Mention tag objects from already-linked content (class="u-url mention").
965function mentionTags(content) {
966 const tags = [], seen = new Set();
967 // The link href is the human profile URL; the actor URI (for the Mention tag) is in data-actor.
968 const re = /<a href="[^"]*" class="u-url mention" data-actor="([^"]+)">@([^<]+)<\/a>/gi;
969 let m;
970 while ((m = re.exec(content || ''))) {
971 const href = m[1];
972 if (seen.has(href)) continue; seen.add(href);
973 tags.push({ type: 'Mention', href, name: '@' + m[2] });
974 }
975 return tags;
976}
977// Resolve inline @user@domain mentions in reply/post text → link them (href = actor URI)
978// and collect the mentioned actors' inboxes so they get notified. Best-effort per mention.
979async function resolveMentionsInText(base, html) {
980 const inboxes = [];
981 const handles = new Set();
982 const re = /(^|[\s>])@([A-Za-z0-9_.-]+@[A-Za-z0-9.-]+)/g;
983 let m;
984 while ((m = re.exec(html || ''))) handles.add(m[2]);
985 let out = String(html || '');
986 for (const h of handles) {
987 let actorUri = null;
988 try { actorUri = await webfingerResolve('@' + h); } catch { actorUri = null; }
989 if (!actorUri) continue;
990 const actor = await fetchActor(actorUri).catch(() => null);
991 const inbox = actor && ((actor.endpoints && actor.endpoints.sharedInbox) || actor.inbox);
992 if (inbox) inboxes.push(inbox);
993 const profileUrl = actorInfo(actor, actorUri).url || actorUri; // human profile page → the link href
994 const esc = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
995 out = out.replace(new RegExp('(^|[\\s>])@' + esc + '(?![A-Za-z0-9_.-])', 'g'),
996 (full, pre) => `${pre}<a href="${profileUrl}" class="u-url mention" data-actor="${actorUri}">@${h}</a>`);
997 }
998 return { html: out, inboxes };
999}
1000
1001export function buildReplyNote(base, site, row) {
1002 const me = actorId(base, site.slug);
1003 return {
1004 id: noteId(base, row.id),
1005 type: 'Note',
1006 attributedTo: me,
1007 inReplyTo: row.in_reply_to || undefined,
1008 content: row.content,
1009 url: row.post_slug ? `${base}/${encodeURIComponent(row.post_slug)}` : undefined,
1010 published: toISO(row.created_at),
1011 to: row.to_actor ? [row.to_actor] : [PUBLIC],
1012 cc: [PUBLIC, `${me}/followers`],
1013 tag: [
1014 ...mentionTags(row.content),
1015 ...hashtagTags(base, row.content),
1016 ],
1017 };
1018}
1019
1020// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
1021export function getOutboxNote(base, id) {
1022 const row = iStmts().getO.get(id);
1023 if (!row) return null;
1024 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
1025 if (!site) return null;
1026 return buildReplyNote(base, site, row);
1027}
1028
1029// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
1030// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
1031export async function deliverReply(site, { postId, postSlug, parent, text }) {
1032 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1033 if (!base || !site || !site.slug || !parent || !String(text || '').trim()) return null;
1034 const me = actorId(base, site.slug);
1035 const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
1036 const dispHandle = handle && handle[0] === '@' ? handle : '@' + (handle || '');
1037 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
1038 const mres = await resolveMentionsInText(base, body); // link inline @mentions + collect their inboxes
1039 const mention = parent.actor_uri
1040 ? `<a href="${escHtml(parent.actor_url || parent.actor_uri)}" class="u-url mention" data-actor="${escHtml(parent.actor_uri)}">${escHtml(dispHandle)}</a> ` : '';
1041 const content = `<p>${mention}${linkHashtags(base, mres.html)}</p>`;
1042 // Dedup: skip if the exact same reply was already sent (double-submit guard).
1043 const dup = db.prepare('SELECT 1 FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? LIMIT 1')
1044 .get(site.slug, parent.object_uri || '', content);
1045 if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
1046 const id = crypto.randomUUID();
1047 iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content);
1048 const row = iStmts().getO.get(id);
1049 const note = buildReplyNote(base, site, row);
1050 const create = {
1051 '@context': 'https://www.w3.org/ns/activitystreams',
1052 id: note.id + '#create', type: 'Create', actor: me,
1053 published: note.published, to: note.to, cc: note.cc, object: note,
1054 };
1055 const keys = getOrCreateKeys(site.slug);
1056 const keyId = `${me}#main-key`;
1057 const inboxes = new Set();
1058 if (parent.actor_uri) {
1059 const a = await fetchActor(parent.actor_uri).catch(() => null);
1060 if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
1061 }
1062 if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
1063 (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
1064 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1065 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the reply
1066 inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
1067 inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
1068 let delivered = 0;
1069 for (const inbox of [...inboxes].filter(Boolean)) {
1070 try { const st = await deliver(inbox, create, keyId, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
1071 }
1072 console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
1073 return { id, content, delivered };
1074}
1075
1076// attributedTo may be a string, an object {id}, or an ARRAY — e.g. a PeerTube Video is
1077// attributed to [Person (account), Group (channel)]. Pick a usable actor URI (prefer Person).
1078function actorUriOf(att) {
1079 if (!att) return null;
1080 if (typeof att === 'string') return att;
1081 if (Array.isArray(att)) {
1082 const person = att.find((a) => a && typeof a === 'object' && a.type === 'Person' && a.id);
1083 if (person) return person.id;
1084 for (const a of att) { if (typeof a === 'string') return a; if (a && a.id) return a.id; }
1085 return null;
1086 }
1087 return att.id || null;
1088}
1089
1090// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
1091// Returns a parent-shaped object usable by deliverReply(), or null.
1092export async function resolveRemoteNote(url) {
1093 if (!/^https?:\/\//i.test(String(url || ''))) return null;
1094 const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
1095 if (!note || !note.id) return null;
1096 const att = note.attributedTo;
1097 const actorUri = actorUriOf(att);
1098 if (!actorUri) return null;
1099 const actor = await fetchActor(actorUri).catch(() => null);
1100 const ai = actorInfo(actor, actorUri);
1101 // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
1102 // link our reply to that local post so it shows nested in the post thread.
1103 const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
1104 // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
1105 // and collect every ancestor author's inbox, so each participant's server —
1106 // including the original post's author — receives + threads our reply.
1107 const threadInboxes = [];
1108 const seenInbox = new Set();
1109 let cursor = note.inReplyTo, guard = 0;
1110 while (cursor && guard++ < 6) {
1111 const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
1112 if (!url) break;
1113 const pn = await fetchActor(url).catch(() => null);
1114 if (!pn) break;
1115 const pa = actorUriOf(pn.attributedTo);
1116 if (pa && pa !== actorUri) {
1117 const paDoc = await fetchActor(pa).catch(() => null);
1118 const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
1119 if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
1120 }
1121 cursor = pn.inReplyTo; // climb to the next ancestor
1122 }
1123 // For non-Note objects (PeerTube Video, Article, …) the meaningful label is `name` (the
1124 // title); prepend it so the reply page shows what you're replying to (sanitize cleans it).
1125 let rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
1126 if (note.name && note.type && note.type !== 'Note') rawHtml = `<p><strong>${note.name}</strong></p>` + rawHtml;
1127 const images = (Array.isArray(note.attachment) ? note.attachment : [])
1128 .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
1129 .map((a) => safeUrl(a.url)).filter(Boolean);
1130 return {
1131 object_uri: safeUrl(note.id) || note.id,
1132 actor_uri: actorUri,
1133 actor_url: ai.url,
1134 actor_handle: ai.handle,
1135 actor_name: ai.name,
1136 actor_icon: ai.icon,
1137 url: note.url || url,
1138 content: HtmlSanitizerService.sanitize(rawHtml), // full, sanitized
1139 sensitive: !!note.sensitive, // remote CW → blur in the Cirkel
1140 cw: note.summary || '',
1141 images,
1142 threadInboxes, // every ancestor author's inbox
1143 localPostId: localTgt ? localTgt.post_id : '', // our post this belongs to (if any)
1144 preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
1145 };
1146}
1147
1148// List a site's own outbound fediverse replies (for the manage/delete view).
1149// The plain editable text of a stored reply (unwrap links → their text, <br> → newline)
1150// so the manage view can prefill an edit box; the mention is re-added on save.
1151function outboxEditableText(content) {
1152 return String(content || '')
1153 .replace(/<br\s*\/?>/gi, '\n')
1154 .replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
1155 .replace(/<[^>]+>/g, '')
1156 .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
1157 .trim();
1158}
1159export function listOutbox(siteSlug) {
1160 return db.prepare('SELECT id, content, to_handle, in_reply_to, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
1161 .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
1162}
1163
1164// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
1165export async function deliverOutboxDelete(site, outboxId) {
1166 const row = iStmts().getO.get(outboxId);
1167 if (!row || row.site_slug !== site.slug) return false;
1168 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1169 if (base) {
1170 const me = actorId(base, site.slug);
1171 const nid = noteId(base, row.id);
1172 const del = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${nid}#delete-${Date.now()}-${rid()}`, type: 'Delete', actor: me, to: [PUBLIC], object: { id: nid, type: 'Tombstone' } };
1173 const keys = getOrCreateKeys(site.slug);
1174 const inboxes = new Set();
1175 if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
1176 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1177 for (const inbox of [...inboxes].filter(Boolean)) { try { await deliver(inbox, del, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } }
1178 }
1179 db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
1180 return true;
1181}
1182
1183// Edit one of our outbound replies: rewrite the stored content (mention re-added + #tags
1184// re-linked) and send an Update(Note) so recipients refresh their cached copy.
1185export async function deliverOutboxUpdate(site, outboxId, newText) {
1186 const row = iStmts().getO.get(outboxId);
1187 if (!row || row.site_slug !== site.slug) return false;
1188 const text = String(newText || '').trim();
1189 if (!text) return false;
1190 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1191 if (!base) return false;
1192 const me = actorId(base, site.slug);
1193 const toActor = row.to_actor ? await fetchActor(row.to_actor).catch(() => null) : null;
1194 const toProfile = row.to_actor ? (actorInfo(toActor, row.to_actor).url || row.to_actor) : '';
1195 const _h = row.to_handle || deriveHandle(row.to_actor);
1196 const toHandle = _h && _h[0] === '@' ? _h : '@' + (_h || '');
1197 const mention = row.to_actor
1198 ? `<a href="${escHtml(toProfile)}" class="u-url mention" data-actor="${escHtml(row.to_actor)}">${escHtml(toHandle)}</a> ` : '';
1199 const mres = await resolveMentionsInText(base, escHtml(text).replace(/\r?\n/g, '<br>'));
1200 const content = `<p>${mention}${linkHashtags(base, mres.html)}</p>`;
1201 db.prepare('UPDATE ap_outbox SET content = ? WHERE id = ?').run(content, outboxId);
1202 const note = buildReplyNote(base, site, iStmts().getO.get(outboxId));
1203 note.updated = new Date().toISOString();
1204 const update = {
1205 '@context': 'https://www.w3.org/ns/activitystreams',
1206 id: `${note.id}#update-${Date.now()}-${rid()}`, type: 'Update', actor: me,
1207 published: note.published, updated: note.updated, to: note.to, cc: note.cc, object: note,
1208 };
1209 const keys = getOrCreateKeys(site.slug);
1210 const inboxes = new Set();
1211 if (toActor) inboxes.add((toActor.endpoints && toActor.endpoints.sharedInbox) || toActor.inbox);
1212 for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
1213 mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the edit
1214 inboxes.delete(`${me}/inbox`); inboxes.delete(`${base}/ap/inbox`);
1215 let delivered = 0;
1216 for (const inbox of [...inboxes].filter(Boolean)) {
1217 try { const st = await deliver(inbox, update, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
1218 }
1219 console.log('[AP] outreply edit', site.slug, 'delivered', delivered);
1220 return { ok: true, content, delivered };
1221}
1222
1223// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
1224// Resolve an @user@domain handle to its actor URL via WebFinger.
1225export async function webfingerResolve(handle) {
1226 const h = String(handle || '').trim().replace(/^@/, '');
1227 const parts = h.split('@');
1228 if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
1229 const acct = `${parts[0]}@${parts[1]}`;
1230 try {
1231 const r = await safeFetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
1232 { headers: { Accept: 'application/jrd+json, application/json' } });
1233 if (!r.ok) return null;
1234 const jrd = await r.json();
1235 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
1236 return safeUrl(link ? link.href : '') || null;
1237 } catch { return null; }
1238}
1239
1240let _insFw, _delFw, _listFw, _accFw, _oneFw, _setAB;
1241function fwStmts() {
1242 if (!_insFw) {
1243 _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)');
1244 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
1245 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
1246 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
1247 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
1248 _setAB = db.prepare('UPDATE ap_following SET auto_boost = ? WHERE slug = ? AND actor_uri = ?');
1249 }
1250 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw, setAB: _setAB };
1251}
1252export function listFollowing(slug) { return fwStmts().list.all(slug); }
1253
1254// Toggle auto-boost ("feature") on an account we already follow.
1255export function setAutoBoost(slug, actorUri, on) {
1256 try { fwStmts().setAB.run(on ? 1 : 0, slug, actorUri); } catch { /* ignore */ }
1257 return { ok: true };
1258}
1259
1260let _insTl, _listTl, _delTl;
1261function tlStmts() {
1262 if (!_insTl) {
1263 _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)');
1264 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ?');
1265 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
1266 }
1267 return { ins: _insTl, list: _listTl, del: _delTl };
1268}
1269export function getTimeline(slug, limit) { return tlStmts().list.all(slug, limit || 50); }
1270
1271// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
1272let _abCount, _cirkelPosts, _cirkelMembers;
1273export function autoBoostCount(slug) {
1274 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; }
1275}
1276export function getCirkelPosts(slug, limit) {
1277 try {
1278 // Cirkel = posts from featured (auto_boost) accounts + posts you boosted
1279 // (t.boosted), mixed by date. One row per note in ap_timeline → no duplicates.
1280 if (!_cirkelPosts) _cirkelPosts = db.prepare(`
1281 SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
1282 t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
1283 FROM ap_timeline t
1284 LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
1285 WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
1286 ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
1287 LIMIT ?`);
1288 return _cirkelPosts.all(slug, limit || 60);
1289 } catch { return []; }
1290}
1291export function getCirkelMembers(slug) {
1292 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 []; }
1293}
1294// Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
1295let _markBoost, _unmarkBoost, _boostedCount;
1296export function markBoosted(slug, noteId) {
1297 try { if (!_markBoost) _markBoost = db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?'); _markBoost.run(slug, noteId); } catch { /* ignore */ }
1298}
1299export function unmarkBoosted(slug, noteId) {
1300 try { if (!_unmarkBoost) _unmarkBoost = db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?'); _unmarkBoost.run(slug, noteId); } catch { /* ignore */ }
1301}
1302let _markLike, _unmarkLike;
1303export function markLiked(slug, noteId) {
1304 try { if (!_markLike) _markLike = db.prepare('UPDATE ap_timeline SET liked = 1 WHERE slug = ? AND id = ?'); _markLike.run(slug, noteId); } catch { /* ignore */ }
1305}
1306export function unmarkLiked(slug, noteId) {
1307 try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
1308}
1309export function getTimelineReaction(slug, noteId) {
1310 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 }; }
1311}
1312// Boost a REMOTE post that may not be in your timeline (you don't follow the author):
1313// store it in ap_timeline (INSERT OR IGNORE → no dup for followed posts) so it shows in
1314// the Cirkel with a Boost badge, then flag it boosted.
1315export function upsertBoostedNote(slug, note) {
1316 if (!slug || !note || !note.object_uri) return;
1317 const id = note.object_uri;
1318 const media = JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
1319 try {
1320 tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
1321 note.actor_icon || '', note.actor_url || '', note.content || '', note.url || null,
1322 new Date().toISOString(), media, note.sensitive ? 1 : 0, note.cw || null);
1323 } catch { /* ignore */ }
1324 markBoosted(slug, id);
1325}
1326export function boostedCount(slug) {
1327 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; }
1328}
1329
1330// Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to
1331// /ap/users/<slug> (content negotiation; Location may be relative). Used by
1332// followActor for bare-domain follows.
1333// NB: the old auto-migration of legacy Cirkels (circle_links -> AP follows) was
1334// REMOVED on 2026-06-26 — it auto-sent Follows on boot, which violates "the code
1335// never throws anything into the fediverse automatically" (would surprise-Follow
1336// for some operators at scale). The dead circle_links table stays as harmless dead
1337// data; an operator restores an old cirkel by re-following in /following (their click).
1338async function resolveApActor(siteUrl) {
1339 try {
1340 const r = await fetch(siteUrl, { headers: { Accept: 'application/activity+json' }, redirect: 'manual' });
1341 if (r.status >= 300 && r.status < 400) { const loc = r.headers.get('location'); if (loc) return new URL(loc, siteUrl).href; }
1342 if (r.ok) return siteUrl;
1343 } catch { /* unreachable */ }
1344 return null;
1345}
1346
1347// ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ──
1348// Runs ONCE per SELFHEAL_VERSION bump — NOT on every boot. Re-fetches each cached
1349// note and refreshes content + media (recovers covers/edits that were delivered
1350// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
1351// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
1352const SELFHEAL_VERSION = 2;
1353async function fetchNoteAP(url) {
1354 try {
1355 const r = await fetch(url, { headers: { Accept: 'application/activity+json' } });
1356 if (r.status === 404 || r.status === 410) return 404;
1357 if (r.ok) return await r.json();
1358 } catch { /* unreachable */ }
1359 return null;
1360}
1361function mediaFromNote(note) {
1362 const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
1363 if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) {
1364 const im = Array.isArray(note.image) ? note.image[0] : note.image;
1365 const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
1366 if (iu) atts.push({ url: iu, type: (im && im.mediaType) || 'image/jpeg' });
1367 }
1368 return JSON.stringify(atts);
1369}
1370let _selfHealing = false;
1371export async function selfHealTimeline() {
1372 if (_selfHealing) return; _selfHealing = true;
1373 try {
1374 let cur = 0;
1375 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; }
1376 if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
1377 let rows = [];
1378 try { rows = db.prepare('SELECT id, content, media_json, nsfw, cw FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
1379 let healed = 0;
1380 for (const r of rows) {
1381 try {
1382 const note = await fetchNoteAP(r.id);
1383 if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
1384 if (!note || typeof note !== 'object') continue;
1385 const html = HtmlSanitizerService.sanitize(note.content || '');
1386 const media = mediaFromNote(note);
1387 const nsfw = note.sensitive ? 1 : 0; // re-sync NSFW/sensitive + CW onto already-cached posts
1388 const cw = note.summary || null;
1389 if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '')) {
1390 db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ? WHERE id = ?').run(html || r.content, media, nsfw, cw, r.id);
1391 healed++;
1392 }
1393 } catch { /* per-note best-effort */ }
1394 }
1395 try { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('selfheal_version', String(SELFHEAL_VERSION)); } catch { /* ignore */ }
1396 if (rows.length) console.log(`[AP] self-heal v${SELFHEAL_VERSION}: ${healed}/${rows.length} timeline notes`);
1397 } catch { /* never block boot */ } finally { _selfHealing = false; }
1398}
1399
1400// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
1401export async function followActor(site, handle, autoBoost = false) {
1402 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1403 if (!base || !site || !site.slug) return { error: 'config' };
1404 // Accept any of: a profile/actor URL, an @user@host handle (WebFinger), or a
1405 // bare site domain (site.com) — for a single-actor site (Klonkt etc.) the root
1406 // resolves to its AP actor, so you can follow a site by just its domain.
1407 const s = String(handle || '').trim();
1408 let actorUrl;
1409 if (/^https?:\/\//i.test(s)) actorUrl = safeUrl(s) || null;
1410 else if (s.includes('@')) actorUrl = await webfingerResolve(s);
1411 else if (/^[a-z0-9.-]+\.[a-z]{2,}/i.test(s)) actorUrl = await resolveApActor('https://' + s.replace(/^\/+|\/+$/g, ''));
1412 else actorUrl = null;
1413 if (!actorUrl) return { error: 'not_found' };
1414 const actor = await fetchActor(actorUrl).catch(() => null);
1415 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
1416 const ai = actorInfo(actor, actor.id);
1417 const me = actorId(base, site.slug);
1418 const keys = getOrCreateKeys(site.slug);
1419 const followId = `${me}#follow-${Date.now()}-${rid()}`;
1420 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending', autoBoost ? 1 : 0);
1421 const follow = { '@context': 'https://www.w3.org/ns/activitystreams', id: followId, type: 'Follow', actor: me, object: actor.id };
1422 try { await deliver(actor.inbox, follow, `${me}#main-key`, keys.private_pem); }
1423 catch (e) { console.warn('[AP] follow deliver failed:', e.message); }
1424 console.log('[AP] follow', site.slug, '→', actor.id);
1425 return { ok: true, name: ai.name, handle: ai.handle, actor: actor.id };
1426}
1427
1428// Resolve a profile URL or @handle to a followable remote actor (for the
1429// authorize_interaction "Follow" flow). Returns display fields + inbox, or null
1430// when it isn't a reachable actor (e.g. the input was a post, not a profile).
1431export async function resolveRemoteActor(input) {
1432 const s = String(input || '').trim();
1433 const actorUrl = /^https?:\/\//i.test(s) ? (safeUrl(s) || null) : await webfingerResolve(s);
1434 if (!actorUrl) return null;
1435 const actor = await fetchActor(actorUrl).catch(() => null);
1436 if (!actor || !actor.id || !actor.inbox) return null;
1437 const ai = actorInfo(actor, actor.id);
1438 return { actor_uri: actor.id, actor_name: ai.name, actor_handle: ai.handle, actor_url: ai.url, actor_icon: ai.icon, inbox: actor.inbox };
1439}
1440
1441export async function unfollowActor(site, actorUri) {
1442 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1443 const me = actorId(base, site.slug);
1444 const keys = getOrCreateKeys(site.slug);
1445 const row = fwStmts().one.get(site.slug, actorUri);
1446 if (row && row.inbox) {
1447 const undo = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#unfollow-${Date.now()}-${rid()}`, type: 'Undo', actor: me, object: { id: row.follow_id || `${me}#follow`, type: 'Follow', actor: me, object: actorUri } };
1448 try { await deliver(row.inbox, undo, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ }
1449 }
1450 fwStmts().del.run(site.slug, actorUri);
1451 return { ok: true };
1452}
1453
1454// Send a Like or Announce (boost) on a remote note FROM this site.
1455export async function sendInteraction(site, kind, targetNoteId, authorUri) {
1456 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
1457 if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
1458 const me = actorId(base, site.slug);
1459 const keys = getOrCreateKeys(site.slug);
1460 // 'unboost' = Undo(Announce): retracts a boost so followers' servers remove the
1461 // reblog (matched on actor+object — no record of the original Announce needed).
1462 const fanout = (kind === 'boost' || kind === 'unboost'); // also goes to our followers
1463 let act;
1464 if (kind === 'unboost' || kind === 'unlike') {
1465 // Undo(Announce) retracts a boost; Undo(Like) un-favourites (matched on actor+object,
1466 // no record of the original activity needed — Mastodon honours both).
1467 const inner = kind === 'unboost' ? 'Announce' : 'Like';
1468 act = {
1469 '@context': 'https://www.w3.org/ns/activitystreams',
1470 id: `${me}#undo-${Date.now()}-${rid()}`, type: 'Undo', actor: me,
1471 object: { id: `${me}#${inner.toLowerCase()}-${Date.now()}-${rid()}`, type: inner, actor: me, object: targetNoteId },
1472 };
1473 if (kind === 'unboost') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
1474 } else {
1475 const type = kind === 'boost' ? 'Announce' : 'Like';
1476 act = {
1477 '@context': 'https://www.w3.org/ns/activitystreams',
1478 id: `${me}#${type.toLowerCase()}-${Date.now()}-${rid()}`,
1479 type, actor: me, object: targetNoteId,
1480 };
1481 if (type === 'Announce') { act.to = [PUBLIC]; act.cc = [`${me}/followers`]; }
1482 }
1483 const inboxes = new Set();
1484 if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
1485 if (fanout) { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
1486 let delivered = 0;
1487 for (const inbox of [...inboxes].filter(Boolean)) { try { const st = await deliver(inbox, act, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ } }
1488 console.log('[AP]', kind, site.slug, '→', targetNoteId, 'delivered', delivered);
1489 return { ok: true, delivered };
1490}
1491
1492// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
1493export function getNotifications(slug, limit) {
1494 const out = [];
1495 try {
1496 for (const f of db.prepare('SELECT actor_uri, created_at FROM ap_followers WHERE slug = ? ORDER BY created_at DESC LIMIT 50').all(slug)) {
1497 out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
1498 }
1499 } catch { /* ignore */ }
1500 try {
1501 const rows = db.prepare(`
1502 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.content, i.created_at,
1503 p.slug AS post_slug, p.title AS post_title
1504 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
1505 WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
1506 ORDER BY i.created_at DESC LIMIT 80
1507 `).all(slug);
1508 for (const r of rows) out.push({
1509 type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url,
1510 content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
1511 });
1512 } catch { /* ignore */ }
1513 out.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
1514 return out.slice(0, limit || 60);
1515}
1516
1517// ── Blocking / defederation ───────────────────────────────────────
1518let _insBl, _delBl, _listBl;
1519function blStmts() {
1520 if (!_insBl) {
1521 _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
1522 _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
1523 _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
1524 }
1525 return { ins: _insBl, del: _delBl, list: _listBl };
1526}
1527export function listBlocks(slug) { return blStmts().list.all(slug); }
1528
1529// True if an actor (or its whole domain) is blocked anywhere on this instance.
1530export function isBlockedAny(actorUri) {
1531 if (!actorUri) return false;
1532 let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
1533 try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
1534 catch { return false; }
1535}
1536
1537function purgeBlocked(kind, target) {
1538 try {
1539 if (kind === 'domain') {
1540 const like = `%//${target}/%`;
1541 db.prepare('DELETE FROM ap_interactions WHERE actor_uri LIKE ?').run(like);
1542 db.prepare('DELETE FROM ap_timeline WHERE author_uri LIKE ?').run(like);
1543 db.prepare('DELETE FROM ap_followers WHERE actor_uri LIKE ?').run(like);
1544 } else {
1545 db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
1546 db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
1547 db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
1548 }
1549 } catch { /* best-effort */ }
1550}
1551
1552// Block an actor (@handle or actor URL) or a whole domain; purges their content.
1553export async function blockTarget(site, input) {
1554 const raw = String(input || '').trim();
1555 if (!site || !site.slug || !raw) return { error: 'empty' };
1556 let kind, target, label;
1557 if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
1558 else if (raw.includes('@')) {
1559 const actorUrl = await webfingerResolve(raw);
1560 if (!actorUrl) return { error: 'not_found' };
1561 kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
1562 } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
1563 blStmts().ins.run(site.slug, target, kind, label);
1564 purgeBlocked(kind, target);
1565 console.log('[AP] block', site.slug, kind, target);
1566 return { ok: true, label };
1567}
1568
1569export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
1570
1571export default {
1572 getOrCreateKeys, apWants, sendAP, actorId, noteId,
1573 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFeatured,
1574 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
1575 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
1576 listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
1577 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, getTimeline, sendInteraction,
1578 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
1579 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
1580 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
1581 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
1582};
Note: See TracBrowser for help on using the repository browser.