| [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 |
|
|---|
| 7 | import db from '../config/database.js';
|
|---|
| [f63cbc2] | 8 | import { verifyBody, KLONKT_PROTO, MIN_PROTO } from './CircleFederation.js';
|
|---|
| [25d4041] | 9 | import { getTenancy } from './SettingsService.js';
|
|---|
| 10 |
|
|---|
| 11 | const FETCH_TIMEOUT_MS = 10000;
|
|---|
| 12 | const MAX_BODY_BYTES = 1024 * 1024; // 1 MB
|
|---|
| 13 | const MAX_ITEMS = 50;
|
|---|
| 14 |
|
|---|
| 15 | function 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 | }
|
|---|
| 22 | function iso(d) {
|
|---|
| 23 | const t = d ? new Date(d) : null;
|
|---|
| 24 | return t && !isNaN(t.getTime()) ? t.toISOString() : null;
|
|---|
| 25 | }
|
|---|
| 26 | function originOf(u) {
|
|---|
| 27 | try { return new URL(u).origin; } catch { return null; }
|
|---|
| 28 | }
|
|---|
| 29 | function baseOf(remoteUrl) {
|
|---|
| 30 | return String(remoteUrl).replace(/\/+$/, '');
|
|---|
| 31 | }
|
|---|
| 32 |
|
|---|
| [834bcc3] | 33 | // AS Hashtag array -> comma-separated tag names (without #), sanitized.
|
|---|
| [221a209] | 34 | function 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] | 46 | function 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] | 58 | async 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] | 83 | let _stmts = null;
|
|---|
| 84 | function 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 |
|
|---|
| 106 | export 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 |
|
|---|
| 205 | export 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 |
|
|---|
| 222 | let _timer = null;
|
|---|
| [834bcc3] | 223 | /** Periodic background sync (gated on tenancy='circle' inside sync()). */
|
|---|
| [25d4041] | 224 | export 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 | }
|
|---|