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

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

Een tweede ronde vult aan in plaats van over te slaan

Robin vroeg of hij eerst moest opschonen. Bij het uitzoeken bleek dat het
antwoord "ja" had moeten zijn, en dat dat een val in mijn eigen ontwerp was.

De ingest onthield per bron-URI dat hij hem gehad had, en passeerde hem daarna
altijd. Dat is prima zolang er niets verandert, maar er kwam juist iets bij:
hoezen, duur en playlists. Wie al opgehaald had zat vast. Opnieuw drukken sloeg
alles over, en opruimen hielp niet, want die blokkade zat in ap_migration en
niet in de tracks. Je zou drie dingen tegelijk moeten wissen om er los te komen,
en dat is geen opdracht die je een gebruiker geeft.

Nu kijkt hij op waar een bron-URI hier terechtkwam, en vult LEGE velden aan op
de rij die er al staat. Wat je zelf hebt aangepast blijft staan: een migratie
hoort je correcties niet terug te draaien. Het geluidsbestand wordt niet opnieuw
gedownload. Playlists werken hetzelfde: dezelfde rij, en de volgorde opnieuw
gezet, want een half bijgewerkte volgorde is erger dan een verse.

Gemeten op de gemeten stand van soundfabrics.nl (139 nummers met bestand, geen
hoes, geen duur, geen playlist), nagebouwd met 3 nummers:

ronde 1 3 nummers binnen, geen hoes, duur 212s uit PT212S
ronde 2 0 erbij, 3 aangevuld, 1 playlist, geen dubbele, mp3 niet opnieuw
ronde 3 0 aangevuld, alles compleet, niets verandert

Changed files:
src/services/MigrationService.js

  • migrationTarget(): waar kwam deze bron-URI terecht
  • tracks worden aangevuld in plaats van gepasseerd; alleen lege velden
  • de trackkaart wordt OOK in die tak gevuld, anders vinden de playlists hun nummers niet en komt er geen plaat
  • playlists werken dezelfde kant op, met hun eigen mapping

src/views/pages/admin-migrate.ejs

  • "nummers aangevuld" en playlists in het ophaalverslag; anders meldt een ronde die drie nummers compleet maakte "0 erbij gekomen"

src/services/i18n.js

  • mig.r_tracks_updated in nl/en/de

remarks: het verslag zegt ook "1 playlists" als er niets aan die plaat
veranderde, want de volgorde wordt elke ronde opnieuw gezet. Klopt wel, leest
iets te groot. Robin hoeft nu niets op te schonen: een keer ophalen na de uitrol
volstaat.

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

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