source: Klonkt/src/services/CircleService.js@ 682d504

main
Last change on this file since 682d504 was 682d504, checked in by roboburr <roboburr@…>, 3 months ago

Circle: remove cached posts on outdated + exclude outdated sites from header

An outdated source (proto mismatch) kept its previously cached remote_posts
→ they remained (buggy) visible in the feed. markOutdated now removes those
posts. The header only shows active circle links (outdated/error are
excluded).

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

  • Property mode set to 100644
File size: 8.9 KB
RevLine 
[25d4041]1// CircleService.js — pull-kant van Cirkels (v1).
2//
3// Haalt per circle_link de remote actor + outbox op, verifieert de Ed25519-
4// handtekening, sanitiseert en cachet publieke posts in remote_actors/remote_posts.
5// Alleen LEZEN van remote; nooit schrijven. Zie docs/cirkels-v1-spec.md §5b.
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) {
16 return String(s || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
17}
18function iso(d) {
19 const t = d ? new Date(d) : null;
20 return t && !isNaN(t.getTime()) ? t.toISOString() : null;
21}
22function originOf(u) {
23 try { return new URL(u).origin; } catch { return null; }
24}
25function baseOf(remoteUrl) {
26 return String(remoteUrl).replace(/\/+$/, '');
27}
28
[f63cbc2]29// Bron buiten de cirkel zetten met een leesbare reden (geen stille mislukking).
30// Aparte status 'outdated' zodat de Beheer-UI er een nette "update vereist"-
31// melding van kan maken i.p.v. een generieke fout.
32function markOutdated(link, msg) {
[682d504]33 // Gecachte posts van deze bron weghalen: we kunnen ze niet meer verifiëren of
34 // verversen (proto-mismatch), dus ze horen niet meer in de cirkel-feed.
35 if (link.remote_actor_id) {
36 try { db.prepare('DELETE FROM remote_posts WHERE actor_id = ?').run(link.remote_actor_id); } catch {}
37 }
[f63cbc2]38 db.prepare("UPDATE circle_links SET status='outdated', last_error=?, last_synced=CURRENT_TIMESTAMP WHERE id=?")
39 .run(String(msg).slice(0, 300), link.id);
40 return { ok: false, outdated: true, link: link.remote_url, error: msg };
41}
42
[25d4041]43// Robuuste, defensieve fetch: alleen https, timeout, body-cap, redirect-follow.
44async function fetchText(url) {
45 if (!/^https:\/\//i.test(url)) throw new Error('alleen https toegestaan');
46 const ac = new AbortController();
47 const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
48 try {
49 const res = await fetch(url, {
50 signal: ac.signal,
51 redirect: 'follow',
[f63cbc2]52 headers: {
53 Accept: 'application/activity+json, application/json',
54 // Vertel de publisher onze proto → die kan ons met 426 weren als we te oud zijn.
55 'Klonkt-Proto': String(KLONKT_PROTO),
56 },
[25d4041]57 });
58 if (!res.ok) throw new Error(`HTTP ${res.status}`);
59 const buf = Buffer.from(await res.arrayBuffer());
60 if (buf.length > MAX_BODY_BYTES) throw new Error('body te groot');
61 return { text: buf.toString('utf8'), headers: res.headers, finalUrl: res.url };
62 } finally {
63 clearTimeout(timer);
64 }
65}
66
[57929aa]67// Lazy prepares — de tabellen bestaan pas ná initializeDatabase(); dit module
68// wordt geïmporteerd vóór die call, dus niet op module-niveau prepare'n.
69let _stmts = null;
70function stmts() {
71 if (_stmts) return _stmts;
72 _stmts = {
73 upsertActor: db.prepare(`
74 INSERT INTO remote_actors (id, url, name, summary, avatar, public_key, fetched_at)
75 VALUES (@id, @url, @name, @summary, @avatar, @public_key, CURRENT_TIMESTAMP)
76 ON CONFLICT(id) DO UPDATE SET
77 url=excluded.url, name=excluded.name, summary=excluded.summary,
78 avatar=excluded.avatar, public_key=excluded.public_key, fetched_at=CURRENT_TIMESTAMP
79 `),
80 upsertPost: db.prepare(`
81 INSERT INTO remote_posts (id, actor_id, published, title, summary, url, media_json, raw_json, fetched_at)
82 VALUES (@id, @actor_id, @published, @title, @summary, @url, @media_json, @raw_json, CURRENT_TIMESTAMP)
83 ON CONFLICT(id) DO UPDATE SET
84 published=excluded.published, title=excluded.title, summary=excluded.summary,
85 url=excluded.url, media_json=excluded.media_json, raw_json=excluded.raw_json, fetched_at=CURRENT_TIMESTAMP
86 `),
87 };
88 return _stmts;
89}
[25d4041]90
91export async function syncOne(link) {
92 const base = baseOf(link.remote_url);
93
94 // 1. Actor ophalen + valideren
95 const actorUrl = `${base}/.klonkt/actor.json`;
96 const a = await fetchText(actorUrl);
97 let actor;
98 try { actor = JSON.parse(a.text); } catch { throw new Error('actor: ongeldige JSON'); }
99 const actorId = actor.id;
100 const pubKey = actor.publicKey && actor.publicKey.publicKeyBase64;
101 if (!actorId || !pubKey) throw new Error('actor mist id/publicKey');
102 if (originOf(actorId) !== originOf(actorUrl)) throw new Error('actor.id heeft andere origin dan de actor-URL');
103
[f63cbc2]104 // Protocol-versie-gate. De proto zit óók in de outbox-handtekening-grondslag,
105 // dus liegen in de (ongetekende) actor helpt niet: bij een echte mismatch faalt
106 // de verificatie verderop alsnog. Hier vooral voor een DUIDELIJKE melding +
107 // buitensluiten zonder stille mislukking.
108 const remoteProto = Number(actor.klonkt && actor.klonkt.proto) || 1;
109 if (remoteProto > KLONKT_PROTO) {
110 return markOutdated(link,
[151a72c]111 `Deze site draait een nieuwere Klonkt (proto ${remoteProto}); jouw instance is proto ${KLONKT_PROTO}. Werk je eigen Klonkt bij om te blijven federeren.`);
[f63cbc2]112 }
113 if (remoteProto < MIN_PROTO) {
114 return markOutdated(link,
[151a72c]115 `Draait een oudere Klonkt (proto ${remoteProto}; minimaal ${MIN_PROTO} vereist). Vraag ze te updaten.`);
[f63cbc2]116 }
117
[25d4041]118 // TOFU: een sleutelwissel vereist expliciete herbevestiging (anti-hijack)
119 const existing = db.prepare('SELECT public_key FROM remote_actors WHERE id = ?').get(actorId);
120 if (existing && existing.public_key !== pubKey) {
121 throw new Error('publieke sleutel gewijzigd — herbevestiging vereist (TOFU)');
122 }
123
[57929aa]124 stmts().upsertActor.run({
[25d4041]125 id: actorId,
126 url: actor.url || base,
127 name: actor.name || null,
128 summary: actor.summary || null,
129 avatar: (actor.icon && actor.icon.url) || null,
130 public_key: pubKey,
131 });
132
133 // 2. Outbox ophalen + handtekening verifiëren
134 const outboxUrl = actor.outbox || `${base}/.klonkt/outbox.json`;
135 const o = await fetchText(outboxUrl);
136 const sigHeader = o.headers.get('klonkt-signature') || '';
137 const sig = (sigHeader.match(/ed25519=(.+)\s*$/) || [])[1];
[f63cbc2]138 if (!sig || !verifyBody(o.text, sig, pubKey, remoteProto)) {
[25d4041]139 throw new Error('outbox-handtekening ongeldig of ontbreekt');
140 }
141 let outbox;
142 try { outbox = JSON.parse(o.text); } catch { throw new Error('outbox: ongeldige JSON'); }
143 const items = Array.isArray(outbox.orderedItems) ? outbox.orderedItems.slice(0, MAX_ITEMS) : [];
144
145 // 3. Objecten sanitizen + cachen (same-origin als de actor = anti-impersonatie)
146 const actorOrigin = originOf(actorId);
147 const seen = new Set();
148 for (const it of items) {
149 const obj = it && it.object;
150 if (!obj || !obj.id) continue;
151 if (originOf(obj.id) !== actorOrigin) continue;
152 const media = [];
153 if (obj.image && obj.image.url) media.push({ type: 'image', url: obj.image.url });
154 if (Array.isArray(obj.attachment)) {
155 for (const att of obj.attachment) {
156 if (att && att.url) media.push({ type: String(att.type || 'link').toLowerCase(), url: att.url, name: att.name, duration: att.duration });
157 }
158 }
[57929aa]159 stmts().upsertPost.run({
[25d4041]160 id: obj.id,
161 actor_id: actorId,
162 published: iso(obj.published || it.published),
163 title: stripHtml(obj.name).slice(0, 300) || '(zonder titel)',
164 summary: stripHtml(obj.summary || obj.content).slice(0, 1000),
165 url: obj.url || obj.id,
166 media_json: media.length ? JSON.stringify(media) : null,
167 raw_json: JSON.stringify(obj).slice(0, 20000),
168 });
169 seen.add(obj.id);
170 }
171
172 // 4. Pruning: posts die niet meer in de outbox staan opruimen
173 const known = db.prepare('SELECT id FROM remote_posts WHERE actor_id = ?').all(actorId).map((r) => r.id);
174 const stale = known.filter((id) => !seen.has(id));
175 if (stale.length) {
176 const del = db.prepare('DELETE FROM remote_posts WHERE id = ?');
177 db.transaction((ids) => ids.forEach((id) => del.run(id)))(stale);
178 }
179
180 db.prepare(
181 "UPDATE circle_links SET remote_actor_id=?, last_synced=CURRENT_TIMESTAMP, status='active', last_error=NULL WHERE id=?"
182 ).run(actorId, link.id);
183
184 return { ok: true, actorId, items: seen.size, pruned: stale.length };
185}
186
187export async function sync() {
188 if (getTenancy() !== 'circle') return { skipped: 'tenancy != circle' };
189 const links = db.prepare("SELECT * FROM circle_links WHERE status != 'paused'").all();
190 const results = [];
191 for (const link of links) {
192 try {
193 results.push(await syncOne(link));
194 } catch (e) {
195 const msg = String((e && e.message) || e).slice(0, 300);
196 db.prepare("UPDATE circle_links SET status='error', last_error=?, last_synced=CURRENT_TIMESTAMP WHERE id=?")
197 .run(msg, link.id);
198 results.push({ ok: false, link: link.remote_url, error: msg });
199 }
200 }
201 return { synced: results.length, results };
202}
203
204let _timer = null;
205/** Periodieke achtergrond-sync (gated op tenancy='circle' binnen sync()). */
206export function startCircleSyncLoop(intervalMs = 15 * 60 * 1000) {
207 if (_timer) return;
208 const run = () => { sync().catch((e) => console.error('[cirkels] sync-fout:', e.message)); };
209 setTimeout(run, 30 * 1000); // korte delay na boot
210 _timer = setInterval(run, intervalMs);
211 if (_timer.unref) _timer.unref();
212}
Note: See TracBrowser for help on using the repository browser.