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

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

Je berichten verhuizen mee: FEP-1580 plus een webinterface ervoor

FEP-7628 verhuist je volgers en zegt zelf dat de inhoud een ander probleem is.
Dat probleem stond open: na een Move bleven je berichten op de oude instantie
staan, en elke reactie van een derde wees naar een URI die verdwijnt zodra dat
domein opgezegd wordt. FEP-1580 regelt dat, status DRAFT.

DE AUTORISATIE IS DE MOVE, NIET EEN CODE. De bronkant behandelt een ondertekend
verzoek namens de doel-actor alsof de bron-actor het zelf deed. Dat mag omdat
moveAccount() no_backreference weigert: moved_to staat er alleen als de
doel-actor ons al in alsoKnownAs had. Beide kanten hebben ooit ja gezegd, dus er
is geen tweede vertrouwensmechanisme nodig. Een typefout komt hier niet binnen,
want die haalt de move zelf niet. Dat dit veilig is leunt op de keyId-binding
uit shaer-xd8i: zonder die controle is "wie tekende dit" te zacht om je hele
geschiedenis aan af te geven.

NIEUWE IDS ZIJN GEEN BUG, DE VERTAALTABEL IS HET ANTWOORD. Een verhuisd bericht
krijgt een eigen URI, want het staat op een ander domein. De migration-collectie
mapt oud naar nieuw en derden lezen die om hun eigen verwijzingen bij te werken.
Zonder die collectie is de draad kapot, met die collectie is het een
verhuisbericht. Niet-publieke items staan er alleen in voor wie ze mocht zien:
een lijst met de URIs van je fan-only posts is een lek, ook zonder de inhoud.

Er gaat geen Create de deur uit. Je volgers hebben die berichten jaren geleden
al gezien; driehonderd posts die als nieuw de tijdlijn in klateren is geen
verhuizing maar spam.

Daarnaast /admin/migrate: exporteren, importeren en ophalen via de
webinterface, zodat verhuizen geen SSH-toegang meer vraagt. Importeren gaat
altijd eerst droog, met een verslag en pas daarna een knop die het echt doet.

Getest op twee draaiende instanties, A verhuisd naar B via de echte
moveAccount. Anoniem zag A 3 van de 4 berichten; ondertekend als de doel-actor
kwamen alle 4 mee, inclusief de fan-only. Titel, webadres en publicatiedatum
blijven staan. Media komt echt over: gedownload, in de mediatabel, B serveert
het. Migration-collectie 4 rijen totaal, 3 publiek.

Changed files:
src/services/ActivityPubService.js

  • isMoveTarget: het hele autorisatiepredicaat van de bronkant
  • outboxAudience en mayReadNote: de doel-actor krijgt onze eigen kijkrechten
  • buildActor adverteert migration en moves, ook leeg (de FEP wijst er apart op dat "niets verhuisd" anders niet te onderscheiden is van "kent dit niet")
  • signedGetJson geexporteerd, de ingest heeft hem nodig
  • isMoveTarget en signedGetJson in de default export (movedLock verstopte zich een dag eerder precies zo)

src/services/ap-core.js

  • FEP-1580-termen in de JSON-LD-context

src/services/ArchiveImportService.js

  • een import uit een zip vult dezelfde vertaaltabel; de spec wil dat een geexporteerde collectie identiek behandeld wordt

src/routes/activitypub.js

  • /ap/users/:slug/migration en /moves
  • de blocked-collectie gaat open voor de doel-actor, want zichtbaarheidsvoorkeuren moeten meeverhuizen

src/config/database.js

  • ap_migration en ap_moves, plus sites.migration_complete

src/server.js

  • /admin/migrate aangesloten

src/views/pages/admin.ejs

  • knop naar Migreren

src/services/i18n.js

  • mig.* in nl/en/de

New file:
src/services/MigrationService.js

  • de doelkant: ingest-routine, migration- en moves-collectie, statusvlag

src/routes/admin-migrate.js

  • exporteren, droog importeren, echt importeren, ophalen bij de oude Klonkt

