source: Klonkt/src/services/CircleFederation.js@ 62dea30

main
Last change on this file since 62dea30 was 834bcc3, checked in by Robin Genis <roboburr@…>, 3 months ago

i18n: translate Dutch code comments to English across src/

Comments in routes/services/views/config/middleware/assets translated to
English for the public repo. A few dev-facing throw/console message strings
were Englished too. No user-facing UI strings or i18n dictionary values changed
(src/services/i18n.js untouched). Logic unchanged.

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 7.6 KB
RevLine 
[834bcc3]1// CircleFederation.js — publication side of "Circles" (v1).
[b300682]2//
[834bcc3]3// Publishes this instance as an ActivityStreams actor with an Ed25519 key,
4// plus an outbox of public posts. The outbox is signed so that consumers
5// (other Klonkt instances) can verify the origin.
[b300682]6//
[834bcc3]7// v1 = PUBLISH + sign only. Pulling/verifying remote circles
8// (CircleService.sync) comes in a later step. See docs/cirkels-v1-spec.md.
[8453812]9//
[834bcc3]10// (The idea of sticking neatly to existing standards was whispered to us by
11// a certain Bart. Who he is, where he came from — nobody knows for sure.
12// He appeared, spoke of ActivityStreams, and was gone.)
[b300682]13
14import crypto from 'crypto';
15import db from '../config/database.js';
16import { getSetting, setSetting } from './SettingsService.js';
17
[834bcc3]18// ── Protocol version (federation) ────────────────────────────
19// KLONKT_PROTO is embedded IN the signed input (see signingInput): an instance
20// not running this proto CANNOT verify our signed outbox, and we cannot verify
21// theirs. Staying current is therefore not a polite check you can patch away,
22// but cryptographically enforced — the only way to participate is to run the
23// same proto (= apply the update). Bump KLONKT_PROTO for every release that
24// touches federation/security, and attach a security fix to each bump →
25// outdated = excluded + insecure.
26// MIN_PROTO = the lowest proto we still federate with.
[f63cbc2]27export const KLONKT_PROTO = 2;
28export const MIN_PROTO = 2;
29
30function signingInput(proto, body) {
31 return `klonkt/proto/${proto}\n${body}`;
32}
33
[834bcc3]34// ── Key management ────────────────────────────────────────────
35// Per-instance Ed25519 keypair, generated once and stored in app_settings.
36// Private = PKCS8 PEM (never served). Public = SPKI DER base64
37// (published in the actor; round-tripped via createPublicKey).
[b300682]38function getKeys() {
39 let priv = getSetting('circle_privkey_pem', null);
40 let pub = getSetting('circle_pubkey_der_b64', null);
41 if (!priv || !pub) {
42 const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
43 priv = privateKey.export({ type: 'pkcs8', format: 'pem' });
44 pub = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
45 setSetting('circle_privkey_pem', priv);
46 setSetting('circle_pubkey_der_b64', pub);
47 }
48 return { priv, pub };
49}
50
51export function getPublicKeyB64() {
52 return getKeys().pub;
53}
54
[834bcc3]55/** Signs a body string, bound to the protocol version (Ed25519). */
[f63cbc2]56export function signBody(rawString, proto = KLONKT_PROTO) {
[b300682]57 const key = crypto.createPrivateKey(getKeys().priv);
[f63cbc2]58 return crypto.sign(null, Buffer.from(signingInput(proto, rawString), 'utf8'), key).toString('base64');
[b300682]59}
60
[834bcc3]61/** Verifies a body against an SPKI-DER-base64 public key for the given proto.
62 * A proto mismatch = a signing-input mismatch = invalid signature. */
[f63cbc2]63export function verifyBody(rawString, sigB64, pubDerB64, proto = KLONKT_PROTO) {
[b300682]64 try {
65 const key = crypto.createPublicKey({
66 key: Buffer.from(pubDerB64, 'base64'), format: 'der', type: 'spki',
67 });
[f63cbc2]68 return crypto.verify(null, Buffer.from(signingInput(proto, rawString), 'utf8'), key, Buffer.from(sigB64, 'base64'));
[b300682]69 } catch {
70 return false;
71 }
72}
73
74// ── Helpers ───────────────────────────────────────────────────
75function primarySite() {
[834bcc3]76 // Solo: the primary/owner site (oldest) — same choice as resolveSite.
[b300682]77 return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
78}
79
80function stripHtml(s) {
[62b899d]81 return String(s || '')
82 .replace(/<[^>]+>/g, ' ')
[834bcc3]83 .replace(/\[\[[^\]]*\]\]/g, ' ') // strip [[playlist:..]] / [[track:..]] / [[album:..]] shortcodes
[62b899d]84 .replace(/\s+/g, ' ')
85 .trim();
[b300682]86}
87
[834bcc3]88// Tags column (JSON array or comma-separated) -> clean string array.
[221a209]89function parseTags(raw) {
90 if (!raw) return [];
91 if (Array.isArray(raw)) return raw.map((t) => String(t).trim()).filter(Boolean);
[834bcc3]92 try { const j = JSON.parse(raw); if (Array.isArray(j)) return j.map((t) => String(t).trim()).filter(Boolean); } catch { /* not JSON */ }
[221a209]93 return String(raw).split(',').map((t) => t.trim()).filter(Boolean);
94}
95
[b300682]96function iso(d) {
97 const t = d ? new Date(d) : new Date();
98 return isNaN(t.getTime()) ? new Date().toISOString() : t.toISOString();
99}
100
101function abs(base, u) {
102 if (!u) return u;
103 return /^https?:\/\//.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`;
104}
105
[834bcc3]106// allow_circle: a site may appear in other instances' circles. v1 ties this
107// to is_public (a separate explicit flag follows in the admin UX step).
[b300682]108function allowsCircle(site) {
[0091cb7]109 return !!site && site.is_public !== 0 && site.allow_circle !== 0;
[b300682]110}
111
112// ── Actor ─────────────────────────────────────────────────────
113export function buildActor(base) {
114 const site = primarySite();
115 const id = `${base}/.klonkt/actor.json`;
116 const icon = site && (site.profile_photo || site.og_image_default);
117 return {
118 '@context': ['https://www.w3.org/ns/activitystreams', 'https://schema.org/'],
119 type: 'Person',
120 id,
121 name: site ? (site.profile_name || site.title || 'Klonkt') : 'Klonkt',
122 summary: site ? (site.profile_bio || site.tagline || site.description || '') : '',
123 url: `${base}/`,
124 ...(icon ? { icon: { type: 'Image', url: abs(base, icon) } } : {}),
125 outbox: `${base}/.klonkt/outbox.json`,
126 publicKey: {
127 id: `${id}#key`,
128 owner: id,
129 algorithm: 'ed25519',
130 publicKeyBase64: getPublicKeyB64(),
131 },
[f63cbc2]132 klonkt: { version: 1, proto: KLONKT_PROTO, allowCircle: allowsCircle(site) },
[b300682]133 };
134}
135
136// ── Outbox ────────────────────────────────────────────────────
137export function buildOutbox(base) {
138 const site = primarySite();
139 const id = `${base}/.klonkt/outbox.json`;
140 const empty = {
141 '@context': 'https://www.w3.org/ns/activitystreams',
[f63cbc2]142 type: 'OrderedCollection', id, totalItems: 0, orderedItems: [], klonkt: { proto: KLONKT_PROTO },
[b300682]143 };
144 if (!allowsCircle(site)) return empty;
145
146 const rows = db.prepare(`
[221a209]147 SELECT slug, title, excerpt, content, cover_image_url, published_at, created_at, type, tags
[b300682]148 FROM posts
149 WHERE site_id = ? AND status = 'published'
150 AND (origin_server = 'local' OR origin_server IS NULL)
151 ORDER BY COALESCE(published_at, created_at) DESC
152 LIMIT 50
153 `).all(site.id);
154
155 const orderedItems = rows.map((p) => {
156 const url = `${base}/${p.slug}`;
157 const published = iso(p.published_at || p.created_at);
158 const summary = (p.excerpt || stripHtml(p.content)).slice(0, 500);
[221a209]159 const tags = parseTags(p.tags).slice(0, 12);
[b300682]160 return {
161 type: 'Create',
162 id: `${url}#create`,
163 published,
164 actor: `${base}/.klonkt/actor.json`,
165 object: {
166 type: p.type === 'audio' ? 'Audio' : 'Article',
167 id: url,
168 name: p.title || '(zonder titel)',
169 summary,
170 url,
171 published,
172 ...(p.cover_image_url ? { image: { type: 'Image', url: abs(base, p.cover_image_url) } } : {}),
[834bcc3]173 // ActivityStreams: tags as Hashtag objects (href points to the source tag page).
[221a209]174 ...(tags.length ? { tag: tags.map((t) => ({ type: 'Hashtag', name: '#' + String(t).replace(/^#/, ''), href: `${base}/tag/${encodeURIComponent(t)}` })) } : {}),
[b300682]175 },
176 };
177 });
178
179 return {
180 '@context': 'https://www.w3.org/ns/activitystreams',
181 type: 'OrderedCollection', id, totalItems: orderedItems.length, orderedItems,
[f63cbc2]182 klonkt: { proto: KLONKT_PROTO },
[b300682]183 };
184}
Note: See TracBrowser for help on using the repository browser.