source: Klonkt/src/services/MigrationService.js@ 1976a10

main
Last change on this file since 1976a10 was 7e474f7, checked in by Robin <roboburr@…>, 4 weeks ago

De publieke vertaaltabel laadde alles om er twintig te tonen

Robin, ter herinnering: honderd miljoen Klonkt-gebruikers. Terecht, en het
wees meteen een echte fout aan in wat ik vandaag bouwde.

migrationItems deed SELECT zonder LIMIT. buildMigration bouwde daar objecten
van en liet pagedCollection er twintig uit snijden. Bij een gebruiker met
honderdduizend berichten betekent dat: honderdduizend rijen uit de database,
honderdduizend objecten in geheugen, 99,98 procent daarvan direct de prullenbak
in. Per verzoek.

En het is geen intern verzoek. Dit endpoint is publiek EN FEP-1580 schrijft
derden voor het herhaaldelijk op te halen tot migrationComplete waar is. Een
ontbrekende LIMIT is daar geen inefficientie maar een hefboom.

Nu in SQL gepagineerd, met dezelfde alGesneden-aanpak die de outbox al had.

DE KALE COLLECTIE DRAAGT GEEN RIJEN MEER, alleen de telling en first/last. Dat
is niet alleen goedkoper maar ook veiliger: een kale collectie MET items was
precies de valstrik waar onze eigen ingest vanmorgen in liep. Hij zag items,
sloeg first over, vond geen next en dacht klaar te zijn bij 18 van de 35.

Changed files:
src/services/MigrationService.js

  • migrationItems kent limit en offset
  • buildMigration telt apart en haalt alleen de gevraagde pagina op

test/fep1580-migration.test.js

  • de kale collectie heeft een telling en geen rijen
  • 45 items geven pagina's van 20/20/5, zonder overlap en zonder verlies

remarks: de rate-limiter dekt /ap al, dus die kant zat goed. Wat NIET opgelost
is en bij deze schaal het echte plafond blijft: buildArchive bouwt de hele zip
in geheugen, inclusief elk audiobestand. Die grens van 512 MB is nog steeds een
beredeneerde gok en geen meting. Streamen staat als shaer-190t genoteerd; bij
honderd miljoen gebruikers is dat geen nice-to-have meer. Suite 1014 groen,
tegenproef gedaan.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

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