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

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

Alle migratie-opties bij elkaar op één pagina

Robin: ik wil alle migratie-opties op de migrate-pagina hebben, dus ook
fediverse alias en move.

Ze stonden op de site-bewerkpagina, tussen de kleuren, de feedinstellingen en
de custom head-html. Dat is de verkeerde plek in twee opzichten: het zijn stap
1 en de laatste stap van een verhuizing, en dat scherm gaat over hoe je site
eruitziet. Een verhuizing is geen uiterlijk.

De pagina vertelt nu het verhaal in de goede volgorde:

1 je oude account claimen (alsoKnownAs)

meenemen (zip)
ophalen bij je oude Klonkt
hierheen halen (zip)

4 de verhuizing aankondigen (Move)

Claimen bovenaan omdat de bron zonder die claim niets afgeeft en de Move
weigert met no_backreference. Aankondigen onderaan met een rode knop: het is de
enige stap op deze pagina die niet terug te draaien is.

De verhuisknop post naar de BESTAANDE /admin/sites/:slug/move. Een tweede
implementatie van een onomkeerbare actie is precies wat je niet wilt; die route
kreeg alleen een next mee zodat je terugkomt waar je vandaan kwam.

EEN VAL DIE IK ONDERWEG MAAKTE. De opslagroute van het site-bewerkscherm
schreef ap_aliases uit het formulier. Haal je dat veld weg, dan komt er niets
binnen, en dan wist een gewone opslag je claim op je oude account. Je verandert
je accentkleur en drie stappen later weigert de Move met no_backreference,
zonder dat iets die twee met elkaar verbindt. Nu geldt: veld niet meegestuurd
is niet aanraken.

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

  • POST /admin/migrate/aliases, met de bestaande parseApAliases
  • aliassen en moved_to in de locals van alle drie de renders

src/routes/admin-sites.js

  • parseApAliases geexporteerd in plaats van gekopieerd
  • de save raakt ap_aliases alleen aan als het veld er echt is
  • /move mag terugsturen naar een eigen pad (geen open redirect)

src/views/pages/admin-migrate.ejs

  • alias bovenaan, verhuizen onderaan met een gewaarschuwde rode knop

src/views/pages/admin-site-edit.ejs

  • beide blokken eruit, een wegwijzer ervoor in de plaats

src/services/i18n.js

  • mig.alias_* en mig.move_* plus asite.moved_to_migrate, in nl/en/de

New file:
test/migrate-page.test.js

  • de velden staan op de goede pagina en niet meer op de oude
  • de volgorde: claimen boven ophalen, aankondigen onderaan
  • en een alias overleeft het opslaan van je uiterlijk

remarks: gedraaid op een echte instantie. Alias opslaan werkt, een onzin-adres
geeft een nette fout EN laat de bestaande alias staan, en de pagina rendert
schoon in nl/en/de. Suite 1005 groen.

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

  • Property mode set to 100644
