source: Klonkt/src/services/MigrationService.js@ 19430fa

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

GUID's blijven altijd behouden bij een verhuizing

Robin vroeg of we bestaande GUID's hergebruiken. Het antwoord was zes vakjes:

zip ophaalknop

posts nieuw bij andere origin altijd nieuw
tracks OUD BEHOUDEN altijd nieuw
playlists OUD BEHOUDEN altijd nieuw

Vier keer nieuw, twee keer niet, en die twee uitzonderingen waren niet
besloten maar ontstaan: de zip schreef INSERT OR REPLACE met het id uit het
archief zonder dat daar ooit over nagedacht is. Die scheve tabel was precies de
oorzaak van de shortcode die Robin op TikTik zag: post uit de zip met
[[track:oud]], nummer uit de pull met een nieuw id.

Zijn besluit: altijd behouden. Nu is het één regel.

WAAROM DAT MAG. Het interne id is niet de AP-URI. https://nieuw/ap/notes/<id>
is een ander adres dan https://oud/ap/notes/<id>, dus je claimt niets van een
ander door het GUID te hergebruiken. Het oude argument in de code ("een id op
andermans domein publiceren is een vervalsingsoppervlak") haalde die twee door
elkaar. Wat je wint: elke interne verwijzing blijft kloppen, [[track:]],
[[playlist:]] en [[album:]] wijzen na de verhuizing nog naar het goede ding.

Wat NIET verandert is de AP-URI. Die is domeingebonden en hoort nieuw te zijn,
en daar is de migration-collectie voor. idsBehouden gaat voortaan alleen daar
nog over.

Gemeten door dezelfde inhoud via BEIDE routes over elkaar heen te halen:

posts oud 6 | nieuw 6 | zelfde id 6 | afwijkend 0
audio_tracks oud 3 | nieuw 3 | zelfde id 3 | afwijkend 0
playlists oud 1 | nieuw 1 | zelfde id 1 | afwijkend 0

Geen dubbele. Zip en ophaalknop zijn daarmee inwisselbaar geworden, en dat was
eerder de combinatie die stukging.

Changed files:
src/services/ArchiveImportService.js

  • posts houden hun id, ongeacht de origin
  • de waarschuwing zegt nu wat er echt verandert: het AP-adres, niet het id

src/services/MigrationService.js

  • posts, tracks en playlists nemen het id van de bron over
  • "staat hij hier al" is daarmee een blik in de tabel in plaats van een omweg via ap_migration; verwijderen en opnieuw ophalen werkt vanzelf
  • de eerderPl/eerder-omwegen konden weg

test/archive-import.test.js

  • de origin-test omgedraaid: het AP-adres verandert, het id blijft

test/fep1580-migration.test.js

  • de shortcode-test toetst nu de UITKOMST (wijst naar een bestaand nummer) in plaats van de route ernaartoe
  • nieuwe test voor het botsingsgeval, waar het bijtrekken wel nodig is

remarks: het bijtrekken van [[track:]] blijft bestaan als vangnet voor een
botsend id. In het normale geval doet het niets meer, en dat is de bedoeling.
Suite 991 groen.

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

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