source: Klonkt/src/services/MigrationService.js@ 861c228

main
Last change on this file since 861c228 was 861c228, checked in by Robin <roboburr@…>, 4 weeks ago

Gebakken links wijzen voortaan naar hier, in beide routes

Robin: de gebakken tracklinks moeten de nieuwe links worden. Terecht, en ik had
ze te makkelijk afgedaan als "geen plaatje, dus niet mijn probleem". Het zijn
tijdbommen: buildNote zet https://oud/<slug>#track-<id> en ?fc=2 in de content,
en zodra dat domein opgezegd wordt staan er dode links in je eigen berichten.

Ze kunnen nu ook echt om, en dat is te danken aan "altijd behouden" van
vanmiddag: slug en track-id zijn hier hetzelfde, dus alleen de host hoeft eraf
en het fragment klopt vanzelf.

DE REGEL: alleen ombuigen voor een slug die hier ECHT bestaat. Wijst een link
naar iets dat we niet hebben, dan blijft hij naar de oude site wijzen. Een
verwijzing naar een pagina die er nog is, is beter dan een 404 op de nieuwe.

Pas aan het EIND van de ronde, want dan pas weten we welke slugs binnen zijn.
Bij een verse ingest zou de helft van de links nog niet te beoordelen zijn.

EN IN BEIDE ROUTES ("het moet wel gebeuren bij migratie direct ook"). Een
zip-import is net zo goed een verhuizing. Daar draait het binnen dezelfde
transactie: half bijgetrokken is erger dan niet.

Changed files:
src/services/MigrationService.js

  • postLinksBijtrekken(), geexporteerd zodat de zip hem ook kan draaien
  • aangeroepen zodra alle berichten binnen zijn

src/services/ArchiveImportService.js

  • dezelfde stap aan het eind van de import, in de transactie
  • rapportveld linksBijgetrokken

src/views/pages/admin-migrate.ejs

  • in het import- en het ophaalverslag

src/services/i18n.js

  • mig.r_links_fixed in nl/en/de

test/fep1580-migration.test.js

  • fragment en querystring blijven heel, een onbekende slug blijft met rust

test/archive-audio.test.js

  • dezelfde eis voor de zip-route

remarks: /media, /audio en /ap worden overgeslagen; die lopen via hun eigen weg.
Suite 1000 groen, tegenproef op allebei de routes.

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

  • Property mode set to 100644
File size: 46.9 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 } 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 } = {}) {
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
76export 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. */
84export 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 */
97export 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
106export 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
121export 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
127export 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
136export 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 */
150export 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 */
178export 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
207export 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. */
217export 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. */
222function 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. */
231function 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. */
236function slugUitUri(uri) {
237 try { return decodeURIComponent(new URL(uri).pathname.split('/').filter(Boolean).pop() || ''); } catch { return ''; }
238}
239
240const 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 */
257export 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 */
297function 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 */
324function 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. */
338function 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 */
361function 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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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 */
376async 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 */
415async 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 */
461export 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}
Note: See TracBrowser for help on using the repository browser.