source: Klonkt/src/routes/admin-migrate.js@ 47f1183

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

Een stappenplan op de migrate-pagina, en de volgorde klopte niet

Robin: misschien moeten we bij migrate een stappenplan beschrijven, waaronder
dus ook dit (de Delete-ronde voor je een domein opzegt).

Bij het uitschrijven bleek de pagina zelf een verkeerde volgorde te suggereren.
Ik noemde het aankondigen "de laatste stap", maar ingestFromSource weigert met
not_moved_here zolang movedTo niet naar ons wijst. De ophaalknop werkt dus PAS
nadat de Move gedaan is. Aankondigen is stap 2, niet de afsluiter.

Met een zip mag het juist andersom: een bestand vraagt niemand toestemming. Die
twee volgordes naast elkaar zijn precies het soort ding waar je halverwege op
vastloopt zonder te snappen waarom, dus staan ze er nu uitgeschreven.

DE PAGINA DRAAIT OP BEIDE INSTANTIES. Stap 1, 3 en 4 horen op de nieuwe kant,
stap 2 en 5 op de oude. Een vast "hier" is dus op de helft van de schermen
onzin. Het plan draait mee met waar je kijkt, en op de vertrekkende kant staat
er een regel boven dat je naar de instantie kijkt die weggaat.

Die omkering had ik bij het bouwen precies verkeerd om: beide labels werden
"hier". Zag ik pas door hem te renderen, niet door hem te lezen.

STAP 5 heeft geen knop, en dat staat erbij. Verwijder je oude berichten voor je
het domein opzegt, zodat boosts van anderen netjes verdwijnen in plaats van te
blijven staan als kapotte kaart; haal daarna de alias weg, want die wijst naar
een adres dat je niet meer beheert. Een stappenplan dat een knop belooft die er
niet is, is erger dan geen stappenplan.

Changed files:
src/routes/admin-migrate.js

  • stappen(): afleiden wat af is uit aliassen, moved_to, de moves-collectie en de vertaaltabel; meegegeven aan alle drie de renders

src/views/pages/admin-migrate.ejs

  • het stappenplan bovenaan, met vinkjes en een waar-label per stap
  • de labels draaien om vanaf de vertrekkende kant

src/services/i18n.js

  • mig.plan_* in nl/en/de
  • mig.move_title is "Stap 2" en niet meer "Laatste stap"
  • de waarschuwing zegt nu dat dit EERST moet bij de ophaalknop

test/migrate-page.test.js

  • het plan staat boven de knoppen die het beschrijft
  • de perspectiefwissel, met de eis dat de twee labels nooit gelijk zijn
  • geen "laatste stap" meer in een van de drie talen
  • stap 5 noemt de Delete-ronde, de alias en dat er geen knop is

remarks: gerenderd vanaf beide kanten om het te controleren. Suite 1013 groen,
tegenproef op de perspectiefwissel.

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

  • Property mode set to 100644
