source: Klonkt/src/services/MigrationService.js@ 98acb97

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

Hoezen en playlists reisden nog niet mee

Robin, na de vorige ronde: "de audio tracks hadden images, die zijn niet
meegegaan, de playlists zijn ook nog niet meegegaan via peer to peer."

Allebei terecht, en de eerste is dezelfde fout als bij de audio zelf, een laag
hoger. cover_url ging wel mee als STRING en het bestand niet. Dus kwam een
nummer aan met een verwijzing naar een plaatje dat er niet was: precies de
halve waarheid die deze hele reeks moest opruimen, en ik liep er zelf opnieuw
in.

De playlists gingen wel via de zip en niet via de ophaalknop. Die liep de
tracks-collectie af en de playlist-collectie niet, en dan heb je alle muziek
en geen enkele plaat. De volgorde staat nergens anders.

Wat er nog meer boven kwam bij het naspelen: over AP komt de duur als
ISO-8601 ("PT212S") binnen en de database wil seconden, en de artiest zit in
summary, niet in een artist-veld. Zonder die twee kwamen nummers naamloos en
zonder duur aan.

En de bronkant moest ook hier open: playlistOpenTracks filtert op fedi_open,
dus de doel-actor kreeg een plaat met gaten. Nu geldt daar dezelfde regel als
bij de outbox en de tracks.

Bewezen op twee draaiende instanties, 3 nummers met hoes plus een plaat in de
volgorde c-a-b:

peer to peer mp3 2503b, hoes 74b, artiest, duur, plaat C -> A -> B
zip idem, plus kind=album

Changed files:
src/services/ArchiveExportService.js

  • hoesToevoegen(): de BYTES van een hoes in het archief, voor tracks en playlists, met shaer:coverFile ernaast

src/services/ArchiveImportService.js

  • hoesTerug(): de hoes op schijf en cover_url daarheen; geen bestand betekent geen cover_url, liever niets dan een img die 404't

src/services/MigrationService.js

  • de ingest haalt de hoes op uit icon/image, ONDERTEKEND
  • de playlists erbij, met hun volgorde en een kaart bron-URI -> nieuw id
  • duurSeconden(): PT212S naar 212; artiest uit summary

src/services/music/index.js

  • playlistOpenTracks({ alles }) voor de doel-actor van een Move

src/routes/activitypub.js

  • de playlist-route geeft die doel-actor de volledige plaat

remarks: over AP komt kind (album/playlist) niet mee, want de AP-collectie
draagt dat veld niet; via de zip wel. Klein verschil, apart te repareren als het
hindert.

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

  • Property mode set to 100644
