source: Klonkt/src/routes/admin-migrate.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: 8.0 KB
Line 
1/**
2 * admin-migrate.js — Beheer → Migreren: je posts en media naar een andere Klonkt.
3 *
4 * Dezelfde machinerie als scripts/export-archive.mjs en import-archive.mjs, maar via
5 * de webinterface, zodat verhuizen geen SSH-toegang meer vraagt. De services doen
6 * het werk; deze routes zijn de deur.
7 *
8 * DRIE KEUZES DIE ER TOE DOEN:
9 *
10 * 1. IMPORTEREN GAAT ALTIJD EERST DROOG. Je krijgt een verslag te zien en pas
11 * daarna een knop die het echt doet. Een archief inlezen is niet terug te
12 * draaien, en "ik dacht dat ik alleen keek" is de duurste vergissing hier.
13 *
14 * 2. OVERSCHRIJVEN IS EEN APARTE, GEWAARSCHUWDE KEUZE. importArchive gebruikt
15 * INSERT OR REPLACE, en dat verwijdert ELKE rij die een unieke sleutel schendt:
16 * een post met een ander id maar dezelfde slug gaat dus stil mee (zie shaer-snv5).
17 * Standaard slaat hij bestaande posts over, en dat blijft ook de standaard hier.
18 *
19 * 3. HET ARCHIEF WORDT IN GEHEUGEN GEBOUWD. buildArchive levert een Map van
20 * Buffers; bij veel media is dat een forse allocatie. Daarom een harde grens en
21 * een eerlijke melding in plaats van een proces dat omvalt. Streamen is
22 * shaer-190t en hoort daar thuis, niet hier.
23 */
24import express from 'express';
25import multer from 'multer';
26import fs from 'fs';
27import path from 'path';
28import { renderPage } from '../middleware/render.js';
29import { requireGod } from '../middleware/auth.js';
30import ActivityPubService from '../services/ActivityPubService.js';
31import { safeFetch, signedGetJson, noteId, noteVisibility } from '../services/ActivityPubService.js';
32import HtmlSanitizerService from '../services/HtmlSanitizerService.js';
33import { MEDIA_ROOT } from '../config/paths.js';
34import * as Migration from '../services/MigrationService.js';
35import { buildArchive, zipArchive } from '../services/ArchiveExportService.js';
36import { readArchiveZip, importArchive } from '../services/ArchiveImportService.js';
37
38const router = express.Router();
39
40// Ruim genoeg voor een gewone site met media, klein genoeg om een instance niet om
41// te laten vallen op een archief dat iemand toevallig aanlevert.
42const MAX_UPLOAD = 512 * 1024 * 1024;
43const MAX_EXPORT = 512 * 1024 * 1024;
44
45const upload = multer({
46 storage: multer.memoryStorage(),
47 limits: { fileSize: MAX_UPLOAD, files: 1 },
48}).single('archief');
49
50const mb = (n) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} kB` : `${(n / (1024 * 1024)).toFixed(1)} MB`);
51
52/** Wat zou er in een export zitten? Droog gebouwd, dus zonder zip. */
53function tellen(site) {
54 if (!site) return { telling: null, fout: null };
55 try {
56 const r = buildArchive(site.slug);
57 let bytes = 0;
58 for (const buf of r.files.values()) bytes += buf.length;
59 return { telling: { ...r.counts, bytes, groot: bytes > MAX_EXPORT, ontbrekend: r.missing.length }, fout: null };
60 } catch (e) { return { telling: null, fout: e && e.message }; }
61}
62
63/**
64 * Waar zouden we vandaan kunnen halen? De alias die we zelf claimen (FEP-7628
65 * alsoKnownAs). Dat is niet toevallig hetzelfde veld als waar de ingest op
66 * controleert: het is de helft van de afspraak die je hier al gezet hebt.
67 */
68function bronKandidaat(site) {
69 try {
70 const aka = JSON.parse((site && site.ap_aliases) || '[]');
71 return Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
72 } catch { return null; }
73}
74
75/** De pagina. Toont wat er in een export zou zitten, zonder hem te bouwen. */
76router.get('/', requireGod, (req, res) => {
77 // Droog bouwen om te tellen. Dat kost hetzelfde geheugen als een echte export,
78 // dus hier meteen de grens bewaken in plaats van pas bij de download.
79 const site = res.locals.site;
80 const { telling, fout } = tellen(site);
81 renderPage(req, res, 'pages/admin-migrate', {
82 pageTitle: 'Migreren', bodyClass: 'on-special',
83 telling, fout, mb,
84 verslag: null,
85 bron: bronKandidaat(site),
86 mig: site ? Migration.migrationStatus(site.slug) : null,
87 haalVerslag: null,
88 success: req.query.success || null, error: req.query.error || fout || null,
89 });
90});
91
92/**
93 * FEP-1580: haal de berichten rechtstreeks bij je oude Klonkt op.
94 *
95 * Geen code, geen token: de autorisatie IS de Move die je al gedaan hebt. De
96 * oude instantie geeft ons zijn eigen kijkrechten omdat `moved_to` daar naar
97 * ons wijst, en dat veld staat er alleen als wij hem toen al in alsoKnownAs
98 * hadden. Beide kanten hebben dus ooit ja gezegd, en dat is precies waarom
99 * hier geen tweede vertrouwensmechanisme bij hoeft.
100 *
101 * De ingest kijkt die afspraak zelf nog een keer na, in beide richtingen. Dit
102 * is een deur, geen controle.
103 */
104router.post('/pull', requireGod, async (req, res) => {
105 const site = res.locals.site;
106 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
107 const opgegeven = String(req.body && req.body.bron ? req.body.bron : '').trim();
108 let r = null;
109 try {
110 r = await Migration.ingestFromSource(site, {
111 sourceUri: opgegeven || null,
112 deps: {
113 getJson: signedGetJson, safeFetch, mediaRoot: MEDIA_ROOT, fs, path, noteId, noteVisibility,
114 sanitize: (h) => HtmlSanitizerService.sanitize(h || ''),
115 },
116 });
117 } catch (e) {
118 r = { error: 'crash', melding: e && e.message };
119 }
120 const { telling } = tellen(site);
121 renderPage(req, res, 'pages/admin-migrate', {
122 pageTitle: 'Migreren', bodyClass: 'on-special',
123 telling, fout: null, mb, verslag: null,
124 bron: opgegeven || bronKandidaat(site),
125 mig: Migration.migrationStatus(site.slug),
126 haalVerslag: r,
127 success: (r && !r.error) ? 'Opgehaald' : null,
128 error: null,
129 });
130});
131
132/** Download het archief als zip. */
133router.get('/export', requireGod, (req, res) => {
134 const site = res.locals.site;
135 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
136 try {
137 const r = buildArchive(site.slug);
138 const zip = zipArchive(r.files);
139 if (zip.length > MAX_EXPORT) {
140 return res.redirect('/admin/migrate?error=' + encodeURIComponent(
141 `Het archief is ${mb(zip.length)} en dat is te groot voor de webinterface. Gebruik scripts/export-archive.mjs op de server.`));
142 }
143 const naam = `klonkt-${site.slug}-${new Date().toISOString().slice(0, 10)}.zip`;
144 res.set('Content-Type', 'application/zip');
145 res.set('Content-Disposition', `attachment; filename="${naam}"`);
146 // Privé: dit is je hele site, niets voor een cache onderweg.
147 res.set('Cache-Control', 'private, no-store');
148 res.send(zip);
149 } catch (e) {
150 res.redirect('/admin/migrate?error=' + encodeURIComponent(`Exporteren mislukt: ${e && e.message}`));
151 }
152});
153
154/**
155 * Importeren. Zonder `echt=1` is dit een DROOGLOOP: hij leest het archief, meldt
156 * wat er zou gebeuren, en raakt niets aan.
157 */
158router.post('/import', requireGod, upload, async (req, res) => {
159 const site = res.locals.site;
160 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
161 if (!req.file || !req.file.buffer || !req.file.buffer.length) {
162 return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen bestand ontvangen'));
163 }
164
165 const echt = String(req.body.echt || '') === '1';
166 const overschrijf = String(req.body.overschrijf || '') === '1';
167
168 let verslag = null;
169 let fout = null;
170 try {
171 const files = readArchiveZip(req.file.buffer);
172 verslag = importArchive(files, {
173 slug: site.slug,
174 dryRun: !echt,
175 overwrite: overschrijf,
176 });
177 } catch (e) {
178 fout = e && e.message;
179 }
180
181 // Bij een DROOGLOOP blijven we op de pagina met het verslag, zodat je kunt
182 // besluiten. Bij een echte import ook, maar dan met de uitkomst.
183 renderPage(req, res, 'pages/admin-migrate', {
184 pageTitle: 'Migreren', bodyClass: 'on-special',
185 telling: tellen(site).telling, fout: null, mb,
186 bron: bronKandidaat(site), mig: Migration.migrationStatus(site.slug), haalVerslag: null,
187 verslag: verslag ? { ...verslag, echt, overschrijf, bestand: req.file.originalname, bytes: req.file.buffer.length } : null,
188 success: (echt && verslag && !fout) ? 'Archief geïmporteerd' : null,
189 error: fout ? `Importeren mislukt: ${fout}` : null,
190 });
191});
192
193export default router;
Note: See TracBrowser for help on using the repository browser.