File size: 11.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, signedGetHeaders, noteId, noteVisibility } from '../services/ActivityPubService.js';
32import HtmlSanitizerService from '../services/HtmlSanitizerService.js';
33import { MEDIA_ROOT, AUDIO_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';
37import { parseApAliases } from './admin-sites.js';
38import db from '../config/database.js';
39
40const router = express.Router();
41
42// Ruim genoeg voor een gewone site met media, klein genoeg om een instance niet om
43// te laten vallen op een archief dat iemand toevallig aanlevert.
44const MAX_UPLOAD = 512 * 1024 * 1024;
45const MAX_EXPORT = 512 * 1024 * 1024;
46
47const upload = multer({
48 storage: multer.memoryStorage(),
49 limits: { fileSize: MAX_UPLOAD, files: 1 },
50}).single('archief');
51
52const mb = (n) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} kB` : `${(n / (1024 * 1024)).toFixed(1)} MB`);
53
54/** Wat zou er in een export zitten? Droog gebouwd, dus zonder zip. */
55function tellen(site) {
56 if (!site) return { telling: null, fout: null };
57 try {
58 const r = buildArchive(site.slug);
59 let bytes = 0;
60 for (const buf of r.files.values()) bytes += buf.length;
61 // ontbrekend telt alleen de MEDIA-verwijzingen; audioMissing komt uit
62 // buildArchive zelf. Ze door elkaar husselen was precies hoe "39
63 // mediabestanden, 14 ontbrekend" een bibliotheek van 140 nummers kon
64 // verzwijgen.
65 const mediaWeg = r.missing.filter((m) => !m.track).length;
66 return {
67 telling: {
68 ...r.counts, bytes, groot: bytes > MAX_EXPORT,
69 ontbrekend: mediaWeg, audioMissing: r.counts.audioMissing || 0,
70 },
71 fout: null,
72 };
73 } catch (e) { return { telling: null, fout: e && e.message }; }
74}
75
76/** De aliassen als tekst, een per regel, zoals het invoerveld ze wil. */
77function aliasTekst(site) {
78 try { return (JSON.parse((site && site.ap_aliases) || '[]') || []).join('\n'); } catch { return ''; }
79}
80
81/**
82 * Waar sta je in de verhuizing?
83 *
84 * Deze pagina draait op BEIDE instanties en elke stap hoort maar op een van de
85 * twee. Zonder dat onderscheid leest de lijst als onzin op de helft van de
86 * schermen. Vandaar: afleiden wat we kunnen zien, en verder eerlijk zeggen
87 * waar iets thuishoort in plaats van het te raden.
88 */
89function stappen(site, mig) {
90 const alias = aliasTekst(site).trim();
91 return {
92 // moved_to gezet betekent: DIT is de instantie die vertrokken is.
93 isOud: !!(site && site.moved_to),
94 geclaimd: !!alias,
95 // Een Move in onze moves-collectie betekent dat de bron hierheen verhuisd is.
96 aangekondigd: !!(site && site.moved_to) || !!(mig && mig.moves > 0),
97 opgehaald: !!(mig && mig.total > 0),
98 };
99}
100
101/**
102 * Waar zouden we vandaan kunnen halen? De alias die we zelf claimen (FEP-7628
103 * alsoKnownAs). Dat is niet toevallig hetzelfde veld als waar de ingest op
104 * controleert: het is de helft van de afspraak die je hier al gezet hebt.
105 */
106function bronKandidaat(site) {
107 try {
108 const aka = JSON.parse((site && site.ap_aliases) || '[]');
109 return Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
110 } catch { return null; }
111}
112
113/** De pagina. Toont wat er in een export zou zitten, zonder hem te bouwen. */
114router.get('/', requireGod, (req, res) => {
115 // Droog bouwen om te tellen. Dat kost hetzelfde geheugen als een echte export,
116 // dus hier meteen de grens bewaken in plaats van pas bij de download.
117 const site = res.locals.site;
118 const { telling, fout } = tellen(site);
119 const _mig = site ? Migration.migrationStatus(site.slug) : null;
120 renderPage(req, res, 'pages/admin-migrate', {
121 pageTitle: 'Migreren', bodyClass: 'on-special',
122 telling, fout, mb,
123 verslag: null,
124 bron: bronKandidaat(site), aliassen: aliasTekst(site), movedTo: (site && site.moved_to) || null,
125 mig: _mig, stap: stappen(site, _mig),
126 haalVerslag: null,
127 success: req.query.success || null, error: req.query.error || fout || null,
128 });
129});
130
131/**
132 * FEP-1580: haal de berichten rechtstreeks bij je oude Klonkt op.
133 *
134 * Geen code, geen token: de autorisatie IS de Move die je al gedaan hebt. De
135 * oude instantie geeft ons zijn eigen kijkrechten omdat `moved_to` daar naar
136 * ons wijst, en dat veld staat er alleen als wij hem toen al in alsoKnownAs
137 * hadden. Beide kanten hebben dus ooit ja gezegd, en dat is precies waarom
138 * hier geen tweede vertrouwensmechanisme bij hoeft.
139 *
140 * De ingest kijkt die afspraak zelf nog een keer na, in beide richtingen. Dit
141 * is een deur, geen controle.
142 */
143router.post('/pull', requireGod, async (req, res) => {
144 const site = res.locals.site;
145 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
146 const opgegeven = String(req.body && req.body.bron ? req.body.bron : '').trim();
147 let r = null;
148 try {
149 r = await Migration.ingestFromSource(site, {
150 sourceUri: opgegeven || null,
151 deps: {
152 getJson: signedGetJson, safeFetch, mediaRoot: MEDIA_ROOT, audioRoot: AUDIO_ROOT, fs, path, noteId, noteVisibility, signHeaders: signedGetHeaders,
153 sanitize: (h) => HtmlSanitizerService.sanitize(h || ''),
154 },
155 });
156 } catch (e) {
157 r = { error: 'crash', melding: e && e.message };
158 }
159 const { telling } = tellen(site);
160 const _mig2 = Migration.migrationStatus(site.slug);
161 renderPage(req, res, 'pages/admin-migrate', {
162 pageTitle: 'Migreren', bodyClass: 'on-special',
163 telling, fout: null, mb, verslag: null,
164 bron: opgegeven || bronKandidaat(site), aliassen: aliasTekst(site), movedTo: site.moved_to || null,
165 mig: _mig2, stap: stappen(site, _mig2),
166 haalVerslag: r,
167 success: (r && !r.error) ? 'Opgehaald' : null,
168 error: null,
169 });
170});
171
172/**
173 * Je oude account claimen (FEP-7628 alsoKnownAs).
174 *
175 * Stond op de site-bewerkpagina, tussen de kleuren en de feedinstellingen. Maar
176 * dit is stap EEN van een verhuizing, en zonder deze claim weigert de oude
177 * instantie de Move met `no_backreference`. Het hoort dus hier, boven de knop
178 * die hem nodig heeft.
179 */
180router.post('/aliases', requireGod, async (req, res) => {
181 const site = res.locals.site;
182 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
183 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
184 let lijst;
185 try {
186 lijst = await parseApAliases(req.body.ap_aliases, ActivityPubService.actorId(base, site.slug));
187 } catch (e) {
188 // Welke regel niet deugde, niet alleen DAT er iets niet deugde.
189 return res.redirect('/admin/migrate?error=' + encodeURIComponent(`Onbruikbaar adres: ${e && e.message}`));
190 }
191 db.prepare('UPDATE sites SET ap_aliases = ? WHERE slug = ?').run(JSON.stringify(lijst), site.slug);
192 res.redirect('/admin/migrate?success=' + encodeURIComponent(
193 lijst.length ? `${lijst.length} adres(sen) opgeslagen als jouw vorige account.` : 'Aliassen leeggemaakt.'));
194});
195
196/** Download het archief als zip. */
197router.get('/export', requireGod, (req, res) => {
198 const site = res.locals.site;
199 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
200 try {
201 const r = buildArchive(site.slug);
202 const zip = zipArchive(r.files);
203 if (zip.length > MAX_EXPORT) {
204 return res.redirect('/admin/migrate?error=' + encodeURIComponent(
205 `Het archief is ${mb(zip.length)} en dat is te groot voor de webinterface. Gebruik scripts/export-archive.mjs op de server.`));
206 }
207 const naam = `klonkt-${site.slug}-${new Date().toISOString().slice(0, 10)}.zip`;
208 res.set('Content-Type', 'application/zip');
209 res.set('Content-Disposition', `attachment; filename="${naam}"`);
210 // Privé: dit is je hele site, niets voor een cache onderweg.
211 res.set('Cache-Control', 'private, no-store');
212 res.send(zip);
213 } catch (e) {
214 res.redirect('/admin/migrate?error=' + encodeURIComponent(`Exporteren mislukt: ${e && e.message}`));
215 }
216});
217
218/**
219 * Importeren. Zonder `echt=1` is dit een DROOGLOOP: hij leest het archief, meldt
220 * wat er zou gebeuren, en raakt niets aan.
221 */
222router.post('/import', requireGod, upload, async (req, res) => {
223 const site = res.locals.site;
224 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
225 if (!req.file || !req.file.buffer || !req.file.buffer.length) {
226 return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen bestand ontvangen'));
227 }
228
229 const echt = String(req.body.echt || '') === '1';
230 const overschrijf = String(req.body.overschrijf || '') === '1';
231
232 let verslag = null;
233 let fout = null;
234 try {
235 const files = readArchiveZip(req.file.buffer);
236 verslag = importArchive(files, {
237 slug: site.slug,
238 dryRun: !echt,
239 overwrite: overschrijf,
240 });
241 } catch (e) {
242 fout = e && e.message;
243 }
244
245 // Bij een DROOGLOOP blijven we op de pagina met het verslag, zodat je kunt
246 // besluiten. Bij een echte import ook, maar dan met de uitkomst.
247 renderPage(req, res, 'pages/admin-migrate', {
248 pageTitle: 'Migreren', bodyClass: 'on-special',
249 telling: tellen(site).telling, fout: null, mb,
250 bron: bronKandidaat(site), aliassen: aliasTekst(site), movedTo: site.moved_to || null,
251 mig: Migration.migrationStatus(site.slug), stap: stappen(site, Migration.migrationStatus(site.slug)), haalVerslag: null,
252 verslag: verslag ? { ...verslag, echt, overschrijf, bestand: req.file.originalname, bytes: req.file.buffer.length } : null,
253 success: (echt && verslag && !fout) ? 'Archief geïmporteerd' : null,
254 error: fout ? `Importeren mislukt: ${fout}` : null,
255 });
256});
257
258export default router;
Note: See TracBrowser for help on using the repository browser.