source: Klonkt/src/services/CircleFederation.js@ 2acdbc5

main
Last change on this file since 2acdbc5 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
Line 
1// CircleFederation.js — publication side of "Circles" (v1).
2//
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.
6//
7// v1 = PUBLISH + sign only. Pulling/verifying remote circles
8// (CircleService.sync) comes in a later step. See docs/cirkels-v1-spec.md.
9//
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.)
13
14import crypto from 'crypto';
15import db from '../config/database.js';
16import { getSetting, setSetting } from './SettingsService.js';
17
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.
27export const KLONKT_PROTO = 2;
28export const MIN_PROTO = 2;
29
30function signingInput(proto, body) {
31 return `klonkt/proto/${proto}\n${body}`;
32}
33
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).
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
55/** Signs a body string, bound to the protocol version (Ed25519). */
56export function signBody(rawString, proto = KLONKT_PROTO) {
57 const key = crypto.createPrivateKey(getKeys().priv);
58 return crypto.sign(null, Buffer.from(signingInput(proto, rawString), 'utf8'), key).toString('base64');
59}
60
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. */
63export function verifyBody(rawString, sigB64, pubDerB64, proto = KLONKT_PROTO) {
64 try {
65 const key = crypto.createPublicKey({
66 key: Buffer.from(pubDerB64, 'base64'), format: 'der', type: 'spki',
67 });
68 return crypto.verify(null, Buffer.from(signingInput(proto, rawString), 'utf8'), key, Buffer.from(sigB64, 'base64'));
69 } catch {
70 return false;
71 }
72}
73
74// ── Helpers ───────────────────────────────────────────────────
75function primarySite() {
76 // Solo: the primary/owner site (oldest) — same choice as resolveSite.
77 return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
78}
79
80function stripHtml(s) {
81 return String(s || '')
82 .replace(/<[^>]+>/g, ' ')
83 .replace(/\[\[[^\]]*\]\]/g, ' ') // strip [[playlist:..]] / [[track:..]] / [[album:..]] shortcodes
84 .replace(/\s+/g, ' ')
85 .trim();
86}
87
88// Tags column (JSON array or comma-separated) -> clean string array.
89function parseTags(raw) {
90 if (!raw) return [];
91 if (Array.isArray(raw)) return raw.map((t) => String(t).trim()).filter(Boolean);
92 try { const j = JSON.parse(raw); if (Array.isArray(j)) return j.map((t) => String(t).trim()).filter(Boolean); } catch { /* not JSON */ }
93 return String(raw).split(',').map((t) => t.trim()).filter(Boolean);
94}
95
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
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).
108function allowsCircle(site) {
109 return !!site && site.is_public !== 0 && site.allow_circle !== 0;
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 },
132 klonkt: { version: 1, proto: KLONKT_PROTO, allowCircle: allowsCircle(site) },
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',
142 type: 'OrderedCollection', id, totalItems: 0, orderedItems: [], klonkt: { proto: KLONKT_PROTO },
143 };
144 if (!allowsCircle(site)) return empty;
145
146 const rows = db.prepare(`
147 SELECT slug, title, excerpt, content, cover_image_url, published_at, created_at, type, tags
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);
159 const tags = parseTags(p.tags).slice(0, 12);
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) } } : {}),
173 // ActivityStreams: tags as Hashtag objects (href points to the source tag page).
174 ...(tags.length ? { tag: tags.map((t) => ({ type: 'Hashtag', name: '#' + String(t).replace(/^#/, ''), href: `${base}/tag/${encodeURIComponent(t)}` })) } : {}),
175 },
176 };
177 });
178
179 return {
180 '@context': 'https://www.w3.org/ns/activitystreams',
181 type: 'OrderedCollection', id, totalItems: orderedItems.length, orderedItems,
182 klonkt: { proto: KLONKT_PROTO },
183 };
184}
Note: See TracBrowser for help on using the repository browser.