File size: 10.1 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 zouden we vandaan kunnen halen? De alias die we zelf claimen (FEP-7628
83 * alsoKnownAs). Dat is niet toevallig hetzelfde veld als waar de ingest op
84 * controleert: het is de helft van de afspraak die je hier al gezet hebt.
85 */
86function bronKandidaat(site) {
87 try {
88 const aka = JSON.parse((site && site.ap_aliases) || '[]');
89 return Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
90 } catch { return null; }
91}
92
93/** De pagina. Toont wat er in een export zou zitten, zonder hem te bouwen. */
94router.get('/', requireGod, (req, res) => {
95 // Droog bouwen om te tellen. Dat kost hetzelfde geheugen als een echte export,
96 // dus hier meteen de grens bewaken in plaats van pas bij de download.
97 const site = res.locals.site;
98 const { telling, fout } = tellen(site);
99 renderPage(req, res, 'pages/admin-migrate', {
100 pageTitle: 'Migreren', bodyClass: 'on-special',
101 telling, fout, mb,
102 verslag: null,
103 bron: bronKandidaat(site), aliassen: aliasTekst(site), movedTo: (site && site.moved_to) || null,
104 mig: site ? Migration.migrationStatus(site.slug) : null,
105 haalVerslag: null,
106 success: req.query.success || null, error: req.query.error || fout || null,
107 });
108});
109
110/**
111 * FEP-1580: haal de berichten rechtstreeks bij je oude Klonkt op.
112 *
113 * Geen code, geen token: de autorisatie IS de Move die je al gedaan hebt. De
114 * oude instantie geeft ons zijn eigen kijkrechten omdat `moved_to` daar naar
115 * ons wijst, en dat veld staat er alleen als wij hem toen al in alsoKnownAs
116 * hadden. Beide kanten hebben dus ooit ja gezegd, en dat is precies waarom
117 * hier geen tweede vertrouwensmechanisme bij hoeft.
118 *
119 * De ingest kijkt die afspraak zelf nog een keer na, in beide richtingen. Dit
120 * is een deur, geen controle.
121 */
122router.post('/pull', requireGod, async (req, res) => {
123 const site = res.locals.site;
124 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
125 const opgegeven = String(req.body && req.body.bron ? req.body.bron : '').trim();
126 let r = null;
127 try {
128 r = await Migration.ingestFromSource(site, {
129 sourceUri: opgegeven || null,
130 deps: {
131 getJson: signedGetJson, safeFetch, mediaRoot: MEDIA_ROOT, audioRoot: AUDIO_ROOT, fs, path, noteId, noteVisibility, signHeaders: signedGetHeaders,
132 sanitize: (h) => HtmlSanitizerService.sanitize(h || ''),
133 },
134 });
135 } catch (e) {
136 r = { error: 'crash', melding: e && e.message };
137 }
138 const { telling } = tellen(site);
139 renderPage(req, res, 'pages/admin-migrate', {
140 pageTitle: 'Migreren', bodyClass: 'on-special',
141 telling, fout: null, mb, verslag: null,
142 bron: opgegeven || bronKandidaat(site), aliassen: aliasTekst(site), movedTo: site.moved_to || null,
143 mig: Migration.migrationStatus(site.slug),
144 haalVerslag: r,
145 success: (r && !r.error) ? 'Opgehaald' : null,
146 error: null,
147 });
148});
149
150/**
151 * Je oude account claimen (FEP-7628 alsoKnownAs).
152 *
153 * Stond op de site-bewerkpagina, tussen de kleuren en de feedinstellingen. Maar
154 * dit is stap EEN van een verhuizing, en zonder deze claim weigert de oude
155 * instantie de Move met `no_backreference`. Het hoort dus hier, boven de knop
156 * die hem nodig heeft.
157 */
158router.post('/aliases', requireGod, async (req, res) => {
159 const site = res.locals.site;
160 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
161 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
162 let lijst;
163 try {
164 lijst = await parseApAliases(req.body.ap_aliases, ActivityPubService.actorId(base, site.slug));
165 } catch (e) {
166 // Welke regel niet deugde, niet alleen DAT er iets niet deugde.
167 return res.redirect('/admin/migrate?error=' + encodeURIComponent(`Onbruikbaar adres: ${e && e.message}`));
168 }
169 db.prepare('UPDATE sites SET ap_aliases = ? WHERE slug = ?').run(JSON.stringify(lijst), site.slug);
170 res.redirect('/admin/migrate?success=' + encodeURIComponent(
171 lijst.length ? `${lijst.length} adres(sen) opgeslagen als jouw vorige account.` : 'Aliassen leeggemaakt.'));
172});
173
174/** Download het archief als zip. */
175router.get('/export', requireGod, (req, res) => {
176 const site = res.locals.site;
177 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
178 try {
179 const r = buildArchive(site.slug);
180 const zip = zipArchive(r.files);
181 if (zip.length > MAX_EXPORT) {
182 return res.redirect('/admin/migrate?error=' + encodeURIComponent(
183 `Het archief is ${mb(zip.length)} en dat is te groot voor de webinterface. Gebruik scripts/export-archive.mjs op de server.`));
184 }
185 const naam = `klonkt-${site.slug}-${new Date().toISOString().slice(0, 10)}.zip`;
186 res.set('Content-Type', 'application/zip');
187 res.set('Content-Disposition', `attachment; filename="${naam}"`);
188 // Privé: dit is je hele site, niets voor een cache onderweg.
189 res.set('Cache-Control', 'private, no-store');
190 res.send(zip);
191 } catch (e) {
192 res.redirect('/admin/migrate?error=' + encodeURIComponent(`Exporteren mislukt: ${e && e.message}`));
193 }
194});
195
196/**
197 * Importeren. Zonder `echt=1` is dit een DROOGLOOP: hij leest het archief, meldt
198 * wat er zou gebeuren, en raakt niets aan.
199 */
200router.post('/import', requireGod, upload, async (req, res) => {
201 const site = res.locals.site;
202 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
203 if (!req.file || !req.file.buffer || !req.file.buffer.length) {
204 return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen bestand ontvangen'));
205 }
206
207 const echt = String(req.body.echt || '') === '1';
208 const overschrijf = String(req.body.overschrijf || '') === '1';
209
210 let verslag = null;
211 let fout = null;
212 try {
213 const files = readArchiveZip(req.file.buffer);
214 verslag = importArchive(files, {
215 slug: site.slug,
216 dryRun: !echt,
217 overwrite: overschrijf,
218 });
219 } catch (e) {
220 fout = e && e.message;
221 }
222
223 // Bij een DROOGLOOP blijven we op de pagina met het verslag, zodat je kunt
224 // besluiten. Bij een echte import ook, maar dan met de uitkomst.
225 renderPage(req, res, 'pages/admin-migrate', {
226 pageTitle: 'Migreren', bodyClass: 'on-special',
227 telling: tellen(site).telling, fout: null, mb,
228 bron: bronKandidaat(site), aliassen: aliasTekst(site), movedTo: site.moved_to || null,
229 mig: Migration.migrationStatus(site.slug), haalVerslag: null,
230 verslag: verslag ? { ...verslag, echt, overschrijf, bestand: req.file.originalname, bytes: req.file.buffer.length } : null,
231 success: (echt && verslag && !fout) ? 'Archief geïmporteerd' : null,
232 error: fout ? `Importeren mislukt: ${fout}` : null,
233 });
234});
235
236export default router;
Note: See TracBrowser for help on using the repository browser.