| 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 | */
|
|---|
| 24 | import express from 'express';
|
|---|
| 25 | import multer from 'multer';
|
|---|
| 26 | import fs from 'fs';
|
|---|
| 27 | import path from 'path';
|
|---|
| 28 | import { renderPage } from '../middleware/render.js';
|
|---|
| 29 | import { requireGod } from '../middleware/auth.js';
|
|---|
| 30 | import ActivityPubService from '../services/ActivityPubService.js';
|
|---|
| 31 | import { safeFetch, signedGetJson, noteId, noteVisibility } from '../services/ActivityPubService.js';
|
|---|
| 32 | import HtmlSanitizerService from '../services/HtmlSanitizerService.js';
|
|---|
| 33 | import { MEDIA_ROOT } from '../config/paths.js';
|
|---|
| 34 | import * as Migration from '../services/MigrationService.js';
|
|---|
| 35 | import { buildArchive, zipArchive } from '../services/ArchiveExportService.js';
|
|---|
| 36 | import { readArchiveZip, importArchive } from '../services/ArchiveImportService.js';
|
|---|
| 37 |
|
|---|
| 38 | const 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.
|
|---|
| 42 | const MAX_UPLOAD = 512 * 1024 * 1024;
|
|---|
| 43 | const MAX_EXPORT = 512 * 1024 * 1024;
|
|---|
| 44 |
|
|---|
| 45 | const upload = multer({
|
|---|
| 46 | storage: multer.memoryStorage(),
|
|---|
| 47 | limits: { fileSize: MAX_UPLOAD, files: 1 },
|
|---|
| 48 | }).single('archief');
|
|---|
| 49 |
|
|---|
| 50 | const 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. */
|
|---|
| 53 | function 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 | */
|
|---|
| 68 | function 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. */
|
|---|
| 76 | router.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 | */
|
|---|
| 104 | router.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. */
|
|---|
| 133 | router.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 | */
|
|---|
| 158 | router.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 |
|
|---|
| 193 | export default router;
|
|---|