| 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 | /** AS2 geeft de duur als ISO-8601 ("PT212S"), de database wil seconden. */
|
|---|
| 222 | function duurSeconden(v) {
|
|---|
| 223 | if (v == null) return null;
|
|---|
| 224 | if (typeof v === 'number') return Math.round(v) || null;
|
|---|
| 225 | const m = /^P(?:.*?T)?(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?$/.exec(String(v));
|
|---|
| 226 | if (!m) { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.round(n) : null; }
|
|---|
| 227 | const sec = (Number(m[1]) || 0) * 3600 + (Number(m[2]) || 0) * 60 + (Number(m[3]) || 0);
|
|---|
| 228 | return sec > 0 ? Math.round(sec) : null;
|
|---|
| 229 | }
|
|---|
| 230 |
|
|---|
| 231 | /**
|
|---|
| 232 | * De titel terugwinnen uit de content.
|
|---|
| 233 | *
|
|---|
| 234 | * Een AS2 Note heeft geen titel: Mastodon negeert `name`, dus Klonkt bakt de
|
|---|
| 235 | * titel als vetgedrukte eerste alinea IN de content (zie buildNote). Over de
|
|---|
| 236 | * lijn is een titel dus geen veld maar een vorm. Doen we hier niets, dan komt
|
|---|
| 237 | * elk bericht titelloos aan en heet het naar zijn id.
|
|---|
| 238 | *
|
|---|
| 239 | * Daarom draaien we precies onze eigen bak terug: alleen als de content BEGINT
|
|---|
| 240 | * met een alinea die niets anders bevat dan vetgedrukte tekst. Dat is de exacte
|
|---|
| 241 | * vorm die buildNote maakt. Een bericht van elders dat toevallig zo begint
|
|---|
| 242 | * verliest die regel niet, hij verhuist naar het titelveld en staat straks
|
|---|
| 243 | * gewoon weer bovenaan.
|
|---|
| 244 | */
|
|---|
| 245 | function titelUitContent(html) {
|
|---|
| 246 | const m = /^\s*<p>\s*<strong>([\s\S]*?)<\/strong>\s*<\/p>/i.exec(String(html || ''));
|
|---|
| 247 | if (!m) return { titel: null, rest: html };
|
|---|
| 248 | const titel = m[1].replace(/<[^>]+>/g, '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').trim();
|
|---|
| 249 | if (!titel || titel.length > 300) return { titel: null, rest: html };
|
|---|
| 250 | return { titel, rest: String(html).slice(m[0].length) };
|
|---|
| 251 | }
|
|---|
| 252 |
|
|---|
| 253 | /**
|
|---|
| 254 | * Haal een bijlage op en zet hem lokaal neer.
|
|---|
| 255 | *
|
|---|
| 256 | * safeFetch is de SSRF-veilige kant van Klonkt; hier is dat geen formaliteit,
|
|---|
| 257 | * want de URL komt van een andere server. Een bron die ons naar 127.0.0.1 wijst
|
|---|
| 258 | * moet stranden, ook als die bron "van onszelf" is.
|
|---|
| 259 | */
|
|---|
| 260 | async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes, submap = 'migrated', headers = null }) {
|
|---|
| 261 | // Ondertekend als het moet. Gehoste audio zit achter dezelfde poort als de
|
|---|
| 262 | // rest van de bron, en een kale fetch krijgt daar een 403: de bron kan dan
|
|---|
| 263 | // niet zien dat wij de doel-actor van zijn Move zijn.
|
|---|
| 264 | const r = await safeFetch(url, { headers: headers || { accept: '*/*' } }).catch(() => null);
|
|---|
| 265 | if (!r || !r.ok) return null;
|
|---|
| 266 | const buf = Buffer.from(await r.arrayBuffer());
|
|---|
| 267 | if (!buf.length || buf.length > maxBytes) return null;
|
|---|
| 268 | const type = String(r.headers.get('content-type') || '').split(';')[0].trim() || 'application/octet-stream';
|
|---|
| 269 | const ext = (() => {
|
|---|
| 270 | const uit = slugUitUri(url);
|
|---|
| 271 | const m = /\.([a-z0-9]{1,5})$/i.exec(uit);
|
|---|
| 272 | if (m) return m[1].toLowerCase();
|
|---|
| 273 | return (type.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'bin';
|
|---|
| 274 | })();
|
|---|
| 275 | const naam = `${crypto.randomUUID()}.${ext}`;
|
|---|
| 276 | // Zonder submap komt het bestand in de root zelf: dat is wat gehoste audio
|
|---|
| 277 | // nodig heeft, want de speler zoekt AUDIO_ROOT + bestandsnaam en kijkt niet
|
|---|
| 278 | // in mappen eronder.
|
|---|
| 279 | const rel = submap ? `${submap}/${naam}` : naam;
|
|---|
| 280 | const abs = submap ? path.join(mediaRoot, submap, naam) : path.join(mediaRoot, naam);
|
|---|
| 281 | fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|---|
| 282 | fs.writeFileSync(abs, buf);
|
|---|
| 283 | return { url: `/media/${rel}`, mediaType: type, size: buf.length, filename: naam, storage_path: abs };
|
|---|
| 284 | }
|
|---|
| 285 |
|
|---|
| 286 | /**
|
|---|
| 287 | * FEP-1580 ingest-routine, de doelkant.
|
|---|
| 288 | *
|
|---|
| 289 | * De autorisatie wordt hier niet verzonnen maar NAGEKEKEN, en in beide
|
|---|
| 290 | * richtingen, precies zoals de spec het voor derden voorschrijft: `movedTo` op
|
|---|
| 291 | * de bron moet naar ons wijzen EN wij moeten de bron in `alsoKnownAs` hebben.
|
|---|
| 292 | * Eén kant is een bewering, twee kanten is een afspraak. Zou ik alleen op onze
|
|---|
| 293 | * eigen alsoKnownAs afgaan, dan kon iedereen die zichzelf een alias geeft de
|
|---|
| 294 | * geschiedenis van een vreemde opeisen.
|
|---|
| 295 | *
|
|---|
| 296 | * `deps` is er voor de test: die moet dit kunnen draaien zonder netwerk.
|
|---|
| 297 | */
|
|---|
| 298 | export async function ingestFromSource(site, {
|
|---|
| 299 | sourceUri = null, max = 1000, maxBytes = 25 * 1024 * 1024, deps = {},
|
|---|
| 300 | } = {}) {
|
|---|
| 301 | const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
|---|
| 302 | if (!base || !site || !site.slug) return { error: 'config' };
|
|---|
| 303 | const me = actorId(base, site.slug);
|
|---|
| 304 |
|
|---|
| 305 | const {
|
|---|
| 306 | getJson = null, safeFetch = null, mediaRoot = null, fs = null, path = null, noteId = null,
|
|---|
| 307 | sanitize = (h) => h,
|
|---|
| 308 | // Standaard 'followers': kan iets niet als publiek bewezen worden, dan
|
|---|
| 309 | // hoort het niet in de publieke vertaaltabel. Fail-closed, want dit is een
|
|---|
| 310 | // privacygrens en niet een weergavedetail.
|
|---|
| 311 | noteVisibility = () => 'followers',
|
|---|
| 312 | audioRoot = null, signHeaders = null,
|
|---|
| 313 | } = deps;
|
|---|
| 314 | const zichtbaarheid = noteVisibility;
|
|---|
| 315 | if (!getJson || !noteId) return { error: 'config' };
|
|---|
| 316 |
|
|---|
| 317 | // 1. Welke bron? Zonder opgave: de alias die we zelf claimen.
|
|---|
| 318 | let bron = sourceUri && /^https?:\/\//i.test(sourceUri) ? sourceUri : null;
|
|---|
| 319 | if (!bron) {
|
|---|
| 320 | try {
|
|---|
| 321 | const aka = JSON.parse(site.ap_aliases || '[]');
|
|---|
| 322 | bron = Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
|
|---|
| 323 | } catch { /* stukke ap_aliases telt als geen alias */ }
|
|---|
| 324 | }
|
|---|
| 325 | if (!bron) return { error: 'no_source' };
|
|---|
| 326 |
|
|---|
| 327 | // 2 + 3. Het bron-actordocument, en de wegwijzer die naar ONS moet wijzen.
|
|---|
| 328 | const bronActor = await getJson(site.slug, bron);
|
|---|
| 329 | if (!bronActor || !bronActor.id) return { error: 'unreachable' };
|
|---|
| 330 | if (bronActor.movedTo !== me) return { error: 'not_moved_here', movedTo: bronActor.movedTo || null };
|
|---|
| 331 |
|
|---|
| 332 | // 4. En de terugverwijzing van onze kant, zodat het een afspraak is.
|
|---|
| 333 | const eigenAka = (() => {
|
|---|
| 334 | try { const a = JSON.parse(site.ap_aliases || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
|
|---|
| 335 | })();
|
|---|
| 336 | if (!eigenAka.includes(bronActor.id)) return { error: 'no_backreference' };
|
|---|
| 337 |
|
|---|
| 338 | // 5 + 6. Vastleggen dat dit een migratie is, en de deur openzetten voor derden.
|
|---|
| 339 | recordMove(site.slug, {
|
|---|
| 340 | moveId: `${bronActor.id}#move`, sourceActor: bronActor.id, targetActor: me,
|
|---|
| 341 | activity: { type: 'Move', actor: bronActor.id, object: bronActor.id, target: me },
|
|---|
| 342 | actorDoc: bronActor,
|
|---|
| 343 | });
|
|---|
| 344 | setMigrationComplete(site.slug, false);
|
|---|
| 345 |
|
|---|
| 346 | const rapport = {
|
|---|
| 347 | bron: bronActor.id, posts: 0, overgeslagen: 0, media: 0, mediaMislukt: 0,
|
|---|
| 348 | blocks: 0, tracksBinnen: 0, tracksMislukt: 0, overgeslagenTracks: 0,
|
|---|
| 349 | playlistsBinnen: 0, playlistsMislukt: 0, waarschuwingen: [],
|
|---|
| 350 | };
|
|---|
| 351 |
|
|---|
| 352 | try {
|
|---|
| 353 | // 7. BLOKKADES EERST. De spec is daar streng over, en terecht: ze bepalen
|
|---|
| 354 | // wie de rest te zien krijgt. Andersom importeer je even je hele
|
|---|
| 355 | // geschiedenis zichtbaar voor iemand die je nou juist buiten wilde.
|
|---|
| 356 | if (bronActor.blocked) {
|
|---|
| 357 | const coll = await getJson(site.slug, typeof bronActor.blocked === 'string' ? bronActor.blocked : bronActor.blocked.id);
|
|---|
| 358 | const lijst = (coll && (coll.orderedItems || coll.items)) || [];
|
|---|
| 359 | for (const b of Array.isArray(lijst) ? lijst : []) {
|
|---|
| 360 | const uri = typeof b === 'string' ? b : (b && (b.object || b.id));
|
|---|
| 361 | if (!uri || !/^https?:\/\//i.test(String(uri))) continue;
|
|---|
| 362 | try {
|
|---|
| 363 | db.prepare("INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label) VALUES (?, ?, 'actor', NULL)").run(site.slug, String(uri));
|
|---|
| 364 | rapport.blocks++;
|
|---|
| 365 | } catch { /* tabel ontbreekt op een verse db */ }
|
|---|
| 366 | }
|
|---|
| 367 | } else {
|
|---|
| 368 | rapport.waarschuwingen.push('de bron gaf geen blokkadelijst, zichtbaarheidsvoorkeuren komen niet mee');
|
|---|
| 369 | }
|
|---|
| 370 |
|
|---|
| 371 | // 8. De outbox aflopen. Pagineren zoals de rest van Klonkt dat doet.
|
|---|
| 372 | if (!bronActor.outbox) return { ...rapport, error: 'no_outbox' };
|
|---|
| 373 | let pagina = await getJson(site.slug, typeof bronActor.outbox === 'string' ? bronActor.outbox : bronActor.outbox.id);
|
|---|
| 374 | if (pagina && pagina.first && !(pagina.orderedItems || pagina.items)) {
|
|---|
| 375 | pagina = await getJson(site.slug, typeof pagina.first === 'string' ? pagina.first : pagina.first.id);
|
|---|
| 376 | }
|
|---|
| 377 |
|
|---|
| 378 | const insPost = db.prepare(`INSERT INTO posts
|
|---|
| 379 | (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url,
|
|---|
| 380 | pinned, type, tags, published_at, created_at, updated_at, fan_only, nsfw, language,
|
|---|
| 381 | content_warning, ap_visibility, c2s_attachments, origin_server)
|
|---|
| 382 | VALUES (@id, @site_id, @slug, @author_id, @title, @content, NULL, 'published', @cover_image_url,
|
|---|
| 383 | 0, 'post', @tags, @published_at, @published_at, @updated_at, @fan_only, @nsfw, @language,
|
|---|
| 384 | @content_warning, @ap_visibility, @c2s_attachments, 'migrated')`);
|
|---|
| 385 |
|
|---|
| 386 | let gezien = 0;
|
|---|
| 387 | while (pagina && gezien < max) {
|
|---|
| 388 | const items = (pagina.orderedItems || pagina.items) || [];
|
|---|
| 389 | for (const it of Array.isArray(items) ? items : []) {
|
|---|
| 390 | if (gezien >= max) break;
|
|---|
| 391 | const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
|
|---|
| 392 | if (!o || !o.id) continue;
|
|---|
| 393 | if (o.type && !['Note', 'Article', 'Question'].includes(o.type)) continue;
|
|---|
| 394 | if (o.inReplyTo) continue; // toplevel; antwoorden hangen aan hun ouder
|
|---|
| 395 | const auteur = typeof o.attributedTo === 'string' ? o.attributedTo : (o.attributedTo && o.attributedTo.id);
|
|---|
| 396 | if (auteur && auteur !== bronActor.id) continue; // alleen wat van HEM was
|
|---|
| 397 | gezien++;
|
|---|
| 398 | if (alGemigreerd(site.slug, o.id)) { rapport.overgeslagen++; continue; }
|
|---|
| 399 |
|
|---|
| 400 | // Media eerst, want een post die naar een plaatje wijst dat we niet
|
|---|
| 401 | // hebben opgehaald is een halve post. Mislukt een bijlage, dan gaat de
|
|---|
| 402 | // post wel door en staat het in het verslag.
|
|---|
| 403 | const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
|
|---|
| 404 | const binnen = [];
|
|---|
| 405 | if (safeFetch && fs && path && mediaRoot) {
|
|---|
| 406 | for (const a of bijlagen.slice(0, 20)) {
|
|---|
| 407 | const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
|
|---|
| 408 | if (!u || !/^https?:\/\//i.test(String(u))) continue;
|
|---|
| 409 | const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes }).catch(() => null);
|
|---|
| 410 | if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`bijlage niet opgehaald: ${u}`); continue; }
|
|---|
| 411 | binnen.push({ ...g, naam: (a && a.name) || null, type: (a && a.mediaType) || g.mediaType });
|
|---|
| 412 | rapport.media++;
|
|---|
| 413 | try {
|
|---|
| 414 | db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
|
|---|
| 415 | .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
|
|---|
| 416 | } catch { /* media-rij is administratie, het bestand staat er */ }
|
|---|
| 417 | }
|
|---|
| 418 | }
|
|---|
| 419 |
|
|---|
| 420 | const id = crypto.randomUUID();
|
|---|
| 421 | const cover = binnen.find((b) => AFBEELDING.test(b.type || ''));
|
|---|
| 422 | const rest = binnen.filter((b) => b !== cover);
|
|---|
| 423 | // De titel zit in de content, niet in een veld (zie titelUitContent).
|
|---|
| 424 | const { titel, rest: body } = o.name ? { titel: o.name, rest: o.content || '' } : titelUitContent(o.content || '');
|
|---|
| 425 | // De slug uit de MENSELIJKE url, niet uit de AP-id. Zo houdt het bericht
|
|---|
| 426 | // hetzelfde webadres als op de oude instantie, en blijft een link die
|
|---|
| 427 | // iemand ergens plakte kloppen op het nieuwe domein.
|
|---|
| 428 | const basisSlug = slugUitUri(o.url || '') || o.name || slugUitUri(o.id) || id;
|
|---|
| 429 | // De publicatiedatum blijft die van het origineel. De spec eist dat, en
|
|---|
| 430 | // het is ook het enige eerlijke: het bericht is niet vandaag geschreven.
|
|---|
| 431 | insPost.run({
|
|---|
| 432 | id, site_id: site.id, slug: vrijeSlug(site.id, basisSlug),
|
|---|
| 433 | author_id: site.owner_id, title: titel || null, content: sanitize(body || ''),
|
|---|
| 434 | cover_image_url: cover ? cover.url : null,
|
|---|
| 435 | tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name || '').replace(/^#/, '')).filter(Boolean).join(', ') || null : null,
|
|---|
| 436 | published_at: o.published || null, updated_at: o.updated || o.published || null,
|
|---|
| 437 | fan_only: 0, nsfw: o.sensitive ? 1 : 0,
|
|---|
| 438 | language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
|
|---|
| 439 | content_warning: o.summary || null,
|
|---|
| 440 | ap_visibility: null,
|
|---|
| 441 | c2s_attachments: rest.length ? JSON.stringify(rest.map((b) => ({ url: b.url, mediaType: b.type, name: b.naam || undefined }))) : null,
|
|---|
| 442 | });
|
|---|
| 443 | recordMigrated(site.slug, {
|
|---|
| 444 | origin: o.id, target: noteId(base, id), sourceActor: bronActor.id,
|
|---|
| 445 | // Publiek in de zin van de spec: gericht aan as:Public. Zo niet, dan
|
|---|
| 446 | // hoort deze regel niet in een publiek leesbare migration-pagina.
|
|---|
| 447 | //
|
|---|
| 448 | // Via noteVisibility en niet met een eigen test op '#Public': die kent
|
|---|
| 449 | // ook de schrijfwijzen 'as:Public' en 'Public', en de rest van Klonkt
|
|---|
| 450 | // beslist er al mee. Een tweede, dunnere versie van dezelfde vraag is
|
|---|
| 451 | // precies hoe twee antwoorden uit elkaar gaan lopen.
|
|---|
| 452 | isPublic: zichtbaarheid(o) === 'public',
|
|---|
| 453 | });
|
|---|
| 454 | rapport.posts++;
|
|---|
| 455 | }
|
|---|
| 456 | const volgende = pagina.next;
|
|---|
| 457 | if (!volgende || gezien >= max) break;
|
|---|
| 458 | pagina = await getJson(site.slug, typeof volgende === 'string' ? volgende : volgende.id);
|
|---|
| 459 | }
|
|---|
| 460 | if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
|
|---|
| 461 |
|
|---|
| 462 | // ── De muziekbibliotheek ──────────────────────────────────────
|
|---|
| 463 | //
|
|---|
| 464 | // Losse nummers staan niet in de outbox: die hangen aan de tracks-collectie
|
|---|
| 465 | // waar de actor via AS2 `streams` naar wijst. Zonder deze lus verhuist een
|
|---|
| 466 | // muzieksite zijn berichten en laat hij zijn bibliotheek achter.
|
|---|
| 467 | //
|
|---|
| 468 | // De bron geeft ons hier alles, niet alleen de fedi_open-nummers, omdat we
|
|---|
| 469 | // de doel-actor van zijn Move zijn (siteOpenTracks({alles})). Hetzelfde
|
|---|
| 470 | // geldt voor de bestanden zelf, die anders achter de gated audio-route
|
|---|
| 471 | // blijven.
|
|---|
| 472 | const trackKaart = new Map(); // bron-URI van een nummer -> ons nieuwe id
|
|---|
| 473 | const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
|
|---|
| 474 | const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
|
|---|
| 475 | if (tracksUrl && safeFetch && fs && path && audioRoot) {
|
|---|
| 476 | const coll = await getJson(site.slug, tracksUrl);
|
|---|
| 477 | const lijst = (coll && (coll.orderedItems || coll.items)) || [];
|
|---|
| 478 | for (const it of (Array.isArray(lijst) ? lijst : []).slice(0, max)) {
|
|---|
| 479 | const a = (it && typeof it.object === 'object' && it.object) ? it.object : it;
|
|---|
| 480 | if (!a || !a.id) continue;
|
|---|
| 481 | if (a.type && a.type !== 'Audio') continue;
|
|---|
| 482 | if (alGemigreerd(site.slug, a.id)) { rapport.overgeslagenTracks++; continue; }
|
|---|
| 483 | 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));
|
|---|
| 484 | if (!bron || !/^https?:\/\//i.test(String(bron))) { rapport.tracksMislukt++; continue; }
|
|---|
| 485 | const g = await haalBijlage(String(bron), {
|
|---|
| 486 | safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '',
|
|---|
| 487 | headers: signHeaders ? signHeaders(site.slug, String(bron), '*/*') : null,
|
|---|
| 488 | }).catch(() => null);
|
|---|
| 489 | if (!g) {
|
|---|
| 490 | rapport.tracksMislukt++;
|
|---|
| 491 | rapport.waarschuwingen.push(`nummer niet opgehaald: ${a.name || bron}`);
|
|---|
| 492 | continue; // dezelfde regel als bij de zip: geen bestand, geen track
|
|---|
| 493 | }
|
|---|
| 494 | // De hoes. Die reisde als URL wel mee en als bestand niet, dus kwam een
|
|---|
| 495 | // nummer aan met een verwijzing naar een plaatje dat er niet is.
|
|---|
| 496 | let hoes = null;
|
|---|
| 497 | const hoesUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
|
|---|
| 498 | if (hoesUrl && /^https?:\/\//i.test(String(hoesUrl))) {
|
|---|
| 499 | const h = await haalBijlage(String(hoesUrl), {
|
|---|
| 500 | safeFetch, mediaRoot, fs, path, maxBytes,
|
|---|
| 501 | headers: signHeaders ? signHeaders(site.slug, String(hoesUrl), '*/*') : null,
|
|---|
| 502 | }).catch(() => null);
|
|---|
| 503 | if (h) { hoes = h.url; rapport.media++; }
|
|---|
| 504 | else rapport.waarschuwingen.push(`hoes niet opgehaald: ${a.name || hoesUrl}`);
|
|---|
| 505 | }
|
|---|
| 506 | const trackId = crypto.randomUUID();
|
|---|
| 507 | const mediaId = crypto.randomUUID();
|
|---|
| 508 | try {
|
|---|
| 509 | db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
|
|---|
| 510 | .run(mediaId, site.id, g.filename, g.mediaType, g.size, g.storage_path);
|
|---|
| 511 | db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, cover_url, fedi_open)
|
|---|
| 512 | VALUES (?,?,?,?,?,?,?,?,0)`)
|
|---|
| 513 | .run(trackId, site.id, a.name || 'zonder titel', a.summary || a.artist || null, a.album || null,
|
|---|
| 514 | duurSeconden(a.duration), mediaId, hoes);
|
|---|
| 515 | recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
|
|---|
| 516 | trackKaart.set(String(a.id), trackId);
|
|---|
| 517 | rapport.tracksBinnen++;
|
|---|
| 518 | } catch (e) {
|
|---|
| 519 | rapport.tracksMislukt++;
|
|---|
| 520 | rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
|
|---|
| 521 | }
|
|---|
| 522 | }
|
|---|
| 523 | } else if (tracksUrl) {
|
|---|
| 524 | rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
|
|---|
| 525 | }
|
|---|
| 526 |
|
|---|
| 527 | // ── De playlists ──────────────────────────────────────────────
|
|---|
| 528 | //
|
|---|
| 529 | // Los van de nummers, want de VOLGORDE is de playlist. Die staat nergens
|
|---|
| 530 | // anders: haal je alleen de tracks op, dan heb je wel alle muziek en geen
|
|---|
| 531 | // enkele plaat. De bron geeft ons de volledige lijst omdat we de doel-actor
|
|---|
| 532 | // zijn; anders zaten er alleen de opengezette nummers in en kreeg je een
|
|---|
| 533 | // plaat met gaten.
|
|---|
| 534 | const plUrl = streams.find((u) => /\/playlists\/?$/.test(u));
|
|---|
| 535 | if (plUrl && trackKaart.size) {
|
|---|
| 536 | const coll = await getJson(site.slug, plUrl);
|
|---|
| 537 | const lijst = (coll && (coll.orderedItems || coll.items)) || [];
|
|---|
| 538 | for (const p of (Array.isArray(lijst) ? lijst : []).slice(0, 200)) {
|
|---|
| 539 | const uri = typeof p === 'string' ? p : (p && p.id);
|
|---|
| 540 | if (!uri) continue;
|
|---|
| 541 | const plc = typeof p === 'object' && (p.orderedItems || p.items) ? p : await getJson(site.slug, uri);
|
|---|
| 542 | if (!plc) { rapport.playlistsMislukt++; continue; }
|
|---|
| 543 | const nummers = (plc.orderedItems || plc.items || [])
|
|---|
| 544 | .map((x) => (x && typeof x === 'object' ? x.id : x))
|
|---|
| 545 | .map((id) => trackKaart.get(String(id)))
|
|---|
| 546 | .filter(Boolean);
|
|---|
| 547 | if (!nummers.length) {
|
|---|
| 548 | rapport.waarschuwingen.push(`playlist ${plc.name || uri}: geen van de nummers is aangekomen, overgeslagen`);
|
|---|
| 549 | continue;
|
|---|
| 550 | }
|
|---|
| 551 | const plId = crypto.randomUUID();
|
|---|
| 552 | try {
|
|---|
| 553 | db.prepare('INSERT INTO playlists (id, site_id, title, artist, year, kind) VALUES (?,?,?,?,?,?)')
|
|---|
| 554 | .run(plId, site.id, plc.name || 'zonder titel', plc.attributedTo && plc.artist || plc.artist || null,
|
|---|
| 555 | plc.year || null, plc['shaer:kind'] || null);
|
|---|
| 556 | const ins = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
|
|---|
| 557 | nummers.forEach((tid, i) => ins.run(plId, tid, i));
|
|---|
| 558 | rapport.playlistsBinnen++;
|
|---|
| 559 | const kwijt = (plc.orderedItems || plc.items || []).length - nummers.length;
|
|---|
| 560 | if (kwijt > 0) rapport.waarschuwingen.push(`playlist ${plc.name || uri}: ${kwijt} nummer(s) ontbraken en zijn eruit gelaten`);
|
|---|
| 561 | } catch (e) {
|
|---|
| 562 | rapport.playlistsMislukt++;
|
|---|
| 563 | rapport.waarschuwingen.push(`playlist niet opgeslagen: ${plc.name || uri} (${e && e.message})`);
|
|---|
| 564 | }
|
|---|
| 565 | }
|
|---|
| 566 | }
|
|---|
| 567 | } catch (e) {
|
|---|
| 568 | // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
|
|---|
| 569 | // en dat is precies goed, want er is nog werk.
|
|---|
| 570 | console.warn('[FEP-1580] ingest afgebroken:', e && e.message);
|
|---|
| 571 | return { ...rapport, error: 'partial', melding: e && e.message };
|
|---|
| 572 | }
|
|---|
| 573 |
|
|---|
| 574 | // 9. Klaar. Nu pas mag een derde stoppen met kijken.
|
|---|
| 575 | setMigrationComplete(site.slug, true);
|
|---|
| 576 | console.log('[FEP-1580] ingest klaar:', site.slug, '<-', bronActor.id, rapport.posts, 'berichten,', rapport.media, 'bestanden');
|
|---|
| 577 | return rapport;
|
|---|
| 578 | }
|
|---|