/**
* ActivityPubService — Klonkt as a real ActivityPub actor (fediverse bridge).
*
* Phase 1 (this file): the PUBLISH/discoverable side.
* - per-site RSA keypair (Mastodon-compatible HTTP Signatures; separate from
* the Ed25519 keys used by the lighter Cirkels v1)
* - builders for the Actor document, Note objects and the Outbox collection
* - apWants(): HTTP content-negotiation helper (activity+json vs HTML)
*
* The interactive side (inbox: Follow/Accept, signature verify, delivery to
* followers) lands in the next step and is tested live against Mastodon.
*
* AP actor URLs live under /ap/* so they never clash with the human pages:
* actor = /ap/users/
* inbox = /inbox outbox = /outbox
* note = /ap/notes/
*/
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import dns from 'dns';
import net from 'net';
import db from '../config/database.js';
import HtmlSanitizerService from './HtmlSanitizerService.js';
import AudioEmbedService from './AudioEmbedService.js';
import EmbedResolver from './EmbedResolver.js';
import Push from './PushService.js';
import { getTenancy } from './SettingsService.js';
import { t as i18nT } from './i18n.js';
import Blocklist from './BlocklistService.js';
import * as Guardianship from './guardianship/index.js';
const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
// Full JSON-LD context for every AP object we emit: AS2 core + security (publicKey) + the
// extension terms we actually use (Mastodon/toot + schema.org), each with a term definition
// so a strict JSON-LD processor resolves them instead of dropping them → valid AS2/JSON-LD.
// This is the same context shape Mastodon publishes, so Mastodon sees no change.
const AP_CONTEXT = [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{
toot: 'http://joinmastodon.org/ns#',
schema: 'http://schema.org#',
sensitive: 'as:sensitive',
Hashtag: 'as:Hashtag',
manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
discoverable: 'toot:discoverable',
featured: { '@id': 'toot:featured', '@type': '@id' },
PropertyValue: 'schema:PropertyValue',
value: 'schema:value',
embedUrl: { '@id': 'schema:embedUrl', '@type': '@id' },
// Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
// per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
// Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
votersCount: 'toot:votersCount',
// FEP-633c (Guardians): the shaer namespace, owned by the guardianship
// module (src/services/guardianship/).
...Guardianship.SHAER_CONTEXT,
},
];
// Short random suffix so two activity ids minted in the same millisecond (e.g.
// parallel saves) don't collide and get deduped by a receiver.
const rid = () => crypto.randomBytes(4).toString('hex');
// Keep only http(s) URLs — drops javascript:/data:/etc so a remote actor can't
// smuggle a dangerous scheme into a stored href/src (rendered in owner-only views).
const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
// ── SSRF guard for outbound fetches ───────────────────────────────
// Remote URLs (actor/keyId/webfinger/inbox/inReplyTo) are attacker-controlled, so
// every outbound fetch must refuse hosts that resolve to private/loopback ranges
// (cloud metadata, internal services) — on the initial host AND each redirect hop.
function isBlockedIp(ip) {
if (!ip) return true;
const v = net.isIP(ip);
if (v === 4) {
const o = ip.split('.').map(Number);
return o[0] === 127 || o[0] === 10 || o[0] === 0
|| (o[0] === 172 && o[1] >= 16 && o[1] <= 31)
|| (o[0] === 192 && o[1] === 168)
|| (o[0] === 169 && o[1] === 254)
|| (o[0] === 100 && o[1] >= 64 && o[1] <= 127); // CGNAT
}
if (v === 6) {
const s = ip.toLowerCase().replace(/^\[|\]$/g, '');
return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')
|| s.startsWith('::ffff:127.') || s.startsWith('::ffff:10.') || s.startsWith('::ffff:192.168.')
|| s.startsWith('::ffff:169.254.') || s.startsWith('::ffff:172.');
}
return true; // not an IP literal we recognise → refuse
}
async function assertPublicHost(hostname) {
if (net.isIP(hostname)) { if (isBlockedIp(hostname)) throw new Error('ssrf-blocked-ip'); return; }
const addrs = await dns.promises.lookup(hostname, { all: true });
if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');
}
export async function safeFetch(url, opts = {}, maxRedirects = 3) {
let target = url;
for (let hop = 0; ; hop++) {
const u = new URL(target); // throws on malformed → caller's catch
if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('ssrf-bad-scheme');
await assertPublicHost(u.hostname);
const r = await fetch(target, { ...opts, redirect: 'manual', signal: AbortSignal.timeout(8000) });
const loc = (r.status >= 300 && r.status < 400) ? r.headers.get('location') : null;
if (loc && hop < maxRedirects) { target = new URL(loc, target).toString(); continue; }
return r;
}
}
const MAX_OUTBOX = 20;
// Cache-buster for the music listen-link → forces Mastodon to re-crawl a FRESH
// (square) player card. Bump this whenever the twitter:player card dimensions change.
const FEDI_CARD_VER = '2';
// ── RSA keys per actor (lazy, cached in DB) ───────────────────────
// Prepared lazily (NOT at module load) — the ap_keys table is created in
// initializeDatabase(), which runs after this module is imported.
let _sel, _ins;
function keyStmts() {
if (!_sel) {
_sel = db.prepare('SELECT public_pem, private_pem FROM ap_keys WHERE slug = ?');
_ins = db.prepare('INSERT OR IGNORE INTO ap_keys (slug, public_pem, private_pem, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)');
}
return { sel: _sel, ins: _ins };
}
export function getOrCreateKeys(slug) {
const { sel, ins } = keyStmts();
const row = sel.get(slug);
if (row) return row;
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
ins.run(slug, publicKey, privateKey);
return sel.get(slug) || { public_pem: publicKey, private_pem: privateKey };
}
// ── content negotiation ───────────────────────────────────────────
// True when the caller wants ActivityPub JSON rather than the HTML page.
export function apWants(req) {
const a = String(req.headers.accept || '').toLowerCase();
return a.includes('application/activity+json') ||
(a.includes('application/ld+json') && a.includes('activitystreams'));
}
const AP_CONTENT_TYPE = 'application/activity+json; charset=utf-8';
export function sendAP(res, obj, cacheControl) {
res.type(AP_CONTENT_TYPE);
// A per-caller (e.g. guardian-widened) view must not be publicly cached.
res.set('Cache-Control', cacheControl || 'public, max-age=120');
res.send(JSON.stringify(obj));
}
// ── document builders ─────────────────────────────────────────────
export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
export function buildActor(base, site) {
const id = actorId(base, site.slug);
const keys = getOrCreateKeys(site.slug);
// FEP-633c §5.3: a ward's follows are gated (guardians approve), so the actor
// MUST advertise manuallyApprovesFollowers:true — otherwise a follower's server
// (Mastodon) assumes auto-accept and shows "Following" while we hold it pending.
const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
const actor = {
'@context': AP_CONTEXT,
id,
type: 'Person',
preferredUsername: site.slug,
name: site.title || site.slug,
summary: site.tagline || site.description || '',
url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
manuallyApprovesFollowers: isWard,
discoverable: true,
inbox: `${id}/inbox`,
outbox: `${id}/outbox`,
followers: `${id}/followers`,
following: `${id}/following`,
featured: `${id}/featured`,
// AP §5.6: the private blocked collection (owner-only GET). The server
// list is the source of truth for Shaer's "in Orbit"; clients keep no
// separate state.
blocked: `${id}/blocked`,
// FEP-633c §2: shaer:guardians / shaer:isGuardian / shaer:queues
// (guardianship module owns these).
...Guardianship.guardianshipActorProps(id, site.slug),
// C2S clients (Shaer apps) discover auth + upload here — no hardcoded paths.
// All four are ActivityPub-spec `endpoints` terms. Dynamic client registration
// (RFC 7591) is discovered via /.well-known/oauth-authorization-server, not here.
endpoints: {
sharedInbox: `${base}/ap/inbox`,
oauthAuthorizationEndpoint: `${base}/oauth/authorize`,
oauthTokenEndpoint: `${base}/oauth/token`,
uploadMedia: `${id}/uploadMedia`,
},
publicKey: {
id: `${id}#main-key`,
owner: id,
publicKeyPem: keys.public_pem,
},
};
if (site.profile_photo) {
const u = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
actor.icon = { type: 'Image', url: u };
}
// Account creation date — shown by Mastodon + read by indexers (additive, standard AS2).
if (site.created_at) { try { actor.published = new Date(site.created_at).toISOString(); } catch { /* skip bad date */ } }
// Profile links → PropertyValue rows: Mastodon/PeerTube/WordPress-ActivityPub render these as
// profile metadata (rel=me enables link-back verification). Additive; ignored by simpler receivers.
try {
const links = JSON.parse(site.profile_links || '[]');
if (Array.isArray(links) && links.length) {
const esc = (s) => String(s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
const rows = links
.filter((l) => l && l.url && /^https?:/i.test(l.url))
.map((l) => ({
type: 'PropertyValue',
name: esc(l.platform || 'Link'),
value: `${esc(String(l.url).replace(/^https?:\/\//, ''))}`,
}));
if (rows.length) actor.attachment = rows;
}
} catch { /* skip malformed profile_links */ }
return actor;
}
// Does a post's audio shortcodes reference at least one PLAYABLE (file-backed)
// track? Link-only tracks (external Spotify/YouTube, media_id NULL) don't count —
// they have no Klonkt-hosted audio to embed, so no player card / cover-suppression.
export function hasPlayableAudio(content, siteId) {
if (!content || !/\[\[(track|album|playlist):/i.test(content)) return false;
try {
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; }
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; }
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; }
} catch { /* non-fatal */ }
return false;
}
// A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
export function buildNote(base, site, post, opts = {}) {
// Replies are Notes too. buildNote is the single entry point for ALL Notes; a reply is
// (for now) the simple flavor: pre-baked content, no title/cover/image/audio/embed
// machinery, addressed to the parent actor + thread. This early branch keeps that output
// byte-identical to the old buildReplyNote. When rich replies land (images/audio/embeds),
// this branch collapses and replies flow through the full post pipeline below. `post` here
// is the ap_outbox reply row (id, in_reply_to, content, post_slug, created_at, to_actor).
if (opts.isReply) {
const meR = actorId(base, site.slug);
// Rich replies: attachments column (JSON [{url, mediaType, name}]) → AS2
// attachment array with absolute URLs and the matching object type.
let replyAtt;
try {
const list = post.attachments ? JSON.parse(post.attachments) : [];
if (Array.isArray(list) && list.length) {
replyAtt = list.map((a) => ({
type: a.mediaType.startsWith('image/') ? 'Image' : a.mediaType.startsWith('audio/') ? 'Audio' : 'Video',
mediaType: a.mediaType,
url: /^https?:/i.test(a.url) ? a.url : `${base}${a.url}`,
name: a.name || undefined,
}));
}
} catch { /* malformed attachments never block the Note */ }
return {
id: noteId(base, post.id),
type: 'Note',
attributedTo: meR,
inReplyTo: post.in_reply_to || undefined,
content: post.content,
// Reply language (rich replies): the AS2 language map next to `content`.
contentMap: post.language ? { [post.language]: post.content } : undefined,
attachment: replyAtt,
url: post.post_slug ? `${base}/${encodeURIComponent(post.post_slug)}` : undefined,
published: toISO(post.created_at),
// A direct note (private mention, shaer-tqc) addresses ONLY its
// recipients: no Public anywhere, so it cannot be boosted and never
// shows in public timelines (the Mastodon DM model).
to: post.visibility === 'direct'
? (JSON.parse(post.to_actors || '[]'))
: (post.to_actor ? [post.to_actor] : [PUBLIC]),
// Followers-only reply ('friends', shaer detail-view Reply): the parent
// author (in `to`) + our followers, but NO Public — it does not federate
// into open discovery. Default reply stays quiet-public (Public in cc).
cc: post.visibility === 'direct' ? []
: post.visibility === 'friends' ? [`${meR}/followers`]
: [PUBLIC, `${meR}/followers`],
// FEP-633c 5.2.1: a ward's call for help. Only ever on direct notes.
...Guardianship.helpRequestProps(post),
...Guardianship.waveProps(post),
...Guardianship.awayProps(post),
// FEP-633c §2.2: object hint that the author is a ward.
...Guardianship.hasGuardiansProps(site.slug),
tag: [
...mentionTags(post.content),
...hashtagTags(base, post.content),
],
};
}
const id = noteId(base, post.id);
const aId = actorId(base, site.slug);
const human = `${base}/${encodeURIComponent(post.slug)}`;
// Mastodon ignores a Note's `name`, so put the title INTO the content (bold
// first line) — the standard blog→fediverse convention. post.content is
// already sanitized HTML; the title is plain text, so escape it.
const escTitle = String(post.title || '').replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
const titleHtml = post.title ? `
${escTitle}
` : '';
// Paid post (klonkt-demo-aki): federate a PUBLIC teaser + link, never the full
// content, so nothing leaks past the paywall. No media attachments either.
if (post.paid) {
const esc = (x) => String(x || '').replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
const _firstP = (String(post.content || '').match(/
`,
url: human,
published: toISO(post.published_at || post.created_at || Date.now()),
to: [PUBLIC],
cc: [`${aId}/followers`],
tag: [...hashtagTags(base, post.content)],
replies: `${id}/replies`,
...Guardianship.hasGuardiansProps(site.slug),
};
}
// Images travel as AP `attachment` (Mastodon strips from content). Collect
// the cover + any inline , make absolute, then strip from the content
// to avoid duplicate rendering on clients that DO keep them.
const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
const mediaType = (u) => {
const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
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';
};
const hadAudio = /\[\[(track|album|playlist):/i.test(post.content || '');
const playable = hasPlayableAudio(post.content || '', site && site.id);
// A post with an external embed (Spotify/YouTube/SoundCloud/Vimeo/Bandcamp/Apple) should let
// Mastodon render the embed's player CARD. Mastodon shows EITHER media attachments OR a link
// card, never both — so when the post has an embed link we skip the image attachments so the
// card wins. (On Klonkt nothing changes: the cover + the embed player still render.)
const hasEmbed = (() => {
const c = post.content || '';
if (/\[\[embed:/i.test(c)) return true;
for (const m of c.matchAll(/https?:\/\/[^\s"'<>]+/gi)) if (AudioEmbedService.detectProvider(m[0])) return true;
return false;
})();
// Link-only tracks (external Spotify/YouTube/SoundCloud, no hosted file): collect their links
// so we federate them — Mastodon cards the first (its player), the rest show as clickable links
// — instead of a bare "listen on site" link, and we suppress the cover so the card can show.
const trackEmbedLinks = (() => {
if (playable) return [];
const out = [];
try {
for (const m of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
const r = db.prepare('SELECT media_id, link_spotify, link_youtube, link_soundcloud FROM audio_tracks WHERE id = ?').get(m[1]);
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);
}
} catch { /* non-fatal */ }
return [...new Set(out)].slice(0, 6);
})();
const noImages = playable || hasEmbed || trackEmbedLinks.length > 0; // suppress images → let the player/embed card show
const urls = [];
// Posts with PLAYABLE hosted audio suppress image attachments so Mastodon renders
// the player CARD (twitter:player) instead of the cover — media attachment and
// link/player card are mutually exclusive on Mastodon. Link-only audio (external)
// keeps its cover (no player card to show).
// An animated cover federates as the muted loop MP4 (→ a Video attachment): animated WebP is
// unreliable on Mastodon and its iOS apps; the MP4 plays everywhere. Else the still cover image.
// Each entry carries the media URL + its alt text (federated as the AS2 attachment `name`, for a11y).
if (post.cover_video_url && !noImages) urls.push({ url: abs(post.cover_video_url), name: post.cover_alt || '' });
else if (post.cover_image_url && !noImages) urls.push({ url: abs(post.cover_image_url), name: post.cover_alt || '' });
// Media a C2S composer attached (shaer-j3uh): federate with their REAL
// mediaType, because the extension map below knows no audio and would call
// an m4a an Image. Images also live inline in the content, so the dedupe
// by URL keeps them single.
try {
for (const a of JSON.parse(post.c2s_attachments || '[]')) {
if (a && a.url) urls.push({ url: abs(a.url), name: a.name || '', mt: a.mediaType, poster: a.poster ? abs(a.poster) : null });
}
} catch { /* malformed never blocks the Note */ }
let body = post.content || '';
// Only federate inline images we can actually serve: absolute http(s) URLs, or our own
// /media/ uploads. A relative path we don't host (e.g. a stale /images/... ref) would 404
// and show up as a black tile in Mastodon's attachment grid. Carry the through
// as the attachment description.
if (!noImages) for (const m of body.matchAll(/]*>/gi)) {
const tag = m[0];
const src = (tag.match(/\bsrc="([^"]+)"/i) || [])[1];
if (!src || !(/^https?:\/\//i.test(src) || src.startsWith('/media/'))) continue;
const alt = (tag.match(/\balt="([^"]*)"/i) || [])[1] || '';
urls.push({ url: abs(src), name: alt });
}
body = body.replace(/]*>/gi, '');
// Audio shortcodes: do NOT federate the raw audio file — Klonkt deliberately
// gates audio (the /audio/stream URL has friction), and shipping it as an AP
// audio attachment would hand Mastodon a plain, downloadable mp3 URL. Instead,
// replace the shortcodes with a "🎵 listen on the site" link so the post invites
// a click-through to the protected player (discovery without leaking the file).
const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
const audioLabels = [];
try {
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); }
for (const m of body.matchAll(/\[\[album:([^\]]+)\]\]/g)) audioLabels.push(m[1].trim());
} catch { /* non-fatal */ }
// fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
// EVERY client incl. the Mastodon apps plays them inline natively. Gated tracks (default)
// stay link/card-only — the file is never exposed for them. Resolve from post.content so a
// later body mutation can't affect it.
const openAudio = [];
if (hadAudio) {
const seenA = new Set();
const addRow = (r) => {
const fn = r.filename || (r.storage_path || '').split('/').pop();
if (!fn || seenA.has(fn)) return; seenA.add(fn);
const a = { type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' };
// Cover art on the Audio attachment (AS2 `icon`): track cover, else the post cover.
// Mastodon renders it as the artwork thumbnail on its native audio player.
const art = abs(r.cover_url || post.cover_image_url || null);
if (art) a.icon = { type: 'Image', mediaType: mediaType(art), url: art };
openAudio.push(a);
};
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 ';
try {
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); }
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);
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);
} catch { /* non-fatal */ }
}
body = body.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
// External embeds ([[embed:url]]) → emit the bare URL as a link so Mastodon
// renders its OWN preview/player card (YouTube/Spotify/SoundCloud/etc) instead
// of federating the raw shortcode text.
body = body.replace(/\[\[embed:([^\]]+)\]\]/gi, (mm, raw) => {
const u = esc(raw.trim().replace(/&/g, '&'));
return `
`;
});
if (hadAudio) {
const lbl = audioLabels.length ? esc(audioLabels.slice(0, 4).join(', ')) : '';
if (trackEmbedLinks.length) {
// Link-only track(s): emit the external link(s). Mastodon cards the first (Spotify → its
// player), the rest render as clickable links — the fediverse-native "embed + links".
body += `
🎵 ${lbl ? `${lbl}` : ''}
`;
for (const u of trackEmbedLinks) { const eu = esc(u); body += `
`; }
} else {
// For playable posts, append a version param to the listen-link so Mastodon
// sees a NEW card URL and re-crawls it (fresh SQUARE player card) instead of
// reusing the cached landscape one. Invisible: the link TEXT stays clean, the
// page ignores the param. Bump FEDI_CARD_VER when the card dimensions change.
const listenHref = playable ? `${human}?fc=${FEDI_CARD_VER}` : human;
body += `
`;
}
}
// Klonkt renders post content with white-space:pre-wrap, so raw newlines ARE line
// breaks on the site. Mastodon (plain HTML) collapses whitespace and would drop them,
// so convert newlines to for the federated copy (content already made with
// shift+enter uses and has no \n → this is a no-op there).
body = body.replace(/\r?\n/g, ' ');
body = linkHashtags(base, body); // link inline #hashtags in the post body too
body = linkUrls(body); // bare URLs → clickable links on the federated copy
// Append the tags-field hashtags to the content so Mastodon renders them as clickable
// hashtags (a Hashtag that's only in the `tag` array isn't shown inline). CamelCase
// multi-word tags; skip any already present inline in the body.
{
const inlineTags = new Set(hashtagTags(base, body).map((h) => h.name.slice(1).toLowerCase()));
const addSeen = new Set();
const tagLinks = normalizeTags(post.tags).map(tagParts).filter(Boolean)
.filter((p) => !inlineTags.has(p.slug) && !addSeen.has(p.slug) && addSeen.add(p.slug))
.map((p) => `#${p.label}`);
if (tagLinks.length) body += `
${tagLinks.join(' ')}
`;
}
const seen = new Set();
const attachment = urls.filter((x) => x && x.url)
.filter((x) => { if (seen.has(x.url)) return false; seen.add(x.url); return true; })
.map((x) => { const mt = x.mt || mediaType(x.url); // the stored type wins; the extension map is the fallback
const ty = /^image\//i.test(mt) ? 'Image' : /^video\//i.test(mt) ? 'Video' : /^audio\//i.test(mt) ? 'Audio' : 'Document';
const a = { type: ty, mediaType: mt, url: x.url };
if (x.name) a.name = String(x.name).slice(0, 1500); // alt text / description (AS2 `name`)
if (x.poster) a.icon = { type: 'Image', url: x.poster }; // the video's still (shaer-zowq)
return a; });
for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players
// Inline @user@host mentions: the Mention tag objects + the mentioned actor URIs. Only
// present when the content was already mention-linked (deliverCreate/Update resolve them
// at send time); a plain buildNote (outbox/notes) yields none.
const _mentionTags = mentionTags(body);
const _mentionCc = _mentionTags.map((t) => t.href);
const note = {
id,
type: 'Note',
attributedTo: aId,
content: titleHtml + body,
url: human,
published: new Date(post.published_at || post.created_at || Date.now()).toISOString(),
// fan_only = "fans only" → followers-only visibility (delivered to your followers
// but not addressed to Public, so Mastodon shows it only to them and can't boost it).
to: (post.fan_only || post.ap_visibility === 'quiet') ? [`${aId}/followers`] : [PUBLIC],
// Mentioned actors (from inline @user@host links the caller resolved) are addressed in cc
// so Mastodon notifies them; empty unless the content was mention-linked (delivery time).
cc: [...new Set([
...(post.ap_visibility === 'quiet' ? [PUBLIC] : []), // quiet public: Public in cc, not to
...((post.fan_only || post.ap_visibility === 'quiet') ? [] : [`${aId}/followers`]),
..._mentionCc])],
tag: [...buildHashtagList(base, post.tags, body), ..._mentionTags],
replies: `${id}/replies`,
// NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler
// (hides the whole post behind a "Gevoelige inhoud" button until the reader opens it).
sensitive: !!post.nsfw,
};
// FEP-633c §2.2: object hint that the author is a ward (safely ignorable).
Object.assign(note, Guardianship.hasGuardiansProps(site.slug));
// FEP-044f: this post quotes a fediverse object. Emit it the way the network
// actually reads it, and address the quoted author so they get told.
applyQuoteProps(note, post.quote_uri, post.quote_actor);
if (post.nsfw) note.summary = post.content_warning || 'Gevoelige inhoud';
if (attachment.length) note.attachment = attachment;
// When the cover attachment is suppressed (hosted audio OR an external embed/link-only track →
// so Mastodon shows the player/link card, not media), still expose the cover via AS2 `image` so
// card/grid consumers (the Klonkt Cirkel/News feed) can show it. Mastodon ignores a Note's
// `image`, so its card is unaffected — but a Klonkt receiver reads it (handleInbox o.image).
if (post.cover_image_url && noImages) {
const cov = abs(post.cover_image_url);
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); }
}
// Experiment (mirrors PeerTube / schema.org `embedUrl`): point at the GATED player page
// (/embed) so a client that honours embedUrl can show an inline player WITHOUT ever
// getting the audio file — the anti-steal posture is untouched. `embedUrl` is a real
// standard field name (not a Klonkt invention); if Mastodon's apps honour it on a Note we
// make it JSON-LD-clean with a context term, otherwise it degrades to the player card.
if (playable) note.embedUrl = `${base}/embed?post=${encodeURIComponent(post.slug)}`;
// Content language → AS2 contentMap (a BCP-47-keyed copy of the content). Mastodon reads the
// language from its key for the timeline language filter + the translate button. Emitted
// alongside `content` (Mastodon sends both); a plain receiver just uses `content`.
if (post.language && /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(post.language)) note.contentMap = { [post.language]: note.content };
// A hosted poll → federate as an AS2 Question (options + live tally). Do this last so it
// reuses the note's content/addressing/tags, then swaps the type and strips media.
const ownPoll = parseOwnPoll(post.poll_json);
if (ownPoll) applyPollToNote(note, post.id, ownPoll);
return note;
}
// All reply note URIs on a local post (inbound fediverse replies + our own
// outbound replies) — backs the Note's `replies` Collection so remote servers
// can fetch the whole thread.
export function getReplyUris(base, postId) {
const out = [];
try {
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);
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}`);
} catch { /* non-fatal */ }
return out;
}
// Notifications "seen" tracking → a real bell badge. Stored per site in app_settings.
export function markNotificationsSeen(slug) {
try {
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")
.run(`fedi_notif_seen:${slug}`, new Date().toISOString());
} catch { /* non-fatal */ }
}
export function countUnseenNotifications(slug) {
try {
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
const seen = row ? Date.parse(row.value) : 0;
let n = 0;
for (const it of getNotifications(slug, 50)) { if (Date.parse(it.created_at) > seen) n++; }
return n;
} catch { return 0; }
}
// The seen-watermark itself (ms epoch, 0 = never marked) — the Messages page reads it
// BEFORE marking seen, so it can render unread dots on the items newer than last visit.
export function notificationsSeenAt(slug) {
try {
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
return row ? (Date.parse(row.value) || 0) : 0;
} catch { return 0; }
}
// Messages = the unified inbox (Reacties + Meldingen merged, decision Robin+Bart 2026-07-16):
// every notification PLUS your own outbound replies ('sent', with edit/delete via their
// outboxId), sorted as one stream. Consecutive likes/boosts on the same post collapse into
// one grouped item (actors list + count) so activity doesn't drown out conversations.
export function getMessages(slug, limit, offset) {
const off = Math.max(0, offset || 0);
const lim = limit || 60;
// The stream is grouped (consecutive likes/boosts collapse), so paging is done by
// recomputing the whole stream top-down and slicing [off, off+lim] — stable across
// pages. Fetch a buffer past off+lim so grouping-shrinkage can't hide a full page.
const need = off + lim + 100;
const items = getNotifications(slug, need);
try {
for (const m of listOutbox(slug).slice(0, need)) {
items.push({
type: 'sent', outboxId: m.id, to_handle: m.to_handle, in_reply_to: m.in_reply_to,
content: m.content, editable: m.editable, language: m.language, created_at: m.created_at,
});
}
} catch { /* ignore */ }
items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications)
const out = [];
for (const it of items) {
const prev = out[out.length - 1];
if ((it.type === 'like' || it.type === 'announce') && prev && prev.type === it.type
&& prev.post_slug === it.post_slug) {
prev.actors = prev.actors || [prev.name || prev.handle || '?'];
prev.actors.push(it.name || it.handle || '?');
prev.count = (prev.count || 1) + 1;
continue;
}
out.push(it);
}
return out.slice(off, off + lim);
}
export function buildCreate(base, site, post) {
const note = buildNote(base, site, post);
return {
'@context': AP_CONTEXT,
id: note.id + '#create',
type: 'Create',
actor: actorId(base, site.slug),
published: note.published,
to: note.to,
cc: note.cc,
object: note,
};
}
export function buildOutbox(base, site, posts) {
const id = `${actorId(base, site.slug)}/outbox`;
const items = (posts || []).slice(0, MAX_OUTBOX).map((p) => buildCreate(base, site, p));
return {
'@context': AP_CONTEXT,
id,
type: 'OrderedCollection',
totalItems: items.length,
orderedItems: items,
};
}
// Public callers get a count-only collection (privacy). The authenticated
// account owner (a C2S bearer scoped to this site) gets the real actor URIs via
// `items`, so their own client can build a friends list.
export function buildFollowers(base, site, count, items = null) {
const id = `${actorId(base, site.slug)}/followers`;
return {
'@context': AP_CONTEXT,
id,
type: 'OrderedCollection',
totalItems: items ? items.length : (count || 0),
orderedItems: items || [], // count-only for the public; full for the owner
};
}
// The accounts this site follows — count only, mirroring buildFollowers. The spec lists
// `following` as a standard actor property; Hubzilla/Friendica + crawlers expect it.
export function buildFollowing(base, site, count, items = null) {
const id = `${actorId(base, site.slug)}/following`;
return {
'@context': AP_CONTEXT,
id,
type: 'OrderedCollection',
totalItems: items ? items.length : (count || 0),
orderedItems: items || [], // count-only for the public; full for the owner
};
}
// Pinned posts → the actor's `featured` collection. Mastodon reads this and shows
// these as the "Featured" tab (pinned to the profile). Posts come ordered by pin
// rank; embedded as full Notes so a remote server doesn't need extra fetches.
export function buildFeatured(base, site, posts) {
const id = `${actorId(base, site.slug)}/featured`;
const items = (posts || []).map((p) => buildNote(base, site, p));
return {
'@context': AP_CONTEXT,
id,
type: 'OrderedCollection',
totalItems: items.length,
orderedItems: items,
};
}
// ── followers store (lazy stmts) ──────────────────────────────────
let _insF, _updFDisp, _delF, _listF, _cntF;
function fStmts() {
if (!_insF) {
_insF = db.prepare('INSERT OR IGNORE INTO ap_followers (slug, actor_uri, inbox, shared_inbox, name, handle, icon, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
_updFDisp = db.prepare('UPDATE ap_followers SET name = COALESCE(?, name), handle = COALESCE(?, handle), icon = COALESCE(?, icon) WHERE slug = ? AND actor_uri = ?');
_delF = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND actor_uri = ?');
_listF = db.prepare('SELECT inbox, shared_inbox FROM ap_followers WHERE slug = ?');
_cntF = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?');
}
return { ins: _insF, del: _delF, list: _listF, cnt: _cntF };
}
export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
// Followers with delivery health, for the management list. Never-delivered accounts
// first, then oldest successful delivery first — i.e. the cleanup candidates on top.
export function listFollowers(slug) {
return db.prepare(
`SELECT id, actor_uri, inbox, shared_inbox, created_at, last_delivery_at, last_error_at
FROM ap_followers WHERE slug = ?
ORDER BY (last_delivery_at IS NULL) DESC, last_delivery_at ASC, created_at ASC`
).all(slug);
}
// Manually drop a follower after a check (a still-live account would have to re-follow).
export function removeFollower(slug, id) {
const info = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND id = ?').run(slug, id);
return info.changes > 0;
}
// Best cached display for an actor URI, across the caches Klonkt already fills:
// followers (now with name/icon), following, interactions, timeline, mentions.
// Falls back to a handle derived from the URI. Display info is not sensitive.
export function actorDisplay(slug, uri) {
const ok = (r) => r && (r.name || r.icon);
try {
let r = db.prepare('SELECT name, handle, icon FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, uri);
if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
r = db.prepare('SELECT name, handle, icon FROM ap_following WHERE slug = ? AND actor_uri = ?').get(slug, uri);
if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
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);
if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
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);
if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
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);
if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
} catch { /* ignore */ }
return { name: null, handle: deriveHandle(uri), icon: null };
}
// FEP-9876: does this `Prefer` header ask for enriched (embedded) members?
// Pure and testable; the route sets the response headers around it.
export function prefersEnriched(preferHeader) {
return /(^|[,;\s])return=representation($|[,;\s])/i.test(String(preferHeader || ''));
}
// AS2 actor reference with display, for the owner C2S followers/following view.
// preferredUsername = the local part of the handle; name = the set display name.
export function buildActorRef(slug, uri) {
const d = actorDisplay(slug, uri);
const user = d.handle && d.handle[0] === '@' ? d.handle.slice(1).split('@')[0] : null;
const out = { id: uri, type: 'Person' };
if (d.name) out.name = d.name;
if (user) out.preferredUsername = user;
if (d.icon) out.icon = { type: 'Image', url: d.icon };
return out;
}
// Merge who-you-follow (ap_following, rich display) with who-follows-you (ap_followers,
// delivery health) into ONE connections list, keyed by actor_uri. Each entry gets a
// direction (following →, follower ←, mutual ↔) and, for accounts we deliver to, an
// `unreachable` flag (never delivered, or last attempt failed after the last success) so
// the view can split dead connections into their own section. Powers the Connect page.
export function listConnections(slug) {
const byUri = new Map();
for (const f of listFollowing(slug)) {
byUri.set(f.actor_uri, {
actor_uri: f.actor_uri, name: f.name || null, handle: f.handle || null,
icon: f.icon || null, url: f.url || null, auto_boost: f.auto_boost ? 1 : 0,
status: f.status || null, following: true, follower: false,
last_delivery_at: null, last_error_at: null, follower_id: null,
});
}
for (const fo of listFollowers(slug)) {
const e = byUri.get(fo.actor_uri);
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; }
else byUri.set(fo.actor_uri, {
actor_uri: fo.actor_uri, name: null, handle: null, icon: null, url: null,
auto_boost: 0, status: null, following: false, follower: true,
last_delivery_at: fo.last_delivery_at, last_error_at: fo.last_error_at, follower_id: fo.id,
});
}
return [...byUri.values()].map((e) => {
e.direction = (e.following && e.follower) ? 'mutual' : (e.following ? 'following' : 'follower');
e.unreachable = e.follower && (!e.last_delivery_at || (!!e.last_error_at && (!e.last_delivery_at || e.last_error_at > e.last_delivery_at)));
return e;
});
}
// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
// ── moderation tombstones (ap_rejected_objects) ───────────────────
// A reply the owner removed stays removed: its object URI is tombstoned and
// checked at ingest AND by the thread-crawler (else thread-filling would
// re-fetch it). Owner moderation acts on the LOCAL copy, so it also works for
// private notes that authorize_interaction can't fetch (401/404).
let _insRj, _hasRj;
function rjStmts() {
if (!_insRj) {
_insRj = db.prepare('INSERT OR IGNORE INTO ap_rejected_objects (object_uri, post_id, reason) VALUES (?,?,?)');
_hasRj = db.prepare('SELECT 1 FROM ap_rejected_objects WHERE object_uri = ?');
}
return { ins: _insRj, has: _hasRj };
}
export function isRejectedObject(uri) {
if (!uri) return false;
try { return !!rjStmts().has.get(String(uri)); } catch { return false; }
}
// Owner removes an incoming reply: tombstone + delete. Tenancy-scoped: the
// interaction's post must belong to the caller's site.
export function rejectInteraction(site, interactionId, reason) {
if (!site || !site.slug) return { error: 'forbidden' };
const row = iStmts().getI.get(interactionId);
if (!row) return { error: 'not_found' };
const owns = db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = (SELECT id FROM sites WHERE slug = ?)')
.get(row.post_id, site.slug);
if (!owns) return { error: 'forbidden' };
if (row.object_uri) { try { rjStmts().ins.run(row.object_uri, row.post_id, reason || 'removed by site owner'); } catch { /* non-fatal */ } }
db.prepare('DELETE FROM ap_interactions WHERE id = ?').run(interactionId);
console.log('[AP] interaction removed by owner', site.slug, row.object_uri || row.actor_uri);
return { ok: true, object_uri: row.object_uri || null, actor_uri: row.actor_uri || null };
}
// Stored URIs of an interaction (tenancy-scoped) → feed sendReport for flagging
// from the local copy (works for private notes; no remote fetch needed to target).
export function interactionReportTarget(site, interactionId) {
if (!site || !site.slug) return null;
const row = iStmts().getI.get(interactionId);
if (!row) return null;
const owns = db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = (SELECT id FROM sites WHERE slug = ?)')
.get(row.post_id, site.slug);
if (!owns) return null;
return { objectUri: row.object_uri || null, actorUri: row.actor_uri || null };
}
// AP addressing → visibility: 'public' | 'unlisted' | 'followers' | 'direct'.
// Mastodon-conventie: Public in `to` = public, Public in `cc` = unlisted, een
// followers-collectie zonder Public = followers-only, anders direct (DM). Public
// kan als volledige URI, 'as:Public' of 'Public' voorkomen (JSON-LD shorthands).
export function noteVisibility(o) {
const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : []));
const isPub = (u) => u === PUBLIC || u === 'as:Public' || u === 'Public';
const to = arr(o && o.to).map(String);
const cc = arr(o && o.cc).map(String);
if (to.some(isPub)) return 'public';
if (cc.some(isPub)) return 'unlisted';
if ([...to, ...cc].some((u) => /\/followers\/?$/.test(u))) return 'followers';
return 'direct';
}
/**
* Does this note belong in the home timeline (de Krant)?
*
* Only if it is a POST. A direct note is addressed to named people, so it is a
* message: a plain DM, a ward's 🛟 help request (FEP-633c 5.2.1) or a
* guardian's wave. Those are stored as mentions instead and surface in
* Berichten and the Guardian PWA. A reply belongs to its thread, not the feed.
*/
export function belongsInTimeline(o) {
if (!o || !o.id || o.inReplyTo) return false;
return noteVisibility(o) !== 'direct';
}
function iStmts() {
if (!_insI) {
_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)');
_delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
_delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
_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');
_getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
_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)');
_listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
_getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
}
return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI, getI: _getI, insO: _insO, listO: _listO, getO: _getO };
}
export function getInteractionById(id) { return iStmts().getI.get(id); }
export function setInteractionBoosted(id, on) {
db.prepare('UPDATE ap_interactions SET acted_boost = ? WHERE id = ?').run(on ? 1 : 0, id);
}
export function setInteractionLiked(id, on) {
db.prepare('UPDATE ap_interactions SET acted_like = ? WHERE id = ?').run(on ? 1 : 0, id);
}
// Your like/boost state on a REMOTE post (interact page toggles).
export function setMyReaction(slug, uri, kind, on) {
if (on) db.prepare('INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind) VALUES (?,?,?)').run(slug, uri, kind);
else db.prepare('DELETE FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ? AND kind = ?').run(slug, uri, kind);
}
export function getMyReactions(slug, uri) {
const rows = (slug && uri) ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, uri) : [];
return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
}
const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
// Extract our local post id from a note URL, but only if it's ours (base match).
function postIdFromNoteUrl(url, base) {
const s = String(url || '');
if (base && !s.startsWith(base)) return null;
const m = s.match(/\/ap\/notes\/([^/?#]+)/);
return m ? decodeURIComponent(m[1]) : null;
}
function deriveHandle(actorUri) {
try { const u = new URL(actorUri); const seg = u.pathname.split('/').filter(Boolean).pop() || ''; return `@${seg}@${u.host}`; } catch { return String(actorUri || ''); }
}
function actorInfo(doc, actorUri) {
let host = ''; try { host = new URL(actorUri).host; } catch { /* keep empty */ }
const handle = doc && doc.preferredUsername ? `@${doc.preferredUsername}@${host}` : deriveHandle(actorUri);
const icon = doc && doc.icon ? (doc.icon.url || (Array.isArray(doc.icon) && doc.icon[0] && doc.icon[0].url)) : null;
const name = (doc && (doc.name || doc.preferredUsername)) || handle;
return {
name,
handle,
url: safeUrl((doc && (doc.url || doc.id)) || actorUri) || null,
icon: safeUrl(icon) || null,
// FEP-9098 custom emojis in the display name (":shortcode:"), so the byline
// renders them. Only computed when the name actually has a shortcode.
emojis: /:[A-Za-z0-9_+-]+:/.test(name) ? actorNameEmojis(doc) : undefined,
};
}
// Map ":shortcode:" → image url from an actor doc's Emoji tags (for a custom-
// emoji display name). Undefined when there are none.
function actorNameEmojis(doc) {
const arr = doc && Array.isArray(doc.tag) ? doc.tag : (doc && doc.tag ? [doc.tag] : []);
const out = {};
for (const t of arr) {
if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Emoji' || typeof t.name !== 'string' || !t.icon) continue;
const u = t.icon.url || (Array.isArray(t.icon) && t.icon[0] && t.icon[0].url);
if (u) out[t.name] = u;
}
return Object.keys(out).length ? out : undefined;
}
// Given an inReplyTo note URL, find which local post the thread belongs to + the
// note being replied to (parent), so a reply-to-a-comment can be nested.
function findThreadTarget(inReplyTo, base) {
if (!inReplyTo) return null;
const seg = postIdFromNoteUrl(inReplyTo, base); // our /ap/notes/ segment (if ours)
if (seg && localPostExists(seg)) return { post_id: seg, parent_uri: inReplyTo };
if (seg) {
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 */ }
}
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 */ }
return null;
}
// Drop the leading @mention(s) a federated reply carries (the person being replied to),
// so a comment reads "dope tekening ouwe" instead of "@jason@jasonhacky.nl dope …".
// Keeps a leading
`;
}
const replyLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
// Dedup: skip if the exact same reply was already sent (double-submit guard).
// Attachments count toward "the same": two media-only replies share content.
const mediaJson = media.length ? JSON.stringify(media) : null;
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')
.get(site.slug, parent.object_uri || '', content, mediaJson);
if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, delivered: 0 }; }
const id = crypto.randomUUID();
iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, toActorUri, toHandle, content, replyLang, mediaJson);
// Followers-only reply (shaer detail-view): mark the row so buildNote drops
// Public from cc. Default (undefined/'public'/'quiet') stays quiet-public.
if (visibility === 'friends') { try { db.prepare('UPDATE ap_outbox SET visibility = ? WHERE id = ?').run('friends', id); } catch { /* ignore */ } }
const row = iStmts().getO.get(id);
const note = buildReplyNote(base, site, row);
const create = {
'@context': AP_CONTEXT,
id: note.id + '#create', type: 'Create', actor: me,
published: note.published, to: note.to, cc: note.cc, object: note,
};
const keys = getOrCreateKeys(site.slug);
const keyId = `${me}#main-key`;
const inboxes = new Set();
// Everyone the mentions bar kept gets pinged; legacy path = the parent only.
const mentionTargets = kept ? kept.map((k) => k.uri) : (parent.actor_uri ? [parent.actor_uri] : []);
for (const uri of mentionTargets) {
const a = await fetchActor(uri).catch(() => null);
if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
}
if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
(parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the reply
inboxes.delete(`${me}/inbox`); // never deliver to ourselves (already in ap_outbox)
inboxes.delete(`${base}/ap/inbox`); // (our own shared inbox) → avoids a self-duplicate
let delivered = 0;
for (const inbox of [...inboxes].filter(Boolean)) {
let ok = false;
try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
if (ok) delivered++;
else enqueueDelivery(site.slug, inbox, create); // durable: retry a briefly-offline recipient (was silently dropped)
}
console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
return { id, content, delivered };
}
// attributedTo may be a string, an object {id}, or an ARRAY — e.g. a PeerTube Video is
// attributed to [Person (account), Group (channel)]. Pick a usable actor URI (prefer Person).
function actorUriOf(att) {
if (!att) return null;
if (typeof att === 'string') return att;
if (Array.isArray(att)) {
const person = att.find((a) => a && typeof a === 'object' && a.type === 'Person' && a.id);
if (person) return person.id;
for (const a of att) { if (typeof a === 'string') return a; if (a && a.id) return a.id; }
return null;
}
return att.id || null;
}
// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
// Returns a parent-shaped object usable by deliverReply(), or null.
export async function resolveRemoteNote(url) {
if (!/^https?:\/\//i.test(String(url || ''))) return null;
const note = await fetchActor(url).catch(() => null); // AP GET (content-negotiates)
if (!note || !note.id) return null;
const att = note.attributedTo;
const actorUri = actorUriOf(att);
if (!actorUri) return null;
const actor = await fetchActor(actorUri).catch(() => null);
const ai = actorInfo(actor, actorUri);
// Is what we're replying to a post (or a comment) on one of OUR posts? If so,
// link our reply to that local post so it shows nested in the post thread.
const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
// Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
// and collect every ancestor author's inbox, so each participant's server —
// including the original post's author — receives + threads our reply.
const threadInboxes = [];
const seenInbox = new Set();
let cursor = note.inReplyTo, guard = 0;
while (cursor && guard++ < 6) {
const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
if (!url) break;
const pn = await fetchActor(url).catch(() => null);
if (!pn) break;
const pa = actorUriOf(pn.attributedTo);
if (pa && pa !== actorUri) {
const paDoc = await fetchActor(pa).catch(() => null);
const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
}
cursor = pn.inReplyTo; // climb to the next ancestor
}
// For non-Note objects (PeerTube Video, Article, …) the meaningful label is `name` (the
// title); prepend it so the reply page shows what you're replying to (sanitize cleans it).
let rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
if (note.name && note.type && note.type !== 'Note') rawHtml = `