| 1 | /**
|
|---|
| 2 | * MigrationService.js — FEP-1580: je OBJECTEN verhuizen bij een Move.
|
|---|
| 3 | *
|
|---|
| 4 | * FEP-7628 verhuist je volgers en zegt zelf dat de rest een ander probleem is.
|
|---|
| 5 | * Dit is dat andere probleem: na een Move stonden je berichten nog op de oude
|
|---|
| 6 | * instantie, en elke reactie van een derde wees naar een URI die verdwijnt zodra
|
|---|
| 7 | * dat domein opgezegd wordt.
|
|---|
| 8 | *
|
|---|
| 9 | * DRIE DINGEN OM TE WETEN VOOR JE HIERIN LEEST:
|
|---|
| 10 | *
|
|---|
| 11 | * 1. DE AUTORISATIE IS DE MOVE, NIET EEN CODE. De bronkant staat in
|
|---|
| 12 | * ActivityPubService.isMoveTarget: een ondertekend verzoek namens de actor
|
|---|
| 13 | * waar de bron naartoe verhuisde telt als de bron zelf. Dat mag omdat
|
|---|
| 14 | * moveAccount() `no_backreference` weigert, dus `moved_to` staat er alleen
|
|---|
| 15 | * als iemand met beheer op BEIDE kanten dat wilde. Hier in dit bestand zit
|
|---|
| 16 | * de DOELkant, die van die toestemming gebruikmaakt.
|
|---|
| 17 | *
|
|---|
| 18 | * 2. NIEUWE IDS ZIJN GEEN BUG, DE VERTAALTABEL IS HET ANTWOORD. Een gemigreerd
|
|---|
| 19 | * bericht krijgt hier een eigen URI, want het staat nu op een ander domein.
|
|---|
| 20 | * De `migration`-collectie mapt oud naar nieuw en derden lezen die om hun
|
|---|
| 21 | * eigen verwijzingen bij te werken. Zonder die collectie is de draad kapot,
|
|---|
| 22 | * met die collectie is het een verhuisbericht.
|
|---|
| 23 | *
|
|---|
| 24 | * 3. ER GAAT GEEN Create DE DEUR UIT. De spec is daar expliciet over, en het is
|
|---|
| 25 | * ook gewoon logisch: je volgers hebben deze berichten jaren geleden al
|
|---|
| 26 | * gezien. Een ingest van driehonderd posts die als driehonderd nieuwe posts
|
|---|
| 27 | * de tijdlijn in klettert is geen verhuizing maar spam.
|
|---|
| 28 | *
|
|---|
| 29 | * WAT HIER ONTBREEKT: FEP-8b32 integrity proofs (shaer-j1v0). De `moves`-
|
|---|
| 30 | * collectie hoort ondertekend te zijn en de Moves erin horen een proof van de
|
|---|
| 31 | * bron-actor te dragen. Klonkt kent 8b32 nog niet. We bewaren wel alle
|
|---|
| 32 | * grondstof (de rauwe activity en het actordocument), zodat het later alleen
|
|---|
| 33 | * ondertekenen is. Bewust geen leeg proof-veld: een derde die het controleert
|
|---|
| 34 | * wordt dan misleid, en dat is erger dan een veld dat ontbreekt.
|
|---|
| 35 | */
|
|---|
| 36 | import crypto from 'crypto';
|
|---|
| 37 | import db from '../config/database.js';
|
|---|
| 38 | import { AP_CONTEXT, actorId, pagedCollection } from './ap-core.js';
|
|---|
| 39 |
|
|---|
| 40 | // ── Vertaaltabel ──────────────────────────────────────────────────
|
|---|
| 41 |
|
|---|
| 42 | const stmts = {};
|
|---|
| 43 | function q(naam, sql) { return (stmts[naam] ||= db.prepare(sql)); }
|
|---|
| 44 |
|
|---|
| 45 | /** Leg vast dat `origin` hier `target` werd. Idempotent: opnieuw draaien mag. */
|
|---|
| 46 | export function recordMigrated(slug, { origin, target, sourceActor = '', isPublic = true } = {}) {
|
|---|
| 47 | if (!slug || !origin || !target) return false;
|
|---|
| 48 | try {
|
|---|
| 49 | q('ins', `INSERT INTO ap_migration (slug, origin, target, source_actor, is_public)
|
|---|
| 50 | VALUES (?, ?, ?, ?, ?)
|
|---|
| 51 | ON CONFLICT(slug, origin) DO UPDATE SET target = excluded.target`)
|
|---|
| 52 | .run(slug, String(origin), String(target), String(sourceActor || ''), isPublic ? 1 : 0);
|
|---|
| 53 | return true;
|
|---|
| 54 | } catch (e) {
|
|---|
| 55 | console.warn('[FEP-1580] mapping niet opgeslagen:', origin, e && e.message);
|
|---|
| 56 | return false;
|
|---|
| 57 | }
|
|---|
| 58 | }
|
|---|
| 59 |
|
|---|
| 60 | /**
|
|---|
| 61 | * De items, nieuwste kopie eerst.
|
|---|
| 62 | *
|
|---|
| 63 | * `alles` is alleen waar voor een geverifieerde lezer uit het publiek van de
|
|---|
| 64 | * niet-publieke objecten. De spec: Moves voor objecten die niet aan as:Public
|
|---|
| 65 | * gericht zijn MOGEN NIET publiek getoond worden. Een migration-collectie die
|
|---|
| 66 | * de URIs van je fan-only posts opsomt is een lek, ook zonder de inhoud.
|
|---|
| 67 | */
|
|---|
| 68 | export function migrationItems(slug, { alles = false } = {}) {
|
|---|
| 69 | try {
|
|---|
| 70 | const sql = `SELECT origin, target, source_actor FROM ap_migration
|
|---|
| 71 | WHERE slug = ?${alles ? '' : ' AND is_public = 1'} ORDER BY id DESC`;
|
|---|
| 72 | return db.prepare(sql).all(slug);
|
|---|
| 73 | } catch { return []; }
|
|---|
| 74 | }
|
|---|
| 75 |
|
|---|
| 76 | export function migrationCount(slug, { alles = false } = {}) {
|
|---|
| 77 | try {
|
|---|
| 78 | const sql = `SELECT COUNT(*) n FROM ap_migration WHERE slug = ?${alles ? '' : ' AND is_public = 1'}`;
|
|---|
| 79 | return db.prepare(sql).get(slug).n;
|
|---|
| 80 | } catch { return 0; }
|
|---|
| 81 | }
|
|---|
| 82 |
|
|---|
| 83 | /** Is deze URI hier al binnen? Houdt een tweede ingest-ronde goedkoop. */
|
|---|
| 84 | export function alGemigreerd(slug, origin) {
|
|---|
| 85 | try { return !!db.prepare('SELECT 1 FROM ap_migration WHERE slug = ? AND origin = ?').get(slug, String(origin)); } catch { return false; }
|
|---|
| 86 | }
|
|---|
| 87 |
|
|---|
| 88 | // ── De Move-activities ────────────────────────────────────────────
|
|---|
| 89 |
|
|---|
| 90 | export function recordMove(slug, { moveId, sourceActor, targetActor, activity, actorDoc = null } = {}) {
|
|---|
| 91 | if (!slug || !moveId || !sourceActor || !targetActor) return false;
|
|---|
| 92 | try {
|
|---|
| 93 | q('insMove', `INSERT INTO ap_moves (slug, move_id, source_actor, target_actor, activity_json, actor_json)
|
|---|
| 94 | VALUES (?, ?, ?, ?, ?, ?)
|
|---|
| 95 | ON CONFLICT(slug, move_id) DO NOTHING`)
|
|---|
| 96 | .run(slug, String(moveId), String(sourceActor), String(targetActor),
|
|---|
| 97 | JSON.stringify(activity || {}), actorDoc ? JSON.stringify(actorDoc) : null);
|
|---|
| 98 | return true;
|
|---|
| 99 | } catch (e) {
|
|---|
| 100 | console.warn('[FEP-1580] Move niet opgeslagen:', moveId, e && e.message);
|
|---|
| 101 | return false;
|
|---|
| 102 | }
|
|---|
| 103 | }
|
|---|
| 104 |
|
|---|
| 105 | export function moveRows(slug) {
|
|---|
| 106 | try { return db.prepare('SELECT * FROM ap_moves WHERE slug = ? ORDER BY id').all(slug); } catch { return []; }
|
|---|
| 107 | }
|
|---|
| 108 |
|
|---|
| 109 | // ── Stand van zaken ───────────────────────────────────────────────
|
|---|
| 110 |
|
|---|
| 111 | export function migrationComplete(slug) {
|
|---|
| 112 | try {
|
|---|
| 113 | const r = db.prepare('SELECT migration_complete FROM sites WHERE slug = ?').get(slug);
|
|---|
| 114 | // Geen kolom of geen rij telt als "klaar": een site die nooit verhuisde
|
|---|
| 115 | // heeft niets openstaan, en derden moeten niet eeuwig blijven pollen.
|
|---|
| 116 | return !r || r.migration_complete === null || r.migration_complete === undefined ? true : !!r.migration_complete;
|
|---|
| 117 | } catch { return true; }
|
|---|
| 118 | }
|
|---|
| 119 |
|
|---|
| 120 | export function setMigrationComplete(slug, klaar) {
|
|---|
| 121 | try { db.prepare('UPDATE sites SET migration_complete = ? WHERE slug = ?').run(klaar ? 1 : 0, slug); } catch { /* kolom ontbreekt op een oude db */ }
|
|---|
| 122 | }
|
|---|
| 123 |
|
|---|
| 124 | // ── De collecties ─────────────────────────────────────────────────
|
|---|
| 125 |
|
|---|
| 126 | /**
|
|---|
| 127 | * De `migration`-collectie. Items zijn Move-activities per OBJECT (niet per
|
|---|
| 128 | * actor): origin is de oude URI, target de nieuwe.
|
|---|
| 129 | *
|
|---|
| 130 | * De spec wil URI-verwijzingen in origin/target in plaats van ingesloten
|
|---|
| 131 | * objecten, en paginering. `pagedCollection` doet dat al voor de rest van
|
|---|
| 132 | * Klonkt, dus die gebruiken we ook hier.
|
|---|
| 133 | */
|
|---|
| 134 | export function buildMigration(base, site, { page = false, alles = false } = {}) {
|
|---|
| 135 | const me = actorId(base, site.slug);
|
|---|
| 136 | const id = `${me}/migration`;
|
|---|
| 137 | const rows = migrationItems(site.slug, { alles });
|
|---|
| 138 | const items = rows.map((r) => ({
|
|---|
| 139 | type: 'Move',
|
|---|
| 140 | actor: r.source_actor || undefined,
|
|---|
| 141 | origin: r.origin,
|
|---|
| 142 | target: r.target,
|
|---|
| 143 | }));
|
|---|
| 144 | return pagedCollection(id, items, {
|
|---|
| 145 | page,
|
|---|
| 146 | extra: {
|
|---|
| 147 | attributedTo: me,
|
|---|
| 148 | moves: `${me}/moves`,
|
|---|
| 149 | migrationComplete: migrationComplete(site.slug),
|
|---|
| 150 | },
|
|---|
| 151 | });
|
|---|
| 152 | }
|
|---|
| 153 |
|
|---|
| 154 | /**
|
|---|
| 155 | * De `moves`-collectie: de Move-activities zelf, met het bron-actordocument
|
|---|
| 156 | * ingesloten zoals de spec aanraadt ("Source instances SHOULD inline the source
|
|---|
| 157 | * Actor object"), zodat een lezer de proof kan nakijken zonder de bron nog te
|
|---|
| 158 | * kunnen bereiken. Dat laatste is precies het geval waarvoor dit bestaat.
|
|---|
| 159 | *
|
|---|
| 160 | * Zonder FEP-8b32 (shaer-j1v0) ontbreekt de handtekening. Zie de kop.
|
|---|
| 161 | */
|
|---|
| 162 | export function buildMoves(base, site) {
|
|---|
| 163 | const me = actorId(base, site.slug);
|
|---|
| 164 | const rows = moveRows(site.slug);
|
|---|
| 165 | const orderedItems = rows.map((r) => {
|
|---|
| 166 | let act = {};
|
|---|
| 167 | try { act = JSON.parse(r.activity_json) || {}; } catch { /* onleesbaar, dan de kale vorm hieronder */ }
|
|---|
| 168 | let actorDoc = null;
|
|---|
| 169 | try { actorDoc = r.actor_json ? JSON.parse(r.actor_json) : null; } catch { /* idem */ }
|
|---|
| 170 | return {
|
|---|
| 171 | id: r.move_id,
|
|---|
| 172 | type: 'Move',
|
|---|
| 173 | origin: r.source_actor,
|
|---|
| 174 | target: r.target_actor,
|
|---|
| 175 | actor: actorDoc || r.source_actor,
|
|---|
| 176 | ...(act.published ? { published: act.published } : {}),
|
|---|
| 177 | };
|
|---|
| 178 | });
|
|---|
| 179 | return {
|
|---|
| 180 | '@context': AP_CONTEXT,
|
|---|
| 181 | id: `${me}/moves`,
|
|---|
| 182 | type: 'OrderedCollection',
|
|---|
| 183 | attributedTo: me,
|
|---|
| 184 | totalItems: orderedItems.length,
|
|---|
| 185 | orderedItems,
|
|---|
| 186 | };
|
|---|
| 187 | }
|
|---|
| 188 |
|
|---|
| 189 | // ── Wat de UI wil weten ───────────────────────────────────────────
|
|---|
| 190 |
|
|---|
| 191 | export function migrationStatus(slug) {
|
|---|
| 192 | return {
|
|---|
| 193 | total: migrationCount(slug, { alles: true }),
|
|---|
| 194 | publiek: migrationCount(slug),
|
|---|
| 195 | moves: moveRows(slug).length,
|
|---|
| 196 | complete: migrationComplete(slug),
|
|---|
| 197 | };
|
|---|
| 198 | }
|
|---|
| 199 |
|
|---|
| 200 | /** Een id dat nergens mee botst, in de vorm die de rest van Klonkt gebruikt. */
|
|---|
| 201 | export function nieuwId() { return crypto.randomUUID(); }
|
|---|
| 202 |
|
|---|
| 203 | // ── De ingest: van de bron hierheen ───────────────────────────────
|
|---|
| 204 |
|
|---|
| 205 | /** Vrije slug binnen deze site. Botst hij, dan -2, -3, enzovoort. */
|
|---|
| 206 | function vrijeSlug(siteId, basis) {
|
|---|
| 207 | const schoon = String(basis || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'bericht';
|
|---|
| 208 | const bestaat = db.prepare('SELECT 1 FROM posts WHERE site_id = ? AND slug = ?');
|
|---|
| 209 | if (!bestaat.get(siteId, schoon)) return schoon;
|
|---|
| 210 | for (let n = 2; n < 500; n++) if (!bestaat.get(siteId, `${schoon}-${n}`)) return `${schoon}-${n}`;
|
|---|
| 211 | return `${schoon}-${crypto.randomBytes(4).toString('hex')}`;
|
|---|
| 212 | }
|
|---|
| 213 |
|
|---|
| 214 | /** De laatste padcomponent van een URI, als beginpunt voor een slug. */
|
|---|
| 215 | function slugUitUri(uri) {
|
|---|
| 216 | try { return decodeURIComponent(new URL(uri).pathname.split('/').filter(Boolean).pop() || ''); } catch { return ''; }
|
|---|
| 217 | }
|
|---|
| 218 |
|
|---|
| 219 | const AFBEELDING = /^image\//i;
|
|---|
| 220 |
|
|---|
| 221 | /**
|
|---|
| 222 | * De titel terugwinnen uit de content.
|
|---|
| 223 | *
|
|---|
| 224 | * Een AS2 Note heeft geen titel: Mastodon negeert `name`, dus Klonkt bakt de
|
|---|
| 225 | * titel als vetgedrukte eerste alinea IN de content (zie buildNote). Over de
|
|---|
| 226 | * lijn is een titel dus geen veld maar een vorm. Doen we hier niets, dan komt
|
|---|
| 227 | * elk bericht titelloos aan en heet het naar zijn id.
|
|---|
| 228 | *
|
|---|
| 229 | * Daarom draaien we precies onze eigen bak terug: alleen als de content BEGINT
|
|---|
| 230 | * met een alinea die niets anders bevat dan vetgedrukte tekst. Dat is de exacte
|
|---|
| 231 | * vorm die buildNote maakt. Een bericht van elders dat toevallig zo begint
|
|---|
| 232 | * verliest die regel niet, hij verhuist naar het titelveld en staat straks
|
|---|
| 233 | * gewoon weer bovenaan.
|
|---|
| 234 | */
|
|---|
| 235 | function titelUitContent(html) {
|
|---|
| 236 | const m = /^\s*<p>\s*<strong>([\s\S]*?)<\/strong>\s*<\/p>/i.exec(String(html || ''));
|
|---|
| 237 | if (!m) return { titel: null, rest: html };
|
|---|
| 238 | const titel = m[1].replace(/<[^>]+>/g, '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').trim();
|
|---|
| 239 | if (!titel || titel.length > 300) return { titel: null, rest: html };
|
|---|
| 240 | return { titel, rest: String(html).slice(m[0].length) };
|
|---|
| 241 | }
|
|---|
| 242 |
|
|---|
| 243 | /**
|
|---|
| 244 | * Haal een bijlage op en zet hem lokaal neer.
|
|---|
| 245 | *
|
|---|
| 246 | * safeFetch is de SSRF-veilige kant van Klonkt; hier is dat geen formaliteit,
|
|---|
| 247 | * want de URL komt van een andere server. Een bron die ons naar 127.0.0.1 wijst
|
|---|
| 248 | * moet stranden, ook als die bron "van onszelf" is.
|
|---|
| 249 | */
|
|---|
| 250 | async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes, submap = 'migrated', headers = null }) {
|
|---|
| 251 | // Ondertekend als het moet. Gehoste audio zit achter dezelfde poort als de
|
|---|
| 252 | // rest van de bron, en een kale fetch krijgt daar een 403: de bron kan dan
|
|---|
| 253 | // niet zien dat wij de doel-actor van zijn Move zijn.
|
|---|
| 254 | const r = await safeFetch(url, { headers: headers || { accept: '*/*' } }).catch(() => null);
|
|---|
| 255 | if (!r || !r.ok) return null;
|
|---|
| 256 | const buf = Buffer.from(await r.arrayBuffer());
|
|---|
| 257 | if (!buf.length || buf.length > maxBytes) return null;
|
|---|
| 258 | const type = String(r.headers.get('content-type') || '').split(';')[0].trim() || 'application/octet-stream';
|
|---|
| 259 | const ext = (() => {
|
|---|
| 260 | const uit = slugUitUri(url);
|
|---|
| 261 | const m = /\.([a-z0-9]{1,5})$/i.exec(uit);
|
|---|
| 262 | if (m) return m[1].toLowerCase();
|
|---|
| 263 | return (type.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'bin';
|
|---|
| 264 | })();
|
|---|
| 265 | const naam = `${crypto.randomUUID()}.${ext}`;
|
|---|
| 266 | // Zonder submap komt het bestand in de root zelf: dat is wat gehoste audio
|
|---|
| 267 | // nodig heeft, want de speler zoekt AUDIO_ROOT + bestandsnaam en kijkt niet
|
|---|
| 268 | // in mappen eronder.
|
|---|
| 269 | const rel = submap ? `${submap}/${naam}` : naam;
|
|---|
| 270 | const abs = submap ? path.join(mediaRoot, submap, naam) : path.join(mediaRoot, naam);
|
|---|
| 271 | fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|---|
| 272 | fs.writeFileSync(abs, buf);
|
|---|
| 273 | return { url: `/media/${rel}`, mediaType: type, size: buf.length, filename: naam, storage_path: abs };
|
|---|
| 274 | }
|
|---|
| 275 |
|
|---|
| 276 | /**
|
|---|
| 277 | * FEP-1580 ingest-routine, de doelkant.
|
|---|
| 278 | *
|
|---|
| 279 | * De autorisatie wordt hier niet verzonnen maar NAGEKEKEN, en in beide
|
|---|
| 280 | * richtingen, precies zoals de spec het voor derden voorschrijft: `movedTo` op
|
|---|
| 281 | * de bron moet naar ons wijzen EN wij moeten de bron in `alsoKnownAs` hebben.
|
|---|
| 282 | * Eén kant is een bewering, twee kanten is een afspraak. Zou ik alleen op onze
|
|---|
| 283 | * eigen alsoKnownAs afgaan, dan kon iedereen die zichzelf een alias geeft de
|
|---|
| 284 | * geschiedenis van een vreemde opeisen.
|
|---|
| 285 | *
|
|---|
| 286 | * `deps` is er voor de test: die moet dit kunnen draaien zonder netwerk.
|
|---|
| 287 | */
|
|---|
| 288 | export async function ingestFromSource(site, {
|
|---|
| 289 | sourceUri = null, max = 1000, maxBytes = 25 * 1024 * 1024, deps = {},
|
|---|
| 290 | } = {}) {
|
|---|
| 291 | const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
|---|
| 292 | if (!base || !site || !site.slug) return { error: 'config' };
|
|---|
| 293 | const me = actorId(base, site.slug);
|
|---|
| 294 |
|
|---|
| 295 | const {
|
|---|
| 296 | getJson = null, safeFetch = null, mediaRoot = null, fs = null, path = null, noteId = null,
|
|---|
| 297 | sanitize = (h) => h,
|
|---|
| 298 | // Standaard 'followers': kan iets niet als publiek bewezen worden, dan
|
|---|
| 299 | // hoort het niet in de publieke vertaaltabel. Fail-closed, want dit is een
|
|---|
| 300 | // privacygrens en niet een weergavedetail.
|
|---|
| 301 | noteVisibility = () => 'followers',
|
|---|
| 302 | audioRoot = null, signHeaders = null,
|
|---|
| 303 | } = deps;
|
|---|
| 304 | const zichtbaarheid = noteVisibility;
|
|---|
| 305 | if (!getJson || !noteId) return { error: 'config' };
|
|---|
| 306 |
|
|---|
| 307 | // 1. Welke bron? Zonder opgave: de alias die we zelf claimen.
|
|---|
| 308 | let bron = sourceUri && /^https?:\/\//i.test(sourceUri) ? sourceUri : null;
|
|---|
| 309 | if (!bron) {
|
|---|
| 310 | try {
|
|---|
| 311 | const aka = JSON.parse(site.ap_aliases || '[]');
|
|---|
| 312 | bron = Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
|
|---|
| 313 | } catch { /* stukke ap_aliases telt als geen alias */ }
|
|---|
| 314 | }
|
|---|
| 315 | if (!bron) return { error: 'no_source' };
|
|---|
| 316 |
|
|---|
| 317 | // 2 + 3. Het bron-actordocument, en de wegwijzer die naar ONS moet wijzen.
|
|---|
| 318 | const bronActor = await getJson(site.slug, bron);
|
|---|
| 319 | if (!bronActor || !bronActor.id) return { error: 'unreachable' };
|
|---|
| 320 | if (bronActor.movedTo !== me) return { error: 'not_moved_here', movedTo: bronActor.movedTo || null };
|
|---|
| 321 |
|
|---|
| 322 | // 4. En de terugverwijzing van onze kant, zodat het een afspraak is.
|
|---|
| 323 | const eigenAka = (() => {
|
|---|
| 324 | try { const a = JSON.parse(site.ap_aliases || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
|
|---|
| 325 | })();
|
|---|
| 326 | if (!eigenAka.includes(bronActor.id)) return { error: 'no_backreference' };
|
|---|
| 327 |
|
|---|
| 328 | // 5 + 6. Vastleggen dat dit een migratie is, en de deur openzetten voor derden.
|
|---|
| 329 | recordMove(site.slug, {
|
|---|
| 330 | moveId: `${bronActor.id}#move`, sourceActor: bronActor.id, targetActor: me,
|
|---|
| 331 | activity: { type: 'Move', actor: bronActor.id, object: bronActor.id, target: me },
|
|---|
| 332 | actorDoc: bronActor,
|
|---|
| 333 | });
|
|---|
| 334 | setMigrationComplete(site.slug, false);
|
|---|
| 335 |
|
|---|
| 336 | const rapport = {
|
|---|
| 337 | bron: bronActor.id, posts: 0, overgeslagen: 0, media: 0, mediaMislukt: 0,
|
|---|
| 338 | blocks: 0, tracksBinnen: 0, tracksMislukt: 0, overgeslagenTracks: 0, waarschuwingen: [],
|
|---|
| 339 | };
|
|---|
| 340 |
|
|---|
| 341 | try {
|
|---|
| 342 | // 7. BLOKKADES EERST. De spec is daar streng over, en terecht: ze bepalen
|
|---|
| 343 | // wie de rest te zien krijgt. Andersom importeer je even je hele
|
|---|
| 344 | // geschiedenis zichtbaar voor iemand die je nou juist buiten wilde.
|
|---|
| 345 | if (bronActor.blocked) {
|
|---|
| 346 | const coll = await getJson(site.slug, typeof bronActor.blocked === 'string' ? bronActor.blocked : bronActor.blocked.id);
|
|---|
| 347 | const lijst = (coll && (coll.orderedItems || coll.items)) || [];
|
|---|
| 348 | for (const b of Array.isArray(lijst) ? lijst : []) {
|
|---|
| 349 | const uri = typeof b === 'string' ? b : (b && (b.object || b.id));
|
|---|
| 350 | if (!uri || !/^https?:\/\//i.test(String(uri))) continue;
|
|---|
| 351 | try {
|
|---|
| 352 | db.prepare("INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label) VALUES (?, ?, 'actor', NULL)").run(site.slug, String(uri));
|
|---|
| 353 | rapport.blocks++;
|
|---|
| 354 | } catch { /* tabel ontbreekt op een verse db */ }
|
|---|
| 355 | }
|
|---|
| 356 | } else {
|
|---|
| 357 | rapport.waarschuwingen.push('de bron gaf geen blokkadelijst, zichtbaarheidsvoorkeuren komen niet mee');
|
|---|
| 358 | }
|
|---|
| 359 |
|
|---|
| 360 | // 8. De outbox aflopen. Pagineren zoals de rest van Klonkt dat doet.
|
|---|
| 361 | if (!bronActor.outbox) return { ...rapport, error: 'no_outbox' };
|
|---|
| 362 | let pagina = await getJson(site.slug, typeof bronActor.outbox === 'string' ? bronActor.outbox : bronActor.outbox.id);
|
|---|
| 363 | if (pagina && pagina.first && !(pagina.orderedItems || pagina.items)) {
|
|---|
| 364 | pagina = await getJson(site.slug, typeof pagina.first === 'string' ? pagina.first : pagina.first.id);
|
|---|
| 365 | }
|
|---|
| 366 |
|
|---|
| 367 | const insPost = db.prepare(`INSERT INTO posts
|
|---|
| 368 | (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url,
|
|---|
| 369 | pinned, type, tags, published_at, created_at, updated_at, fan_only, nsfw, language,
|
|---|
| 370 | content_warning, ap_visibility, c2s_attachments, origin_server)
|
|---|
| 371 | VALUES (@id, @site_id, @slug, @author_id, @title, @content, NULL, 'published', @cover_image_url,
|
|---|
| 372 | 0, 'post', @tags, @published_at, @published_at, @updated_at, @fan_only, @nsfw, @language,
|
|---|
| 373 | @content_warning, @ap_visibility, @c2s_attachments, 'migrated')`);
|
|---|
| 374 |
|
|---|
| 375 | let gezien = 0;
|
|---|
| 376 | while (pagina && gezien < max) {
|
|---|
| 377 | const items = (pagina.orderedItems || pagina.items) || [];
|
|---|
| 378 | for (const it of Array.isArray(items) ? items : []) {
|
|---|
| 379 | if (gezien >= max) break;
|
|---|
| 380 | const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
|
|---|
| 381 | if (!o || !o.id) continue;
|
|---|
| 382 | if (o.type && !['Note', 'Article', 'Question'].includes(o.type)) continue;
|
|---|
| 383 | if (o.inReplyTo) continue; // toplevel; antwoorden hangen aan hun ouder
|
|---|
| 384 | const auteur = typeof o.attributedTo === 'string' ? o.attributedTo : (o.attributedTo && o.attributedTo.id);
|
|---|
| 385 | if (auteur && auteur !== bronActor.id) continue; // alleen wat van HEM was
|
|---|
| 386 | gezien++;
|
|---|
| 387 | if (alGemigreerd(site.slug, o.id)) { rapport.overgeslagen++; continue; }
|
|---|
| 388 |
|
|---|
| 389 | // Media eerst, want een post die naar een plaatje wijst dat we niet
|
|---|
| 390 | // hebben opgehaald is een halve post. Mislukt een bijlage, dan gaat de
|
|---|
| 391 | // post wel door en staat het in het verslag.
|
|---|
| 392 | const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
|
|---|
| 393 | const binnen = [];
|
|---|
| 394 | if (safeFetch && fs && path && mediaRoot) {
|
|---|
| 395 | for (const a of bijlagen.slice(0, 20)) {
|
|---|
| 396 | const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
|
|---|
| 397 | if (!u || !/^https?:\/\//i.test(String(u))) continue;
|
|---|
| 398 | const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes }).catch(() => null);
|
|---|
| 399 | if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`bijlage niet opgehaald: ${u}`); continue; }
|
|---|
| 400 | binnen.push({ ...g, naam: (a && a.name) || null, type: (a && a.mediaType) || g.mediaType });
|
|---|
| 401 | rapport.media++;
|
|---|
| 402 | try {
|
|---|
| 403 | db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
|
|---|
| 404 | .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
|
|---|
| 405 | } catch { /* media-rij is administratie, het bestand staat er */ }
|
|---|
| 406 | }
|
|---|
| 407 | }
|
|---|
| 408 |
|
|---|
| 409 | const id = crypto.randomUUID();
|
|---|
| 410 | const cover = binnen.find((b) => AFBEELDING.test(b.type || ''));
|
|---|
| 411 | const rest = binnen.filter((b) => b !== cover);
|
|---|
| 412 | // De titel zit in de content, niet in een veld (zie titelUitContent).
|
|---|
| 413 | const { titel, rest: body } = o.name ? { titel: o.name, rest: o.content || '' } : titelUitContent(o.content || '');
|
|---|
| 414 | // De slug uit de MENSELIJKE url, niet uit de AP-id. Zo houdt het bericht
|
|---|
| 415 | // hetzelfde webadres als op de oude instantie, en blijft een link die
|
|---|
| 416 | // iemand ergens plakte kloppen op het nieuwe domein.
|
|---|
| 417 | const basisSlug = slugUitUri(o.url || '') || o.name || slugUitUri(o.id) || id;
|
|---|
| 418 | // De publicatiedatum blijft die van het origineel. De spec eist dat, en
|
|---|
| 419 | // het is ook het enige eerlijke: het bericht is niet vandaag geschreven.
|
|---|
| 420 | insPost.run({
|
|---|
| 421 | id, site_id: site.id, slug: vrijeSlug(site.id, basisSlug),
|
|---|
| 422 | author_id: site.owner_id, title: titel || null, content: sanitize(body || ''),
|
|---|
| 423 | cover_image_url: cover ? cover.url : null,
|
|---|
| 424 | tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name || '').replace(/^#/, '')).filter(Boolean).join(', ') || null : null,
|
|---|
| 425 | published_at: o.published || null, updated_at: o.updated || o.published || null,
|
|---|
| 426 | fan_only: 0, nsfw: o.sensitive ? 1 : 0,
|
|---|
| 427 | language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
|
|---|
| 428 | content_warning: o.summary || null,
|
|---|
| 429 | ap_visibility: null,
|
|---|
| 430 | c2s_attachments: rest.length ? JSON.stringify(rest.map((b) => ({ url: b.url, mediaType: b.type, name: b.naam || undefined }))) : null,
|
|---|
| 431 | });
|
|---|
| 432 | recordMigrated(site.slug, {
|
|---|
| 433 | origin: o.id, target: noteId(base, id), sourceActor: bronActor.id,
|
|---|
| 434 | // Publiek in de zin van de spec: gericht aan as:Public. Zo niet, dan
|
|---|
| 435 | // hoort deze regel niet in een publiek leesbare migration-pagina.
|
|---|
| 436 | //
|
|---|
| 437 | // Via noteVisibility en niet met een eigen test op '#Public': die kent
|
|---|
| 438 | // ook de schrijfwijzen 'as:Public' en 'Public', en de rest van Klonkt
|
|---|
| 439 | // beslist er al mee. Een tweede, dunnere versie van dezelfde vraag is
|
|---|
| 440 | // precies hoe twee antwoorden uit elkaar gaan lopen.
|
|---|
| 441 | isPublic: zichtbaarheid(o) === 'public',
|
|---|
| 442 | });
|
|---|
| 443 | rapport.posts++;
|
|---|
| 444 | }
|
|---|
| 445 | const volgende = pagina.next;
|
|---|
| 446 | if (!volgende || gezien >= max) break;
|
|---|
| 447 | pagina = await getJson(site.slug, typeof volgende === 'string' ? volgende : volgende.id);
|
|---|
| 448 | }
|
|---|
| 449 | if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
|
|---|
| 450 |
|
|---|
| 451 | // ── De muziekbibliotheek ──────────────────────────────────────
|
|---|
| 452 | //
|
|---|
| 453 | // Losse nummers staan niet in de outbox: die hangen aan de tracks-collectie
|
|---|
| 454 | // waar de actor via AS2 `streams` naar wijst. Zonder deze lus verhuist een
|
|---|
| 455 | // muzieksite zijn berichten en laat hij zijn bibliotheek achter.
|
|---|
| 456 | //
|
|---|
| 457 | // De bron geeft ons hier alles, niet alleen de fedi_open-nummers, omdat we
|
|---|
| 458 | // de doel-actor van zijn Move zijn (siteOpenTracks({alles})). Hetzelfde
|
|---|
| 459 | // geldt voor de bestanden zelf, die anders achter de gated audio-route
|
|---|
| 460 | // blijven.
|
|---|
| 461 | const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
|
|---|
| 462 | const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
|
|---|
| 463 | if (tracksUrl && safeFetch && fs && path && audioRoot) {
|
|---|
| 464 | const coll = await getJson(site.slug, tracksUrl);
|
|---|
| 465 | const lijst = (coll && (coll.orderedItems || coll.items)) || [];
|
|---|
| 466 | for (const it of (Array.isArray(lijst) ? lijst : []).slice(0, max)) {
|
|---|
| 467 | const a = (it && typeof it.object === 'object' && it.object) ? it.object : it;
|
|---|
| 468 | if (!a || !a.id) continue;
|
|---|
| 469 | if (a.type && a.type !== 'Audio') continue;
|
|---|
| 470 | if (alGemigreerd(site.slug, a.id)) { rapport.overgeslagenTracks++; continue; }
|
|---|
| 471 | const bron = a.url && (typeof a.url === 'string' ? a.url : (Array.isArray(a.url) ? (a.url[0] && (a.url[0].href || a.url[0])) : a.url.href));
|
|---|
| 472 | if (!bron || !/^https?:\/\//i.test(String(bron))) { rapport.tracksMislukt++; continue; }
|
|---|
| 473 | const g = await haalBijlage(String(bron), {
|
|---|
| 474 | safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '',
|
|---|
| 475 | headers: signHeaders ? signHeaders(site.slug, String(bron), '*/*') : null,
|
|---|
| 476 | }).catch(() => null);
|
|---|
| 477 | if (!g) {
|
|---|
| 478 | rapport.tracksMislukt++;
|
|---|
| 479 | rapport.waarschuwingen.push(`nummer niet opgehaald: ${a.name || bron}`);
|
|---|
| 480 | continue; // dezelfde regel als bij de zip: geen bestand, geen track
|
|---|
| 481 | }
|
|---|
| 482 | const trackId = crypto.randomUUID();
|
|---|
| 483 | const mediaId = crypto.randomUUID();
|
|---|
| 484 | try {
|
|---|
| 485 | db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
|
|---|
| 486 | .run(mediaId, site.id, g.filename, g.mediaType, g.size, g.storage_path);
|
|---|
| 487 | db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, fedi_open)
|
|---|
| 488 | VALUES (?,?,?,?,?,?,?,0)`)
|
|---|
| 489 | .run(trackId, site.id, a.name || 'zonder titel', a.artist || null, a.album || null,
|
|---|
| 490 | Number(a.duration) || null, mediaId);
|
|---|
| 491 | recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
|
|---|
| 492 | rapport.tracksBinnen++;
|
|---|
| 493 | } catch (e) {
|
|---|
| 494 | rapport.tracksMislukt++;
|
|---|
| 495 | rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
|
|---|
| 496 | }
|
|---|
| 497 | }
|
|---|
| 498 | } else if (tracksUrl) {
|
|---|
| 499 | rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
|
|---|
| 500 | }
|
|---|
| 501 | } catch (e) {
|
|---|
| 502 | // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
|
|---|
| 503 | // en dat is precies goed, want er is nog werk.
|
|---|
| 504 | console.warn('[FEP-1580] ingest afgebroken:', e && e.message);
|
|---|
| 505 | return { ...rapport, error: 'partial', melding: e && e.message };
|
|---|
| 506 | }
|
|---|
| 507 |
|
|---|
| 508 | // 9. Klaar. Nu pas mag een derde stoppen met kijken.
|
|---|
| 509 | setMigrationComplete(site.slug, true);
|
|---|
| 510 | console.log('[FEP-1580] ingest klaar:', site.slug, '<-', bronActor.id, rapport.posts, 'berichten,', rapport.media, 'bestanden');
|
|---|
| 511 | return rapport;
|
|---|
| 512 | }
|
|---|