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

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

De teller zweeg over je muziek

Robin liet een schermafbeelding van sound-fabrics.com zien: 36 berichten, 39
mediabestanden, 14 ontbrekende verwijzingen, 4,5 MB groot. Op een site met 140
nummers en 11 playlists. Zijn reactie: dit klopt dan niet.

Dat klopte inderdaad niet, en het was niet alleen de oude formaatversie. Ook
ná v2 zou de pagina er zo uitzien, want ik had tracks, playlists en
audioMissing wel in de service gezet en nooit op het scherm. Je exporteert dan
je hele bibliotheek en ziet nergens of hij erin zit. Dat is precies het stille
verlies dat v2 zou opruimen, alleen dan in de interface.

Drie dingen rechtgezet:

MUZIEK TELT APART. Niet onder "media". Die ene regel was waarom een export van
140 nummers er als "39 mediabestanden" uit kon zien.

DE TELLER TELT WAT ER ECHT MEEGAAT. Eerst stond er het aantal rijen in de
database, dus "8 nummers" met daaronder "van 3 ontbreekt het bestand". Dat telt
niet op. Nu staat er 5, en de waarschuwing vertelt de andere 3.

DE VERSLAGEN OOK. Import en ophalen lieten hun muziek net zo min zien. Bij een
mislukt nummer staat er nu bij waarom er GEEN track is aangemaakt, want anders
leest "3 niet aangekomen" als een fout in plaats van als de bedoeling.

Gemeten met 8 nummers waarvan 3 zoek: de pagina zegt "5 nummers" plus "van 3
nummer(s) is het geluidsbestand niet te vinden", en het importverslag zegt "5
nummers, 1 playlists, 3 niet aangekomen". Alles telt op.

Changed files:
src/views/pages/admin-migrate.ejs

  • nummers en playlists in de exporttelling, en het aantal dat ECHT meegaat
  • eigen waarschuwing voor ontbrekende audio, los van media
  • import- en ophaalverslag tonen nummers, playlists en wat er niet kwam
  • dode tak eruit die nooit kon vuren (tracks 0 EN audioMissing 0 EN muziek)

src/routes/admin-migrate.js

  • ontbrekend telt alleen nog media; audioMissing komt apart mee

src/services/i18n.js

  • mig.c_tracks / c_playlists / audio_missing / r_tracks_missing in nl/en/de

remarks: sound-fabrics.com draait nog v1, dus daar blijft die schermafbeelding
kloppen tot de update erop staat. Die deploy is nog niet gedaan.

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

  • Property mode set to 100644
