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

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

Audio verhuisde niet mee: archiefformaat v2

Robin verhuisde naar soundfabrics.nl en meldde: de berichten zijn er, de
plaatjes zijn er, een deel van de audio staat er maar speelt niet af, en
daarnaast mist er veel. Drie losse oorzaken, alle drie echt.

Gemeten op de twee live instanties:

berichten 36 -> 36
tracks 140 -> 14, waarvan 13 stuk
playlists 11 -> 0

DE EXPORTER LAS storage_path, DE SPELER NIET. routes/audio.js resolvet met
AUDIO_DIR plus bestandsnaam en kijkt niet naar media.storage_path. Op
sound-fabrics.com wees storage_path voor 124 van de 139 audiorijen nog naar
/srv/prutfolio/storage/audio, van voor de dataverhuizing. De site speelde
gewoon door; de exporter vond niets, markeerde availability: missing, en liet
124 nummers stil achter. Vanuit de exporter gezien was het bestand er echt niet,
dus er kwam ook geen klacht. Nu is er een resolveAudioPath die zoekt zoals de
speler zoekt.

DE IMPORTER MAAKTE EEN TRACK ZONDER BESTAND. Bij gewone bijlagen werd
availability: missing netjes gecontroleerd, in de audio-tak eronder niet. Die
zette een media-rij met size 0 en een audio_tracks-rij neer voor bytes die nooit
zijn weggeschreven. Dat is erger dan ontbreken: het ziet eruit alsof de
verhuizing gelukt is, dus je zegt je oude instantie op. Nu geldt er een harde
regel: geen bestand, geen track.

AUDIO PASTE NIET IN HET FORMAAT. Gehoste audio staat buiten MEDIA_ROOT, achter
een eigen gated route, en het archief droeg alleen bestanden onder media/. De
exporter rekende er met path.relative een /media/../audio/x.mp3 van, en
veiligMediaPad weigert dat pad terecht. Audio kon dus principieel niet
aankomen. Daarbovenop ging alleen mee wat met [[track:]] in een bericht stond,
14 van de 140, en playlists helemaal niet.

De ophaalknop leerde audio erbij. Dat vroeg meer dan verwacht: de bron toont
over AP alleen fedi_open-nummers, en de bestanden zitten achter dezelfde poort.
Beide staan nu open voor precies de doel-actor van de Move, en de ingest haalt
de bytes ONDERTEKEND op. De eerste poging gaf drie keer "nummer niet opgehaald",
want haalBijlage deed een kale fetch en kreeg een 403 van onze eigen poort.

Bewezen op twee draaiende instanties, met een track die opzettelijk een
verouderd pad had. Zip: 3/3 included, playlist-volgorde intact, bestanden op
schijf met de goede grootte, /audio/stream geeft 200. Ophaalknop: 5 berichten
plus 3 tracks met echte bytes, ook de gesloten nummers.

Changed files:
src/config/paths.js

  • AUDIO_ROOT en resolveAudioPath(): een audiobestand zoeken zoals de speler het zoekt, met het opgeslagen pad als terugval

src/services/ArchiveExportService.js

  • FORMAT_VERSION 2
  • audioBibliotheek(): de HELE tracklijst plus de playlists, met de bestanden in een eigen audio/-gebied
  • [[track:]] weg uit de media-tak; die kon nooit aankomen
  • audioOf() wijst naar het archiefpad in plaats van naar een bijlage

src/services/ArchiveImportService.js

  • tracksTerug() en playlistsTerug(); de per-post audio-tak is weg
  • bestanden naar AUDIO_ROOT, en geen rij zonder bestand
  • een playlist verwijst nooit naar een nummer dat niet aankwam
  • nieuwe rapportvelden tracks / tracksMissing / playlists

src/services/MigrationService.js

  • de ingest loopt de tracks-collectie af en haalt de bytes ondertekend op
  • haalBijlage kreeg submap en headers

src/services/ActivityPubService.js

  • signedGetHeaders() losgetrokken uit signedGetJson, want een verhuizing moet ook bytes kunnen ophalen

src/services/music/index.js

  • siteOpenTracks({ alles }): de doel-actor ziet ook de gesloten nummers

src/routes/audio.js

  • isMoveTargetAudio(): een ondertekend verzoek van de doel-actor mag alle audio ophalen, ook wat niet fedi_open is

src/routes/activitypub.js

  • magAlles(): een plek voor de vraag "mag deze aanvrager alles zien"

src/routes/admin-migrate.js

  • AUDIO_ROOT en signedGetHeaders doorgegeven aan de ingest

test/archive-export.test.js

  • de audio-testen naar het v2-contract, plus bibliotheek en ontbrekend
  • resolveAudioPath getoetst op VOLGORDE met een nep-fs

New file:
test/archive-audio.test.js

  • de hele rondgang met audio, inclusief het verouderde pad, de playlist- volgorde en de droogloop

remarks: soundfabrics.nl staat nu met 13 lege tracks; die verdwijnen niet
vanzelf bij een nieuwe import. En een archief van voor deze commit is v1 en
bevat de audio niet, dus opnieuw exporteren is nodig. Suite 985 groen;
tegenproef gedaan op de resolver en op de geen-bestand-geen-track-regel.

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

  • Property mode set to 100644
File size: 8.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';
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 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 */
68function 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. */
76router.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 */
104router.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, audioRoot: AUDIO_ROOT, fs, path, noteId, noteVisibility, signHeaders: signedGetHeaders,
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. */
133router.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 */
158router.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
193export default router;
Note: See TracBrowser for help on using the repository browser.