source: Klonkt/src/services/MigrationService.js@ f894be5

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

De helft van de outbox, hotlinks in de tekst, en een lege images-tab

Robin na de echte fetch: 18 van de 35 berichten binnen, alle plaatjes
hotlinken naar de oude site, en de images-tab is leeg. Drie oorzaken, en de
eerste was de pijnlijkste omdat hij zonder één waarschuwing "klaar" meldde.

DE KALE OUTBOX IS EEN VALSTRIK. Klonkt zet er first EN een kopie van pagina 1
in (Pleroma eiste ooit een first, sindsdien staan ze er allebei). De ingest
zag items op de kale collectie, sloeg first over, vond daarna geen next (dat
veld bestaat alleen op echte pagina's) en dacht klaar te zijn. Precies 18 van
de 35, en "ingest klaar" in het log. Nu wordt de keten ALTIJD via first
gevolgd, en telt de ingest na: meldt de bron meer items dan er verwerkt zijn,
dan staat dat in het verslag.

PLAATJES IN DE TEKST BLEVEN HOTLINKS. De gebakken content draagt absolute
verwijzingen naar de bron, in img en in de href eromheen. Die worden nu
gedownload naar HETZELFDE pad onder onze mediamap en relatief herschreven.
Hetzelfde pad is drie reparaties in een: de verwijzingen in de tekst kloppen,
de zip en de pull convergeren op dezelfde bestanden, en de images-tab (die de
MAP post-images scant, niet de databasetabel) is niet langer leeg.

BESTAANDE POSTS WORDEN GEREPAREERD. Robins 18 staan er al, met hotlinks en
zonder cover. Een tweede ronde herschrijft hun tekst, haalt de bestanden
binnen en vult de cover aan. Overslaan is voortaan alleen voor posts waar
niets aan mankeert.

En onderweg twee eigen missers gevonden en gedicht:

  • de regex voor het herschrijven stond als string in een template literal; \s verloor zijn backslash en werd de LETTER s, dus elke URL met een s erin (post-images!) knapte af. De waarschuwing ".../media/po" gaf hem weg.
  • haalBijlage plakte /media/ voor een pad dat al met /media/ begon.

Gemeten op twee instanties met 28 posts, elk met cover en inline plaatje:
28/28 binnen, 0 hotlinks, 28 covers met bestand, post-images-map 28 stuks, en
de reparatieronde maakt een met opzet kapotgemaakte post weer heel.

Changed files:
src/services/MigrationService.js

  • first wordt altijd gevolgd; natellen tegen totalItems
  • bronMediaPad(): bron-media naar hetzelfde pad hier, met ../-bewaking
  • inhoudMediaBinnen(): alle bron-media in de tekst binnenhalen en relatief maken; idempotent
  • bestaande posts: tekst gerepareerd en cover aangevuld op een tweede ronde
  • de dubbele /media/ en de opgegeten backslash

src/views/pages/admin-migrate.ejs

  • "berichten gerepareerd" in het ophaalverslag

src/services/i18n.js

  • mig.r_posts_updated in nl/en/de

test/fep1580-migration.test.js

  • regressietests: de kale-collectie-valstrik, de verzwegen pagina, en het herschrijven van een URL met een s erin, letter voor letter

remarks: de reden dat mijn eigen proef dit nooit zag: mijn nepbron gaf
orderedItems zonder first, precies de vorm die de bug ontweek. En een post met
een ongeldige published_at sloopt de HELE outbox van de bron (unhandled
rejection in buildOutbox); hier gevonden met kapotte testdata, nog niet
gerepareerd. Suite 997 groen; tegenproef op de paginatie en de backslash, en de
eerste tegenproef op die backslash was VALS (sabotage kwam niet aan), daarna
opnieuw met bewijs.

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

  • Property mode set to 100644
File size: 42.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 * Hoort deze URL bij de bron, en wijst hij onder /media/?
244 *
245 * Dan behouden we het PAD. Drie redenen tegelijk:
246 * - de gebakken content verwijst relatief of absoluut naar dat pad, en met
247 * hetzelfde pad hier klopt elke verwijzing zonder herschrijf-acrobatiek;
248 * - de media-bibliotheek (Beheer, Media) scant de MAP post-images, niet de
249 * databasetabel. Een bestand onder migrated/<uuid> bestaat wel en is
250 * onzichtbaar: Robins lege images-tab;
251 * - de zip-import bewaart originele paden al, dus zo convergeren beide
252 * routes op dezelfde bestanden.
253 *
254 * De ../-bewaking is geen formaliteit: het pad komt van een andere server.
255 */
256function bronMediaPad(url, bronOrigin, { mediaRoot, path }) {
257 try {
258 const u = new URL(String(url));
259 if (`${u.protocol}//${u.host}` !== bronOrigin) return null;
260 if (!u.pathname.startsWith('/media/')) return null;
261 const rel = decodeURIComponent(u.pathname.slice('/media/'.length));
262 const abs = path.resolve(mediaRoot, rel);
263 const root = path.resolve(mediaRoot);
264 if (abs === root || !abs.startsWith(`${root}${path.sep}`)) return null;
265 return { rel: `/media/${rel}`, abs };
266 } catch { return null; }
267}
268
269/** AS2 geeft de duur als ISO-8601 ("PT212S"), de database wil seconden. */
270function duurSeconden(v) {
271 if (v == null) return null;
272 if (typeof v === 'number') return Math.round(v) || null;
273 const m = /^P(?:.*?T)?(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?$/.exec(String(v));
274 if (!m) { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.round(n) : null; }
275 const sec = (Number(m[1]) || 0) * 3600 + (Number(m[2]) || 0) * 60 + (Number(m[3]) || 0);
276 return sec > 0 ? Math.round(sec) : null;
277}
278
279/**
280 * De titel terugwinnen uit de content.
281 *
282 * Een AS2 Note heeft geen titel: Mastodon negeert `name`, dus Klonkt bakt de
283 * titel als vetgedrukte eerste alinea IN de content (zie buildNote). Over de
284 * lijn is een titel dus geen veld maar een vorm. Doen we hier niets, dan komt
285 * elk bericht titelloos aan en heet het naar zijn id.
286 *
287 * Daarom draaien we precies onze eigen bak terug: alleen als de content BEGINT
288 * met een alinea die niets anders bevat dan vetgedrukte tekst. Dat is de exacte
289 * vorm die buildNote maakt. Een bericht van elders dat toevallig zo begint
290 * verliest die regel niet, hij verhuist naar het titelveld en staat straks
291 * gewoon weer bovenaan.
292 */
293function titelUitContent(html) {
294 const m = /^\s*<p>\s*<strong>([\s\S]*?)<\/strong>\s*<\/p>/i.exec(String(html || ''));
295 if (!m) return { titel: null, rest: html };
296 const titel = m[1].replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
297 if (!titel || titel.length > 300) return { titel: null, rest: html };
298 return { titel, rest: String(html).slice(m[0].length) };
299}
300
301/**
302 * Haal een bijlage op en zet hem lokaal neer.
303 *
304 * safeFetch is de SSRF-veilige kant van Klonkt; hier is dat geen formaliteit,
305 * want de URL komt van een andere server. Een bron die ons naar 127.0.0.1 wijst
306 * moet stranden, ook als die bron "van onszelf" is.
307 */
308async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes, submap = 'migrated', headers = null, doel = null }) {
309 // Ondertekend als het moet. Gehoste audio zit achter dezelfde poort als de
310 // rest van de bron, en een kale fetch krijgt daar een 403: de bron kan dan
311 // niet zien dat wij de doel-actor van zijn Move zijn.
312 const r = await safeFetch(url, { headers: headers || { accept: '*/*' } }).catch(() => null);
313 if (!r || !r.ok) return null;
314 const buf = Buffer.from(await r.arrayBuffer());
315 if (!buf.length || buf.length > maxBytes) return null;
316 const type = String(r.headers.get('content-type') || '').split(';')[0].trim() || 'application/octet-stream';
317 const ext = (() => {
318 const uit = slugUitUri(url);
319 const m = /\.([a-z0-9]{1,5})$/i.exec(uit);
320 if (m) return m[1].toLowerCase();
321 return (type.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'bin';
322 })();
323 const naam = `${crypto.randomUUID()}.${ext}`;
324 // `doel` wint: dan behouden we het pad van de bron (zie bronMediaPad).
325 // Zonder submap komt het bestand in de root zelf: dat is wat gehoste audio
326 // nodig heeft, want de speler zoekt AUDIO_ROOT + bestandsnaam en kijkt niet
327 // in mappen eronder.
328 const rel = doel ? doel.rel : (submap ? `${submap}/${naam}` : naam);
329 const abs = doel ? doel.abs : (submap ? path.join(mediaRoot, submap, naam) : path.join(mediaRoot, naam));
330 fs.mkdirSync(path.dirname(abs), { recursive: true });
331 fs.writeFileSync(abs, buf);
332 // doel.rel is al een volledig /media/-pad; de submap-variant is dat nog niet.
333 return { url: doel ? doel.rel : `/media/${rel}`, mediaType: type, size: buf.length, filename: naam, storage_path: abs };
334}
335
336/**
337 * Alle bron-media in een lap HTML binnenhalen en de verwijzingen relatief maken.
338 *
339 * Werkt op ALLE https://bron/media/...-voorkomens, niet alleen op <img src>:
340 * de gebakken content zet dezelfde URL ook in een href om het plaatje groot te
341 * openen, en een half herschreven paar (lokaal plaatje, hotlink eromheen) is
342 * verwarrender dan geen herschrijving.
343 *
344 * Idempotent: wat al gedownload is wordt niet opnieuw gehaald, en een tweede
345 * ronde over dezelfde tekst vindt gewoon niets meer te doen.
346 */
347async function inhoudMediaBinnen(html, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes }) {
348 let inhoud = String(html || '');
349 if (!inhoud || !bronOrigin) return { inhoud, n: 0 };
350 // LET OP de dubbele backslash: dit is een STRING die een RegExp wordt. Met een
351 // enkele \s eet de template literal de backslash op en sluit de klasse de
352 // LETTER s uit; "post-images" knapte dan af op de s en elke URL met een s
353 // erin werd half herschreven. Gevonden doordat de waarschuwing ".../media/po"
354 // meldde, afgekapt precies voor de s.
355 const patroon = new RegExp(`${bronOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/media/[^"'\\s)<>]+)`, 'g');
356 const gezien = new Set();
357 let n = 0;
358 for (const m of [...inhoud.matchAll(patroon)]) {
359 const vol = m[0];
360 if (gezien.has(vol)) continue;
361 gezien.add(vol);
362 const doel = bronMediaPad(vol, bronOrigin, { mediaRoot, path });
363 if (!doel) { rapport.waarschuwingen.push(`onbruikbaar mediapad in tekst: ${vol}`); continue; }
364 let ok = false;
365 try { fs.statSync(doel.abs); ok = true; } catch { /* nog niet binnen */ }
366 if (!ok) {
367 const g = await haalBijlage(vol, { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
368 if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`plaatje in tekst niet opgehaald: ${vol}`); continue; }
369 rapport.media++;
370 try {
371 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
372 .run(crypto.randomUUID(), site.id, path.basename(doel.abs), g.mediaType, g.size, doel.abs);
373 } catch { /* administratie */ }
374 }
375 inhoud = inhoud.split(vol).join(doel.rel);
376 n++;
377 }
378 return { inhoud, n };
379}
380
381/**
382 * FEP-1580 ingest-routine, de doelkant.
383 *
384 * De autorisatie wordt hier niet verzonnen maar NAGEKEKEN, en in beide
385 * richtingen, precies zoals de spec het voor derden voorschrijft: `movedTo` op
386 * de bron moet naar ons wijzen EN wij moeten de bron in `alsoKnownAs` hebben.
387 * Eén kant is een bewering, twee kanten is een afspraak. Zou ik alleen op onze
388 * eigen alsoKnownAs afgaan, dan kon iedereen die zichzelf een alias geeft de
389 * geschiedenis van een vreemde opeisen.
390 *
391 * `deps` is er voor de test: die moet dit kunnen draaien zonder netwerk.
392 */
393export async function ingestFromSource(site, {
394 sourceUri = null, max = 1000, maxBytes = 25 * 1024 * 1024, deps = {},
395} = {}) {
396 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
397 if (!base || !site || !site.slug) return { error: 'config' };
398 const me = actorId(base, site.slug);
399
400 const {
401 getJson = null, safeFetch = null, mediaRoot = null, fs = null, path = null, noteId = null,
402 sanitize = (h) => h,
403 // Standaard 'followers': kan iets niet als publiek bewezen worden, dan
404 // hoort het niet in de publieke vertaaltabel. Fail-closed, want dit is een
405 // privacygrens en niet een weergavedetail.
406 noteVisibility = () => 'followers',
407 audioRoot = null, signHeaders = null,
408 } = deps;
409 const zichtbaarheid = noteVisibility;
410 if (!getJson || !noteId) return { error: 'config' };
411
412 // 1. Welke bron? Zonder opgave: de alias die we zelf claimen.
413 let bron = sourceUri && /^https?:\/\//i.test(sourceUri) ? sourceUri : null;
414 if (!bron) {
415 try {
416 const aka = JSON.parse(site.ap_aliases || '[]');
417 bron = Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
418 } catch { /* stukke ap_aliases telt als geen alias */ }
419 }
420 if (!bron) return { error: 'no_source' };
421
422 // 2 + 3. Het bron-actordocument, en de wegwijzer die naar ONS moet wijzen.
423 const bronActor = await getJson(site.slug, bron);
424 if (!bronActor || !bronActor.id) return { error: 'unreachable' };
425 // De origin van de bron: alles op deze host onder /media/ is van hem en mag
426 // naar hetzelfde pad hier. Uit de actor-id, niet uit de invoer.
427 const bronOrigin = (() => { try { const u = new URL(bronActor.id); return `${u.protocol}//${u.host}`; } catch { return null; } })();
428 if (bronActor.movedTo !== me) return { error: 'not_moved_here', movedTo: bronActor.movedTo || null };
429
430 // 4. En de terugverwijzing van onze kant, zodat het een afspraak is.
431 const eigenAka = (() => {
432 try { const a = JSON.parse(site.ap_aliases || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
433 })();
434 if (!eigenAka.includes(bronActor.id)) return { error: 'no_backreference' };
435
436 // 5 + 6. Vastleggen dat dit een migratie is, en de deur openzetten voor derden.
437 recordMove(site.slug, {
438 moveId: `${bronActor.id}#move`, sourceActor: bronActor.id, targetActor: me,
439 activity: { type: 'Move', actor: bronActor.id, object: bronActor.id, target: me },
440 actorDoc: bronActor,
441 });
442 setMigrationComplete(site.slug, false);
443
444 const rapport = {
445 bron: bronActor.id, posts: 0, overgeslagen: 0, opnieuw: 0, postsBijgewerkt: 0, media: 0, mediaMislukt: 0,
446 blocks: 0, tracksBinnen: 0, tracksMislukt: 0, tracksBijgewerkt: 0, overgeslagenTracks: 0,
447 playlistsBinnen: 0, playlistsMislukt: 0, waarschuwingen: [],
448 };
449
450 try {
451 // 7. BLOKKADES EERST. De spec is daar streng over, en terecht: ze bepalen
452 // wie de rest te zien krijgt. Andersom importeer je even je hele
453 // geschiedenis zichtbaar voor iemand die je nou juist buiten wilde.
454 if (bronActor.blocked) {
455 const coll = await getJson(site.slug, typeof bronActor.blocked === 'string' ? bronActor.blocked : bronActor.blocked.id);
456 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
457 for (const b of Array.isArray(lijst) ? lijst : []) {
458 const uri = typeof b === 'string' ? b : (b && (b.object || b.id));
459 if (!uri || !/^https?:\/\//i.test(String(uri))) continue;
460 try {
461 db.prepare("INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label) VALUES (?, ?, 'actor', NULL)").run(site.slug, String(uri));
462 rapport.blocks++;
463 } catch { /* tabel ontbreekt op een verse db */ }
464 }
465 } else {
466 rapport.waarschuwingen.push('de bron gaf geen blokkadelijst, zichtbaarheidsvoorkeuren komen niet mee');
467 }
468
469 // 8. De outbox aflopen. Pagineren zoals de rest van Klonkt dat doet.
470 if (!bronActor.outbox) return { ...rapport, error: 'no_outbox' };
471 let pagina = await getJson(site.slug, typeof bronActor.outbox === 'string' ? bronActor.outbox : bronActor.outbox.id);
472 const verwacht = pagina && Number(pagina.totalItems) || null;
473 // Is er een `first`, dan ALTIJD de paginaketen volgen, ook als de kale
474 // collectie zelf items draagt. Klonkt zet daar een kopie van pagina 1 in
475 // (Pleroma eiste een first, en sindsdien staan ze er allebei), maar alleen
476 // echte pagina's dragen een `next`. Wie op de kale collectie blijft hangen
477 // verwerkt pagina 1 en denkt dan klaar te zijn: precies 18 van Robins 35
478 // berichten, zonder één waarschuwing.
479 if (pagina && pagina.first) {
480 pagina = await getJson(site.slug, typeof pagina.first === 'string' ? pagina.first : pagina.first.id);
481 }
482
483 const insPost = db.prepare(`INSERT INTO posts
484 (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url,
485 pinned, type, tags, published_at, created_at, updated_at, fan_only, nsfw, language,
486 content_warning, ap_visibility, c2s_attachments, origin_server)
487 VALUES (@id, @site_id, @slug, @author_id, @title, @content, NULL, 'published', @cover_image_url,
488 0, 'post', @tags, @published_at, @published_at, @updated_at, @fan_only, @nsfw, @language,
489 @content_warning, @ap_visibility, @c2s_attachments, 'migrated')`);
490
491 let gezien = 0;
492 while (pagina && gezien < max) {
493 const items = (pagina.orderedItems || pagina.items) || [];
494 for (const it of Array.isArray(items) ? items : []) {
495 if (gezien >= max) break;
496 const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
497 if (!o || !o.id) continue;
498 if (o.type && !['Note', 'Article', 'Question'].includes(o.type)) continue;
499 if (o.inReplyTo) continue; // toplevel; antwoorden hangen aan hun ouder
500 const auteur = typeof o.attributedTo === 'string' ? o.attributedTo : (o.attributedTo && o.attributedTo.id);
501 if (auteur && auteur !== bronActor.id) continue; // alleen wat van HEM was
502 gezien++;
503 // Het interne id BLIJFT (Robins besluit, 14-8). Daarmee is "staat hij
504 // hier al" gewoon een blik in de tabel, en niet iets dat je uit een
505 // aparte mapping moet afleiden. Verwijder je een bericht en haal je
506 // opnieuw op, dan komt het gewoon terug: er staat immers niets meer.
507 const id = ruwId(o.id) || crypto.randomUUID();
508 const bestaand = db.prepare('SELECT id, content, cover_image_url FROM posts WHERE id = ? AND site_id = ?').get(id, site.id);
509 if (bestaand) {
510 // Niet alleen overslaan: REPAREREN wat een eerdere ronde liet liggen.
511 // Robins 18 posts stonden er al, met hotlinks naar de bron in de
512 // tekst en zonder cover. Een tweede ronde die dat ziet en passeert
513 // laat je met een site vol verwijzingen naar een domein dat
514 // opgezegd wordt.
515 if (safeFetch && fs && path && mediaRoot && bronOrigin && String(bestaand.content || '').includes(bronOrigin)) {
516 const r2 = await inhoudMediaBinnen(bestaand.content, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes });
517 if (r2.n) {
518 db.prepare('UPDATE posts SET content = ? WHERE id = ?').run(r2.inhoud, bestaand.id);
519 rapport.postsBijgewerkt++;
520 }
521 }
522 if (!bestaand.cover_image_url && safeFetch && fs && path && mediaRoot) {
523 // De cover alsnog: hij zit als bijlage op de Note.
524 for (const a of (Array.isArray(o.attachment) ? o.attachment : []).slice(0, 20)) {
525 const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
526 if (!u || !AFBEELDING.test(String((a && a.mediaType) || ''))) continue;
527 const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
528 const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
529 if (g) {
530 db.prepare('UPDATE posts SET cover_image_url = ? WHERE id = ?').run(g.url, bestaand.id);
531 rapport.media++;
532 rapport.postsBijgewerkt++;
533 }
534 break;
535 }
536 }
537 rapport.overgeslagen++;
538 continue;
539 }
540 if (migrationTarget(site.slug, o.id)) rapport.opnieuw++; // was er, is weg, komt terug
541
542 // Media eerst, want een post die naar een plaatje wijst dat we niet
543 // hebben opgehaald is een halve post. Mislukt een bijlage, dan gaat de
544 // post wel door en staat het in het verslag.
545 const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
546 const binnen = [];
547 let inhoud = o.content || '';
548 if (safeFetch && fs && path && mediaRoot) {
549 for (const a of bijlagen.slice(0, 20)) {
550 const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
551 if (!u || !/^https?:\/\//i.test(String(u))) continue;
552 const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
553 const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
554 if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`bijlage niet opgehaald: ${u}`); continue; }
555 binnen.push({ ...g, naam: (a && a.name) || null, type: (a && a.mediaType) || g.mediaType });
556 rapport.media++;
557 try {
558 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
559 .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
560 } catch { /* media-rij is administratie, het bestand staat er */ }
561 }
562 // De PLAATJES IN DE TEKST. De gebakken content draagt absolute
563 // verwijzingen naar de bron (https://oud/media/...), en die bleven
564 // gewoon staan: elke afbeelding hotlinkte naar een domein dat je gaat
565 // opzeggen, en je eigen mediamap bleef leeg. Downloaden naar
566 // HETZELFDE pad en de verwijzing relatief maken; wat niet lukt blijft
567 // absoluut staan en wordt gemeld, want een lokale 404 is erger dan
568 // een hotlink.
569 const r2 = await inhoudMediaBinnen(inhoud, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes });
570 inhoud = r2.inhoud;
571 }
572
573 const cover = binnen.find((b) => AFBEELDING.test(b.type || ''));
574 const rest = binnen.filter((b) => b !== cover);
575 // De titel zit in de content, niet in een veld (zie titelUitContent).
576 const { titel, rest: body } = o.name ? { titel: o.name, rest: inhoud } : titelUitContent(inhoud);
577 // De slug uit de MENSELIJKE url, niet uit de AP-id. Zo houdt het bericht
578 // hetzelfde webadres als op de oude instantie, en blijft een link die
579 // iemand ergens plakte kloppen op het nieuwe domein.
580 const basisSlug = slugUitUri(o.url || '') || o.name || slugUitUri(o.id) || id;
581 // De publicatiedatum blijft die van het origineel. De spec eist dat, en
582 // het is ook het enige eerlijke: het bericht is niet vandaag geschreven.
583 insPost.run({
584 id, site_id: site.id, slug: vrijeSlug(site.id, basisSlug),
585 author_id: site.owner_id, title: titel || null, content: sanitize(body || ''),
586 cover_image_url: cover ? cover.url : null,
587 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name || '').replace(/^#/, '')).filter(Boolean).join(', ') || null : null,
588 published_at: o.published || null, updated_at: o.updated || o.published || null,
589 fan_only: 0, nsfw: o.sensitive ? 1 : 0,
590 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
591 content_warning: o.summary || null,
592 ap_visibility: null,
593 c2s_attachments: rest.length ? JSON.stringify(rest.map((b) => ({ url: b.url, mediaType: b.type, name: b.naam || undefined }))) : null,
594 });
595 recordMigrated(site.slug, {
596 origin: o.id, target: noteId(base, id), sourceActor: bronActor.id,
597 // Publiek in de zin van de spec: gericht aan as:Public. Zo niet, dan
598 // hoort deze regel niet in een publiek leesbare migration-pagina.
599 //
600 // Via noteVisibility en niet met een eigen test op '#Public': die kent
601 // ook de schrijfwijzen 'as:Public' en 'Public', en de rest van Klonkt
602 // beslist er al mee. Een tweede, dunnere versie van dezelfde vraag is
603 // precies hoe twee antwoorden uit elkaar gaan lopen.
604 isPublic: zichtbaarheid(o) === 'public',
605 });
606 rapport.posts++;
607 }
608 const volgende = pagina.next;
609 if (!volgende || gezien >= max) break;
610 pagina = await getJson(site.slug, typeof volgende === 'string' ? volgende : volgende.id);
611 }
612 if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
613 // Silently minder ophalen dan de bron zegt te hebben is precies hoe 18 van
614 // de 35 wekenlang op "klaar" had kunnen staan. Tel na en zeg het.
615 if (verwacht && gezien < verwacht && gezien < max) {
616 rapport.waarschuwingen.push(`de bron meldt ${verwacht} items en er zijn er ${gezien} verwerkt; een pagina is mogelijk niet opgehaald, probeer het nog eens`);
617 }
618
619 // ── De muziekbibliotheek ──────────────────────────────────────
620 //
621 // Losse nummers staan niet in de outbox: die hangen aan de tracks-collectie
622 // waar de actor via AS2 `streams` naar wijst. Zonder deze lus verhuist een
623 // muzieksite zijn berichten en laat hij zijn bibliotheek achter.
624 //
625 // De bron geeft ons hier alles, niet alleen de fedi_open-nummers, omdat we
626 // de doel-actor van zijn Move zijn (siteOpenTracks({alles})). Hetzelfde
627 // geldt voor de bestanden zelf, die anders achter de gated audio-route
628 // blijven.
629 const trackKaart = new Map(); // bron-URI van een nummer -> ons nieuwe id
630 // En het RUWE id zoals het in de posttekst staat. Klonkt schrijft
631 // [[track:<id>]] in de content, en die tekst reist letterlijk mee over AP.
632 // Krijgt het nummer hier een ander id, dan wijst die shorthand nergens meer
633 // heen en zie je de code zelf in je bericht staan.
634 const ruwKaart = new Map(); // ruw bron-id -> ons id
635 const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
636 const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
637 if (tracksUrl && safeFetch && fs && path && audioRoot) {
638 const coll = await getJson(site.slug, tracksUrl);
639 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
640 for (const it of (Array.isArray(lijst) ? lijst : []).slice(0, max)) {
641 const a = (it && typeof it.object === 'object' && it.object) ? it.object : it;
642 if (!a || !a.id) continue;
643 if (a.type && a.type !== 'Audio') continue;
644 // AL BINNEN? Dan AANVULLEN, niet overslaan. Een tweede ronde bestaat
645 // juist omdat er iets bij is gekomen (hoezen, duur, playlists), en een
646 // pull die dan alles passeert laat je met een half resultaat zitten
647 // zonder uitweg: opruimen hielp niet, want deze tabel hield de blokkade
648 // in stand.
649 //
650 // Alleen LEGE velden worden gevuld. Wat jij zelf hebt aangepast blijft
651 // staan; een migratie hoort je correcties niet terug te draaien.
652 const trackId = ruwId(a.id) || crypto.randomUUID();
653 {
654 const rij = db.prepare('SELECT id, cover_url, duration, artist FROM audio_tracks WHERE id = ? AND site_id = ?')
655 .get(trackId, site.id);
656 if (rij) {
657 trackKaart.set(String(a.id), rij.id); // MOET, anders vinden de playlists hem niet
658 ruwKaart.set(ruwId(a.id), rij.id);
659 const duur = rij.duration ? null : duurSeconden(a.duration);
660 const artiest = rij.artist ? null : (a.summary || a.artist || null);
661 let hoes = null;
662 const hUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
663 if (!rij.cover_url && hUrl && /^https?:\/\//i.test(String(hUrl)) && safeFetch && fs && path && mediaRoot) {
664 const h = await haalBijlage(String(hUrl), {
665 safeFetch, mediaRoot, fs, path, maxBytes,
666 headers: signHeaders ? signHeaders(site.slug, String(hUrl), '*/*') : null,
667 }).catch(() => null);
668 if (h) { hoes = h.url; rapport.media++; }
669 }
670 if (duur || artiest || hoes) {
671 db.prepare(`UPDATE audio_tracks SET
672 duration = COALESCE(?, duration),
673 artist = COALESCE(?, artist),
674 cover_url = COALESCE(?, cover_url)
675 WHERE id = ?`).run(duur, artiest, hoes, rij.id);
676 rapport.tracksBijgewerkt++;
677 } else {
678 rapport.overgeslagenTracks++;
679 }
680 continue;
681 }
682 }
683 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));
684 if (!bron || !/^https?:\/\//i.test(String(bron))) { rapport.tracksMislukt++; continue; }
685 const g = await haalBijlage(String(bron), {
686 safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '',
687 headers: signHeaders ? signHeaders(site.slug, String(bron), '*/*') : null,
688 }).catch(() => null);
689 if (!g) {
690 rapport.tracksMislukt++;
691 rapport.waarschuwingen.push(`nummer niet opgehaald: ${a.name || bron}`);
692 continue; // dezelfde regel als bij de zip: geen bestand, geen track
693 }
694 // De hoes. Die reisde als URL wel mee en als bestand niet, dus kwam een
695 // nummer aan met een verwijzing naar een plaatje dat er niet is.
696 let hoes = null;
697 const hoesUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
698 if (hoesUrl && /^https?:\/\//i.test(String(hoesUrl))) {
699 const h = await haalBijlage(String(hoesUrl), {
700 safeFetch, mediaRoot, fs, path, maxBytes,
701 headers: signHeaders ? signHeaders(site.slug, String(hoesUrl), '*/*') : null,
702 }).catch(() => null);
703 if (h) { hoes = h.url; rapport.media++; }
704 else rapport.waarschuwingen.push(`hoes niet opgehaald: ${a.name || hoesUrl}`);
705 }
706 const mediaId = crypto.randomUUID();
707 try {
708 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
709 .run(mediaId, site.id, g.filename, g.mediaType, g.size, g.storage_path);
710 db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, cover_url, fedi_open)
711 VALUES (?,?,?,?,?,?,?,?,0)`)
712 .run(trackId, site.id, a.name || 'zonder titel', a.summary || a.artist || null, a.album || null,
713 duurSeconden(a.duration), mediaId, hoes);
714 recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
715 trackKaart.set(String(a.id), trackId);
716 ruwKaart.set(ruwId(a.id), trackId);
717 rapport.tracksBinnen++;
718 } catch (e) {
719 rapport.tracksMislukt++;
720 rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
721 }
722 }
723 } else if (tracksUrl) {
724 rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
725 }
726
727 // ── De verwijzingen in de tekst bijtrekken ────────────────────
728 //
729 // Klonkt schrijft [[track:<id>]] in posts.content, en die tekst reist
730 // letterlijk mee. Krijgt het nummer hier een ander id, dan wijst de
731 // shorthand nergens heen en zie je de code zelf in je bericht staan in
732 // plaats van een speler. Precies wat Robin op TikTik zag.
733 //
734 // Pas NA de tracks, want daarvoor is de kaart nog leeg. En alleen waar het
735 // id echt veranderde: een gelijk id hoeft niet aangeraakt.
736 {
737 const paren = [...ruwKaart.entries()].filter(([oud, nieuwId]) => oud && oud !== nieuwId);
738 if (paren.length) {
739 const upd = db.prepare('UPDATE posts SET content = REPLACE(content, ?, ?) WHERE site_id = ? AND content LIKE ?');
740 let n = 0;
741 for (const [oud, nieuwId] of paren) {
742 const r = upd.run(`[[track:${oud}]]`, `[[track:${nieuwId}]]`, site.id, `%[[track:${oud}]]%`);
743 if (r && r.changes) n += r.changes;
744 }
745 if (n) { rapport.tekstBijgewerkt = n; console.log('[FEP-1580] track-verwijzingen bijgetrokken in', n, 'bericht(en)'); }
746 }
747 }
748
749 // ── De playlists ──────────────────────────────────────────────
750 //
751 // Los van de nummers, want de VOLGORDE is de playlist. Die staat nergens
752 // anders: haal je alleen de tracks op, dan heb je wel alle muziek en geen
753 // enkele plaat. De bron geeft ons de volledige lijst omdat we de doel-actor
754 // zijn; anders zaten er alleen de opengezette nummers in en kreeg je een
755 // plaat met gaten.
756 const plUrl = streams.find((u) => /\/playlists\/?$/.test(u));
757 if (plUrl && trackKaart.size) {
758 const coll = await getJson(site.slug, plUrl);
759 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
760 for (const p of (Array.isArray(lijst) ? lijst : []).slice(0, 200)) {
761 const uri = typeof p === 'string' ? p : (p && p.id);
762 if (!uri) continue;
763 const plc = typeof p === 'object' && (p.orderedItems || p.items) ? p : await getJson(site.slug, uri);
764 if (!plc) { rapport.playlistsMislukt++; continue; }
765 const nummers = (plc.orderedItems || plc.items || [])
766 .map((x) => (x && typeof x === 'object' ? x.id : x))
767 .map((id) => trackKaart.get(String(id)))
768 .filter(Boolean);
769 if (!nummers.length) {
770 rapport.waarschuwingen.push(`playlist ${plc.name || uri}: geen van de nummers is aangekomen, overgeslagen`);
771 continue;
772 }
773 // De hoes van de plaat, net als bij een nummer.
774 let plHoes = null;
775 const plHoesUrl = (plc.icon && (plc.icon.url || plc.icon)) || (plc.image && (plc.image.url || plc.image)) || null;
776 if (plHoesUrl && /^https?:\/\//i.test(String(plHoesUrl)) && safeFetch && fs && path && mediaRoot) {
777 const h = await haalBijlage(String(plHoesUrl), {
778 safeFetch, mediaRoot, fs, path, maxBytes,
779 headers: signHeaders ? signHeaders(site.slug, String(plHoesUrl), '*/*') : null,
780 }).catch(() => null);
781 if (h) { plHoes = h.url; rapport.media++; }
782 else rapport.waarschuwingen.push(`hoes van playlist niet opgehaald: ${plc.name || uri}`);
783 }
784 // Ook hier het id van de bron. Dan blijft [[playlist:<id>]] in een
785 // bericht wijzen, en is een tweede ronde vanzelf dezelfde rij.
786 const plId = ruwId(uri) || crypto.randomUUID();
787 try {
788 db.prepare(`INSERT INTO playlists (id, site_id, title, artist, year, kind, cover_url) VALUES (?,?,?,?,?,?,?)
789 ON CONFLICT(id) DO UPDATE SET
790 title = excluded.title,
791 artist = COALESCE(playlists.artist, excluded.artist),
792 cover_url = COALESCE(playlists.cover_url, excluded.cover_url)`)
793 .run(plId, site.id, plc.name || 'zonder titel', plc.artist || null,
794 plc.year || null, plc['shaer:kind'] || null, plHoes);
795 // De volgorde opnieuw zetten: die IS de plaat, en een halve
796 // bijgewerkte volgorde is erger dan een verse.
797 db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(plId);
798 const ins = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
799 nummers.forEach((tid, i) => ins.run(plId, tid, i));
800 recordMigrated(site.slug, { origin: uri, target: `${me}/ap/playlists/${plId}`, sourceActor: bronActor.id, isPublic: false });
801 rapport.playlistsBinnen++;
802 const kwijt = (plc.orderedItems || plc.items || []).length - nummers.length;
803 if (kwijt > 0) rapport.waarschuwingen.push(`playlist ${plc.name || uri}: ${kwijt} nummer(s) ontbraken en zijn eruit gelaten`);
804 } catch (e) {
805 rapport.playlistsMislukt++;
806 rapport.waarschuwingen.push(`playlist niet opgeslagen: ${plc.name || uri} (${e && e.message})`);
807 }
808 }
809 }
810 } catch (e) {
811 // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
812 // en dat is precies goed, want er is nog werk.
813 console.warn('[FEP-1580] ingest afgebroken:', e && e.message);
814 return { ...rapport, error: 'partial', melding: e && e.message };
815 }
816
817 // 9. Klaar. Nu pas mag een derde stoppen met kijken.
818 setMigrationComplete(site.slug, true);
819 console.log('[FEP-1580] ingest klaar:', site.slug, '<-', bronActor.id, rapport.posts, 'berichten,', rapport.media, 'bestanden');
820 return rapport;
821}
Note: See TracBrowser for help on using the repository browser.