source: Klonkt/src/services/MigrationService.js@ 6c4ff7e

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

Audio verhuisde niet mee: archiefformaat v2

Robin verhuisde naar soundfabrics.nl en meldde: de berichten zijn er, de
plaatjes zijn er, een deel van de audio staat er maar speelt niet af, en
daarnaast mist er veel. Drie losse oorzaken, alle drie echt.

Gemeten op de twee live instanties:

berichten 36 -> 36
tracks 140 -> 14, waarvan 13 stuk
playlists 11 -> 0

DE EXPORTER LAS storage_path, DE SPELER NIET. routes/audio.js resolvet met
AUDIO_DIR plus bestandsnaam en kijkt niet naar media.storage_path. Op
sound-fabrics.com wees storage_path voor 124 van de 139 audiorijen nog naar
/srv/prutfolio/storage/audio, van voor de dataverhuizing. De site speelde
gewoon door; de exporter vond niets, markeerde availability: missing, en liet
124 nummers stil achter. Vanuit de exporter gezien was het bestand er echt niet,
dus er kwam ook geen klacht. Nu is er een resolveAudioPath die zoekt zoals de
speler zoekt.

DE IMPORTER MAAKTE EEN TRACK ZONDER BESTAND. Bij gewone bijlagen werd
availability: missing netjes gecontroleerd, in de audio-tak eronder niet. Die
zette een media-rij met size 0 en een audio_tracks-rij neer voor bytes die nooit
zijn weggeschreven. Dat is erger dan ontbreken: het ziet eruit alsof de
verhuizing gelukt is, dus je zegt je oude instantie op. Nu geldt er een harde
regel: geen bestand, geen track.

AUDIO PASTE NIET IN HET FORMAAT. Gehoste audio staat buiten MEDIA_ROOT, achter
een eigen gated route, en het archief droeg alleen bestanden onder media/. De
exporter rekende er met path.relative een /media/../audio/x.mp3 van, en
veiligMediaPad weigert dat pad terecht. Audio kon dus principieel niet
aankomen. Daarbovenop ging alleen mee wat met [[track:]] in een bericht stond,
14 van de 140, en playlists helemaal niet.

De ophaalknop leerde audio erbij. Dat vroeg meer dan verwacht: de bron toont
over AP alleen fedi_open-nummers, en de bestanden zitten achter dezelfde poort.
Beide staan nu open voor precies de doel-actor van de Move, en de ingest haalt
de bytes ONDERTEKEND op. De eerste poging gaf drie keer "nummer niet opgehaald",
want haalBijlage deed een kale fetch en kreeg een 403 van onze eigen poort.

Bewezen op twee draaiende instanties, met een track die opzettelijk een
verouderd pad had. Zip: 3/3 included, playlist-volgorde intact, bestanden op
schijf met de goede grootte, /audio/stream geeft 200. Ophaalknop: 5 berichten
plus 3 tracks met echte bytes, ook de gesloten nummers.

Changed files:
src/config/paths.js

  • AUDIO_ROOT en resolveAudioPath(): een audiobestand zoeken zoals de speler het zoekt, met het opgeslagen pad als terugval

src/services/ArchiveExportService.js

  • FORMAT_VERSION 2
  • audioBibliotheek(): de HELE tracklijst plus de playlists, met de bestanden in een eigen audio/-gebied
  • [[track:]] weg uit de media-tak; die kon nooit aankomen
  • audioOf() wijst naar het archiefpad in plaats van naar een bijlage

src/services/ArchiveImportService.js

  • tracksTerug() en playlistsTerug(); de per-post audio-tak is weg
  • bestanden naar AUDIO_ROOT, en geen rij zonder bestand
  • een playlist verwijst nooit naar een nummer dat niet aankwam
  • nieuwe rapportvelden tracks / tracksMissing / playlists

src/services/MigrationService.js

  • de ingest loopt de tracks-collectie af en haalt de bytes ondertekend op
  • haalBijlage kreeg submap en headers

src/services/ActivityPubService.js

  • signedGetHeaders() losgetrokken uit signedGetJson, want een verhuizing moet ook bytes kunnen ophalen

src/services/music/index.js

  • siteOpenTracks({ alles }): de doel-actor ziet ook de gesloten nummers