File size: 29.4 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// ── De Move-activities ────────────────────────────────────────────
89
90export function recordMove(slug, { moveId, sourceActor, targetActor, activity, actorDoc = null } = {}) {
91 if (!slug || !moveId || !sourceActor || !targetActor) return false;
92 try {
93 q('insMove', `INSERT INTO ap_moves (slug, move_id, source_actor, target_actor, activity_json, actor_json)
94 VALUES (?, ?, ?, ?, ?, ?)
95 ON CONFLICT(slug, move_id) DO NOTHING`)
96 .run(slug, String(moveId), String(sourceActor), String(targetActor),
97 JSON.stringify(activity || {}), actorDoc ? JSON.stringify(actorDoc) : null);
98 return true;
99 } catch (e) {
100 console.warn('[FEP-1580] Move niet opgeslagen:', moveId, e && e.message);
101 return false;
102 }
103}
104
105export function moveRows(slug) {
106 try { return db.prepare('SELECT * FROM ap_moves WHERE slug = ? ORDER BY id').all(slug); } catch { return []; }
107}
108
109// ── Stand van zaken ───────────────────────────────────────────────
110
111export function migrationComplete(slug) {
112 try {
113 const r = db.prepare('SELECT migration_complete FROM sites WHERE slug = ?').get(slug);
114 // Geen kolom of geen rij telt als "klaar": een site die nooit verhuisde
115 // heeft niets openstaan, en derden moeten niet eeuwig blijven pollen.
116 return !r || r.migration_complete === null || r.migration_complete === undefined ? true : !!r.migration_complete;
117 } catch { return true; }
118}
119
120export function setMigrationComplete(slug, klaar) {
121 try { db.prepare('UPDATE sites SET migration_complete = ? WHERE slug = ?').run(klaar ? 1 : 0, slug); } catch { /* kolom ontbreekt op een oude db */ }
122}
123
124// ── De collecties ─────────────────────────────────────────────────
125
126/**
127 * De `migration`-collectie. Items zijn Move-activities per OBJECT (niet per
128 * actor): origin is de oude URI, target de nieuwe.
129 *
130 * De spec wil URI-verwijzingen in origin/target in plaats van ingesloten
131 * objecten, en paginering. `pagedCollection` doet dat al voor de rest van
132 * Klonkt, dus die gebruiken we ook hier.
133 */
134export function buildMigration(base, site, { page = false, alles = false } = {}) {
135 const me = actorId(base, site.slug);
136 const id = `${me}/migration`;
137 const rows = migrationItems(site.slug, { alles });
138 const items = rows.map((r) => ({
139 type: 'Move',
140 actor: r.source_actor || undefined,
141 origin: r.origin,
142 target: r.target,
143 }));
144 return pagedCollection(id, items, {
145 page,
146 extra: {
147 attributedTo: me,
148 moves: `${me}/moves`,
149 migrationComplete: migrationComplete(site.slug),
150 },
151 });
152}
153
154/**
155 * De `moves`-collectie: de Move-activities zelf, met het bron-actordocument
156 * ingesloten zoals de spec aanraadt ("Source instances SHOULD inline the source
157 * Actor object"), zodat een lezer de proof kan nakijken zonder de bron nog te
158 * kunnen bereiken. Dat laatste is precies het geval waarvoor dit bestaat.
159 *
160 * Zonder FEP-8b32 (shaer-j1v0) ontbreekt de handtekening. Zie de kop.
161 */
162export function buildMoves(base, site) {
163 const me = actorId(base, site.slug);
164 const rows = moveRows(site.slug);
165 const orderedItems = rows.map((r) => {
166 let act = {};
167 try { act = JSON.parse(r.activity_json) || {}; } catch { /* onleesbaar, dan de kale vorm hieronder */ }
168 let actorDoc = null;
169 try { actorDoc = r.actor_json ? JSON.parse(r.actor_json) : null; } catch { /* idem */ }
170 return {
171 id: r.move_id,
172 type: 'Move',
173 origin: r.source_actor,
174 target: r.target_actor,
175 actor: actorDoc || r.source_actor,
176 ...(act.published ? { published: act.published } : {}),
177 };
178 });
179 return {
180 '@context': AP_CONTEXT,
181 id: `${me}/moves`,
182 type: 'OrderedCollection',
183 attributedTo: me,
184 totalItems: orderedItems.length,
185 orderedItems,
186 };
187}
188
189// ── Wat de UI wil weten ───────────────────────────────────────────
190
191export function migrationStatus(slug) {
192 return {
193 total: migrationCount(slug, { alles: true }),
194 publiek: migrationCount(slug),
195 moves: moveRows(slug).length,
196 complete: migrationComplete(slug),
197 };
198}
199
200/** Een id dat nergens mee botst, in de vorm die de rest van Klonkt gebruikt. */
201export function nieuwId() { return crypto.randomUUID(); }
202
203// ── De ingest: van de bron hierheen ───────────────────────────────
204
205/** Vrije slug binnen deze site. Botst hij, dan -2, -3, enzovoort. */
206function vrijeSlug(siteId, basis) {
207 const schoon = String(basis || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'bericht';
208 const bestaat = db.prepare('SELECT 1 FROM posts WHERE site_id = ? AND slug = ?');
209 if (!bestaat.get(siteId, schoon)) return schoon;
210 for (let n = 2; n < 500; n++) if (!bestaat.get(siteId, `${schoon}-${n}`)) return `${schoon}-${n}`;
211 return `${schoon}-${crypto.randomBytes(4).toString('hex')}`;
212}
213
214/** De laatste padcomponent van een URI, als beginpunt voor een slug. */
215function slugUitUri(uri) {
216 try { return decodeURIComponent(new URL(uri).pathname.split('/').filter(Boolean).pop() || ''); } catch { return ''; }
217}
218
219const AFBEELDING = /^image\//i;
220
221/** AS2 geeft de duur als ISO-8601 ("PT212S"), de database wil seconden. */
222function duurSeconden(v) {
223 if (v == null) return null;
224 if (typeof v === 'number') return Math.round(v) || null;
225 const m = /^P(?:.*?T)?(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?$/.exec(String(v));
226 if (!m) { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.round(n) : null; }
227 const sec = (Number(m[1]) || 0) * 3600 + (Number(m[2]) || 0) * 60 + (Number(m[3]) || 0);
228 return sec > 0 ? Math.round(sec) : null;
229}
230
231/**
232 * De titel terugwinnen uit de content.
233 *
234 * Een AS2 Note heeft geen titel: Mastodon negeert `name`, dus Klonkt bakt de
235 * titel als vetgedrukte eerste alinea IN de content (zie buildNote). Over de
236 * lijn is een titel dus geen veld maar een vorm. Doen we hier niets, dan komt
237 * elk bericht titelloos aan en heet het naar zijn id.
238 *
239 * Daarom draaien we precies onze eigen bak terug: alleen als de content BEGINT
240 * met een alinea die niets anders bevat dan vetgedrukte tekst. Dat is de exacte
241 * vorm die buildNote maakt. Een bericht van elders dat toevallig zo begint
242 * verliest die regel niet, hij verhuist naar het titelveld en staat straks
243 * gewoon weer bovenaan.
244 */
245function titelUitContent(html) {
246 const m = /^\s*<p>\s*<strong>([\s\S]*?)<\/strong>\s*<\/p>/i.exec(String(html || ''));
247 if (!m) return { titel: null, rest: html };
248 const titel = m[1].replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
249 if (!titel || titel.length > 300) return { titel: null, rest: html };
250 return { titel, rest: String(html).slice(m[0].length) };
251}
252
253/**
254 * Haal een bijlage op en zet hem lokaal neer.
255 *
256 * safeFetch is de SSRF-veilige kant van Klonkt; hier is dat geen formaliteit,
257 * want de URL komt van een andere server. Een bron die ons naar 127.0.0.1 wijst
258 * moet stranden, ook als die bron "van onszelf" is.
259 */
260async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes, submap = 'migrated', headers = null }) {
261 // Ondertekend als het moet. Gehoste audio zit achter dezelfde poort als de
262 // rest van de bron, en een kale fetch krijgt daar een 403: de bron kan dan
263 // niet zien dat wij de doel-actor van zijn Move zijn.
264 const r = await safeFetch(url, { headers: headers || { accept: '*/*' } }).catch(() => null);
265 if (!r || !r.ok) return null;
266 const buf = Buffer.from(await r.arrayBuffer());
267 if (!buf.length || buf.length > maxBytes) return null;
268 const type = String(r.headers.get('content-type') || '').split(';')[0].trim() || 'application/octet-stream';
269 const ext = (() => {
270 const uit = slugUitUri(url);
271 const m = /\.([a-z0-9]{1,5})$/i.exec(uit);
272 if (m) return m[1].toLowerCase();
273 return (type.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'bin';
274 })();
275 const naam = `${crypto.randomUUID()}.${ext}`;
276 // Zonder submap komt het bestand in de root zelf: dat is wat gehoste audio
277 // nodig heeft, want de speler zoekt AUDIO_ROOT + bestandsnaam en kijkt niet
278 // in mappen eronder.
279 const rel = submap ? `${submap}/${naam}` : naam;
280 const abs = submap ? path.join(mediaRoot, submap, naam) : path.join(mediaRoot, naam);
281 fs.mkdirSync(path.dirname(abs), { recursive: true });
282 fs.writeFileSync(abs, buf);
283 return { url: `/media/${rel}`, mediaType: type, size: buf.length, filename: naam, storage_path: abs };
284}
285
286/**
287 * FEP-1580 ingest-routine, de doelkant.
288 *
289 * De autorisatie wordt hier niet verzonnen maar NAGEKEKEN, en in beide
290 * richtingen, precies zoals de spec het voor derden voorschrijft: `movedTo` op
291 * de bron moet naar ons wijzen EN wij moeten de bron in `alsoKnownAs` hebben.
292 * Eén kant is een bewering, twee kanten is een afspraak. Zou ik alleen op onze
293 * eigen alsoKnownAs afgaan, dan kon iedereen die zichzelf een alias geeft de
294 * geschiedenis van een vreemde opeisen.
295 *
296 * `deps` is er voor de test: die moet dit kunnen draaien zonder netwerk.
297 */
298export async function ingestFromSource(site, {
299 sourceUri = null, max = 1000, maxBytes = 25 * 1024 * 1024, deps = {},
300} = {}) {
301 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
302 if (!base || !site || !site.slug) return { error: 'config' };
303 const me = actorId(base, site.slug);
304
305 const {
306 getJson = null, safeFetch = null, mediaRoot = null, fs = null, path = null, noteId = null,
307 sanitize = (h) => h,
308 // Standaard 'followers': kan iets niet als publiek bewezen worden, dan
309 // hoort het niet in de publieke vertaaltabel. Fail-closed, want dit is een
310 // privacygrens en niet een weergavedetail.
311 noteVisibility = () => 'followers',
312 audioRoot = null, signHeaders = null,
313 } = deps;
314 const zichtbaarheid = noteVisibility;
315 if (!getJson || !noteId) return { error: 'config' };
316
317 // 1. Welke bron? Zonder opgave: de alias die we zelf claimen.
318 let bron = sourceUri && /^https?:\/\//i.test(sourceUri) ? sourceUri : null;
319 if (!bron) {
320 try {
321 const aka = JSON.parse(site.ap_aliases || '[]');
322 bron = Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
323 } catch { /* stukke ap_aliases telt als geen alias */ }
324 }
325 if (!bron) return { error: 'no_source' };
326
327 // 2 + 3. Het bron-actordocument, en de wegwijzer die naar ONS moet wijzen.
328 const bronActor = await getJson(site.slug, bron);
329 if (!bronActor || !bronActor.id) return { error: 'unreachable' };
330 if (bronActor.movedTo !== me) return { error: 'not_moved_here', movedTo: bronActor.movedTo || null };
331
332 // 4. En de terugverwijzing van onze kant, zodat het een afspraak is.
333 const eigenAka = (() => {
334 try { const a = JSON.parse(site.ap_aliases || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
335 })();
336 if (!eigenAka.includes(bronActor.id)) return { error: 'no_backreference' };
337
338 // 5 + 6. Vastleggen dat dit een migratie is, en de deur openzetten voor derden.
339 recordMove(site.slug, {
340 moveId: `${bronActor.id}#move`, sourceActor: bronActor.id, targetActor: me,
341 activity: { type: 'Move', actor: bronActor.id, object: bronActor.id, target: me },
342 actorDoc: bronActor,
343 });
344 setMigrationComplete(site.slug, false);
345
346 const rapport = {
347 bron: bronActor.id, posts: 0, overgeslagen: 0, media: 0, mediaMislukt: 0,
348 blocks: 0, tracksBinnen: 0, tracksMislukt: 0, overgeslagenTracks: 0,
349 playlistsBinnen: 0, playlistsMislukt: 0, waarschuwingen: [],
350 };
351
352 try {
353 // 7. BLOKKADES EERST. De spec is daar streng over, en terecht: ze bepalen
354 // wie de rest te zien krijgt. Andersom importeer je even je hele
355 // geschiedenis zichtbaar voor iemand die je nou juist buiten wilde.
356 if (bronActor.blocked) {
357 const coll = await getJson(site.slug, typeof bronActor.blocked === 'string' ? bronActor.blocked : bronActor.blocked.id);
358 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
359 for (const b of Array.isArray(lijst) ? lijst : []) {
360 const uri = typeof b === 'string' ? b : (b && (b.object || b.id));
361 if (!uri || !/^https?:\/\//i.test(String(uri))) continue;
362 try {
363 db.prepare("INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label) VALUES (?, ?, 'actor', NULL)").run(site.slug, String(uri));
364 rapport.blocks++;
365 } catch { /* tabel ontbreekt op een verse db */ }
366 }
367 } else {
368 rapport.waarschuwingen.push('de bron gaf geen blokkadelijst, zichtbaarheidsvoorkeuren komen niet mee');
369 }
370
371 // 8. De outbox aflopen. Pagineren zoals de rest van Klonkt dat doet.
372 if (!bronActor.outbox) return { ...rapport, error: 'no_outbox' };
373 let pagina = await getJson(site.slug, typeof bronActor.outbox === 'string' ? bronActor.outbox : bronActor.outbox.id);
374 if (pagina && pagina.first && !(pagina.orderedItems || pagina.items)) {
375 pagina = await getJson(site.slug, typeof pagina.first === 'string' ? pagina.first : pagina.first.id);
376 }
377
378 const insPost = db.prepare(`INSERT INTO posts
379 (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url,
380 pinned, type, tags, published_at, created_at, updated_at, fan_only, nsfw, language,
381 content_warning, ap_visibility, c2s_attachments, origin_server)
382 VALUES (@id, @site_id, @slug, @author_id, @title, @content, NULL, 'published', @cover_image_url,
383 0, 'post', @tags, @published_at, @published_at, @updated_at, @fan_only, @nsfw, @language,
384 @content_warning, @ap_visibility, @c2s_attachments, 'migrated')`);
385
386 let gezien = 0;
387 while (pagina && gezien < max) {
388 const items = (pagina.orderedItems || pagina.items) || [];
389 for (const it of Array.isArray(items) ? items : []) {
390 if (gezien >= max) break;
391 const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
392 if (!o || !o.id) continue;
393 if (o.type && !['Note', 'Article', 'Question'].includes(o.type)) continue;
394 if (o.inReplyTo) continue; // toplevel; antwoorden hangen aan hun ouder
395 const auteur = typeof o.attributedTo === 'string' ? o.attributedTo : (o.attributedTo && o.attributedTo.id);
396 if (auteur && auteur !== bronActor.id) continue; // alleen wat van HEM was
397 gezien++;
398 if (alGemigreerd(site.slug, o.id)) { rapport.overgeslagen++; continue; }
399
400 // Media eerst, want een post die naar een plaatje wijst dat we niet
401 // hebben opgehaald is een halve post. Mislukt een bijlage, dan gaat de
402 // post wel door en staat het in het verslag.
403 const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
404 const binnen = [];
405 if (safeFetch && fs && path && mediaRoot) {
406 for (const a of bijlagen.slice(0, 20)) {
407 const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
408 if (!u || !/^https?:\/\//i.test(String(u))) continue;
409 const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes }).catch(() => null);
410 if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`bijlage niet opgehaald: ${u}`); continue; }
411 binnen.push({ ...g, naam: (a && a.name) || null, type: (a && a.mediaType) || g.mediaType });
412 rapport.media++;
413 try {
414 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
415 .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
416 } catch { /* media-rij is administratie, het bestand staat er */ }
417 }
418 }
419
420 const id = crypto.randomUUID();
421 const cover = binnen.find((b) => AFBEELDING.test(b.type || ''));
422 const rest = binnen.filter((b) => b !== cover);
423 // De titel zit in de content, niet in een veld (zie titelUitContent).
424 const { titel, rest: body } = o.name ? { titel: o.name, rest: o.content || '' } : titelUitContent(o.content || '');
425 // De slug uit de MENSELIJKE url, niet uit de AP-id. Zo houdt het bericht
426 // hetzelfde webadres als op de oude instantie, en blijft een link die
427 // iemand ergens plakte kloppen op het nieuwe domein.
428 const basisSlug = slugUitUri(o.url || '') || o.name || slugUitUri(o.id) || id;
429 // De publicatiedatum blijft die van het origineel. De spec eist dat, en
430 // het is ook het enige eerlijke: het bericht is niet vandaag geschreven.
431 insPost.run({
432 id, site_id: site.id, slug: vrijeSlug(site.id, basisSlug),
433 author_id: site.owner_id, title: titel || null, content: sanitize(body || ''),
434 cover_image_url: cover ? cover.url : null,
435 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name || '').replace(/^#/, '')).filter(Boolean).join(', ') || null : null,
436 published_at: o.published || null, updated_at: o.updated || o.published || null,
437 fan_only: 0, nsfw: o.sensitive ? 1 : 0,
438 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
439 content_warning: o.summary || null,
440 ap_visibility: null,
441 c2s_attachments: rest.length ? JSON.stringify(rest.map((b) => ({ url: b.url, mediaType: b.type, name: b.naam || undefined }))) : null,
442 });
443 recordMigrated(site.slug, {
444 origin: o.id, target: noteId(base, id), sourceActor: bronActor.id,
445 // Publiek in de zin van de spec: gericht aan as:Public. Zo niet, dan
446 // hoort deze regel niet in een publiek leesbare migration-pagina.
447 //
448 // Via noteVisibility en niet met een eigen test op '#Public': die kent
449 // ook de schrijfwijzen 'as:Public' en 'Public', en de rest van Klonkt
450 // beslist er al mee. Een tweede, dunnere versie van dezelfde vraag is
451 // precies hoe twee antwoorden uit elkaar gaan lopen.
452 isPublic: zichtbaarheid(o) === 'public',
453 });
454 rapport.posts++;
455 }
456 const volgende = pagina.next;
457 if (!volgende || gezien >= max) break;
458 pagina = await getJson(site.slug, typeof volgende === 'string' ? volgende : volgende.id);
459 }
460 if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
461
462 // ── De muziekbibliotheek ──────────────────────────────────────
463 //
464 // Losse nummers staan niet in de outbox: die hangen aan de tracks-collectie
465 // waar de actor via AS2 `streams` naar wijst. Zonder deze lus verhuist een
466 // muzieksite zijn berichten en laat hij zijn bibliotheek achter.
467 //
468 // De bron geeft ons hier alles, niet alleen de fedi_open-nummers, omdat we
469 // de doel-actor van zijn Move zijn (siteOpenTracks({alles})). Hetzelfde
470 // geldt voor de bestanden zelf, die anders achter de gated audio-route
471 // blijven.
472 const trackKaart = new Map(); // bron-URI van een nummer -> ons nieuwe id
473 const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
474 const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
475 if (tracksUrl && safeFetch && fs && path && audioRoot) {
476 const coll = await getJson(site.slug, tracksUrl);
477 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
478 for (const it of (Array.isArray(lijst) ? lijst : []).slice(0, max)) {
479 const a = (it && typeof it.object === 'object' && it.object) ? it.object : it;
480 if (!a || !a.id) continue;
481 if (a.type && a.type !== 'Audio') continue;
482 if (alGemigreerd(site.slug, a.id)) { rapport.overgeslagenTracks++; continue; }
483 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));
484 if (!bron || !/^https?:\/\//i.test(String(bron))) { rapport.tracksMislukt++; continue; }
485 const g = await haalBijlage(String(bron), {
486 safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '',
487 headers: signHeaders ? signHeaders(site.slug, String(bron), '*/*') : null,
488 }).catch(() => null);
489 if (!g) {
490 rapport.tracksMislukt++;
491 rapport.waarschuwingen.push(`nummer niet opgehaald: ${a.name || bron}`);
492 continue; // dezelfde regel als bij de zip: geen bestand, geen track
493 }
494 // De hoes. Die reisde als URL wel mee en als bestand niet, dus kwam een
495 // nummer aan met een verwijzing naar een plaatje dat er niet is.
496 let hoes = null;
497 const hoesUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
498 if (hoesUrl && /^https?:\/\//i.test(String(hoesUrl))) {
499 const h = await haalBijlage(String(hoesUrl), {
500 safeFetch, mediaRoot, fs, path, maxBytes,
501 headers: signHeaders ? signHeaders(site.slug, String(hoesUrl), '*/*') : null,
502 }).catch(() => null);
503 if (h) { hoes = h.url; rapport.media++; }
504 else rapport.waarschuwingen.push(`hoes niet opgehaald: ${a.name || hoesUrl}`);
505 }
506 const trackId = crypto.randomUUID();
507 const mediaId = crypto.randomUUID();
508 try {
509 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
510 .run(mediaId, site.id, g.filename, g.mediaType, g.size, g.storage_path);
511 db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, cover_url, fedi_open)
512 VALUES (?,?,?,?,?,?,?,?,0)`)
513 .run(trackId, site.id, a.name || 'zonder titel', a.summary || a.artist || null, a.album || null,
514 duurSeconden(a.duration), mediaId, hoes);
515 recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
516 trackKaart.set(String(a.id), trackId);
517 rapport.tracksBinnen++;
518 } catch (e) {
519 rapport.tracksMislukt++;
520 rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
521 }
522 }
523 } else if (tracksUrl) {
524 rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
525 }
526
527 // ── De playlists ──────────────────────────────────────────────
528 //
529 // Los van de nummers, want de VOLGORDE is de playlist. Die staat nergens
530 // anders: haal je alleen de tracks op, dan heb je wel alle muziek en geen
531 // enkele plaat. De bron geeft ons de volledige lijst omdat we de doel-actor
532 // zijn; anders zaten er alleen de opengezette nummers in en kreeg je een
533 // plaat met gaten.
534 const plUrl = streams.find((u) => /\/playlists\/?$/.test(u));
535 if (plUrl && trackKaart.size) {
536 const coll = await getJson(site.slug, plUrl);
537 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
538 for (const p of (Array.isArray(lijst) ? lijst : []).slice(0, 200)) {
539 const uri = typeof p === 'string' ? p : (p && p.id);
540 if (!uri) continue;
541 const plc = typeof p === 'object' && (p.orderedItems || p.items) ? p : await getJson(site.slug, uri);
542 if (!plc) { rapport.playlistsMislukt++; continue; }
543 const nummers = (plc.orderedItems || plc.items || [])
544 .map((x) => (x && typeof x === 'object' ? x.id : x))
545 .map((id) => trackKaart.get(String(id)))
546 .filter(Boolean);
547 if (!nummers.length) {
548 rapport.waarschuwingen.push(`playlist ${plc.name || uri}: geen van de nummers is aangekomen, overgeslagen`);
549 continue;
550 }
551 const plId = crypto.randomUUID();
552 try {
553 db.prepare('INSERT INTO playlists (id, site_id, title, artist, year, kind) VALUES (?,?,?,?,?,?)')
554 .run(plId, site.id, plc.name || 'zonder titel', plc.attributedTo && plc.artist || plc.artist || null,
555 plc.year || null, plc['shaer:kind'] || null);
556 const ins = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
557 nummers.forEach((tid, i) => ins.run(plId, tid, i));
558 rapport.playlistsBinnen++;
559 const kwijt = (plc.orderedItems || plc.items || []).length - nummers.length;
560 if (kwijt > 0) rapport.waarschuwingen.push(`playlist ${plc.name || uri}: ${kwijt} nummer(s) ontbraken en zijn eruit gelaten`);
561 } catch (e) {
562 rapport.playlistsMislukt++;
563 rapport.waarschuwingen.push(`playlist niet opgeslagen: ${plc.name || uri} (${e && e.message})`);
564 }
565 }
566 }
567 } catch (e) {
568 // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
569 // en dat is precies goed, want er is nog werk.
570 console.warn('[FEP-1580] ingest afgebroken:', e && e.message);
571 return { ...rapport, error: 'partial', melding: e && e.message };
572 }
573
574 // 9. Klaar. Nu pas mag een derde stoppen met kijken.
575 setMigrationComplete(site.slug, true);
576 console.log('[FEP-1580] ingest klaar:', site.slug, '<-', bronActor.id, rapport.posts, 'berichten,', rapport.media, 'bestanden');
577 return rapport;
578}
Note: See TracBrowser for help on using the repository browser.