source: Klonkt/src/services/MigrationService.js@ 919b82d

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

De shortcode in een bericht wees nergens meer heen

Robin stuurde een schermafbeelding van TikTik: het bericht toonde
[[track:8df2d8a4-...]] als kale tekst in plaats van een speler. Plus: playlists
hadden nog geen hoes.

WAAROM DIE SHORTHAND BREEKT. Zijn berichten kwamen uit de ZIP, die posts.content
letterlijk bewaart inclusief [[track:<oud id>]]. Zijn nummers kwamen uit de
PULL, en die gaf ze een nieuw id. De tekst wees dus naar een nummer dat hier
niet bestaat, en dan valt de weergave terug op de code zelf. Twee wegen die
allebei werken en samen niet.

Na de tracks worden die verwijzingen nu bijgetrokken, met een kaart van het
RUWE bron-id naar het onze. Alleen waar het id echt veranderde.

Onderweg bleek de outbox de shorthand overigens al gebakken door te geven, als
een link terug naar de oude site. Dus via de pull alleen zag je dit niet, en
via de zip alleen ook niet. Alleen de combinatie.

EN DEZELFDE VAL BIJ POSTS. Robin schreef dat hij de berichten handmatig zou
verwijderen om opnieuw op te halen. Dat had niet gewerkt: de mapping in
ap_migration zei "al gehad" en dan werd alles overgeslagen, hoe leeg de site
ook was. Nu telt de mapping alleen als het bericht er OOK nog staat; is het weg,
dan is opnieuw ophalen precies wat je bedoelt.

Gemeten door de combinatie na te spelen: post via de zip met [[track:t-a]],
daarna ophalen. De inhoud werd [[track:3bc1f5d9-...]] en dat nummer bestaat.

Changed files:
src/services/MigrationService.js

  • ruwId() en ruwKaart: het id zoals het in de posttekst staat
  • na de tracks worden [[track:]]-verwijzingen bijgetrokken
  • een verwijderd bericht wordt opnieuw opgehaald in plaats van overgeslagen
  • de hoes van een playlist wordt opgehaald uit icon/image
  • nieuw rapportveld opnieuw

test/fep1580-migration.test.js

  • de shorthand-herschrijving, en een verwijderd bericht dat terugkomt

remarks: over AP komen posts binnen met een gebakken link naar de OUDE site in
plaats van de shorthand. Dat werkt, maar het verwijst wel naar een domein dat je
gaat opzeggen. Aparte kwestie, nog niet aangepakt. Suite 990 groen; tegenproef
op beide fixes.

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

  • Property mode set to 100644
