| 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 | /**
|
|---|
| 89 | * Waar kwam deze bron-URI hier terecht? Null als hij nog niet gemigreerd is.
|
|---|
| 90 | *
|
|---|
| 91 | * Bestaat omdat "al gehad" en "overslaan" niet hetzelfde horen te zijn. Een
|
|---|
| 92 | * tweede ronde na een uitgebreide ingest (hoezen, duur, playlists erbij) moet
|
|---|
| 93 | * de bestaande nummers KUNNEN AANVULLEN in plaats van ze te passeren. Deed hij
|
|---|
| 94 | * dat niet, dan zat je vast: opnieuw ophalen sloeg alles over, en opruimen hielp
|
|---|
| 95 | * niet omdat deze tabel de blokkade in stand hield.
|
|---|
| 96 | */
|
|---|
| 97 | export function migrationTarget(slug, origin) {
|
|---|
| 98 | try {
|
|---|
| 99 | const r = db.prepare('SELECT target FROM ap_migration WHERE slug = ? AND origin = ?').get(slug, String(origin));
|
|---|
| 100 | return r ? r.target : null;
|
|---|
| 101 | } catch { return null; }
|
|---|
| 102 | }
|
|---|
| 103 |
|
|---|
| 104 | // ── De Move-activities ────────────────────────────────────────────
|
|---|
| 105 |
|
|---|
| 106 | export function recordMove(slug, { moveId, sourceActor, targetActor, activity, actorDoc = null } = {}) {
|
|---|
| 107 | if (!slug || !moveId || !sourceActor || !targetActor) return false;
|
|---|
| 108 | try {
|
|---|
| 109 | q('insMove', `INSERT INTO ap_moves (slug, move_id, source_actor, target_actor, activity_json, actor_json)
|
|---|
| 110 | VALUES (?, ?, ?, ?, ?, ?)
|
|---|
| 111 | ON CONFLICT(slug, move_id) DO NOTHING`)
|
|---|
| 112 | .run(slug, String(moveId), String(sourceActor), String(targetActor),
|
|---|
| 113 | JSON.stringify(activity || {}), actorDoc ? JSON.stringify(actorDoc) : null);
|
|---|
| 114 | return true;
|
|---|
| 115 | } catch (e) {
|
|---|
| 116 | console.warn('[FEP-1580] Move niet opgeslagen:', moveId, e && e.message);
|
|---|
| 117 | return false;
|
|---|
| 118 | }
|
|---|
| 119 | }
|
|---|
| 120 |
|
|---|
| 121 | export function moveRows(slug) {
|
|---|
| 122 | try { return db.prepare('SELECT * FROM ap_moves WHERE slug = ? ORDER BY id').all(slug); } catch { return []; }
|
|---|
| 123 | }
|
|---|
| 124 |
|
|---|
| 125 | // ── Stand van zaken ───────────────────────────────────────────────
|
|---|
| 126 |
|
|---|
| 127 | export function migrationComplete(slug) {
|
|---|
| 128 | try {
|
|---|
| 129 | const r = db.prepare('SELECT migration_complete FROM sites WHERE slug = ?').get(slug);
|
|---|
| 130 | // Geen kolom of geen rij telt als "klaar": een site die nooit verhuisde
|
|---|
| 131 | // heeft niets openstaan, en derden moeten niet eeuwig blijven pollen.
|
|---|
| 132 | return !r || r.migration_complete === null || r.migration_complete === undefined ? true : !!r.migration_complete;
|
|---|
| 133 | } catch { return true; }
|
|---|
| 134 | }
|
|---|
| 135 |
|
|---|
| 136 | export function setMigrationComplete(slug, klaar) {
|
|---|
| 137 | try { db.prepare('UPDATE sites SET migration_complete = ? WHERE slug = ?').run(klaar ? 1 : 0, slug); } catch { /* kolom ontbreekt op een oude db */ }
|
|---|
| 138 | }
|
|---|
| 139 |
|
|---|
| 140 | // ── De collecties ─────────────────────────────────────────────────
|
|---|
| 141 |
|
|---|
| 142 | /**
|
|---|
| 143 | * De `migration`-collectie. Items zijn Move-activities per OBJECT (niet per
|
|---|
| 144 | * actor): origin is de oude URI, target de nieuwe.
|
|---|
| 145 | *
|
|---|
| 146 | * De spec wil URI-verwijzingen in origin/target in plaats van ingesloten
|
|---|
| 147 | * objecten, en paginering. `pagedCollection` doet dat al voor de rest van
|
|---|
| 148 | * Klonkt, dus die gebruiken we ook hier.
|
|---|
| 149 | */
|
|---|
| 150 | export function buildMigration(base, site, { page = false, alles = false } = {}) {
|
|---|
| 151 | const me = actorId(base, site.slug);
|
|---|
| 152 | const id = `${me}/migration`;
|
|---|
| 153 | const rows = migrationItems(site.slug, { alles });
|
|---|
| 154 | const items = rows.map((r) => ({
|
|---|
| 155 | type: 'Move',
|
|---|
| 156 | actor: r.source_actor || undefined,
|
|---|
| 157 | origin: r.origin,
|
|---|
| 158 | target: r.target,
|
|---|
| 159 | }));
|
|---|
| 160 | return pagedCollection(id, items, {
|
|---|
| 161 | page,
|
|---|
| 162 | extra: {
|
|---|
| 163 | attributedTo: me,
|
|---|
| 164 | moves: `${me}/moves`,
|
|---|
| 165 | migrationComplete: migrationComplete(site.slug),
|
|---|
| 166 | },
|
|---|
| 167 | });
|
|---|
| 168 | }
|
|---|
| 169 |
|
|---|
| 170 | /**
|
|---|
| 171 | * De `moves`-collectie: de Move-activities zelf, met het bron-actordocument
|
|---|
| 172 | * ingesloten zoals de spec aanraadt ("Source instances SHOULD inline the source
|
|---|
| 173 | * Actor object"), zodat een lezer de proof kan nakijken zonder de bron nog te
|
|---|
| 174 | * kunnen bereiken. Dat laatste is precies het geval waarvoor dit bestaat.
|
|---|
| 175 | *
|
|---|
| 176 | * Zonder FEP-8b32 (shaer-j1v0) ontbreekt de handtekening. Zie de kop.
|
|---|
| 177 | */
|
|---|
| 178 | export function buildMoves(base, site) {
|
|---|
| 179 | const me = actorId(base, site.slug);
|
|---|
| 180 | const rows = moveRows(site.slug);
|
|---|
| 181 | const orderedItems = rows.map((r) => {
|
|---|
| 182 | let act = {};
|
|---|
| 183 | try { act = JSON.parse(r.activity_json) || {}; } catch { /* onleesbaar, dan de kale vorm hieronder */ }
|
|---|
| 184 | let actorDoc = null;
|
|---|
| 185 | try { actorDoc = r.actor_json ? JSON.parse(r.actor_json) : null; } catch { /* idem */ }
|
|---|
| 186 | return {
|
|---|
| 187 | id: r.move_id,
|
|---|
| 188 | type: 'Move',
|
|---|
| 189 | origin: r.source_actor,
|
|---|
| 190 | target: r.target_actor,
|
|---|
| 191 | actor: actorDoc || r.source_actor,
|
|---|
| 192 | ...(act.published ? { published: act.published } : {}),
|
|---|
| 193 | };
|
|---|
| 194 | });
|
|---|
| 195 | return {
|
|---|
| 196 | '@context': AP_CONTEXT,
|
|---|
| 197 | id: `${me}/moves`,
|
|---|
| 198 | type: 'OrderedCollection',
|
|---|
| 199 | attributedTo: me,
|
|---|
| 200 | totalItems: orderedItems.length,
|
|---|
| 201 | orderedItems,
|
|---|
| 202 | };
|
|---|
| 203 | }
|
|---|
| 204 |
|
|---|
| 205 | // ── Wat de UI wil weten ───────────────────────────────────────────
|
|---|
| 206 |
|
|---|
| 207 | export function migrationStatus(slug) {
|
|---|
| 208 | return {
|
|---|
| 209 | total: migrationCount(slug, { alles: true }),
|
|---|
| 210 | publiek: migrationCount(slug),
|
|---|
| 211 | moves: moveRows(slug).length,
|
|---|
| 212 | complete: migrationComplete(slug),
|
|---|
| 213 | };
|
|---|
| 214 | }
|
|---|
| 215 |
|
|---|
| 216 | /** Een id dat nergens mee botst, in de vorm die de rest van Klonkt gebruikt. */
|
|---|
| 217 | export function nieuwId() { return crypto.randomUUID(); }
|
|---|
| 218 |
|
|---|
| 219 | // ── De ingest: van de bron hierheen ───────────────────────────────
|
|---|
| 220 |
|
|---|
| 221 | /** Vrije slug binnen deze site. Botst hij, dan -2, -3, enzovoort. */
|
|---|
| 222 | function vrijeSlug(siteId, basis) {
|
|---|
| 223 | const schoon = String(basis || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'bericht';
|
|---|
| 224 | const bestaat = db.prepare('SELECT 1 FROM posts WHERE site_id = ? AND slug = ?');
|
|---|
| 225 | if (!bestaat.get(siteId, schoon)) return schoon;
|
|---|
| 226 | for (let n = 2; n < 500; n++) if (!bestaat.get(siteId, `${schoon}-${n}`)) return `${schoon}-${n}`;
|
|---|
| 227 | return `${schoon}-${crypto.randomBytes(4).toString('hex')}`;
|
|---|
| 228 | }
|
|---|
| 229 |
|
|---|
| 230 | /** Het kale id uit een track-URI: .../tracks/t-een -> t-een. */
|
|---|
| 231 | function ruwId(uri) {
|
|---|
| 232 | try { return decodeURIComponent(String(uri).split('/').filter(Boolean).pop() || ''); } catch { return ''; }
|
|---|
| 233 | }
|
|---|
| 234 |
|
|---|
| 235 | /** De laatste padcomponent van een URI, als beginpunt voor een slug. */
|
|---|
| 236 | function slugUitUri(uri) {
|
|---|
| 237 | try { return decodeURIComponent(new URL(uri).pathname.split('/').filter(Boolean).pop() || ''); } catch { return ''; }
|
|---|
| 238 | }
|
|---|
| 239 |
|
|---|
| 240 | const AFBEELDING = /^image\//i;
|
|---|
| 241 |
|
|---|
| 242 | /**
|
|---|
| 243 | * Links naar de BRONPOSTS ombuigen naar hier.
|
|---|
| 244 | *
|
|---|
| 245 | * De gebakken content zit vol met https://oud/<slug>#track-<id> en
|
|---|
| 246 | * https://oud/<slug>?fc=2: de "luister op"-links die buildNote maakt. Die
|
|---|
| 247 | * blijven naar de oude site wijzen, en dat is een tijdbom, want zodra dat
|
|---|
| 248 | * domein opgezegd wordt zijn het dode links in je eigen berichten.
|
|---|
| 249 | *
|
|---|
| 250 | * Kan pas als ALLE posts binnen zijn, en alleen voor een slug die hier echt
|
|---|
| 251 | * bestaat. Een link naar iets dat we niet hebben laten we met rust: dan is een
|
|---|
| 252 | * verwijzing naar de oude site nog altijd beter dan een 404 op de nieuwe.
|
|---|
| 253 | *
|
|---|
| 254 | * De #track-<id>-fragmenten kloppen vanzelf, want sinds "altijd behouden" is
|
|---|
| 255 | * dat id hier hetzelfde.
|
|---|
| 256 | */
|
|---|
| 257 | export function postLinksBijtrekken(site, bronOrigin, rapport = {}) {
|
|---|
| 258 | if (!bronOrigin) return 0;
|
|---|
| 259 | let n = 0;
|
|---|
| 260 | const rijen = db.prepare('SELECT id, content FROM posts WHERE site_id = ? AND content LIKE ?')
|
|---|
| 261 | .all(site.id, `%${bronOrigin}/%`);
|
|---|
| 262 | if (!rijen.length) return 0;
|
|---|
| 263 | const heeftSlug = db.prepare('SELECT 1 FROM posts WHERE site_id = ? AND slug = ?');
|
|---|
| 264 | const upd = db.prepare('UPDATE posts SET content = ? WHERE id = ?');
|
|---|
| 265 | const patroon = new RegExp(`${bronOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([A-Za-z0-9._~-]+)`, 'g');
|
|---|
| 266 | for (const r of rijen) {
|
|---|
| 267 | let inhoud = String(r.content || '');
|
|---|
| 268 | let raak = false;
|
|---|
| 269 | for (const m of [...new Set([...inhoud.matchAll(patroon)].map((x) => x[1]))]) {
|
|---|
| 270 | // media en audio lopen via hun eigen weg; hier gaat het om postpagina's
|
|---|
| 271 | if (m === 'media' || m === 'audio' || m === 'ap') continue;
|
|---|
| 272 | if (!heeftSlug.get(site.id, m)) continue;
|
|---|
| 273 | inhoud = inhoud.split(`${bronOrigin}/${m}`).join(`/${m}`);
|
|---|
| 274 | raak = true;
|
|---|
| 275 | }
|
|---|
| 276 | if (raak) { upd.run(inhoud, r.id); n++; }
|
|---|
| 277 | }
|
|---|
| 278 | if (n) {
|
|---|
| 279 | rapport.linksBijgetrokken = n;
|
|---|
| 280 | console.log('[FEP-1580] postlinks bijgetrokken in', n, 'bericht(en)');
|
|---|
| 281 | }
|
|---|
| 282 | return n;
|
|---|
| 283 | }
|
|---|
| 284 |
|
|---|
| 285 | /**
|
|---|
| 286 | * Waar kan de omslag van een bericht zitten?
|
|---|
| 287 | *
|
|---|
| 288 | * Niet alleen in `attachment`. Klonkt onderdrukt de beeldbijlage met opzet
|
|---|
| 289 | * zodra een post een speler of embed heeft (zie noImages in buildNote), en zet
|
|---|
| 290 | * de cover dan in `image` zodat Mastodon zijn spelerkaart toont en een Klonkt
|
|---|
| 291 | * hem alsnog vindt. Dat is precies wat er bij Robin misging: 18 van de 20
|
|---|
| 292 | * berichten op pagina 1 hadden geen enkele bijlage, en toch een cover, en die
|
|---|
| 293 | * viel er stil tussenuit.
|
|---|
| 294 | *
|
|---|
| 295 | * En een Audio-bijlage draagt zijn eigen hoes in `icon`; die telt ook mee.
|
|---|
| 296 | */
|
|---|
| 297 | function coverKandidaten(o) {
|
|---|
| 298 | const uit = [];
|
|---|
| 299 | const pak = (v) => {
|
|---|
| 300 | if (!v) return;
|
|---|
| 301 | const u = typeof v === 'string' ? v : (v.url && (typeof v.url === 'string' ? v.url : v.url.href)) || v.href;
|
|---|
| 302 | if (u && /^https?:\/\//i.test(String(u))) uit.push(String(u));
|
|---|
| 303 | };
|
|---|
| 304 | pak(o.image);
|
|---|
| 305 | pak(o.icon);
|
|---|
| 306 | for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) pak(a && a.icon);
|
|---|
| 307 | return [...new Set(uit)];
|
|---|
| 308 | }
|
|---|
| 309 |
|
|---|
| 310 | /**
|
|---|
| 311 | * Hoort deze URL bij de bron, en wijst hij onder /media/?
|
|---|
| 312 | *
|
|---|
| 313 | * Dan behouden we het PAD. Drie redenen tegelijk:
|
|---|
| 314 | * - de gebakken content verwijst relatief of absoluut naar dat pad, en met
|
|---|
| 315 | * hetzelfde pad hier klopt elke verwijzing zonder herschrijf-acrobatiek;
|
|---|
| 316 | * - de media-bibliotheek (Beheer, Media) scant de MAP post-images, niet de
|
|---|
| 317 | * databasetabel. Een bestand onder migrated/<uuid> bestaat wel en is
|
|---|
| 318 | * onzichtbaar: Robins lege images-tab;
|
|---|
| 319 | * - de zip-import bewaart originele paden al, dus zo convergeren beide
|
|---|
| 320 | * routes op dezelfde bestanden.
|
|---|
| 321 | *
|
|---|
| 322 | * De ../-bewaking is geen formaliteit: het pad komt van een andere server.
|
|---|
| 323 | */
|
|---|
| 324 | function bronMediaPad(url, bronOrigin, { mediaRoot, path }) {
|
|---|
| 325 | try {
|
|---|
| 326 | const u = new URL(String(url));
|
|---|
| 327 | if (`${u.protocol}//${u.host}` !== bronOrigin) return null;
|
|---|
| 328 | if (!u.pathname.startsWith('/media/')) return null;
|
|---|
| 329 | const rel = decodeURIComponent(u.pathname.slice('/media/'.length));
|
|---|
| 330 | const abs = path.resolve(mediaRoot, rel);
|
|---|
| 331 | const root = path.resolve(mediaRoot);
|
|---|
| 332 | if (abs === root || !abs.startsWith(`${root}${path.sep}`)) return null;
|
|---|
| 333 | return { rel: `/media/${rel}`, abs };
|
|---|
| 334 | } catch { return null; }
|
|---|
| 335 | }
|
|---|
| 336 |
|
|---|
| 337 | /** AS2 geeft de duur als ISO-8601 ("PT212S"), de database wil seconden. */
|
|---|
| 338 | function duurSeconden(v) {
|
|---|
| 339 | if (v == null) return null;
|
|---|
| 340 | if (typeof v === 'number') return Math.round(v) || null;
|
|---|
| 341 | const m = /^P(?:.*?T)?(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?$/.exec(String(v));
|
|---|
| 342 | if (!m) { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.round(n) : null; }
|
|---|
| 343 | const sec = (Number(m[1]) || 0) * 3600 + (Number(m[2]) || 0) * 60 + (Number(m[3]) || 0);
|
|---|
| 344 | return sec > 0 ? Math.round(sec) : null;
|
|---|
| 345 | }
|
|---|
| 346 |
|
|---|
| 347 | /**
|
|---|
| 348 | * De titel terugwinnen uit de content.
|
|---|
| 349 | *
|
|---|
| 350 | * Een AS2 Note heeft geen titel: Mastodon negeert `name`, dus Klonkt bakt de
|
|---|
| 351 | * titel als vetgedrukte eerste alinea IN de content (zie buildNote). Over de
|
|---|
| 352 | * lijn is een titel dus geen veld maar een vorm. Doen we hier niets, dan komt
|
|---|
| 353 | * elk bericht titelloos aan en heet het naar zijn id.
|
|---|
| 354 | *
|
|---|
| 355 | * Daarom draaien we precies onze eigen bak terug: alleen als de content BEGINT
|
|---|
| 356 | * met een alinea die niets anders bevat dan vetgedrukte tekst. Dat is de exacte
|
|---|
| 357 | * vorm die buildNote maakt. Een bericht van elders dat toevallig zo begint
|
|---|
| 358 | * verliest die regel niet, hij verhuist naar het titelveld en staat straks
|
|---|
| 359 | * gewoon weer bovenaan.
|
|---|
| 360 | */
|
|---|
| 361 | function titelUitContent(html) {
|
|---|
| 362 | const m = /^\s*<p>\s*<strong>([\s\S]*?)<\/strong>\s*<\/p>/i.exec(String(html || ''));
|
|---|
| 363 | if (!m) return { titel: null, rest: html };
|
|---|
| 364 | const titel = m[1].replace(/<[^>]+>/g, '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').trim();
|
|---|
| 365 | if (!titel || titel.length > 300) return { titel: null, rest: html };
|
|---|
| 366 | return { titel, rest: String(html).slice(m[0].length) };
|
|---|
| 367 | }
|
|---|
| 368 |
|
|---|
| 369 | /**
|
|---|
| 370 | * Haal een bijlage op en zet hem lokaal neer.
|
|---|
| 371 | *
|
|---|
| 372 | * safeFetch is de SSRF-veilige kant van Klonkt; hier is dat geen formaliteit,
|
|---|
| 373 | * want de URL komt van een andere server. Een bron die ons naar 127.0.0.1 wijst
|
|---|
| 374 | * moet stranden, ook als die bron "van onszelf" is.
|
|---|
| 375 | */
|
|---|
| 376 | async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes, submap = 'migrated', headers = null, doel = null }) {
|
|---|
| 377 | // Ondertekend als het moet. Gehoste audio zit achter dezelfde poort als de
|
|---|
| 378 | // rest van de bron, en een kale fetch krijgt daar een 403: de bron kan dan
|
|---|
| 379 | // niet zien dat wij de doel-actor van zijn Move zijn.
|
|---|
| 380 | const r = await safeFetch(url, { headers: headers || { accept: '*/*' } }).catch(() => null);
|
|---|
| 381 | if (!r || !r.ok) return null;
|
|---|
| 382 | const buf = Buffer.from(await r.arrayBuffer());
|
|---|
| 383 | if (!buf.length || buf.length > maxBytes) return null;
|
|---|
| 384 | const type = String(r.headers.get('content-type') || '').split(';')[0].trim() || 'application/octet-stream';
|
|---|
| 385 | const ext = (() => {
|
|---|
| 386 | const uit = slugUitUri(url);
|
|---|
| 387 | const m = /\.([a-z0-9]{1,5})$/i.exec(uit);
|
|---|
| 388 | if (m) return m[1].toLowerCase();
|
|---|
| 389 | return (type.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'bin';
|
|---|
| 390 | })();
|
|---|
| 391 | const naam = `${crypto.randomUUID()}.${ext}`;
|
|---|
| 392 | // `doel` wint: dan behouden we het pad van de bron (zie bronMediaPad).
|
|---|
| 393 | // Zonder submap komt het bestand in de root zelf: dat is wat gehoste audio
|
|---|
| 394 | // nodig heeft, want de speler zoekt AUDIO_ROOT + bestandsnaam en kijkt niet
|
|---|
| 395 | // in mappen eronder.
|
|---|
| 396 | const rel = doel ? doel.rel : (submap ? `${submap}/${naam}` : naam);
|
|---|
| 397 | const abs = doel ? doel.abs : (submap ? path.join(mediaRoot, submap, naam) : path.join(mediaRoot, naam));
|
|---|
| 398 | fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|---|
| 399 | fs.writeFileSync(abs, buf);
|
|---|
| 400 | // doel.rel is al een volledig /media/-pad; de submap-variant is dat nog niet.
|
|---|
| 401 | return { url: doel ? doel.rel : `/media/${rel}`, mediaType: type, size: buf.length, filename: naam, storage_path: abs };
|
|---|
| 402 | }
|
|---|
| 403 |
|
|---|
| 404 | /**
|
|---|
| 405 | * Alle bron-media in een lap HTML binnenhalen en de verwijzingen relatief maken.
|
|---|
| 406 | *
|
|---|
| 407 | * Werkt op ALLE https://bron/media/...-voorkomens, niet alleen op <img src>:
|
|---|
| 408 | * de gebakken content zet dezelfde URL ook in een href om het plaatje groot te
|
|---|
| 409 | * openen, en een half herschreven paar (lokaal plaatje, hotlink eromheen) is
|
|---|
| 410 | * verwarrender dan geen herschrijving.
|
|---|
| 411 | *
|
|---|
| 412 | * Idempotent: wat al gedownload is wordt niet opnieuw gehaald, en een tweede
|
|---|
| 413 | * ronde over dezelfde tekst vindt gewoon niets meer te doen.
|
|---|
| 414 | */
|
|---|
| 415 | async function inhoudMediaBinnen(html, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes }) {
|
|---|
| 416 | let inhoud = String(html || '');
|
|---|
| 417 | if (!inhoud || !bronOrigin) return { inhoud, n: 0 };
|
|---|
| 418 | // LET OP de dubbele backslash: dit is een STRING die een RegExp wordt. Met een
|
|---|
| 419 | // enkele \s eet de template literal de backslash op en sluit de klasse de
|
|---|
| 420 | // LETTER s uit; "post-images" knapte dan af op de s en elke URL met een s
|
|---|
| 421 | // erin werd half herschreven. Gevonden doordat de waarschuwing ".../media/po"
|
|---|
| 422 | // meldde, afgekapt precies voor de s.
|
|---|
| 423 | const patroon = new RegExp(`${bronOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/media/[^"'\\s)<>]+)`, 'g');
|
|---|
| 424 | const gezien = new Set();
|
|---|
| 425 | let n = 0;
|
|---|
| 426 | for (const m of [...inhoud.matchAll(patroon)]) {
|
|---|
| 427 | const vol = m[0];
|
|---|
| 428 | if (gezien.has(vol)) continue;
|
|---|
| 429 | gezien.add(vol);
|
|---|
| 430 | const doel = bronMediaPad(vol, bronOrigin, { mediaRoot, path });
|
|---|
| 431 | if (!doel) { rapport.waarschuwingen.push(`onbruikbaar mediapad in tekst: ${vol}`); continue; }
|
|---|
| 432 | let ok = false;
|
|---|
| 433 | try { fs.statSync(doel.abs); ok = true; } catch { /* nog niet binnen */ }
|
|---|
| 434 | if (!ok) {
|
|---|
| 435 | const g = await haalBijlage(vol, { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
|
|---|
| 436 | if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`plaatje in tekst niet opgehaald: ${vol}`); continue; }
|
|---|
| 437 | rapport.media++;
|
|---|
| 438 | try {
|
|---|
| 439 | db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
|
|---|
| 440 | .run(crypto.randomUUID(), site.id, path.basename(doel.abs), g.mediaType, g.size, doel.abs);
|
|---|
| 441 | } catch { /* administratie */ }
|
|---|
| 442 | }
|
|---|
| 443 | inhoud = inhoud.split(vol).join(doel.rel);
|
|---|
| 444 | n++;
|
|---|
| 445 | }
|
|---|
| 446 | return { inhoud, n };
|
|---|
| 447 | }
|
|---|
| 448 |
|
|---|
| 449 | /**
|
|---|
| 450 | * FEP-1580 ingest-routine, de doelkant.
|
|---|
| 451 | *
|
|---|
| 452 | * De autorisatie wordt hier niet verzonnen maar NAGEKEKEN, en in beide
|
|---|
| 453 | * richtingen, precies zoals de spec het voor derden voorschrijft: `movedTo` op
|
|---|
| 454 | * de bron moet naar ons wijzen EN wij moeten de bron in `alsoKnownAs` hebben.
|
|---|
| 455 | * Eén kant is een bewering, twee kanten is een afspraak. Zou ik alleen op onze
|
|---|
| 456 | * eigen alsoKnownAs afgaan, dan kon iedereen die zichzelf een alias geeft de
|
|---|
| 457 | * geschiedenis van een vreemde opeisen.
|
|---|
| 458 | *
|
|---|
| 459 | * `deps` is er voor de test: die moet dit kunnen draaien zonder netwerk.
|
|---|
| 460 | */
|
|---|
| 461 | export async function ingestFromSource(site, {
|
|---|
| 462 | sourceUri = null, max = 1000, maxBytes = 25 * 1024 * 1024, deps = {},
|
|---|
| 463 | } = {}) {
|
|---|
| 464 | const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
|---|
| 465 | if (!base || !site || !site.slug) return { error: 'config' };
|
|---|
| 466 | const me = actorId(base, site.slug);
|
|---|
| 467 |
|
|---|
| 468 | const {
|
|---|
| 469 | getJson = null, safeFetch = null, mediaRoot = null, fs = null, path = null, noteId = null,
|
|---|
| 470 | sanitize = (h) => h,
|
|---|
| 471 | // Standaard 'followers': kan iets niet als publiek bewezen worden, dan
|
|---|
| 472 | // hoort het niet in de publieke vertaaltabel. Fail-closed, want dit is een
|
|---|
| 473 | // privacygrens en niet een weergavedetail.
|
|---|
| 474 | noteVisibility = () => 'followers',
|
|---|
| 475 | audioRoot = null, signHeaders = null,
|
|---|
| 476 | } = deps;
|
|---|
| 477 | const zichtbaarheid = noteVisibility;
|
|---|
| 478 | if (!getJson || !noteId) return { error: 'config' };
|
|---|
| 479 |
|
|---|
| 480 | // 1. Welke bron? Zonder opgave: de alias die we zelf claimen.
|
|---|
| 481 | let bron = sourceUri && /^https?:\/\//i.test(sourceUri) ? sourceUri : null;
|
|---|
| 482 | if (!bron) {
|
|---|
| 483 | try {
|
|---|
| 484 | const aka = JSON.parse(site.ap_aliases || '[]');
|
|---|
| 485 | bron = Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
|
|---|
| 486 | } catch { /* stukke ap_aliases telt als geen alias */ }
|
|---|
| 487 | }
|
|---|
| 488 | if (!bron) return { error: 'no_source' };
|
|---|
| 489 |
|
|---|
| 490 | // 2 + 3. Het bron-actordocument, en de wegwijzer die naar ONS moet wijzen.
|
|---|
| 491 | const bronActor = await getJson(site.slug, bron);
|
|---|
| 492 | if (!bronActor || !bronActor.id) return { error: 'unreachable' };
|
|---|
| 493 | // De origin van de bron: alles op deze host onder /media/ is van hem en mag
|
|---|
| 494 | // naar hetzelfde pad hier. Uit de actor-id, niet uit de invoer.
|
|---|
| 495 | const bronOrigin = (() => { try { const u = new URL(bronActor.id); return `${u.protocol}//${u.host}`; } catch { return null; } })();
|
|---|
| 496 | if (bronActor.movedTo !== me) return { error: 'not_moved_here', movedTo: bronActor.movedTo || null };
|
|---|
| 497 |
|
|---|
| 498 | // 4. En de terugverwijzing van onze kant, zodat het een afspraak is.
|
|---|
| 499 | const eigenAka = (() => {
|
|---|
| 500 | try { const a = JSON.parse(site.ap_aliases || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
|
|---|
| 501 | })();
|
|---|
| 502 | if (!eigenAka.includes(bronActor.id)) return { error: 'no_backreference' };
|
|---|
| 503 |
|
|---|
| 504 | // 5 + 6. Vastleggen dat dit een migratie is, en de deur openzetten voor derden.
|
|---|
| 505 | recordMove(site.slug, {
|
|---|
| 506 | moveId: `${bronActor.id}#move`, sourceActor: bronActor.id, targetActor: me,
|
|---|
| 507 | activity: { type: 'Move', actor: bronActor.id, object: bronActor.id, target: me },
|
|---|
| 508 | actorDoc: bronActor,
|
|---|
| 509 | });
|
|---|
| 510 | setMigrationComplete(site.slug, false);
|
|---|
| 511 |
|
|---|
| 512 | const rapport = {
|
|---|
| 513 | bron: bronActor.id, posts: 0, overgeslagen: 0, opnieuw: 0, postsBijgewerkt: 0, media: 0, mediaMislukt: 0,
|
|---|
| 514 | blocks: 0, tracksBinnen: 0, tracksMislukt: 0, tracksBijgewerkt: 0, overgeslagenTracks: 0,
|
|---|
| 515 | playlistsBinnen: 0, playlistsMislukt: 0, waarschuwingen: [],
|
|---|
| 516 | };
|
|---|
| 517 |
|
|---|
| 518 | try {
|
|---|
| 519 | // 7. BLOKKADES EERST. De spec is daar streng over, en terecht: ze bepalen
|
|---|
| 520 | // wie de rest te zien krijgt. Andersom importeer je even je hele
|
|---|
| 521 | // geschiedenis zichtbaar voor iemand die je nou juist buiten wilde.
|
|---|
| 522 | if (bronActor.blocked) {
|
|---|
| 523 | const coll = await getJson(site.slug, typeof bronActor.blocked === 'string' ? bronActor.blocked : bronActor.blocked.id);
|
|---|
| 524 | const lijst = (coll && (coll.orderedItems || coll.items)) || [];
|
|---|
| 525 | for (const b of Array.isArray(lijst) ? lijst : []) {
|
|---|
| 526 | const uri = typeof b === 'string' ? b : (b && (b.object || b.id));
|
|---|
| 527 | if (!uri || !/^https?:\/\//i.test(String(uri))) continue;
|
|---|
| 528 | try {
|
|---|
| 529 | db.prepare("INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label) VALUES (?, ?, 'actor', NULL)").run(site.slug, String(uri));
|
|---|
| 530 | rapport.blocks++;
|
|---|
| 531 | } catch { /* tabel ontbreekt op een verse db */ }
|
|---|
| 532 | }
|
|---|
| 533 | } else {
|
|---|
| 534 | rapport.waarschuwingen.push('de bron gaf geen blokkadelijst, zichtbaarheidsvoorkeuren komen niet mee');
|
|---|
| 535 | }
|
|---|
| 536 |
|
|---|
| 537 | // 8. De outbox aflopen. Pagineren zoals de rest van Klonkt dat doet.
|
|---|
| 538 | if (!bronActor.outbox) return { ...rapport, error: 'no_outbox' };
|
|---|
| 539 | let pagina = await getJson(site.slug, typeof bronActor.outbox === 'string' ? bronActor.outbox : bronActor.outbox.id);
|
|---|
| 540 | const verwacht = pagina && Number(pagina.totalItems) || null;
|
|---|
| 541 | // Is er een `first`, dan ALTIJD de paginaketen volgen, ook als de kale
|
|---|
| 542 | // collectie zelf items draagt. Klonkt zet daar een kopie van pagina 1 in
|
|---|
| 543 | // (Pleroma eiste een first, en sindsdien staan ze er allebei), maar alleen
|
|---|
| 544 | // echte pagina's dragen een `next`. Wie op de kale collectie blijft hangen
|
|---|
| 545 | // verwerkt pagina 1 en denkt dan klaar te zijn: precies 18 van Robins 35
|
|---|
| 546 | // berichten, zonder één waarschuwing.
|
|---|
| 547 | if (pagina && pagina.first) {
|
|---|
| 548 | pagina = await getJson(site.slug, typeof pagina.first === 'string' ? pagina.first : pagina.first.id);
|
|---|
| 549 | }
|
|---|
| 550 |
|
|---|
| 551 | const insPost = db.prepare(`INSERT INTO posts
|
|---|
| 552 | (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url,
|
|---|
| 553 | pinned, type, tags, published_at, created_at, updated_at, fan_only, nsfw, language,
|
|---|
| 554 | content_warning, ap_visibility, c2s_attachments, origin_server)
|
|---|
| 555 | VALUES (@id, @site_id, @slug, @author_id, @title, @content, NULL, 'published', @cover_image_url,
|
|---|
| 556 | 0, 'post', @tags, @published_at, @published_at, @updated_at, @fan_only, @nsfw, @language,
|
|---|
| 557 | @content_warning, @ap_visibility, @c2s_attachments, 'migrated')`);
|
|---|
| 558 |
|
|---|
| 559 | let gezien = 0;
|
|---|
| 560 | while (pagina && gezien < max) {
|
|---|
| 561 | const items = (pagina.orderedItems || pagina.items) || [];
|
|---|
| 562 | for (const it of Array.isArray(items) ? items : []) {
|
|---|
| 563 | if (gezien >= max) break;
|
|---|
| 564 | const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
|
|---|
| 565 | if (!o || !o.id) continue;
|
|---|
| 566 | if (o.type && !['Note', 'Article', 'Question'].includes(o.type)) continue;
|
|---|
| 567 | if (o.inReplyTo) continue; // toplevel; antwoorden hangen aan hun ouder
|
|---|
| 568 | const auteur = typeof o.attributedTo === 'string' ? o.attributedTo : (o.attributedTo && o.attributedTo.id);
|
|---|
| 569 | if (auteur && auteur !== bronActor.id) continue; // alleen wat van HEM was
|
|---|
| 570 | gezien++;
|
|---|
| 571 | // Het interne id BLIJFT (Robins besluit, 14-8). Daarmee is "staat hij
|
|---|
| 572 | // hier al" gewoon een blik in de tabel, en niet iets dat je uit een
|
|---|
| 573 | // aparte mapping moet afleiden. Verwijder je een bericht en haal je
|
|---|
| 574 | // opnieuw op, dan komt het gewoon terug: er staat immers niets meer.
|
|---|
| 575 | const id = ruwId(o.id) || crypto.randomUUID();
|
|---|
| 576 | const bestaand = db.prepare('SELECT id, content, cover_image_url FROM posts WHERE id = ? AND site_id = ?').get(id, site.id);
|
|---|
| 577 | if (bestaand) {
|
|---|
| 578 | // Niet alleen overslaan: REPAREREN wat een eerdere ronde liet liggen.
|
|---|
| 579 | // Robins 18 posts stonden er al, met hotlinks naar de bron in de
|
|---|
| 580 | // tekst en zonder cover. Een tweede ronde die dat ziet en passeert
|
|---|
| 581 | // laat je met een site vol verwijzingen naar een domein dat
|
|---|
| 582 | // opgezegd wordt.
|
|---|
| 583 | if (safeFetch && fs && path && mediaRoot && bronOrigin && String(bestaand.content || '').includes(bronOrigin)) {
|
|---|
| 584 | const r2 = await inhoudMediaBinnen(bestaand.content, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes });
|
|---|
| 585 | if (r2.n) {
|
|---|
| 586 | db.prepare('UPDATE posts SET content = ? WHERE id = ?').run(r2.inhoud, bestaand.id);
|
|---|
| 587 | rapport.postsBijgewerkt++;
|
|---|
| 588 | }
|
|---|
| 589 | }
|
|---|
| 590 | if (!bestaand.cover_image_url && safeFetch && fs && path && mediaRoot) {
|
|---|
| 591 | // De omslag alsnog. Uit de beeldbijlage als die er is, anders uit
|
|---|
| 592 | // image/icon: bij een post met een speler staat hij daar.
|
|---|
| 593 | const uit = [
|
|---|
| 594 | ...(Array.isArray(o.attachment) ? o.attachment : [])
|
|---|
| 595 | .filter((a) => AFBEELDING.test(String((a && a.mediaType) || '')))
|
|---|
| 596 | .map((a) => (typeof a.url === 'string' ? a.url : (a.url && a.url.href)))
|
|---|
| 597 | .filter(Boolean),
|
|---|
| 598 | ...coverKandidaten(o),
|
|---|
| 599 | ];
|
|---|
| 600 | for (const u of uit) {
|
|---|
| 601 | const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
|
|---|
| 602 | const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
|
|---|
| 603 | if (!g) continue;
|
|---|
| 604 | db.prepare('UPDATE posts SET cover_image_url = ? WHERE id = ?').run(g.url, bestaand.id);
|
|---|
| 605 | rapport.media++;
|
|---|
| 606 | rapport.postsBijgewerkt++;
|
|---|
| 607 | break;
|
|---|
| 608 | }
|
|---|
| 609 | }
|
|---|
| 610 | rapport.overgeslagen++;
|
|---|
| 611 | continue;
|
|---|
| 612 | }
|
|---|
| 613 | if (migrationTarget(site.slug, o.id)) rapport.opnieuw++; // was er, is weg, komt terug
|
|---|
| 614 |
|
|---|
| 615 | // Media eerst, want een post die naar een plaatje wijst dat we niet
|
|---|
| 616 | // hebben opgehaald is een halve post. Mislukt een bijlage, dan gaat de
|
|---|
| 617 | // post wel door en staat het in het verslag.
|
|---|
| 618 | const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
|
|---|
| 619 | const binnen = [];
|
|---|
| 620 | let inhoud = o.content || '';
|
|---|
| 621 | if (safeFetch && fs && path && mediaRoot) {
|
|---|
| 622 | for (const a of bijlagen.slice(0, 20)) {
|
|---|
| 623 | const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
|
|---|
| 624 | if (!u || !/^https?:\/\//i.test(String(u))) continue;
|
|---|
| 625 | const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
|
|---|
| 626 | const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
|
|---|
| 627 | if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`bijlage niet opgehaald: ${u}`); continue; }
|
|---|
| 628 | binnen.push({ ...g, naam: (a && a.name) || null, type: (a && a.mediaType) || g.mediaType });
|
|---|
| 629 | rapport.media++;
|
|---|
| 630 | try {
|
|---|
| 631 | db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
|
|---|
| 632 | .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
|
|---|
| 633 | } catch { /* media-rij is administratie, het bestand staat er */ }
|
|---|
| 634 | }
|
|---|
| 635 | // De PLAATJES IN DE TEKST. De gebakken content draagt absolute
|
|---|
| 636 | // verwijzingen naar de bron (https://oud/media/...), en die bleven
|
|---|
| 637 | // gewoon staan: elke afbeelding hotlinkte naar een domein dat je gaat
|
|---|
| 638 | // opzeggen, en je eigen mediamap bleef leeg. Downloaden naar
|
|---|
| 639 | // HETZELFDE pad en de verwijzing relatief maken; wat niet lukt blijft
|
|---|
| 640 | // absoluut staan en wordt gemeld, want een lokale 404 is erger dan
|
|---|
| 641 | // een hotlink.
|
|---|
| 642 | const r2 = await inhoudMediaBinnen(inhoud, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes });
|
|---|
| 643 | inhoud = r2.inhoud;
|
|---|
| 644 | // De omslag zit lang niet altijd in attachment (zie coverKandidaten).
|
|---|
| 645 | if (!binnen.some((b) => AFBEELDING.test(b.type || ''))) {
|
|---|
| 646 | for (const u of coverKandidaten(o)) {
|
|---|
| 647 | const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
|
|---|
| 648 | const g = await haalBijlage(u, { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
|
|---|
| 649 | if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`omslag niet opgehaald: ${u}`); continue; }
|
|---|
| 650 | binnen.unshift({ ...g, naam: null, type: g.mediaType });
|
|---|
| 651 | rapport.media++;
|
|---|
| 652 | try {
|
|---|
| 653 | db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
|
|---|
| 654 | .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
|
|---|
| 655 | } catch { /* administratie */ }
|
|---|
| 656 | break; // een omslag is genoeg
|
|---|
| 657 | }
|
|---|
| 658 | }
|
|---|
| 659 | }
|
|---|
| 660 |
|
|---|
| 661 | const cover = binnen.find((b) => AFBEELDING.test(b.type || ''));
|
|---|
| 662 | const rest = binnen.filter((b) => b !== cover);
|
|---|
| 663 | // De titel zit in de content, niet in een veld (zie titelUitContent).
|
|---|
| 664 | const { titel, rest: body } = o.name ? { titel: o.name, rest: inhoud } : titelUitContent(inhoud);
|
|---|
| 665 | // De slug uit de MENSELIJKE url, niet uit de AP-id. Zo houdt het bericht
|
|---|
| 666 | // hetzelfde webadres als op de oude instantie, en blijft een link die
|
|---|
| 667 | // iemand ergens plakte kloppen op het nieuwe domein.
|
|---|
| 668 | const basisSlug = slugUitUri(o.url || '') || o.name || slugUitUri(o.id) || id;
|
|---|
| 669 | // De publicatiedatum blijft die van het origineel. De spec eist dat, en
|
|---|
| 670 | // het is ook het enige eerlijke: het bericht is niet vandaag geschreven.
|
|---|
| 671 | insPost.run({
|
|---|
| 672 | id, site_id: site.id, slug: vrijeSlug(site.id, basisSlug),
|
|---|
| 673 | author_id: site.owner_id, title: titel || null, content: sanitize(body || ''),
|
|---|
| 674 | cover_image_url: cover ? cover.url : null,
|
|---|
| 675 | tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name || '').replace(/^#/, '')).filter(Boolean).join(', ') || null : null,
|
|---|
| 676 | published_at: o.published || null, updated_at: o.updated || o.published || null,
|
|---|
| 677 | fan_only: 0, nsfw: o.sensitive ? 1 : 0,
|
|---|
| 678 | language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
|
|---|
| 679 | content_warning: o.summary || null,
|
|---|
| 680 | ap_visibility: null,
|
|---|
| 681 | c2s_attachments: rest.length ? JSON.stringify(rest.map((b) => ({ url: b.url, mediaType: b.type, name: b.naam || undefined }))) : null,
|
|---|
| 682 | });
|
|---|
| 683 | recordMigrated(site.slug, {
|
|---|
| 684 | origin: o.id, target: noteId(base, id), sourceActor: bronActor.id,
|
|---|
| 685 | // Publiek in de zin van de spec: gericht aan as:Public. Zo niet, dan
|
|---|
| 686 | // hoort deze regel niet in een publiek leesbare migration-pagina.
|
|---|
| 687 | //
|
|---|
| 688 | // Via noteVisibility en niet met een eigen test op '#Public': die kent
|
|---|
| 689 | // ook de schrijfwijzen 'as:Public' en 'Public', en de rest van Klonkt
|
|---|
| 690 | // beslist er al mee. Een tweede, dunnere versie van dezelfde vraag is
|
|---|
| 691 | // precies hoe twee antwoorden uit elkaar gaan lopen.
|
|---|
| 692 | isPublic: zichtbaarheid(o) === 'public',
|
|---|
| 693 | });
|
|---|
| 694 | rapport.posts++;
|
|---|
| 695 | }
|
|---|
| 696 | const volgende = pagina.next;
|
|---|
| 697 | if (!volgende || gezien >= max) break;
|
|---|
| 698 | pagina = await getJson(site.slug, typeof volgende === 'string' ? volgende : volgende.id);
|
|---|
| 699 | }
|
|---|
| 700 | if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
|
|---|
| 701 | // Silently minder ophalen dan de bron zegt te hebben is precies hoe 18 van
|
|---|
| 702 | // de 35 wekenlang op "klaar" had kunnen staan. Tel na en zeg het.
|
|---|
| 703 | if (verwacht && gezien < verwacht && gezien < max) {
|
|---|
| 704 | rapport.waarschuwingen.push(`de bron meldt ${verwacht} items en er zijn er ${gezien} verwerkt; een pagina is mogelijk niet opgehaald, probeer het nog eens`);
|
|---|
| 705 | }
|
|---|
| 706 |
|
|---|
| 707 | // ── De muziekbibliotheek ──────────────────────────────────────
|
|---|
| 708 | //
|
|---|
| 709 | // Losse nummers staan niet in de outbox: die hangen aan de tracks-collectie
|
|---|
| 710 | // waar de actor via AS2 `streams` naar wijst. Zonder deze lus verhuist een
|
|---|
| 711 | // muzieksite zijn berichten en laat hij zijn bibliotheek achter.
|
|---|
| 712 | //
|
|---|
| 713 | // De bron geeft ons hier alles, niet alleen de fedi_open-nummers, omdat we
|
|---|
| 714 | // de doel-actor van zijn Move zijn (siteOpenTracks({alles})). Hetzelfde
|
|---|
| 715 | // geldt voor de bestanden zelf, die anders achter de gated audio-route
|
|---|
| 716 | // blijven.
|
|---|
| 717 | const trackKaart = new Map(); // bron-URI van een nummer -> ons nieuwe id
|
|---|
| 718 | // En het RUWE id zoals het in de posttekst staat. Klonkt schrijft
|
|---|
| 719 | // [[track:<id>]] in de content, en die tekst reist letterlijk mee over AP.
|
|---|
| 720 | // Krijgt het nummer hier een ander id, dan wijst die shorthand nergens meer
|
|---|
| 721 | // heen en zie je de code zelf in je bericht staan.
|
|---|
| 722 | const ruwKaart = new Map(); // ruw bron-id -> ons id
|
|---|
| 723 | const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
|
|---|
| 724 | const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
|
|---|
| 725 | if (tracksUrl && safeFetch && fs && path && audioRoot) {
|
|---|
| 726 | const coll = await getJson(site.slug, tracksUrl);
|
|---|
| 727 | const lijst = (coll && (coll.orderedItems || coll.items)) || [];
|
|---|
| 728 | for (const it of (Array.isArray(lijst) ? lijst : []).slice(0, max)) {
|
|---|
| 729 | const a = (it && typeof it.object === 'object' && it.object) ? it.object : it;
|
|---|
| 730 | if (!a || !a.id) continue;
|
|---|
| 731 | if (a.type && a.type !== 'Audio') continue;
|
|---|
| 732 | // AL BINNEN? Dan AANVULLEN, niet overslaan. Een tweede ronde bestaat
|
|---|
| 733 | // juist omdat er iets bij is gekomen (hoezen, duur, playlists), en een
|
|---|
| 734 | // pull die dan alles passeert laat je met een half resultaat zitten
|
|---|
| 735 | // zonder uitweg: opruimen hielp niet, want deze tabel hield de blokkade
|
|---|
| 736 | // in stand.
|
|---|
| 737 | //
|
|---|
| 738 | // Alleen LEGE velden worden gevuld. Wat jij zelf hebt aangepast blijft
|
|---|
| 739 | // staan; een migratie hoort je correcties niet terug te draaien.
|
|---|
| 740 | const trackId = ruwId(a.id) || crypto.randomUUID();
|
|---|
| 741 | {
|
|---|
| 742 | const rij = db.prepare('SELECT id, cover_url, duration, artist FROM audio_tracks WHERE id = ? AND site_id = ?')
|
|---|
| 743 | .get(trackId, site.id);
|
|---|
| 744 | if (rij) {
|
|---|
| 745 | trackKaart.set(String(a.id), rij.id); // MOET, anders vinden de playlists hem niet
|
|---|
| 746 | ruwKaart.set(ruwId(a.id), rij.id);
|
|---|
| 747 | const duur = rij.duration ? null : duurSeconden(a.duration);
|
|---|
| 748 | const artiest = rij.artist ? null : (a.summary || a.artist || null);
|
|---|
| 749 | let hoes = null;
|
|---|
| 750 | const hUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
|
|---|
| 751 | if (!rij.cover_url && hUrl && /^https?:\/\//i.test(String(hUrl)) && safeFetch && fs && path && mediaRoot) {
|
|---|
| 752 | const h = await haalBijlage(String(hUrl), {
|
|---|
| 753 | safeFetch, mediaRoot, fs, path, maxBytes,
|
|---|
| 754 | headers: signHeaders ? signHeaders(site.slug, String(hUrl), '*/*') : null,
|
|---|
| 755 | }).catch(() => null);
|
|---|
| 756 | if (h) { hoes = h.url; rapport.media++; }
|
|---|
| 757 | }
|
|---|
| 758 | if (duur || artiest || hoes) {
|
|---|
| 759 | db.prepare(`UPDATE audio_tracks SET
|
|---|
| 760 | duration = COALESCE(?, duration),
|
|---|
| 761 | artist = COALESCE(?, artist),
|
|---|
| 762 | cover_url = COALESCE(?, cover_url)
|
|---|
| 763 | WHERE id = ?`).run(duur, artiest, hoes, rij.id);
|
|---|
| 764 | rapport.tracksBijgewerkt++;
|
|---|
| 765 | } else {
|
|---|
| 766 | rapport.overgeslagenTracks++;
|
|---|
| 767 | }
|
|---|
| 768 | continue;
|
|---|
| 769 | }
|
|---|
| 770 | }
|
|---|
| 771 | 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));
|
|---|
| 772 | if (!bron || !/^https?:\/\//i.test(String(bron))) { rapport.tracksMislukt++; continue; }
|
|---|
| 773 | const g = await haalBijlage(String(bron), {
|
|---|
| 774 | safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '',
|
|---|
| 775 | headers: signHeaders ? signHeaders(site.slug, String(bron), '*/*') : null,
|
|---|
| 776 | }).catch(() => null);
|
|---|
| 777 | if (!g) {
|
|---|
| 778 | rapport.tracksMislukt++;
|
|---|
| 779 | rapport.waarschuwingen.push(`nummer niet opgehaald: ${a.name || bron}`);
|
|---|
| 780 | continue; // dezelfde regel als bij de zip: geen bestand, geen track
|
|---|
| 781 | }
|
|---|
| 782 | // De hoes. Die reisde als URL wel mee en als bestand niet, dus kwam een
|
|---|
| 783 | // nummer aan met een verwijzing naar een plaatje dat er niet is.
|
|---|
| 784 | let hoes = null;
|
|---|
| 785 | const hoesUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
|
|---|
| 786 | if (hoesUrl && /^https?:\/\//i.test(String(hoesUrl))) {
|
|---|
| 787 | const h = await haalBijlage(String(hoesUrl), {
|
|---|
| 788 | safeFetch, mediaRoot, fs, path, maxBytes,
|
|---|
| 789 | headers: signHeaders ? signHeaders(site.slug, String(hoesUrl), '*/*') : null,
|
|---|
| 790 | }).catch(() => null);
|
|---|
| 791 | if (h) { hoes = h.url; rapport.media++; }
|
|---|
| 792 | else rapport.waarschuwingen.push(`hoes niet opgehaald: ${a.name || hoesUrl}`);
|
|---|
| 793 | }
|
|---|
| 794 | const mediaId = crypto.randomUUID();
|
|---|
| 795 | try {
|
|---|
| 796 | db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
|
|---|
| 797 | .run(mediaId, site.id, g.filename, g.mediaType, g.size, g.storage_path);
|
|---|
| 798 | db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, cover_url, fedi_open)
|
|---|
| 799 | VALUES (?,?,?,?,?,?,?,?,0)`)
|
|---|
| 800 | .run(trackId, site.id, a.name || 'zonder titel', a.summary || a.artist || null, a.album || null,
|
|---|
| 801 | duurSeconden(a.duration), mediaId, hoes);
|
|---|
| 802 | recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
|
|---|
| 803 | trackKaart.set(String(a.id), trackId);
|
|---|
| 804 | ruwKaart.set(ruwId(a.id), trackId);
|
|---|
| 805 | rapport.tracksBinnen++;
|
|---|
| 806 | } catch (e) {
|
|---|
| 807 | rapport.tracksMislukt++;
|
|---|
| 808 | rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
|
|---|
| 809 | }
|
|---|
| 810 | }
|
|---|
| 811 | } else if (tracksUrl) {
|
|---|
| 812 | rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
|
|---|
| 813 | }
|
|---|
| 814 |
|
|---|
| 815 | // Postlinks eerst: pas nu zijn ALLE berichten binnen, dus pas nu weten we
|
|---|
| 816 | // welke slugs hier bestaan.
|
|---|
| 817 | postLinksBijtrekken(site, bronOrigin, rapport);
|
|---|
| 818 |
|
|---|
| 819 | // ── De verwijzingen in de tekst bijtrekken ────────────────────
|
|---|
| 820 | //
|
|---|
| 821 | // Klonkt schrijft [[track:<id>]] in posts.content, en die tekst reist
|
|---|
| 822 | // letterlijk mee. Krijgt het nummer hier een ander id, dan wijst de
|
|---|
| 823 | // shorthand nergens heen en zie je de code zelf in je bericht staan in
|
|---|
| 824 | // plaats van een speler. Precies wat Robin op TikTik zag.
|
|---|
| 825 | //
|
|---|
| 826 | // Pas NA de tracks, want daarvoor is de kaart nog leeg. En alleen waar het
|
|---|
| 827 | // id echt veranderde: een gelijk id hoeft niet aangeraakt.
|
|---|
| 828 | {
|
|---|
| 829 | const paren = [...ruwKaart.entries()].filter(([oud, nieuwId]) => oud && oud !== nieuwId);
|
|---|
| 830 | if (paren.length) {
|
|---|
| 831 | const upd = db.prepare('UPDATE posts SET content = REPLACE(content, ?, ?) WHERE site_id = ? AND content LIKE ?');
|
|---|
| 832 | let n = 0;
|
|---|
| 833 | for (const [oud, nieuwId] of paren) {
|
|---|
| 834 | const r = upd.run(`[[track:${oud}]]`, `[[track:${nieuwId}]]`, site.id, `%[[track:${oud}]]%`);
|
|---|
| 835 | if (r && r.changes) n += r.changes;
|
|---|
| 836 | }
|
|---|
| 837 | if (n) { rapport.tekstBijgewerkt = n; console.log('[FEP-1580] track-verwijzingen bijgetrokken in', n, 'bericht(en)'); }
|
|---|
| 838 | }
|
|---|
| 839 | }
|
|---|
| 840 |
|
|---|
| 841 | // ── De playlists ──────────────────────────────────────────────
|
|---|
| 842 | //
|
|---|
| 843 | // Los van de nummers, want de VOLGORDE is de playlist. Die staat nergens
|
|---|
| 844 | // anders: haal je alleen de tracks op, dan heb je wel alle muziek en geen
|
|---|
| 845 | // enkele plaat. De bron geeft ons de volledige lijst omdat we de doel-actor
|
|---|
| 846 | // zijn; anders zaten er alleen de opengezette nummers in en kreeg je een
|
|---|
| 847 | // plaat met gaten.
|
|---|
| 848 | const plUrl = streams.find((u) => /\/playlists\/?$/.test(u));
|
|---|
| 849 | if (plUrl && trackKaart.size) {
|
|---|
| 850 | const coll = await getJson(site.slug, plUrl);
|
|---|
| 851 | const lijst = (coll && (coll.orderedItems || coll.items)) || [];
|
|---|
| 852 | for (const p of (Array.isArray(lijst) ? lijst : []).slice(0, 200)) {
|
|---|
| 853 | const uri = typeof p === 'string' ? p : (p && p.id);
|
|---|
| 854 | if (!uri) continue;
|
|---|
| 855 | const plc = typeof p === 'object' && (p.orderedItems || p.items) ? p : await getJson(site.slug, uri);
|
|---|
| 856 | if (!plc) { rapport.playlistsMislukt++; continue; }
|
|---|
| 857 | const nummers = (plc.orderedItems || plc.items || [])
|
|---|
| 858 | .map((x) => (x && typeof x === 'object' ? x.id : x))
|
|---|
| 859 | .map((id) => trackKaart.get(String(id)))
|
|---|
| 860 | .filter(Boolean);
|
|---|
| 861 | if (!nummers.length) {
|
|---|
| 862 | rapport.waarschuwingen.push(`playlist ${plc.name || uri}: geen van de nummers is aangekomen, overgeslagen`);
|
|---|
| 863 | continue;
|
|---|
| 864 | }
|
|---|
| 865 | // De hoes van de plaat, net als bij een nummer.
|
|---|
| 866 | let plHoes = null;
|
|---|
| 867 | const plHoesUrl = (plc.icon && (plc.icon.url || plc.icon)) || (plc.image && (plc.image.url || plc.image)) || null;
|
|---|
| 868 | if (plHoesUrl && /^https?:\/\//i.test(String(plHoesUrl)) && safeFetch && fs && path && mediaRoot) {
|
|---|
| 869 | const h = await haalBijlage(String(plHoesUrl), {
|
|---|
| 870 | safeFetch, mediaRoot, fs, path, maxBytes,
|
|---|
| 871 | headers: signHeaders ? signHeaders(site.slug, String(plHoesUrl), '*/*') : null,
|
|---|
| 872 | }).catch(() => null);
|
|---|
| 873 | if (h) { plHoes = h.url; rapport.media++; }
|
|---|
| 874 | else rapport.waarschuwingen.push(`hoes van playlist niet opgehaald: ${plc.name || uri}`);
|
|---|
| 875 | }
|
|---|
| 876 | // Ook hier het id van de bron. Dan blijft [[playlist:<id>]] in een
|
|---|
| 877 | // bericht wijzen, en is een tweede ronde vanzelf dezelfde rij.
|
|---|
| 878 | const plId = ruwId(uri) || crypto.randomUUID();
|
|---|
| 879 | try {
|
|---|
| 880 | db.prepare(`INSERT INTO playlists (id, site_id, title, artist, year, kind, cover_url) VALUES (?,?,?,?,?,?,?)
|
|---|
| 881 | ON CONFLICT(id) DO UPDATE SET
|
|---|
| 882 | title = excluded.title,
|
|---|
| 883 | artist = COALESCE(playlists.artist, excluded.artist),
|
|---|
| 884 | cover_url = COALESCE(playlists.cover_url, excluded.cover_url)`)
|
|---|
| 885 | .run(plId, site.id, plc.name || 'zonder titel', plc.artist || null,
|
|---|
| 886 | plc.year || null, plc['shaer:kind'] || null, plHoes);
|
|---|
| 887 | // De volgorde opnieuw zetten: die IS de plaat, en een halve
|
|---|
| 888 | // bijgewerkte volgorde is erger dan een verse.
|
|---|
| 889 | db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(plId);
|
|---|
| 890 | const ins = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
|
|---|
| 891 | nummers.forEach((tid, i) => ins.run(plId, tid, i));
|
|---|
| 892 | recordMigrated(site.slug, { origin: uri, target: `${me}/ap/playlists/${plId}`, sourceActor: bronActor.id, isPublic: false });
|
|---|
| 893 | rapport.playlistsBinnen++;
|
|---|
| 894 | const kwijt = (plc.orderedItems || plc.items || []).length - nummers.length;
|
|---|
| 895 | if (kwijt > 0) rapport.waarschuwingen.push(`playlist ${plc.name || uri}: ${kwijt} nummer(s) ontbraken en zijn eruit gelaten`);
|
|---|
| 896 | } catch (e) {
|
|---|
| 897 | rapport.playlistsMislukt++;
|
|---|
| 898 | rapport.waarschuwingen.push(`playlist niet opgeslagen: ${plc.name || uri} (${e && e.message})`);
|
|---|
| 899 | }
|
|---|
| 900 | }
|
|---|
| 901 | }
|
|---|
| 902 | } catch (e) {
|
|---|
| 903 | // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
|
|---|
| 904 | // en dat is precies goed, want er is nog werk.
|
|---|
| 905 | console.warn('[FEP-1580] ingest afgebroken:', e && e.message);
|
|---|
| 906 | return { ...rapport, error: 'partial', melding: e && e.message };
|
|---|
| 907 | }
|
|---|
| 908 |
|
|---|
| 909 | // 9. Klaar. Nu pas mag een derde stoppen met kijken.
|
|---|
| 910 | setMigrationComplete(site.slug, true);
|
|---|
| 911 | console.log('[FEP-1580] ingest klaar:', site.slug, '<-', bronActor.id, rapport.posts, 'berichten,', rapport.media, 'bestanden');
|
|---|
| 912 | return rapport;
|
|---|
| 913 | }
|
|---|