File size: 8.5 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';
37
38const 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.
42const MAX_UPLOAD = 512 * 1024 * 1024;
43const MAX_EXPORT = 512 * 1024 * 1024;
44
45const upload = multer({
46 storage: multer.memoryStorage(),
47 limits: { fileSize: MAX_UPLOAD, files: 1 },
48}).single('archief');
49
50const 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. */
53function 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 // ontbrekend telt alleen de MEDIA-verwijzingen; audioMissing komt uit
60 // buildArchive zelf. Ze door elkaar husselen was precies hoe "39
61 // mediabestanden, 14 ontbrekend" een bibliotheek van 140 nummers kon
62 // verzwijgen.
63 const mediaWeg = r.missing.filter((m) => !m.track).length;
64 return {
65 telling: {
66 ...r.counts, bytes, groot: bytes > MAX_EXPORT,
67 ontbrekend: mediaWeg, audioMissing: r.counts.audioMissing || 0,
68 },
69 fout: null,
70 };
71 } catch (e) { return { telling: null, fout: e && e.message }; }
72}
73
74/**
75 * Waar zouden we vandaan kunnen halen? De alias die we zelf claimen (FEP-7628
76 * alsoKnownAs). Dat is niet toevallig hetzelfde veld als waar de ingest op
77 * controleert: het is de helft van de afspraak die je hier al gezet hebt.
78 */
79function bronKandidaat(site) {
80 try {
81 const aka = JSON.parse((site && site.ap_aliases) || '[]');
82 return Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
83 } catch { return null; }
84}
85
86/** De pagina. Toont wat er in een export zou zitten, zonder hem te bouwen. */
87router.get('/', requireGod, (req, res) => {
88 // Droog bouwen om te tellen. Dat kost hetzelfde geheugen als een echte export,
89 // dus hier meteen de grens bewaken in plaats van pas bij de download.
90 const site = res.locals.site;
91 const { telling, fout } = tellen(site);
92 renderPage(req, res, 'pages/admin-migrate', {
93 pageTitle: 'Migreren', bodyClass: 'on-special',
94 telling, fout, mb,
95 verslag: null,
96 bron: bronKandidaat(site),
97 mig: site ? Migration.migrationStatus(site.slug) : null,
98 haalVerslag: null,
99 success: req.query.success || null, error: req.query.error || fout || null,
100 });
101});
102
103/**
104 * FEP-1580: haal de berichten rechtstreeks bij je oude Klonkt op.
105 *
106 * Geen code, geen token: de autorisatie IS de Move die je al gedaan hebt. De
107 * oude instantie geeft ons zijn eigen kijkrechten omdat `moved_to` daar naar
108 * ons wijst, en dat veld staat er alleen als wij hem toen al in alsoKnownAs
109 * hadden. Beide kanten hebben dus ooit ja gezegd, en dat is precies waarom
110 * hier geen tweede vertrouwensmechanisme bij hoeft.
111 *
112 * De ingest kijkt die afspraak zelf nog een keer na, in beide richtingen. Dit
113 * is een deur, geen controle.
114 */
115router.post('/pull', requireGod, async (req, res) => {
116 const site = res.locals.site;
117 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
118 const opgegeven = String(req.body && req.body.bron ? req.body.bron : '').trim();
119 let r = null;
120 try {
121 r = await Migration.ingestFromSource(site, {
122 sourceUri: opgegeven || null,
123 deps: {
124 getJson: signedGetJson, safeFetch, mediaRoot: MEDIA_ROOT, audioRoot: AUDIO_ROOT, fs, path, noteId, noteVisibility, signHeaders: signedGetHeaders,
125 sanitize: (h) => HtmlSanitizerService.sanitize(h || ''),
126 },
127 });
128 } catch (e) {
129 r = { error: 'crash', melding: e && e.message };
130 }
131 const { telling } = tellen(site);
132 renderPage(req, res, 'pages/admin-migrate', {
133 pageTitle: 'Migreren', bodyClass: 'on-special',
134 telling, fout: null, mb, verslag: null,
135 bron: opgegeven || bronKandidaat(site),
136 mig: Migration.migrationStatus(site.slug),
137 haalVerslag: r,
138 success: (r && !r.error) ? 'Opgehaald' : null,
139 error: null,
140 });
141});
142
143/** Download het archief als zip. */
144router.get('/export', requireGod, (req, res) => {
145 const site = res.locals.site;
146 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
147 try {
148 const r = buildArchive(site.slug);
149 const zip = zipArchive(r.files);
150 if (zip.length > MAX_EXPORT) {
151 return res.redirect('/admin/migrate?error=' + encodeURIComponent(
152 `Het archief is ${mb(zip.length)} en dat is te groot voor de webinterface. Gebruik scripts/export-archive.mjs op de server.`));
153 }
154 const naam = `klonkt-${site.slug}-${new Date().toISOString().slice(0, 10)}.zip`;
155 res.set('Content-Type', 'application/zip');
156 res.set('Content-Disposition', `attachment; filename="${naam}"`);
157 // Privé: dit is je hele site, niets voor een cache onderweg.
158 res.set('Cache-Control', 'private, no-store');
159 res.send(zip);
160 } catch (e) {
161 res.redirect('/admin/migrate?error=' + encodeURIComponent(`Exporteren mislukt: ${e && e.message}`));
162 }
163});
164
165/**
166 * Importeren. Zonder `echt=1` is dit een DROOGLOOP: hij leest het archief, meldt
167 * wat er zou gebeuren, en raakt niets aan.
168 */
169router.post('/import', requireGod, upload, async (req, res) => {
170 const site = res.locals.site;
171 if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
172 if (!req.file || !req.file.buffer || !req.file.buffer.length) {
173 return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen bestand ontvangen'));
174 }
175
176 const echt = String(req.body.echt || '') === '1';
177 const overschrijf = String(req.body.overschrijf || '') === '1';
178
179 let verslag = null;
180 let fout = null;
181 try {
182 const files = readArchiveZip(req.file.buffer);
183 verslag = importArchive(files, {
184 slug: site.slug,
185 dryRun: !echt,
186 overwrite: overschrijf,
187 });
188 } catch (e) {
189 fout = e && e.message;
190 }
191
192 // Bij een DROOGLOOP blijven we op de pagina met het verslag, zodat je kunt
193 // besluiten. Bij een echte import ook, maar dan met de uitkomst.
194 renderPage(req, res, 'pages/admin-migrate', {
195 pageTitle: 'Migreren', bodyClass: 'on-special',
196 telling: tellen(site).telling, fout: null, mb,
197 bron: bronKandidaat(site), mig: Migration.migrationStatus(site.slug), haalVerslag: null,
198 verslag: verslag ? { ...verslag, echt, overschrijf, bestand: req.file.originalname, bytes: req.file.buffer.length } : null,
199 success: (echt && verslag && !fout) ? 'Archief geïmporteerd' : null,
200 error: fout ? `Importeren mislukt: ${fout}` : null,
201 });
202});
203
204export default router;
Note: See TracBrowser for help on using the repository browser.