File size: 36.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/**
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 // Al binnen? Alleen overslaan als het bericht er OOK nog staat. Heb je
420 // het verwijderd, dan is opnieuw ophalen precies wat je bedoelt, en
421 // een mapping die dat blokkeert is een val: opruimen hielp dan niet,
422 // want de blokkade zat in ap_migration en niet in de posts.
423 const eerderPost = migrationTarget(site.slug, o.id);
424 if (eerderPost) {
425 const postId = String(eerderPost).split('/').pop();
426 if (db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = ?').get(decodeURIComponent(postId), site.id)) {
427 rapport.overgeslagen++;
428 continue;
429 }
430 rapport.opnieuw++; // weg hier, dus opnieuw binnenhalen
431 }
432
433 // Media eerst, want een post die naar een plaatje wijst dat we niet
434 // hebben opgehaald is een halve post. Mislukt een bijlage, dan gaat de
435 // post wel door en staat het in het verslag.
436 const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
437 const binnen = [];
438 if (safeFetch && fs && path && mediaRoot) {
439 for (const a of bijlagen.slice(0, 20)) {
440 const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
441 if (!u || !/^https?:\/\//i.test(String(u))) continue;
442 const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes }).catch(() => null);
443 if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`bijlage niet opgehaald: ${u}`); continue; }
444 binnen.push({ ...g, naam: (a && a.name) || null, type: (a && a.mediaType) || g.mediaType });
445 rapport.media++;
446 try {
447 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
448 .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
449 } catch { /* media-rij is administratie, het bestand staat er */ }
450 }
451 }
452
453 const id = crypto.randomUUID();
454 const cover = binnen.find((b) => AFBEELDING.test(b.type || ''));
455 const rest = binnen.filter((b) => b !== cover);
456 // De titel zit in de content, niet in een veld (zie titelUitContent).
457 const { titel, rest: body } = o.name ? { titel: o.name, rest: o.content || '' } : titelUitContent(o.content || '');
458 // De slug uit de MENSELIJKE url, niet uit de AP-id. Zo houdt het bericht
459 // hetzelfde webadres als op de oude instantie, en blijft een link die
460 // iemand ergens plakte kloppen op het nieuwe domein.
461 const basisSlug = slugUitUri(o.url || '') || o.name || slugUitUri(o.id) || id;
462 // De publicatiedatum blijft die van het origineel. De spec eist dat, en
463 // het is ook het enige eerlijke: het bericht is niet vandaag geschreven.
464 insPost.run({
465 id, site_id: site.id, slug: vrijeSlug(site.id, basisSlug),
466 author_id: site.owner_id, title: titel || null, content: sanitize(body || ''),
467 cover_image_url: cover ? cover.url : null,
468 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name || '').replace(/^#/, '')).filter(Boolean).join(', ') || null : null,
469 published_at: o.published || null, updated_at: o.updated || o.published || null,
470 fan_only: 0, nsfw: o.sensitive ? 1 : 0,
471 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
472 content_warning: o.summary || null,
473 ap_visibility: null,
474 c2s_attachments: rest.length ? JSON.stringify(rest.map((b) => ({ url: b.url, mediaType: b.type, name: b.naam || undefined }))) : null,
475 });
476 recordMigrated(site.slug, {
477 origin: o.id, target: noteId(base, id), sourceActor: bronActor.id,
478 // Publiek in de zin van de spec: gericht aan as:Public. Zo niet, dan
479 // hoort deze regel niet in een publiek leesbare migration-pagina.
480 //
481 // Via noteVisibility en niet met een eigen test op '#Public': die kent
482 // ook de schrijfwijzen 'as:Public' en 'Public', en de rest van Klonkt
483 // beslist er al mee. Een tweede, dunnere versie van dezelfde vraag is
484 // precies hoe twee antwoorden uit elkaar gaan lopen.
485 isPublic: zichtbaarheid(o) === 'public',
486 });
487 rapport.posts++;
488 }
489 const volgende = pagina.next;
490 if (!volgende || gezien >= max) break;
491 pagina = await getJson(site.slug, typeof volgende === 'string' ? volgende : volgende.id);
492 }
493 if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
494
495 // ── De muziekbibliotheek ──────────────────────────────────────
496 //
497 // Losse nummers staan niet in de outbox: die hangen aan de tracks-collectie
498 // waar de actor via AS2 `streams` naar wijst. Zonder deze lus verhuist een
499 // muzieksite zijn berichten en laat hij zijn bibliotheek achter.
500 //
501 // De bron geeft ons hier alles, niet alleen de fedi_open-nummers, omdat we
502 // de doel-actor van zijn Move zijn (siteOpenTracks({alles})). Hetzelfde
503 // geldt voor de bestanden zelf, die anders achter de gated audio-route
504 // blijven.
505 const trackKaart = new Map(); // bron-URI van een nummer -> ons nieuwe id
506 // En het RUWE id zoals het in de posttekst staat. Klonkt schrijft
507 // [[track:<id>]] in de content, en die tekst reist letterlijk mee over AP.
508 // Krijgt het nummer hier een ander id, dan wijst die shorthand nergens meer
509 // heen en zie je de code zelf in je bericht staan.
510 const ruwKaart = new Map(); // ruw bron-id -> ons id
511 const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
512 const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
513 if (tracksUrl && safeFetch && fs && path && audioRoot) {
514 const coll = await getJson(site.slug, tracksUrl);
515 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
516 for (const it of (Array.isArray(lijst) ? lijst : []).slice(0, max)) {
517 const a = (it && typeof it.object === 'object' && it.object) ? it.object : it;
518 if (!a || !a.id) continue;
519 if (a.type && a.type !== 'Audio') continue;
520 // AL BINNEN? Dan AANVULLEN, niet overslaan. Een tweede ronde bestaat
521 // juist omdat er iets bij is gekomen (hoezen, duur, playlists), en een
522 // pull die dan alles passeert laat je met een half resultaat zitten
523 // zonder uitweg: opruimen hielp niet, want deze tabel hield de blokkade
524 // in stand.
525 //
526 // Alleen LEGE velden worden gevuld. Wat jij zelf hebt aangepast blijft
527 // staan; een migratie hoort je correcties niet terug te draaien.
528 const eerder = migrationTarget(site.slug, a.id);
529 if (eerder) {
530 const lokaalId = String(eerder).split('/').pop();
531 const rij = db.prepare('SELECT id, cover_url, duration, artist FROM audio_tracks WHERE id = ? AND site_id = ?')
532 .get(lokaalId, site.id);
533 if (rij) {
534 trackKaart.set(String(a.id), rij.id); // MOET, anders vinden de playlists hem niet
535 ruwKaart.set(ruwId(a.id), rij.id);
536 const duur = rij.duration ? null : duurSeconden(a.duration);
537 const artiest = rij.artist ? null : (a.summary || a.artist || null);
538 let hoes = null;
539 const hUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
540 if (!rij.cover_url && hUrl && /^https?:\/\//i.test(String(hUrl)) && safeFetch && fs && path && mediaRoot) {
541 const h = await haalBijlage(String(hUrl), {
542 safeFetch, mediaRoot, fs, path, maxBytes,
543 headers: signHeaders ? signHeaders(site.slug, String(hUrl), '*/*') : null,
544 }).catch(() => null);
545 if (h) { hoes = h.url; rapport.media++; }
546 }
547 if (duur || artiest || hoes) {
548 db.prepare(`UPDATE audio_tracks SET
549 duration = COALESCE(?, duration),
550 artist = COALESCE(?, artist),
551 cover_url = COALESCE(?, cover_url)
552 WHERE id = ?`).run(duur, artiest, hoes, rij.id);
553 rapport.tracksBijgewerkt++;
554 } else {
555 rapport.overgeslagenTracks++;
556 }
557 continue;
558 }
559 // De rij is weg maar de mapping staat er nog. Dan is opnieuw ophalen
560 // precies wat je wilt, dus we vallen door naar de gewone tak.
561 }
562 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));
563 if (!bron || !/^https?:\/\//i.test(String(bron))) { rapport.tracksMislukt++; continue; }
564 const g = await haalBijlage(String(bron), {
565 safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '',
566 headers: signHeaders ? signHeaders(site.slug, String(bron), '*/*') : null,
567 }).catch(() => null);
568 if (!g) {
569 rapport.tracksMislukt++;
570 rapport.waarschuwingen.push(`nummer niet opgehaald: ${a.name || bron}`);
571 continue; // dezelfde regel als bij de zip: geen bestand, geen track
572 }
573 // De hoes. Die reisde als URL wel mee en als bestand niet, dus kwam een
574 // nummer aan met een verwijzing naar een plaatje dat er niet is.
575 let hoes = null;
576 const hoesUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
577 if (hoesUrl && /^https?:\/\//i.test(String(hoesUrl))) {
578 const h = await haalBijlage(String(hoesUrl), {
579 safeFetch, mediaRoot, fs, path, maxBytes,
580 headers: signHeaders ? signHeaders(site.slug, String(hoesUrl), '*/*') : null,
581 }).catch(() => null);
582 if (h) { hoes = h.url; rapport.media++; }
583 else rapport.waarschuwingen.push(`hoes niet opgehaald: ${a.name || hoesUrl}`);
584 }
585 const trackId = crypto.randomUUID();
586 const mediaId = crypto.randomUUID();
587 try {
588 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
589 .run(mediaId, site.id, g.filename, g.mediaType, g.size, g.storage_path);
590 db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, cover_url, fedi_open)
591 VALUES (?,?,?,?,?,?,?,?,0)`)
592 .run(trackId, site.id, a.name || 'zonder titel', a.summary || a.artist || null, a.album || null,
593 duurSeconden(a.duration), mediaId, hoes);
594 recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
595 trackKaart.set(String(a.id), trackId);
596 ruwKaart.set(ruwId(a.id), trackId);
597 rapport.tracksBinnen++;
598 } catch (e) {
599 rapport.tracksMislukt++;
600 rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
601 }
602 }
603 } else if (tracksUrl) {
604 rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
605 }
606
607 // ── De verwijzingen in de tekst bijtrekken ────────────────────
608 //
609 // Klonkt schrijft [[track:<id>]] in posts.content, en die tekst reist
610 // letterlijk mee. Krijgt het nummer hier een ander id, dan wijst de
611 // shorthand nergens heen en zie je de code zelf in je bericht staan in
612 // plaats van een speler. Precies wat Robin op TikTik zag.
613 //
614 // Pas NA de tracks, want daarvoor is de kaart nog leeg. En alleen waar het
615 // id echt veranderde: een gelijk id hoeft niet aangeraakt.
616 {
617 const paren = [...ruwKaart.entries()].filter(([oud, nieuwId]) => oud && oud !== nieuwId);
618 if (paren.length) {
619 const upd = db.prepare('UPDATE posts SET content = REPLACE(content, ?, ?) WHERE site_id = ? AND content LIKE ?');
620 let n = 0;
621 for (const [oud, nieuwId] of paren) {
622 const r = upd.run(`[[track:${oud}]]`, `[[track:${nieuwId}]]`, site.id, `%[[track:${oud}]]%`);
623 if (r && r.changes) n += r.changes;
624 }
625 if (n) { rapport.tekstBijgewerkt = n; console.log('[FEP-1580] track-verwijzingen bijgetrokken in', n, 'bericht(en)'); }
626 }
627 }
628
629 // ── De playlists ──────────────────────────────────────────────
630 //
631 // Los van de nummers, want de VOLGORDE is de playlist. Die staat nergens
632 // anders: haal je alleen de tracks op, dan heb je wel alle muziek en geen
633 // enkele plaat. De bron geeft ons de volledige lijst omdat we de doel-actor
634 // zijn; anders zaten er alleen de opengezette nummers in en kreeg je een
635 // plaat met gaten.
636 const plUrl = streams.find((u) => /\/playlists\/?$/.test(u));
637 if (plUrl && trackKaart.size) {
638 const coll = await getJson(site.slug, plUrl);
639 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
640 for (const p of (Array.isArray(lijst) ? lijst : []).slice(0, 200)) {
641 const uri = typeof p === 'string' ? p : (p && p.id);
642 if (!uri) continue;
643 const plc = typeof p === 'object' && (p.orderedItems || p.items) ? p : await getJson(site.slug, uri);
644 if (!plc) { rapport.playlistsMislukt++; continue; }
645 const nummers = (plc.orderedItems || plc.items || [])
646 .map((x) => (x && typeof x === 'object' ? x.id : x))
647 .map((id) => trackKaart.get(String(id)))
648 .filter(Boolean);
649 if (!nummers.length) {
650 rapport.waarschuwingen.push(`playlist ${plc.name || uri}: geen van de nummers is aangekomen, overgeslagen`);
651 continue;
652 }
653 // Bestond hij al? Dan dezelfde rij bijwerken. Zonder deze stap levert
654 // elke tweede ronde een dubbele plaat op.
655 const eerderPl = migrationTarget(site.slug, uri);
656 // De hoes van de plaat, net als bij een nummer.
657 let plHoes = null;
658 const plHoesUrl = (plc.icon && (plc.icon.url || plc.icon)) || (plc.image && (plc.image.url || plc.image)) || null;
659 if (plHoesUrl && /^https?:\/\//i.test(String(plHoesUrl)) && safeFetch && fs && path && mediaRoot) {
660 const h = await haalBijlage(String(plHoesUrl), {
661 safeFetch, mediaRoot, fs, path, maxBytes,
662 headers: signHeaders ? signHeaders(site.slug, String(plHoesUrl), '*/*') : null,
663 }).catch(() => null);
664 if (h) { plHoes = h.url; rapport.media++; }
665 else rapport.waarschuwingen.push(`hoes van playlist niet opgehaald: ${plc.name || uri}`);
666 }
667 const plId = (() => {
668 if (!eerderPl) return crypto.randomUUID();
669 const bestaand = String(eerderPl).split('/').pop();
670 return db.prepare('SELECT 1 FROM playlists WHERE id = ? AND site_id = ?').get(bestaand, site.id)
671 ? bestaand : crypto.randomUUID();
672 })();
673 try {
674 db.prepare(`INSERT INTO playlists (id, site_id, title, artist, year, kind, cover_url) VALUES (?,?,?,?,?,?,?)
675 ON CONFLICT(id) DO UPDATE SET
676 title = excluded.title,
677 artist = COALESCE(playlists.artist, excluded.artist),
678 cover_url = COALESCE(playlists.cover_url, excluded.cover_url)`)
679 .run(plId, site.id, plc.name || 'zonder titel', plc.artist || null,
680 plc.year || null, plc['shaer:kind'] || null, plHoes);
681 // De volgorde opnieuw zetten: die IS de plaat, en een halve
682 // bijgewerkte volgorde is erger dan een verse.
683 db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(plId);
684 const ins = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
685 nummers.forEach((tid, i) => ins.run(plId, tid, i));
686 recordMigrated(site.slug, { origin: uri, target: `${me}/ap/playlists/${plId}`, sourceActor: bronActor.id, isPublic: false });
687 rapport.playlistsBinnen++;
688 const kwijt = (plc.orderedItems || plc.items || []).length - nummers.length;
689 if (kwijt > 0) rapport.waarschuwingen.push(`playlist ${plc.name || uri}: ${kwijt} nummer(s) ontbraken en zijn eruit gelaten`);
690 } catch (e) {
691 rapport.playlistsMislukt++;
692 rapport.waarschuwingen.push(`playlist niet opgeslagen: ${plc.name || uri} (${e && e.message})`);
693 }
694 }
695 }
696 } catch (e) {
697 // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
698 // en dat is precies goed, want er is nog werk.
699 console.warn('[FEP-1580] ingest afgebroken:', e && e.message);
700 return { ...rapport, error: 'partial', melding: e && e.message };
701 }
702
703 // 9. Klaar. Nu pas mag een derde stoppen met kijken.
704 setMigrationComplete(site.slug, true);
705 console.log('[FEP-1580] ingest klaar:', site.slug, '<-', bronActor.id, rapport.posts, 'berichten,', rapport.media, 'bestanden');
706 return rapport;
707}
Note: See TracBrowser for help on using the repository browser.