src/views/pages/admin-migrate.ejs

  • de pagina

test/fep1580-migration.test.js

  • 22 tests over beide rollen, plus de regressietest bij 4101c89

remarks: FEP-8b32 ontbreekt volledig (shaer-j1v0), dus er staat geen
handtekening onder de moves-collectie en we zijn niet naleveringsklaar. Bewust
geen leeg proof-veld: een derde die het controleert wordt dan misleid. De DERDE
rol zit er ook niet in, Klonkt leest nog geen migration-collecties van anderen,
dus andermans verhuizing repareert onze verwijzingen nog niet. Alle betrokken
FEPs zijn DRAFT, ook 7628 die we al volgden; 1580 is vers en de auteur schrijft
zelf dat het een audit verdient.

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

  • Property mode set to 100644
File size: 21.9 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 }) {
251 const r = await safeFetch(url, { headers: { accept: '*/*' } }).catch(() => null);
252 if (!r || !r.ok) return null;
253 const buf = Buffer.from(await r.arrayBuffer());
254 if (!buf.length || buf.length > maxBytes) return null;
255 const type = String(r.headers.get('content-type') || '').split(';')[0].trim() || 'application/octet-stream';
256 const ext = (() => {
257 const uit = slugUitUri(url);
258 const m = /\.([a-z0-9]{1,5})$/i.exec(uit);
259 if (m) return m[1].toLowerCase();
260 return (type.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'bin';
261 })();
262 const naam = `${crypto.randomUUID()}.${ext}`;
263 const rel = `migrated/${naam}`;
264 const abs = path.join(mediaRoot, 'migrated', naam);
265 fs.mkdirSync(path.dirname(abs), { recursive: true });
266 fs.writeFileSync(abs, buf);
267 return { url: `/media/${rel}`, mediaType: type, size: buf.length, filename: naam, storage_path: abs };
268}
269
270/**
271 * FEP-1580 ingest-routine, de doelkant.
272 *
273 * De autorisatie wordt hier niet verzonnen maar NAGEKEKEN, en in beide
274 * richtingen, precies zoals de spec het voor derden voorschrijft: `movedTo` op
275 * de bron moet naar ons wijzen EN wij moeten de bron in `alsoKnownAs` hebben.
276 * Eén kant is een bewering, twee kanten is een afspraak. Zou ik alleen op onze
277 * eigen alsoKnownAs afgaan, dan kon iedereen die zichzelf een alias geeft de
278 * geschiedenis van een vreemde opeisen.
279 *
280 * `deps` is er voor de test: die moet dit kunnen draaien zonder netwerk.
281 */
282export async function ingestFromSource(site, {
283 sourceUri = null, max = 1000, maxBytes = 25 * 1024 * 1024, deps = {},
284} = {}) {
285 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
286 if (!base || !site || !site.slug) return { error: 'config' };
287 const me = actorId(base, site.slug);
288
289 const {
290 getJson = null, safeFetch = null, mediaRoot = null, fs = null, path = null, noteId = null,
291 sanitize = (h) => h,
292 // Standaard 'followers': kan iets niet als publiek bewezen worden, dan
293 // hoort het niet in de publieke vertaaltabel. Fail-closed, want dit is een
294 // privacygrens en niet een weergavedetail.
295 noteVisibility = () => 'followers',
296 } = deps;
297 const zichtbaarheid = noteVisibility;
298 if (!getJson || !noteId) return { error: 'config' };
299
300 // 1. Welke bron? Zonder opgave: de alias die we zelf claimen.
301 let bron = sourceUri && /^https?:\/\//i.test(sourceUri) ? sourceUri : null;
302 if (!bron) {
303 try {
304 const aka = JSON.parse(site.ap_aliases || '[]');
305 bron = Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
306 } catch { /* stukke ap_aliases telt als geen alias */ }
307 }
308 if (!bron) return { error: 'no_source' };
309
310 // 2 + 3. Het bron-actordocument, en de wegwijzer die naar ONS moet wijzen.
311 const bronActor = await getJson(site.slug, bron);
312 if (!bronActor || !bronActor.id) return { error: 'unreachable' };
313 if (bronActor.movedTo !== me) return { error: 'not_moved_here', movedTo: bronActor.movedTo || null };
314
315 // 4. En de terugverwijzing van onze kant, zodat het een afspraak is.
316 const eigenAka = (() => {
317 try { const a = JSON.parse(site.ap_aliases || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
318 })();
319 if (!eigenAka.includes(bronActor.id)) return { error: 'no_backreference' };
320
321 // 5 + 6. Vastleggen dat dit een migratie is, en de deur openzetten voor derden.
322 recordMove(site.slug, {
323 moveId: `${bronActor.id}#move`, sourceActor: bronActor.id, targetActor: me,
324 activity: { type: 'Move', actor: bronActor.id, object: bronActor.id, target: me },
325 actorDoc: bronActor,
326 });
327 setMigrationComplete(site.slug, false);
328
329 const rapport = {
330 bron: bronActor.id, posts: 0, overgeslagen: 0, media: 0, mediaMislukt: 0,
331 blocks: 0, waarschuwingen: [],
332 };
333
334 try {
335 // 7. BLOKKADES EERST. De spec is daar streng over, en terecht: ze bepalen
336 // wie de rest te zien krijgt. Andersom importeer je even je hele
337 // geschiedenis zichtbaar voor iemand die je nou juist buiten wilde.
338 if (bronActor.blocked) {
339 const coll = await getJson(site.slug, typeof bronActor.blocked === 'string' ? bronActor.blocked : bronActor.blocked.id);
340 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
341 for (const b of Array.isArray(lijst) ? lijst : []) {
342 const uri = typeof b === 'string' ? b : (b && (b.object || b.id));
343 if (!uri || !/^https?:\/\//i.test(String(uri))) continue;
344 try {
345 db.prepare("INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label) VALUES (?, ?, 'actor', NULL)").run(site.slug, String(uri));
346 rapport.blocks++;
347 } catch { /* tabel ontbreekt op een verse db */ }
348 }
349 } else {
350 rapport.waarschuwingen.push('de bron gaf geen blokkadelijst, zichtbaarheidsvoorkeuren komen niet mee');
351 }
352
353 // 8. De outbox aflopen. Pagineren zoals de rest van Klonkt dat doet.
354 if (!bronActor.outbox) return { ...rapport, error: 'no_outbox' };
355 let pagina = await getJson(site.slug, typeof bronActor.outbox === 'string' ? bronActor.outbox : bronActor.outbox.id);
356 if (pagina && pagina.first && !(pagina.orderedItems || pagina.items)) {
357 pagina = await getJson(site.slug, typeof pagina.first === 'string' ? pagina.first : pagina.first.id);
358 }
359
360 const insPost = db.prepare(`INSERT INTO posts
361 (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url,
362 pinned, type, tags, published_at, created_at, updated_at, fan_only, nsfw, language,
363 content_warning, ap_visibility, c2s_attachments, origin_server)
364 VALUES (@id, @site_id, @slug, @author_id, @title, @content, NULL, 'published', @cover_image_url,
365 0, 'post', @tags, @published_at, @published_at, @updated_at, @fan_only, @nsfw, @language,
366 @content_warning, @ap_visibility, @c2s_attachments, 'migrated')`);
367
368 let gezien = 0;
369 while (pagina && gezien < max) {
370 const items = (pagina.orderedItems || pagina.items) || [];
371 for (const it of Array.isArray(items) ? items : []) {
372 if (gezien >= max) break;
373 const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
374 if (!o || !o.id) continue;
375 if (o.type && !['Note', 'Article', 'Question'].includes(o.type)) continue;
376 if (o.inReplyTo) continue; // toplevel; antwoorden hangen aan hun ouder
377 const auteur = typeof o.attributedTo === 'string' ? o.attributedTo : (o.attributedTo && o.attributedTo.id);
378 if (auteur && auteur !== bronActor.id) continue; // alleen wat van HEM was
379 gezien++;
380 if (alGemigreerd(site.slug, o.id)) { rapport.overgeslagen++; continue; }
381
382 // Media eerst, want een post die naar een plaatje wijst dat we niet
383 // hebben opgehaald is een halve post. Mislukt een bijlage, dan gaat de
384 // post wel door en staat het in het verslag.
385 const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
386 const binnen = [];
387 if (safeFetch && fs && path && mediaRoot) {
388 for (const a of bijlagen.slice(0, 20)) {
389 const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
390 if (!u || !/^https?:\/\//i.test(String(u))) continue;
391 const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes }).catch(() => null);
392 if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`bijlage niet opgehaald: ${u}`); continue; }
393 binnen.push({ ...g, naam: (a && a.name) || null, type: (a && a.mediaType) || g.mediaType });
394 rapport.media++;
395 try {
396 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
397 .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
398 } catch { /* media-rij is administratie, het bestand staat er */ }
399 }
400 }
401
402 const id = crypto.randomUUID();
403 const cover = binnen.find((b) => AFBEELDING.test(b.type || ''));
404 const rest = binnen.filter((b) => b !== cover);
405 // De titel zit in de content, niet in een veld (zie titelUitContent).
406 const { titel, rest: body } = o.name ? { titel: o.name, rest: o.content || '' } : titelUitContent(o.content || '');
407 // De slug uit de MENSELIJKE url, niet uit de AP-id. Zo houdt het bericht
408 // hetzelfde webadres als op de oude instantie, en blijft een link die
409 // iemand ergens plakte kloppen op het nieuwe domein.
410 const basisSlug = slugUitUri(o.url || '') || o.name || slugUitUri(o.id) || id;
411 // De publicatiedatum blijft die van het origineel. De spec eist dat, en
412 // het is ook het enige eerlijke: het bericht is niet vandaag geschreven.
413 insPost.run({
414 id, site_id: site.id, slug: vrijeSlug(site.id, basisSlug),
415 author_id: site.owner_id, title: titel || null, content: sanitize(body || ''),
416 cover_image_url: cover ? cover.url : null,
417 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name || '').replace(/^#/, '')).filter(Boolean).join(', ') || null : null,
418 published_at: o.published || null, updated_at: o.updated || o.published || null,
419 fan_only: 0, nsfw: o.sensitive ? 1 : 0,
420 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
421 content_warning: o.summary || null,
422 ap_visibility: null,
423 c2s_attachments: rest.length ? JSON.stringify(rest.map((b) => ({ url: b.url, mediaType: b.type, name: b.naam || undefined }))) : null,
424 });
425 recordMigrated(site.slug, {
426 origin: o.id, target: noteId(base, id), sourceActor: bronActor.id,
427 // Publiek in de zin van de spec: gericht aan as:Public. Zo niet, dan
428 // hoort deze regel niet in een publiek leesbare migration-pagina.
429 //
430 // Via noteVisibility en niet met een eigen test op '#Public': die kent
431 // ook de schrijfwijzen 'as:Public' en 'Public', en de rest van Klonkt
432 // beslist er al mee. Een tweede, dunnere versie van dezelfde vraag is
433 // precies hoe twee antwoorden uit elkaar gaan lopen.
434 isPublic: zichtbaarheid(o) === 'public',
435 });
436 rapport.posts++;
437 }
438 const volgende = pagina.next;
439 if (!volgende || gezien >= max) break;
440 pagina = await getJson(site.slug, typeof volgende === 'string' ? volgende : volgende.id);
441 }
442 if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
443 } catch (e) {
444 // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
445 // en dat is precies goed, want er is nog werk.
446 console.warn('[FEP-1580] ingest afgebroken:', e && e.message);
447 return { ...rapport, error: 'partial', melding: e && e.message };
448 }
449
450 // 9. Klaar. Nu pas mag een derde stoppen met kijken.
451 setMigrationComplete(site.slug, true);
452 console.log('[FEP-1580] ingest klaar:', site.slug, '<-', bronActor.id, rapport.posts, 'berichten,', rapport.media, 'bestanden');
453 return rapport;
454}
Note: See TracBrowser for help on using the repository browser.