source: Klonkt/src/services/CircleFederation.js@ 230e446

main
Last change on this file since 230e446 was 221a209, checked in by roboburr <roboburr@…>, 3 months ago

Circle: carry over + display tags from the original post

Publisher puts tags as AS Hashtag array in the outbox (href pointing to the
source tag page). Consumer parses + caches them (remote_posts.tags). Feed
cards display them (post-card), and the reader shows tag chips that link to
the /tag page of the source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@…>

  • Property mode set to 100644
File size: 7.7 KB
Line 
1// CircleFederation.js — eigen publicatie-kant van "Cirkels" (v1).
2//
3// Publiceert deze instance als een ActivityStreams-actor met een Ed25519-
4// sleutel, plus een outbox van publieke posts. De outbox wordt getekend zodat
5// consumenten (andere Klonkt-instances) de herkomst kunnen verifiëren.
6//
7// v1 = alleen PUBLICEREN + tekenen. Het pullen/verifiëren van remote cirkels
8// (CircleService.sync) komt in een volgende stap. Zie docs/cirkels-v1-spec.md.
9//
10// (Het idee om je netjes aan de bestaande standaarden te houden fluisterde een
11// zekere Bart ons in. Wie hij is, waar hij vandaan komt — niemand die het zeker
12// weet. Hij verscheen, sprak van ActivityStreams, en was weer weg.)
13
14import crypto from 'crypto';
15import db from '../config/database.js';
16import { getSetting, setSetting } from './SettingsService.js';
17
18// ── Protocol-versie (federatie) ───────────────────────────────
19// KLONKT_PROTO zit IN de ondertekende grondslag (zie signingInput): een instance
20// die niet op deze proto draait kan onze getekende outbox NIET verifiëren, en wij
21// de hare niet. Bijblijven is dus geen beleefde check die je wegpatcht, maar
22// cryptografisch afgedwongen — de enige manier om mee te doen is dezelfde proto
23// draaien (= de update). Bump KLONKT_PROTO bij elke release die federatie/security
24// raakt, en koppel een securityfix aan elke bump → outdated = buiten + onveilig.
25// MIN_PROTO = de laagste proto waarmee we nog federeren.
26export const KLONKT_PROTO = 2;
27export const MIN_PROTO = 2;
28
29function signingInput(proto, body) {
30 return `klonkt/proto/${proto}\n${body}`;
31}
32
33// ── Sleutelbeheer ─────────────────────────────────────────────
34// Per-instance Ed25519-keypair, eenmalig gegenereerd en in app_settings
35// bewaard. Privé = PKCS8-PEM (nooit serveren). Publiek = SPKI-DER base64
36// (gepubliceerd in de actor; round-trip via createPublicKey).
37function getKeys() {
38 let priv = getSetting('circle_privkey_pem', null);
39 let pub = getSetting('circle_pubkey_der_b64', null);
40 if (!priv || !pub) {
41 const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
42 priv = privateKey.export({ type: 'pkcs8', format: 'pem' });
43 pub = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
44 setSetting('circle_privkey_pem', priv);
45 setSetting('circle_pubkey_der_b64', pub);
46 }
47 return { priv, pub };
48}
49
50export function getPublicKeyB64() {
51 return getKeys().pub;
52}
53
54/** Tekent een body-string, gebonden aan de protocol-versie (Ed25519). */
55export function signBody(rawString, proto = KLONKT_PROTO) {
56 const key = crypto.createPrivateKey(getKeys().priv);
57 return crypto.sign(null, Buffer.from(signingInput(proto, rawString), 'utf8'), key).toString('base64');
58}
59
60/** Verifieert een body tegen een SPKI-DER-base64 publieke sleutel, voor de gegeven
61 * proto. Een mismatch in proto = mismatch in grondslag = ongeldige handtekening. */
62export function verifyBody(rawString, sigB64, pubDerB64, proto = KLONKT_PROTO) {
63 try {
64 const key = crypto.createPublicKey({
65 key: Buffer.from(pubDerB64, 'base64'), format: 'der', type: 'spki',
66 });
67 return crypto.verify(null, Buffer.from(signingInput(proto, rawString), 'utf8'), key, Buffer.from(sigB64, 'base64'));
68 } catch {
69 return false;
70 }
71}
72
73// ── Helpers ───────────────────────────────────────────────────
74function primarySite() {
75 // Solo: de primaire/owner-site (eerst aangemaakt) — zelfde keuze als resolveSite.
76 return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
77}
78
79function stripHtml(s) {
80 return String(s || '')
81 .replace(/<[^>]+>/g, ' ')
82 .replace(/\[\[[^\]]*\]\]/g, ' ') // [[playlist:..]]/[[track:..]]/[[album:..]]-shortcodes weg
83 .replace(/\s+/g, ' ')
84 .trim();
85}
86
87// Tags-kolom (JSON-array of comma-separated) -> nette string-array.
88function parseTags(raw) {
89 if (!raw) return [];
90 if (Array.isArray(raw)) return raw.map((t) => String(t).trim()).filter(Boolean);
91 try { const j = JSON.parse(raw); if (Array.isArray(j)) return j.map((t) => String(t).trim()).filter(Boolean); } catch { /* geen JSON */ }
92 return String(raw).split(',').map((t) => t.trim()).filter(Boolean);
93}
94
95function iso(d) {
96 const t = d ? new Date(d) : new Date();
97 return isNaN(t.getTime()) ? new Date().toISOString() : t.toISOString();
98}
99
100function abs(base, u) {
101 if (!u) return u;
102 return /^https?:\/\//.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`;
103}
104
105// allow_circle: een site mag in cirkels van anderen verschijnen. v1 koppelt dit
106// aan is_public (aparte expliciete flag volgt in de Beheer-UX-stap).
107function allowsCircle(site) {
108 return !!site && site.is_public !== 0 && site.allow_circle !== 0;
109}
110
111// ── Actor ─────────────────────────────────────────────────────
112export function buildActor(base) {
113 const site = primarySite();
114 const id = `${base}/.klonkt/actor.json`;
115 const icon = site && (site.profile_photo || site.og_image_default);
116 return {
117 '@context': ['https://www.w3.org/ns/activitystreams', 'https://schema.org/'],
118 type: 'Person',
119 id,
120 name: site ? (site.profile_name || site.title || 'Klonkt') : 'Klonkt',
121 summary: site ? (site.profile_bio || site.tagline || site.description || '') : '',
122 url: `${base}/`,
123 ...(icon ? { icon: { type: 'Image', url: abs(base, icon) } } : {}),
124 outbox: `${base}/.klonkt/outbox.json`,
125 publicKey: {
126 id: `${id}#key`,
127 owner: id,
128 algorithm: 'ed25519',
129 publicKeyBase64: getPublicKeyB64(),
130 },
131 klonkt: { version: 1, proto: KLONKT_PROTO, allowCircle: allowsCircle(site) },
132 };
133}
134
135// ── Outbox ────────────────────────────────────────────────────
136export function buildOutbox(base) {
137 const site = primarySite();
138 const id = `${base}/.klonkt/outbox.json`;
139 const empty = {
140 '@context': 'https://www.w3.org/ns/activitystreams',
141 type: 'OrderedCollection', id, totalItems: 0, orderedItems: [], klonkt: { proto: KLONKT_PROTO },
142 };
143 if (!allowsCircle(site)) return empty;
144
145 const rows = db.prepare(`
146 SELECT slug, title, excerpt, content, cover_image_url, published_at, created_at, type, tags
147 FROM posts
148 WHERE site_id = ? AND status = 'published'
149 AND (origin_server = 'local' OR origin_server IS NULL)
150 ORDER BY COALESCE(published_at, created_at) DESC
151 LIMIT 50
152 `).all(site.id);
153
154 const orderedItems = rows.map((p) => {
155 const url = `${base}/${p.slug}`;
156 const published = iso(p.published_at || p.created_at);
157 const summary = (p.excerpt || stripHtml(p.content)).slice(0, 500);
158 const tags = parseTags(p.tags).slice(0, 12);
159 return {
160 type: 'Create',
161 id: `${url}#create`,
162 published,
163 actor: `${base}/.klonkt/actor.json`,
164 object: {
165 type: p.type === 'audio' ? 'Audio' : 'Article',
166 id: url,
167 name: p.title || '(zonder titel)',
168 summary,
169 url,
170 published,
171 ...(p.cover_image_url ? { image: { type: 'Image', url: abs(base, p.cover_image_url) } } : {}),
172 // ActivityStreams: tags als Hashtag-objecten (href naar de bron-tagpagina).
173 ...(tags.length ? { tag: tags.map((t) => ({ type: 'Hashtag', name: '#' + String(t).replace(/^#/, ''), href: `${base}/tag/${encodeURIComponent(t)}` })) } : {}),
174 },
175 };
176 });
177
178 return {
179 '@context': 'https://www.w3.org/ns/activitystreams',
180 type: 'OrderedCollection', id, totalItems: orderedItems.length, orderedItems,
181 klonkt: { proto: KLONKT_PROTO },
182 };
183}
Note: See TracBrowser for help on using the repository browser.