source: Klonkt/src/services/CircleService.js@ 46f3dd6

main
Last change on this file since 46f3dd6 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: 9.5 KB
RevLine 
[834bcc3]1// CircleService.js — pull side of Circles (v1).
[25d4041]2//
[834bcc3]3// Per circle_link: fetches the remote actor + outbox, verifies the Ed25519
4// signature, sanitizes, and caches public posts in remote_actors/remote_posts.
5// READ ONLY from remote; never write. See docs/cirkels-v1-spec.md §5b.
[25d4041]6
7import db from '../config/database.js';
[f63cbc2]8import { verifyBody, KLONKT_PROTO, MIN_PROTO } from './CircleFederation.js';
[25d4041]9import { getTenancy } from './SettingsService.js';
10
11const FETCH_TIMEOUT_MS = 10000;
12const MAX_BODY_BYTES = 1024 * 1024; // 1 MB
13const MAX_ITEMS = 50;
14
15function stripHtml(s) {
[62b899d]16 return String(s || '')
17 .replace(/<[^>]+>/g, ' ')
[834bcc3]18 .replace(/\[\[[^\]]*\]\]/g, ' ') // strip [[playlist:..]] / [[track:..]] / [[album:..]] shortcodes
[62b899d]19 .replace(/\s+/g, ' ')
20 .trim();
[25d4041]21}
22function iso(d) {
23 const t = d ? new Date(d) : null;
24 return t && !isNaN(t.getTime()) ? t.toISOString() : null;
25}
26function originOf(u) {
27 try { return new URL(u).origin; } catch { return null; }
28}
29function baseOf(remoteUrl) {
30 return String(remoteUrl).replace(/\/+$/, '');
31}
32
[834bcc3]33// AS Hashtag array -> comma-separated tag names (without #), sanitized.
[221a209]34function extractTags(tag) {
35 if (!Array.isArray(tag)) return null;
36 const names = tag
37 .map((t) => String((t && t.name) || '').replace(/^#/, '').trim())
38 .filter(Boolean)
39 .slice(0, 12);
40 return names.length ? names.join(', ') : null;
41}
42
[834bcc3]43// Mark a source as outside the circle with a readable reason (no silent failure).
44// Separate 'outdated' status so the admin UI can show a clean "update required"
45// notice instead of a generic error.
[f63cbc2]46function markOutdated(link, msg) {
[834bcc3]47 // Remove cached posts from this source: we can no longer verify or refresh
48 // them (proto mismatch), so they no longer belong in the circle feed.
[682d504]49 if (link.remote_actor_id) {
50 try { db.prepare('DELETE FROM remote_posts WHERE actor_id = ?').run(link.remote_actor_id); } catch {}
51 }
[f63cbc2]52 db.prepare("UPDATE circle_links SET status='outdated', last_error=?, last_synced=CURRENT_TIMESTAMP WHERE id=?")
53 .run(String(msg).slice(0, 300), link.id);
54 return { ok: false, outdated: true, link: link.remote_url, error: msg };
55}
56
[834bcc3]57// Robust, defensive fetch: https only, timeout, body cap, redirect follow.
[25d4041]58async function fetchText(url) {
59 if (!/^https:\/\//i.test(url)) throw new Error('alleen https toegestaan');
60 const ac = new AbortController();
61 const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
62 try {
63 const res = await fetch(url, {
64 signal: ac.signal,
65 redirect: 'follow',
[f63cbc2]66 headers: {
67 Accept: 'application/activity+json, application/json',
[834bcc3]68 // Tell the publisher our proto → they can reject us with 426 if we are too old.
[f63cbc2]69 'Klonkt-Proto': String(KLONKT_PROTO),
70 },
[25d4041]71 });
72 if (!res.ok) throw new Error(`HTTP ${res.status}`);
73 const buf = Buffer.from(await res.arrayBuffer());
[834bcc3]74 if (buf.length > MAX_BODY_BYTES) throw new Error('body too large');
[25d4041]75 return { text: buf.toString('utf8'), headers: res.headers, finalUrl: res.url };
76 } finally {
77 clearTimeout(timer);
78 }
79}
80
[834bcc3]81// Lazy prepares — tables only exist after initializeDatabase(); this module is
82// imported before that call, so do not prepare at module level.
[57929aa]83let _stmts = null;
84function stmts() {
85 if (_stmts) return _stmts;
86 _stmts = {
87 upsertActor: db.prepare(`
88 INSERT INTO remote_actors (id, url, name, summary, avatar, public_key, fetched_at)
89 VALUES (@id, @url, @name, @summary, @avatar, @public_key, CURRENT_TIMESTAMP)
90 ON CONFLICT(id) DO UPDATE SET
91 url=excluded.url, name=excluded.name, summary=excluded.summary,
92 avatar=excluded.avatar, public_key=excluded.public_key, fetched_at=CURRENT_TIMESTAMP
93 `),
94 upsertPost: db.prepare(`
[221a209]95 INSERT INTO remote_posts (id, actor_id, published, title, summary, url, media_json, tags, raw_json, fetched_at)
96 VALUES (@id, @actor_id, @published, @title, @summary, @url, @media_json, @tags, @raw_json, CURRENT_TIMESTAMP)
[57929aa]97 ON CONFLICT(id) DO UPDATE SET
98 published=excluded.published, title=excluded.title, summary=excluded.summary,
[221a209]99 url=excluded.url, media_json=excluded.media_json, tags=excluded.tags,
100 raw_json=excluded.raw_json, fetched_at=CURRENT_TIMESTAMP
[57929aa]101 `),
102 };
103 return _stmts;
104}
[25d4041]105
106export async function syncOne(link) {
107 const base = baseOf(link.remote_url);
108
[834bcc3]109 // 1. Fetch + validate actor
[25d4041]110 const actorUrl = `${base}/.klonkt/actor.json`;
111 const a = await fetchText(actorUrl);
112 let actor;
113 try { actor = JSON.parse(a.text); } catch { throw new Error('actor: ongeldige JSON'); }
114 const actorId = actor.id;
115 const pubKey = actor.publicKey && actor.publicKey.publicKeyBase64;
116 if (!actorId || !pubKey) throw new Error('actor mist id/publicKey');
117 if (originOf(actorId) !== originOf(actorUrl)) throw new Error('actor.id heeft andere origin dan de actor-URL');
118
[834bcc3]119 // Protocol version gate. The proto is also embedded in the outbox signing
120 // input, so lying in the (unsigned) actor does not help: a real mismatch
121 // will still fail verification later. This check is mainly for a CLEAR
122 // message + exclusion without silent failure.
[f63cbc2]123 const remoteProto = Number(actor.klonkt && actor.klonkt.proto) || 1;
124 if (remoteProto > KLONKT_PROTO) {
125 return markOutdated(link,
[151a72c]126 `Deze site draait een nieuwere Klonkt (proto ${remoteProto}); jouw instance is proto ${KLONKT_PROTO}. Werk je eigen Klonkt bij om te blijven federeren.`);
[f63cbc2]127 }
128 if (remoteProto < MIN_PROTO) {
129 return markOutdated(link,
[151a72c]130 `Draait een oudere Klonkt (proto ${remoteProto}; minimaal ${MIN_PROTO} vereist). Vraag ze te updaten.`);
[f63cbc2]131 }
132
[834bcc3]133 // TOFU: a key change requires explicit re-confirmation (anti-hijack)
[25d4041]134 const existing = db.prepare('SELECT public_key FROM remote_actors WHERE id = ?').get(actorId);
135 if (existing && existing.public_key !== pubKey) {
136 throw new Error('publieke sleutel gewijzigd — herbevestiging vereist (TOFU)');
137 }
138
[57929aa]139 stmts().upsertActor.run({
[25d4041]140 id: actorId,
141 url: actor.url || base,
142 name: actor.name || null,
143 summary: actor.summary || null,
144 avatar: (actor.icon && actor.icon.url) || null,
145 public_key: pubKey,
146 });
147
[834bcc3]148 // 2. Fetch outbox + verify signature
[25d4041]149 const outboxUrl = actor.outbox || `${base}/.klonkt/outbox.json`;
150 const o = await fetchText(outboxUrl);
151 const sigHeader = o.headers.get('klonkt-signature') || '';
152 const sig = (sigHeader.match(/ed25519=(.+)\s*$/) || [])[1];
[f63cbc2]153 if (!sig || !verifyBody(o.text, sig, pubKey, remoteProto)) {
[25d4041]154 throw new Error('outbox-handtekening ongeldig of ontbreekt');
155 }
156 let outbox;
157 try { outbox = JSON.parse(o.text); } catch { throw new Error('outbox: ongeldige JSON'); }
158 const items = Array.isArray(outbox.orderedItems) ? outbox.orderedItems.slice(0, MAX_ITEMS) : [];
159
[834bcc3]160 // 3. Sanitize + cache objects (same origin as actor = anti-impersonation)
[25d4041]161 const actorOrigin = originOf(actorId);
162 const seen = new Set();
163 for (const it of items) {
164 const obj = it && it.object;
165 if (!obj || !obj.id) continue;
166 if (originOf(obj.id) !== actorOrigin) continue;
167 const media = [];
168 if (obj.image && obj.image.url) media.push({ type: 'image', url: obj.image.url });
169 if (Array.isArray(obj.attachment)) {
170 for (const att of obj.attachment) {
171 if (att && att.url) media.push({ type: String(att.type || 'link').toLowerCase(), url: att.url, name: att.name, duration: att.duration });
172 }
173 }
[57929aa]174 stmts().upsertPost.run({
[25d4041]175 id: obj.id,
176 actor_id: actorId,
177 published: iso(obj.published || it.published),
178 title: stripHtml(obj.name).slice(0, 300) || '(zonder titel)',
179 summary: stripHtml(obj.summary || obj.content).slice(0, 1000),
180 url: obj.url || obj.id,
181 media_json: media.length ? JSON.stringify(media) : null,
[221a209]182 tags: extractTags(obj.tag),
[25d4041]183 raw_json: JSON.stringify(obj).slice(0, 20000),
184 });
185 seen.add(obj.id);
186 }
187
[834bcc3]188 // 4. Pruning: remove posts that are no longer in the outbox
[25d4041]189 const known = db.prepare('SELECT id FROM remote_posts WHERE actor_id = ?').all(actorId).map((r) => r.id);
190 const stale = known.filter((id) => !seen.has(id));
191 if (stale.length) {
192 const del = db.prepare('DELETE FROM remote_posts WHERE id = ?');
193 db.transaction((ids) => ids.forEach((id) => del.run(id)))(stale);
194 }
195
[834bcc3]196 // Automatically adopt the name from the remote actor (no manual entry needed).
197 // COALESCE: if the actor has no name, any existing label is preserved.
[25d4041]198 db.prepare(
[814ad8b]199 "UPDATE circle_links SET remote_actor_id=?, label=COALESCE(?, label), last_synced=CURRENT_TIMESTAMP, status='active', last_error=NULL WHERE id=?"
200 ).run(actorId, actor.name || null, link.id);
[25d4041]201
202 return { ok: true, actorId, items: seen.size, pruned: stale.length };
203}
204
205export async function sync() {
206 if (getTenancy() !== 'circle') return { skipped: 'tenancy != circle' };
207 const links = db.prepare("SELECT * FROM circle_links WHERE status != 'paused'").all();
208 const results = [];
209 for (const link of links) {
210 try {
211 results.push(await syncOne(link));
212 } catch (e) {
213 const msg = String((e && e.message) || e).slice(0, 300);
214 db.prepare("UPDATE circle_links SET status='error', last_error=?, last_synced=CURRENT_TIMESTAMP WHERE id=?")
215 .run(msg, link.id);
216 results.push({ ok: false, link: link.remote_url, error: msg });
217 }
218 }
219 return { synced: results.length, results };
220}
221
222let _timer = null;
[834bcc3]223/** Periodic background sync (gated on tenancy='circle' inside sync()). */
[25d4041]224export function startCircleSyncLoop(intervalMs = 15 * 60 * 1000) {
225 if (_timer) return;
226 const run = () => { sync().catch((e) => console.error('[cirkels] sync-fout:', e.message)); };
[834bcc3]227 setTimeout(run, 30 * 1000); // short delay after boot
[25d4041]228 _timer = setInterval(run, intervalMs);
229 if (_timer.unref) _timer.unref();
230}
Note: See TracBrowser for help on using the repository browser.