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

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

De teksten op de migrate-pagina in mensentaal

Robin: er zijn miljoenen Klonkt-gebruikers, dus het moet gebruiksvriendelijk
worden gemaakt. Ik had die opmerking eerst gelezen als een schaalwaarschuwing
en zat prestaties te repareren. Dat leverde wel een echte fout op (7e474f7),
maar het was zijn vraag niet.

Wat er stond was voor mij geschreven, niet voor wie verhuist:

claimen, alias, actor-adres, instantie, AP-ids, fediverse,
"een bericht met dezelfde webadres-naam maar een ander id"

Achttien teksten herschreven in nl/en/de. "Je oude account claimen" werd "je
vorige account koppelen". De overschrijf-waarschuwing gaat niet meer over
unieke sleutels maar over wat je kwijtraakt. De foutmelding bij een ontbrekende
koppeling verwijst nu naar stap 1 op het scherm in plaats van naar een begrip.

DE VOORBEELDEN TONEN HANDLES. Er stond https://oude-klonkt.nl/ap/users/jij als
placeholder. Niemand kent zijn eigen actor-URL; mensen kennen
@robo@….

EN HET VELD GEEFT TERUG WAT JE GAF. Je typte een handle, wij slaan de actor-URL
op omdat het protocol daarop draait, en dan kreeg je een adres terug dat je
nooit had ingetypt. Nu tonen we de handle, en alleen als er echt een uit te
halen valt: een verkeerde handle is erger dan een lelijke URL. Opslaan leest
beide vormen, dus daar verandert niets.

Changed files:
src/services/i18n.js

  • 19 teksten herschreven in drie talen, jargon eruit

src/views/pages/admin-migrate.ejs

  • placeholders tonen @handle in plaats van een actor-URL

src/routes/admin-migrate.js

  • aliasTekst() toont handles, met de URL als terugval

test/migrate-page.test.js

  • een jargonwacht over 23 sleutels maal drie talen
  • de handle-heenweg en -terugweg
  • de stap-5-test toetst nu op BETEKENIS; hij brak omdat het woord "alias" verdween, en dat was precies de bedoeling

remarks: de indeling blijft zoals hij was (Robins keuze). Het grotere idee, een
vraag bovenaan of je hier binnenkomt of weggaat en dan alleen die helft tonen,
is niet gedaan. Suite 1016 groen.

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

  • Property mode set to 100644
