Index: src/routes/admin-migrate.js
===================================================================
--- src/routes/admin-migrate.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
+++ src/routes/admin-migrate.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
@@ -0,0 +1,193 @@
+/**
+ * admin-migrate.js — Beheer → Migreren: je posts en media naar een andere Klonkt.
+ *
+ * Dezelfde machinerie als scripts/export-archive.mjs en import-archive.mjs, maar via
+ * de webinterface, zodat verhuizen geen SSH-toegang meer vraagt. De services doen
+ * het werk; deze routes zijn de deur.
+ *
+ * DRIE KEUZES DIE ER TOE DOEN:
+ *
+ * 1. IMPORTEREN GAAT ALTIJD EERST DROOG. Je krijgt een verslag te zien en pas
+ *    daarna een knop die het echt doet. Een archief inlezen is niet terug te
+ *    draaien, en "ik dacht dat ik alleen keek" is de duurste vergissing hier.
+ *
+ * 2. OVERSCHRIJVEN IS EEN APARTE, GEWAARSCHUWDE KEUZE. importArchive gebruikt
+ *    INSERT OR REPLACE, en dat verwijdert ELKE rij die een unieke sleutel schendt:
+ *    een post met een ander id maar dezelfde slug gaat dus stil mee (zie shaer-snv5).
+ *    Standaard slaat hij bestaande posts over, en dat blijft ook de standaard hier.
+ *
+ * 3. HET ARCHIEF WORDT IN GEHEUGEN GEBOUWD. buildArchive levert een Map van
+ *    Buffers; bij veel media is dat een forse allocatie. Daarom een harde grens en
+ *    een eerlijke melding in plaats van een proces dat omvalt. Streamen is
+ *    shaer-190t en hoort daar thuis, niet hier.
+ */
+import express from 'express';
+import multer from 'multer';
+import fs from 'fs';
+import path from 'path';
+import { renderPage } from '../middleware/render.js';
+import { requireGod } from '../middleware/auth.js';
+import ActivityPubService from '../services/ActivityPubService.js';
+import { safeFetch, signedGetJson, noteId, noteVisibility } from '../services/ActivityPubService.js';
+import HtmlSanitizerService from '../services/HtmlSanitizerService.js';
+import { MEDIA_ROOT } from '../config/paths.js';
+import * as Migration from '../services/MigrationService.js';
+import { buildArchive, zipArchive } from '../services/ArchiveExportService.js';
+import { readArchiveZip, importArchive } from '../services/ArchiveImportService.js';
+
+const router = express.Router();
+
+// Ruim genoeg voor een gewone site met media, klein genoeg om een instance niet om
+// te laten vallen op een archief dat iemand toevallig aanlevert.
+const MAX_UPLOAD = 512 * 1024 * 1024;
+const MAX_EXPORT = 512 * 1024 * 1024;
+
+const upload = multer({
+  storage: multer.memoryStorage(),
+  limits: { fileSize: MAX_UPLOAD, files: 1 },
+}).single('archief');
+
+const mb = (n) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} kB` : `${(n / (1024 * 1024)).toFixed(1)} MB`);
+
+/** Wat zou er in een export zitten? Droog gebouwd, dus zonder zip. */
+function tellen(site) {
+  if (!site) return { telling: null, fout: null };
+  try {
+    const r = buildArchive(site.slug);
+    let bytes = 0;
+    for (const buf of r.files.values()) bytes += buf.length;
+    return { telling: { ...r.counts, bytes, groot: bytes > MAX_EXPORT, ontbrekend: r.missing.length }, fout: null };
+  } catch (e) { return { telling: null, fout: e && e.message }; }
+}
+
+/**
+ * Waar zouden we vandaan kunnen halen? De alias die we zelf claimen (FEP-7628
+ * alsoKnownAs). Dat is niet toevallig hetzelfde veld als waar de ingest op
+ * controleert: het is de helft van de afspraak die je hier al gezet hebt.
+ */
+function bronKandidaat(site) {
+  try {
+    const aka = JSON.parse((site && site.ap_aliases) || '[]');
+    return Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
+  } catch { return null; }
+}
+
+/** De pagina. Toont wat er in een export zou zitten, zonder hem te bouwen. */
+router.get('/', requireGod, (req, res) => {
+  // Droog bouwen om te tellen. Dat kost hetzelfde geheugen als een echte export,
+  // dus hier meteen de grens bewaken in plaats van pas bij de download.
+  const site = res.locals.site;
+  const { telling, fout } = tellen(site);
+  renderPage(req, res, 'pages/admin-migrate', {
+    pageTitle: 'Migreren', bodyClass: 'on-special',
+    telling, fout, mb,
+    verslag: null,
+    bron: bronKandidaat(site),
+    mig: site ? Migration.migrationStatus(site.slug) : null,
+    haalVerslag: null,
+    success: req.query.success || null, error: req.query.error || fout || null,
+  });
+});
+
+/**
+ * FEP-1580: haal de berichten rechtstreeks bij je oude Klonkt op.
+ *
+ * Geen code, geen token: de autorisatie IS de Move die je al gedaan hebt. De
+ * oude instantie geeft ons zijn eigen kijkrechten omdat `moved_to` daar naar
+ * ons wijst, en dat veld staat er alleen als wij hem toen al in alsoKnownAs
+ * hadden. Beide kanten hebben dus ooit ja gezegd, en dat is precies waarom
+ * hier geen tweede vertrouwensmechanisme bij hoeft.
+ *
+ * De ingest kijkt die afspraak zelf nog een keer na, in beide richtingen. Dit
+ * is een deur, geen controle.
+ */
+router.post('/pull', requireGod, async (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
+  const opgegeven = String(req.body && req.body.bron ? req.body.bron : '').trim();
+  let r = null;
+  try {
+    r = await Migration.ingestFromSource(site, {
+      sourceUri: opgegeven || null,
+      deps: {
+        getJson: signedGetJson, safeFetch, mediaRoot: MEDIA_ROOT, fs, path, noteId, noteVisibility,
+        sanitize: (h) => HtmlSanitizerService.sanitize(h || ''),
+      },
+    });
+  } catch (e) {
+    r = { error: 'crash', melding: e && e.message };
+  }
+  const { telling } = tellen(site);
+  renderPage(req, res, 'pages/admin-migrate', {
+    pageTitle: 'Migreren', bodyClass: 'on-special',
+    telling, fout: null, mb, verslag: null,
+    bron: opgegeven || bronKandidaat(site),
+    mig: Migration.migrationStatus(site.slug),
+    haalVerslag: r,
+    success: (r && !r.error) ? 'Opgehaald' : null,
+    error: null,
+  });
+});
+
+/** Download het archief als zip. */
+router.get('/export', requireGod, (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
+  try {
+    const r = buildArchive(site.slug);
+    const zip = zipArchive(r.files);
+    if (zip.length > MAX_EXPORT) {
+      return res.redirect('/admin/migrate?error=' + encodeURIComponent(
+        `Het archief is ${mb(zip.length)} en dat is te groot voor de webinterface. Gebruik scripts/export-archive.mjs op de server.`));
+    }
+    const naam = `klonkt-${site.slug}-${new Date().toISOString().slice(0, 10)}.zip`;
+    res.set('Content-Type', 'application/zip');
+    res.set('Content-Disposition', `attachment; filename="${naam}"`);
+    // Privé: dit is je hele site, niets voor een cache onderweg.
+    res.set('Cache-Control', 'private, no-store');
+    res.send(zip);
+  } catch (e) {
+    res.redirect('/admin/migrate?error=' + encodeURIComponent(`Exporteren mislukt: ${e && e.message}`));
+  }
+});
+
+/**
+ * Importeren. Zonder `echt=1` is dit een DROOGLOOP: hij leest het archief, meldt
+ * wat er zou gebeuren, en raakt niets aan.
+ */
+router.post('/import', requireGod, upload, async (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
+  if (!req.file || !req.file.buffer || !req.file.buffer.length) {
+    return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen bestand ontvangen'));
+  }
+
+  const echt = String(req.body.echt || '') === '1';
+  const overschrijf = String(req.body.overschrijf || '') === '1';
+
+  let verslag = null;
+  let fout = null;
+  try {
+    const files = readArchiveZip(req.file.buffer);
+    verslag = importArchive(files, {
+      slug: site.slug,
+      dryRun: !echt,
+      overwrite: overschrijf,
+    });
+  } catch (e) {
+    fout = e && e.message;
+  }
+
+  // Bij een DROOGLOOP blijven we op de pagina met het verslag, zodat je kunt
+  // besluiten. Bij een echte import ook, maar dan met de uitkomst.
+  renderPage(req, res, 'pages/admin-migrate', {
+    pageTitle: 'Migreren', bodyClass: 'on-special',
+    telling: tellen(site).telling, fout: null, mb,
+    bron: bronKandidaat(site), mig: Migration.migrationStatus(site.slug), haalVerslag: null,
+    verslag: verslag ? { ...verslag, echt, overschrijf, bestand: req.file.originalname, bytes: req.file.buffer.length } : null,
+    success: (echt && verslag && !fout) ? 'Archief geïmporteerd' : null,
+    error: fout ? `Importeren mislukt: ${fout}` : null,
+  });
+});
+
+export default router;