src/routes/audio.js

  • isMoveTargetAudio(): een ondertekend verzoek van de doel-actor mag alle audio ophalen, ook wat niet fedi_open is

src/routes/activitypub.js

  • magAlles(): een plek voor de vraag "mag deze aanvrager alles zien"

src/routes/admin-migrate.js

  • AUDIO_ROOT en signedGetHeaders doorgegeven aan de ingest

test/archive-export.test.js

  • de audio-testen naar het v2-contract, plus bibliotheek en ontbrekend
  • resolveAudioPath getoetst op VOLGORDE met een nep-fs

New file:
test/archive-audio.test.js

  • de hele rondgang met audio, inclusief het verouderde pad, de playlist- volgorde en de droogloop

remarks: soundfabrics.nl staat nu met 13 lege tracks; die verdwijnen niet
vanzelf bij een nieuwe import. En een archief van voor deze commit is v1 en
bevat de audio niet, dus opnieuw exporteren is nodig. Suite 985 groen;
tegenproef gedaan op de resolver en op de geen-bestand-geen-track-regel.

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

  • Property mode set to 100644
File size: 25.6 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/**
222 * De titel terugwinnen uit de content.
223 *
224 * Een AS2 Note heeft geen titel: Mastodon negeert `name`, dus Klonkt bakt de
225 * titel als vetgedrukte eerste alinea IN de content (zie buildNote). Over de
226 * lijn is een titel dus geen veld maar een vorm. Doen we hier niets, dan komt
227 * elk bericht titelloos aan en heet het naar zijn id.
228 *
229 * Daarom draaien we precies onze eigen bak terug: alleen als de content BEGINT
230 * met een alinea die niets anders bevat dan vetgedrukte tekst. Dat is de exacte
231 * vorm die buildNote maakt. Een bericht van elders dat toevallig zo begint
232 * verliest die regel niet, hij verhuist naar het titelveld en staat straks
233 * gewoon weer bovenaan.
234 */
235function titelUitContent(html) {
236 const m = /^\s*<p>\s*<strong>([\s\S]*?)<\/strong>\s*<\/p>/i.exec(String(html || ''));
237 if (!m) return { titel: null, rest: html };
238 const titel = m[1].replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
239 if (!titel || titel.length > 300) return { titel: null, rest: html };
240 return { titel, rest: String(html).slice(m[0].length) };
241}
242
243/**
244 * Haal een bijlage op en zet hem lokaal neer.
245 *
246 * safeFetch is de SSRF-veilige kant van Klonkt; hier is dat geen formaliteit,
247 * want de URL komt van een andere server. Een bron die ons naar 127.0.0.1 wijst
248 * moet stranden, ook als die bron "van onszelf" is.
249 */
250async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes, submap = 'migrated', headers = null }) {
251 // Ondertekend als het moet. Gehoste audio zit achter dezelfde poort als de
252 // rest van de bron, en een kale fetch krijgt daar een 403: de bron kan dan
253 // niet zien dat wij de doel-actor van zijn Move zijn.
254 const r = await safeFetch(url, { headers: headers || { accept: '*/*' } }).catch(() => null);
255 if (!r || !r.ok) return null;
256 const buf = Buffer.from(await r.arrayBuffer());
257 if (!buf.length || buf.length > maxBytes) return null;
258 const type = String(r.headers.get('content-type') || '').split(';')[0].trim() || 'application/octet-stream';
259 const ext = (() => {
260 const uit = slugUitUri(url);
261 const m = /\.([a-z0-9]{1,5})$/i.exec(uit);
262 if (m) return m[1].toLowerCase();
263 return (type.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'bin';
264 })();
265 const naam = `${crypto.randomUUID()}.${ext}`;
266 // Zonder submap komt het bestand in de root zelf: dat is wat gehoste audio
267 // nodig heeft, want de speler zoekt AUDIO_ROOT + bestandsnaam en kijkt niet
268 // in mappen eronder.
269 const rel = submap ? `${submap}/${naam}` : naam;
270 const abs = submap ? path.join(mediaRoot, submap, naam) : path.join(mediaRoot, naam);
271 fs.mkdirSync(path.dirname(abs), { recursive: true });
272 fs.writeFileSync(abs, buf);
273 return { url: `/media/${rel}`, mediaType: type, size: buf.length, filename: naam, storage_path: abs };
274}
275
276/**
277 * FEP-1580 ingest-routine, de doelkant.
278 *
279 * De autorisatie wordt hier niet verzonnen maar NAGEKEKEN, en in beide
280 * richtingen, precies zoals de spec het voor derden voorschrijft: `movedTo` op
281 * de bron moet naar ons wijzen EN wij moeten de bron in `alsoKnownAs` hebben.
282 * Eén kant is een bewering, twee kanten is een afspraak. Zou ik alleen op onze
283 * eigen alsoKnownAs afgaan, dan kon iedereen die zichzelf een alias geeft de
284 * geschiedenis van een vreemde opeisen.
285 *
286 * `deps` is er voor de test: die moet dit kunnen draaien zonder netwerk.
287 */
288export async function ingestFromSource(site, {
289 sourceUri = null, max = 1000, maxBytes = 25 * 1024 * 1024, deps = {},
290} = {}) {
291 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
292 if (!base || !site || !site.slug) return { error: 'config' };
293 const me = actorId(base, site.slug);
294
295 const {
296 getJson = null, safeFetch = null, mediaRoot = null, fs = null, path = null, noteId = null,
297 sanitize = (h) => h,
298 // Standaard 'followers': kan iets niet als publiek bewezen worden, dan
299 // hoort het niet in de publieke vertaaltabel. Fail-closed, want dit is een
300 // privacygrens en niet een weergavedetail.
301 noteVisibility = () => 'followers',
302 audioRoot = null, signHeaders = null,
303 } = deps;
304 const zichtbaarheid = noteVisibility;
305 if (!getJson || !noteId) return { error: 'config' };
306
307 // 1. Welke bron? Zonder opgave: de alias die we zelf claimen.
308 let bron = sourceUri && /^https?:\/\//i.test(sourceUri) ? sourceUri : null;
309 if (!bron) {
310 try {
311 const aka = JSON.parse(site.ap_aliases || '[]');
312 bron = Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
313 } catch { /* stukke ap_aliases telt als geen alias */ }
314 }
315 if (!bron) return { error: 'no_source' };
316
317 // 2 + 3. Het bron-actordocument, en de wegwijzer die naar ONS moet wijzen.
318 const bronActor = await getJson(site.slug, bron);
319 if (!bronActor || !bronActor.id) return { error: 'unreachable' };
320 if (bronActor.movedTo !== me) return { error: 'not_moved_here', movedTo: bronActor.movedTo || null };
321
322 // 4. En de terugverwijzing van onze kant, zodat het een afspraak is.
323 const eigenAka = (() => {
324 try { const a = JSON.parse(site.ap_aliases || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
325 })();
326 if (!eigenAka.includes(bronActor.id)) return { error: 'no_backreference' };
327
328 // 5 + 6. Vastleggen dat dit een migratie is, en de deur openzetten voor derden.
329 recordMove(site.slug, {
330 moveId: `${bronActor.id}#move`, sourceActor: bronActor.id, targetActor: me,
331 activity: { type: 'Move', actor: bronActor.id, object: bronActor.id, target: me },
332 actorDoc: bronActor,
333 });
334 setMigrationComplete(site.slug, false);
335
336 const rapport = {
337 bron: bronActor.id, posts: 0, overgeslagen: 0, media: 0, mediaMislukt: 0,
338 blocks: 0, tracksBinnen: 0, tracksMislukt: 0, overgeslagenTracks: 0, waarschuwingen: [],
339 };
340
341 try {
342 // 7. BLOKKADES EERST. De spec is daar streng over, en terecht: ze bepalen
343 // wie de rest te zien krijgt. Andersom importeer je even je hele
344 // geschiedenis zichtbaar voor iemand die je nou juist buiten wilde.
345 if (bronActor.blocked) {
346 const coll = await getJson(site.slug, typeof bronActor.blocked === 'string' ? bronActor.blocked : bronActor.blocked.id);
347 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
348 for (const b of Array.isArray(lijst) ? lijst : []) {
349 const uri = typeof b === 'string' ? b : (b && (b.object || b.id));
350 if (!uri || !/^https?:\/\//i.test(String(uri))) continue;
351 try {
352 db.prepare("INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label) VALUES (?, ?, 'actor', NULL)").run(site.slug, String(uri));
353 rapport.blocks++;
354 } catch { /* tabel ontbreekt op een verse db */ }
355 }
356 } else {
357 rapport.waarschuwingen.push('de bron gaf geen blokkadelijst, zichtbaarheidsvoorkeuren komen niet mee');
358 }
359
360 // 8. De outbox aflopen. Pagineren zoals de rest van Klonkt dat doet.
361 if (!bronActor.outbox) return { ...rapport, error: 'no_outbox' };
362 let pagina = await getJson(site.slug, typeof bronActor.outbox === 'string' ? bronActor.outbox : bronActor.outbox.id);
363 if (pagina && pagina.first && !(pagina.orderedItems || pagina.items)) {
364 pagina = await getJson(site.slug, typeof pagina.first === 'string' ? pagina.first : pagina.first.id);
365 }
366
367 const insPost = db.prepare(`INSERT INTO posts
368 (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url,
369 pinned, type, tags, published_at, created_at, updated_at, fan_only, nsfw, language,
370 content_warning, ap_visibility, c2s_attachments, origin_server)
371 VALUES (@id, @site_id, @slug, @author_id, @title, @content, NULL, 'published', @cover_image_url,
372 0, 'post', @tags, @published_at, @published_at, @updated_at, @fan_only, @nsfw, @language,
373 @content_warning, @ap_visibility, @c2s_attachments, 'migrated')`);
374
375 let gezien = 0;
376 while (pagina && gezien < max) {
377 const items = (pagina.orderedItems || pagina.items) || [];
378 for (const it of Array.isArray(items) ? items : []) {
379 if (gezien >= max) break;
380 const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
381 if (!o || !o.id) continue;
382 if (o.type && !['Note', 'Article', 'Question'].includes(o.type)) continue;
383 if (o.inReplyTo) continue; // toplevel; antwoorden hangen aan hun ouder
384 const auteur = typeof o.attributedTo === 'string' ? o.attributedTo : (o.attributedTo && o.attributedTo.id);
385 if (auteur && auteur !== bronActor.id) continue; // alleen wat van HEM was
386 gezien++;
387 if (alGemigreerd(site.slug, o.id)) { rapport.overgeslagen++; continue; }
388
389 // Media eerst, want een post die naar een plaatje wijst dat we niet
390 // hebben opgehaald is een halve post. Mislukt een bijlage, dan gaat de
391 // post wel door en staat het in het verslag.
392 const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
393 const binnen = [];
394 if (safeFetch && fs && path && mediaRoot) {
395 for (const a of bijlagen.slice(0, 20)) {
396 const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
397 if (!u || !/^https?:\/\//i.test(String(u))) continue;
398 const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes }).catch(() => null);
399 if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`bijlage niet opgehaald: ${u}`); continue; }
400 binnen.push({ ...g, naam: (a && a.name) || null, type: (a && a.mediaType) || g.mediaType });
401 rapport.media++;
402 try {
403 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
404 .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
405 } catch { /* media-rij is administratie, het bestand staat er */ }
406 }
407 }
408
409 const id = crypto.randomUUID();
410 const cover = binnen.find((b) => AFBEELDING.test(b.type || ''));
411 const rest = binnen.filter((b) => b !== cover);
412 // De titel zit in de content, niet in een veld (zie titelUitContent).
413 const { titel, rest: body } = o.name ? { titel: o.name, rest: o.content || '' } : titelUitContent(o.content || '');
414 // De slug uit de MENSELIJKE url, niet uit de AP-id. Zo houdt het bericht
415 // hetzelfde webadres als op de oude instantie, en blijft een link die
416 // iemand ergens plakte kloppen op het nieuwe domein.
417 const basisSlug = slugUitUri(o.url || '') || o.name || slugUitUri(o.id) || id;
418 // De publicatiedatum blijft die van het origineel. De spec eist dat, en
419 // het is ook het enige eerlijke: het bericht is niet vandaag geschreven.
420 insPost.run({
421 id, site_id: site.id, slug: vrijeSlug(site.id, basisSlug),
422 author_id: site.owner_id, title: titel || null, content: sanitize(body || ''),
423 cover_image_url: cover ? cover.url : null,
424 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name || '').replace(/^#/, '')).filter(Boolean).join(', ') || null : null,
425 published_at: o.published || null, updated_at: o.updated || o.published || null,
426 fan_only: 0, nsfw: o.sensitive ? 1 : 0,
427 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
428 content_warning: o.summary || null,
429 ap_visibility: null,
430 c2s_attachments: rest.length ? JSON.stringify(rest.map((b) => ({ url: b.url, mediaType: b.type, name: b.naam || undefined }))) : null,
431 });
432 recordMigrated(site.slug, {
433 origin: o.id, target: noteId(base, id), sourceActor: bronActor.id,
434 // Publiek in de zin van de spec: gericht aan as:Public. Zo niet, dan
435 // hoort deze regel niet in een publiek leesbare migration-pagina.
436 //
437 // Via noteVisibility en niet met een eigen test op '#Public': die kent
438 // ook de schrijfwijzen 'as:Public' en 'Public', en de rest van Klonkt
439 // beslist er al mee. Een tweede, dunnere versie van dezelfde vraag is
440 // precies hoe twee antwoorden uit elkaar gaan lopen.
441 isPublic: zichtbaarheid(o) === 'public',
442 });
443 rapport.posts++;
444 }
445 const volgende = pagina.next;
446 if (!volgende || gezien >= max) break;
447 pagina = await getJson(site.slug, typeof volgende === 'string' ? volgende : volgende.id);
448 }
449 if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
450
451 // ── De muziekbibliotheek ──────────────────────────────────────
452 //
453 // Losse nummers staan niet in de outbox: die hangen aan de tracks-collectie
454 // waar de actor via AS2 `streams` naar wijst. Zonder deze lus verhuist een
455 // muzieksite zijn berichten en laat hij zijn bibliotheek achter.
456 //
457 // De bron geeft ons hier alles, niet alleen de fedi_open-nummers, omdat we
458 // de doel-actor van zijn Move zijn (siteOpenTracks({alles})). Hetzelfde
459 // geldt voor de bestanden zelf, die anders achter de gated audio-route
460 // blijven.
461 const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
462 const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
463 if (tracksUrl && safeFetch && fs && path && audioRoot) {
464 const coll = await getJson(site.slug, tracksUrl);
465 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
466 for (const it of (Array.isArray(lijst) ? lijst : []).slice(0, max)) {
467 const a = (it && typeof it.object === 'object' && it.object) ? it.object : it;
468 if (!a || !a.id) continue;
469 if (a.type && a.type !== 'Audio') continue;
470 if (alGemigreerd(site.slug, a.id)) { rapport.overgeslagenTracks++; continue; }
471 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));
472 if (!bron || !/^https?:\/\//i.test(String(bron))) { rapport.tracksMislukt++; continue; }
473 const g = await haalBijlage(String(bron), {
474 safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '',
475 headers: signHeaders ? signHeaders(site.slug, String(bron), '*/*') : null,
476 }).catch(() => null);
477 if (!g) {
478 rapport.tracksMislukt++;
479 rapport.waarschuwingen.push(`nummer niet opgehaald: ${a.name || bron}`);
480 continue; // dezelfde regel als bij de zip: geen bestand, geen track
481 }
482 const trackId = crypto.randomUUID();
483 const mediaId = crypto.randomUUID();
484 try {
485 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
486 .run(mediaId, site.id, g.filename, g.mediaType, g.size, g.storage_path);
487 db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, fedi_open)
488 VALUES (?,?,?,?,?,?,?,0)`)
489 .run(trackId, site.id, a.name || 'zonder titel', a.artist || null, a.album || null,
490 Number(a.duration) || null, mediaId);
491 recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
492 rapport.tracksBinnen++;
493 } catch (e) {
494 rapport.tracksMislukt++;
495 rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
496 }
497 }
498 } else if (tracksUrl) {
499 rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
500 }
501 } catch (e) {
502 // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
503 // en dat is precies goed, want er is nog werk.
504 console.warn('[FEP-1580] ingest afgebroken:', e && e.message);
505 return { ...rapport, error: 'partial', melding: e && e.message };
506 }
507
508 // 9. Klaar. Nu pas mag een derde stoppen met kijken.
509 setMigrationComplete(site.slug, true);
510 console.log('[FEP-1580] ingest klaar:', site.slug, '<-', bronActor.id, rapport.posts, 'berichten,', rapport.media, 'bestanden');
511 return rapport;
512}
Note: See TracBrowser for help on using the repository browser.