File size: 11.6 KB
RevLine 
[fbfd7a1]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';
[6c4ff7e]31import { safeFetch, signedGetJson, signedGetHeaders, noteId, noteVisibility } from '../services/ActivityPubService.js';
[fbfd7a1]32import HtmlSanitizerService from '../services/HtmlSanitizerService.js';
[6c4ff7e]33import { MEDIA_ROOT, AUDIO_ROOT } from '../config/paths.js';
[fbfd7a1]34import * as Migration from '../services/MigrationService.js';
35import { buildArchive, zipArchive } from '../services/ArchiveExportService.js';
36import { readArchiveZip, importArchive } from '../services/ArchiveImportService.js';
[e9128bc]37import { parseApAliases } from './admin-sites.js';
38import db from '../config/database.js';
[fbfd7a1]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;
[9529d7b]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 };
[fbfd7a1]73 } catch (e) { return { telling: null, fout: e && e.message }; }
74}
75
[bfadc15]76/**
77 * De gekoppelde accounts als tekst, een per regel.
78 *
79 * Als HANDLE, niet als de opgeslagen URL. Je typt @jij@mastodon.social, wij
80 * slaan de actor-URL op omdat de rest van het protocol daarop draait, en dan
81 * kreeg je een adres terug dat je nooit hebt ingetypt en niet herkent. Wat je
82 * hier ziet hoort te lijken op wat je gaf.
83 *
84 * Alleen als er echt een handle uit te halen valt; anders de URL, want een
85 * verkeerde handle is erger dan een lelijke URL. parseApAliases leest beide
86 * vormen, dus opslaan blijft werken wat er ook in het veld staat.
87 */
[e9128bc]88function aliasTekst(site) {
[bfadc15]89 try {
90 const lijst = JSON.parse((site && site.ap_aliases) || '[]') || [];
91 return lijst.map((u) => {
92 const h = ActivityPubService.deriveHandle(u);
93 return /^@[^@\s]+@[^@\s]+$/.test(h) ? h : u;
94 }).join('\n');
95 } catch { return ''; }
[e9128bc]96}
97
[47f1183]98/**
99 * Waar sta je in de verhuizing?
100 *
101 * Deze pagina draait op BEIDE instanties en elke stap hoort maar op een van de
102 * twee. Zonder dat onderscheid leest de lijst als onzin op de helft van de
103 * schermen. Vandaar: afleiden wat we kunnen zien, en verder eerlijk zeggen
104 * waar iets thuishoort in plaats van het te raden.
105 */
106function stappen(site, mig) {
107 const alias = aliasTekst(site).trim();
108 return {
109 // moved_to gezet betekent: DIT is de instantie die vertrokken is.
110 isOud: !!(site && site.moved_to),
111 geclaimd: !!alias,
112 // Een Move in onze moves-collectie betekent dat de bron hierheen verhuisd is.
113 aangekondigd: !!(site && site.moved_to) || !!(mig && mig.moves > 0),
114 opgehaald: !!(mig && mig.total > 0),
115 };
116}
117
[fbfd7a1]118/**
119 * Waar zouden we vandaan kunnen halen? De alias die we zelf claimen (FEP-7628
120 * alsoKnownAs). Dat is niet toevallig hetzelfde veld als waar de ingest op
121 * controleert: het is de helft van de afspraak die je hier al gezet hebt.
122 */
123function bronKandidaat(site) {
124 try {
125 const aka = JSON.parse((site && site.ap_aliases) || '[]');
126 return Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
127 } catch { return null; }
128}
129
130/** De pagina. Toont wat er in een export zou zitten, zonder hem te bouwen. */
131router.get('/', requireGod, (req, res) => {
132 // Droog bouwen om te tellen. Dat kost hetzelfde geheugen als een echte export,
133 // dus hier meteen de grens bewaken in plaats van pas bij de download.
134 const site = res.locals.site;
135 const { telling, fout } = tellen(site);
[47f1183]136 const _mig = site ? Migration.migrationStatus(site.slug) : null;
[fbfd7a1]137 renderPage(req, res, 'pages/admin-migrate', {
138 pageTitle: 'Migreren', bodyClass: 'on-special',
139 telling, fout, mb,
140 verslag: null,
[e9128bc]141 bron: bronKandidaat(site), aliassen: aliasTekst(site), movedTo: (site && site.moved_to) || null,
[47f1183]142 mig: _mig, stap: stappen(site, _mig),
[fbfd7a1]143 haalVerslag: null,
144 success: req.query.success || null, error: req.query.error || fout || null,
145 });
146});
147
148/**
149 * FEP-1580: haal de berichten rechtstreeks bij je oude Klonkt op.
150 *
151 * Geen code, geen token: de autorisatie IS de Move die je al gedaan hebt. De
152 * oude instantie geeft ons zijn eigen kijkrechten omdat `moved_to` daar naar
153 * ons wijst, en dat veld staat er alleen als wij hem toen al in alsoKnownAs
154 * hadden. Beide kanten hebben dus ooit ja gezegd, en dat is precies waarom
155 * hier geen tweede vertrouwensmechanisme bij hoeft.
156 *
157 * De ingest kijkt die afspraak zelf nog een keer na, in beide richtingen. Dit
158 * is een deur, geen controle.
159 */
160router.post('/pull', requireGod, async (req, res) => {
161 const site = res.locals.site;
162 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
163 const opgegeven = String(req.body && req.body.bron ? req.body.bron : '').trim();
164 let r = null;
165 try {
166 r = await Migration.ingestFromSource(site, {
167 sourceUri: opgegeven || null,
168 deps: {
[6c4ff7e]169 getJson: signedGetJson, safeFetch, mediaRoot: MEDIA_ROOT, audioRoot: AUDIO_ROOT, fs, path, noteId, noteVisibility, signHeaders: signedGetHeaders,
[fbfd7a1]170 sanitize: (h) => HtmlSanitizerService.sanitize(h || ''),
171 },
172 });
173 } catch (e) {
174 r = { error: 'crash', melding: e && e.message };
175 }
176 const { telling } = tellen(site);
[47f1183]177 const _mig2 = Migration.migrationStatus(site.slug);
[fbfd7a1]178 renderPage(req, res, 'pages/admin-migrate', {
179 pageTitle: 'Migreren', bodyClass: 'on-special',
180 telling, fout: null, mb, verslag: null,
[e9128bc]181 bron: opgegeven || bronKandidaat(site), aliassen: aliasTekst(site), movedTo: site.moved_to || null,
[47f1183]182 mig: _mig2, stap: stappen(site, _mig2),
[fbfd7a1]183 haalVerslag: r,
184 success: (r && !r.error) ? 'Opgehaald' : null,
185 error: null,
186 });
187});
188
[e9128bc]189/**
190 * Je oude account claimen (FEP-7628 alsoKnownAs).
191 *
192 * Stond op de site-bewerkpagina, tussen de kleuren en de feedinstellingen. Maar
193 * dit is stap EEN van een verhuizing, en zonder deze claim weigert de oude
194 * instantie de Move met `no_backreference`. Het hoort dus hier, boven de knop
195 * die hem nodig heeft.
196 */
197router.post('/aliases', requireGod, async (req, res) => {
198 const site = res.locals.site;
199 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
200 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
201 let lijst;
202 try {
203 lijst = await parseApAliases(req.body.ap_aliases, ActivityPubService.actorId(base, site.slug));
204 } catch (e) {
205 // Welke regel niet deugde, niet alleen DAT er iets niet deugde.
206 return res.redirect('/admin/migrate?error=' + encodeURIComponent(`Onbruikbaar adres: ${e && e.message}`));
207 }
208 db.prepare('UPDATE sites SET ap_aliases = ? WHERE slug = ?').run(JSON.stringify(lijst), site.slug);
209 res.redirect('/admin/migrate?success=' + encodeURIComponent(
210 lijst.length ? `${lijst.length} adres(sen) opgeslagen als jouw vorige account.` : 'Aliassen leeggemaakt.'));
211});
212
[fbfd7a1]213/** Download het archief als zip. */
214router.get('/export', requireGod, (req, res) => {
215 const site = res.locals.site;
216 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
217 try {
218 const r = buildArchive(site.slug);
219 const zip = zipArchive(r.files);
220 if (zip.length > MAX_EXPORT) {
221 return res.redirect('/admin/migrate?error=' + encodeURIComponent(
222 `Het archief is ${mb(zip.length)} en dat is te groot voor de webinterface. Gebruik scripts/export-archive.mjs op de server.`));
223 }
224 const naam = `klonkt-${site.slug}-${new Date().toISOString().slice(0, 10)}.zip`;
225 res.set('Content-Type', 'application/zip');
226 res.set('Content-Disposition', `attachment; filename="${naam}"`);
227 // Privé: dit is je hele site, niets voor een cache onderweg.
228 res.set('Cache-Control', 'private, no-store');
229 res.send(zip);
230 } catch (e) {
231 res.redirect('/admin/migrate?error=' + encodeURIComponent(`Exporteren mislukt: ${e && e.message}`));
232 }
233});
234
235/**
236 * Importeren. Zonder `echt=1` is dit een DROOGLOOP: hij leest het archief, meldt
237 * wat er zou gebeuren, en raakt niets aan.
238 */
239router.post('/import', requireGod, upload, async (req, res) => {
240 const site = res.locals.site;
241 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
242 if (!req.file || !req.file.buffer || !req.file.buffer.length) {
243 return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen bestand ontvangen'));
244 }
245
246 const echt = String(req.body.echt || '') === '1';
247 const overschrijf = String(req.body.overschrijf || '') === '1';
248
249 let verslag = null;
250 let fout = null;
251 try {
252 const files = readArchiveZip(req.file.buffer);
253 verslag = importArchive(files, {
254 slug: site.slug,
255 dryRun: !echt,
256 overwrite: overschrijf,
257 });
258 } catch (e) {
259 fout = e && e.message;
260 }
261
262 // Bij een DROOGLOOP blijven we op de pagina met het verslag, zodat je kunt
263 // besluiten. Bij een echte import ook, maar dan met de uitkomst.
264 renderPage(req, res, 'pages/admin-migrate', {
265 pageTitle: 'Migreren', bodyClass: 'on-special',
266 telling: tellen(site).telling, fout: null, mb,
[e9128bc]267 bron: bronKandidaat(site), aliassen: aliasTekst(site), movedTo: site.moved_to || null,
[47f1183]268 mig: Migration.migrationStatus(site.slug), stap: stappen(site, Migration.migrationStatus(site.slug)), haalVerslag: null,
[fbfd7a1]269 verslag: verslag ? { ...verslag, echt, overschrijf, bestand: req.file.originalname, bytes: req.file.buffer.length } : null,
270 success: (echt && verslag && !fout) ? 'Archief geïmporteerd' : null,
271 error: fout ? `Importeren mislukt: ${fout}` : null,
272 });
273});
274
275export default router;
Note: See TracBrowser for help on using the repository browser.