| 1 | /**
|
|---|
| 2 | * Import van een draagbaar inhoudsarchief (shaer-pmr).
|
|---|
| 3 | *
|
|---|
| 4 | * Leest wat docs/EXPORT-FORMAT.md beschrijft. Vier regels uit dat document zijn
|
|---|
| 5 | * geen implementatiekeuze maar eis, en ze staan hier alle vier expliciet:
|
|---|
| 6 | *
|
|---|
| 7 | * VERSIE EERST Een hogere onbekende formatVersion wordt in zijn GEHEEL
|
|---|
| 8 | * geweigerd. Een half begrepen herstel is erger dan geen
|
|---|
| 9 | * herstel, want het ziet eruit alsof het gelukt is.
|
|---|
| 10 | * IDENTITEIT De origin uit het manifest bepaalt of de AP-ids behouden
|
|---|
| 11 | * blijven. Dat is geen vraag aan de gebruiker: een verkeerd
|
|---|
| 12 | * antwoord publiceert objecten onder een id dat je niet beheert.
|
|---|
| 13 | * NIETS STILS Ontbrekende media worden geteld en gemeld.
|
|---|
| 14 | * GEEN UITZENDING Geen Update de fediverse in. Verouderde kopieen elders
|
|---|
| 15 | * rechttrekken is een aparte, bewuste actie.
|
|---|
| 16 | *
|
|---|
| 17 | * `readable/` wordt nooit gelezen. Dat is de hele reden dat het afgeleid is.
|
|---|
| 18 | */
|
|---|
| 19 |
|
|---|
| 20 | import fs from 'fs';
|
|---|
| 21 | import path from 'path';
|
|---|
| 22 | import zlib from 'zlib';
|
|---|
| 23 | import crypto from 'crypto';
|
|---|
| 24 | import { randomUUID } from 'crypto';
|
|---|
| 25 | import db from '../config/database.js';
|
|---|
| 26 | import { MEDIA_ROOT, AUDIO_ROOT } from '../config/paths.js';
|
|---|
| 27 | import { FORMAT_VERSION, parseFollowingCsv } from './ArchiveExportService.js';
|
|---|
| 28 | import * as Migration from './MigrationService.js';
|
|---|
| 29 |
|
|---|
| 30 | const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
|
|---|
| 31 | // De tijdstempel gaat er ONGEWIJZIGD in. Omzetten naar SQL-notatie kostte de
|
|---|
| 32 | // sub-seconde, en twee posts in dezelfde seconde staan dan in willekeurige
|
|---|
| 33 | // volgorde. Klonkt schrijft zelf ook ISO in deze kolommen.
|
|---|
| 34 | const tijd = (iso) => (iso && !isNaN(Date.parse(iso)) ? String(iso) : null);
|
|---|
| 35 |
|
|---|
| 36 | // ── Inlezen ───────────────────────────────────────────────────────
|
|---|
| 37 |
|
|---|
| 38 | /** Lees een archiefmap in als pad -> Buffer. */
|
|---|
| 39 | export function readArchiveDir(dir) {
|
|---|
| 40 | const files = new Map();
|
|---|
| 41 | const loop = (sub) => {
|
|---|
| 42 | for (const naam of fs.readdirSync(path.join(dir, sub), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|---|
| 43 | const rel = sub ? `${sub}/${naam.name}` : naam.name;
|
|---|
| 44 | if (naam.isDirectory()) loop(rel);
|
|---|
| 45 | else files.set(rel, fs.readFileSync(path.join(dir, rel)));
|
|---|
| 46 | }
|
|---|
| 47 | };
|
|---|
| 48 | loop('');
|
|---|
| 49 | return files;
|
|---|
| 50 | }
|
|---|
| 51 |
|
|---|
| 52 | /**
|
|---|
| 53 | * Lees een zip in. Onze eigen export is store-only, maar een archief dat elders
|
|---|
| 54 | * gemaakt is mag deflate gebruiken -- anders is het geen uitwisselformaat.
|
|---|
| 55 | */
|
|---|
| 56 | export function readArchiveZip(buf) {
|
|---|
| 57 | const files = new Map();
|
|---|
| 58 | const eocd = (() => {
|
|---|
| 59 | for (let i = buf.length - 22; i >= 0 && i > buf.length - 66000; i--) if (buf.readUInt32LE(i) === 0x06054b50) return i;
|
|---|
| 60 | return -1;
|
|---|
| 61 | })();
|
|---|
| 62 | if (eocd < 0) throw new Error('geen zip: het eind-record ontbreekt');
|
|---|
| 63 | const aantal = buf.readUInt16LE(eocd + 10);
|
|---|
| 64 | let p = buf.readUInt32LE(eocd + 16);
|
|---|
| 65 | for (let n = 0; n < aantal; n++) {
|
|---|
| 66 | if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('beschadigde zip: centrale ingang klopt niet');
|
|---|
| 67 | const methode = buf.readUInt16LE(p + 10);
|
|---|
| 68 | const gecomp = buf.readUInt32LE(p + 20);
|
|---|
| 69 | const naamLen = buf.readUInt16LE(p + 28);
|
|---|
| 70 | const extraLen = buf.readUInt16LE(p + 30);
|
|---|
| 71 | const commentLen = buf.readUInt16LE(p + 32);
|
|---|
| 72 | const lokaalOffset = buf.readUInt32LE(p + 42);
|
|---|
| 73 | const naam = buf.toString('utf8', p + 46, p + 46 + naamLen);
|
|---|
| 74 | const lNaam = buf.readUInt16LE(lokaalOffset + 26);
|
|---|
| 75 | const lExtra = buf.readUInt16LE(lokaalOffset + 28);
|
|---|
| 76 | const start = lokaalOffset + 30 + lNaam + lExtra;
|
|---|
| 77 | const rauw = buf.subarray(start, start + gecomp);
|
|---|
| 78 | if (!naam.endsWith('/')) {
|
|---|
| 79 | files.set(naam, methode === 8 ? zlib.inflateRawSync(rauw) : Buffer.from(rauw));
|
|---|
| 80 | }
|
|---|
| 81 | p += 46 + naamLen + extraLen + commentLen;
|
|---|
| 82 | }
|
|---|
| 83 | return files;
|
|---|
| 84 | }
|
|---|
| 85 |
|
|---|
| 86 | export function readArchive(bron) {
|
|---|
| 87 | const st = fs.statSync(bron);
|
|---|
| 88 | return st.isDirectory() ? readArchiveDir(bron) : readArchiveZip(fs.readFileSync(bron));
|
|---|
| 89 | }
|
|---|
| 90 |
|
|---|
| 91 | // ── Importeren ────────────────────────────────────────────────────
|
|---|
| 92 |
|
|---|
| 93 | /** Een pad onder MEDIA_ROOT houden. Een archief van elders is invoer, geen vriend. */
|
|---|
| 94 | function veiligMediaPad(urlPad) {
|
|---|
| 95 | if (!urlPad || !urlPad.startsWith('/media/')) return null;
|
|---|
| 96 | const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(urlPad.slice('/media/'.length)));
|
|---|
| 97 | const root = path.resolve(MEDIA_ROOT);
|
|---|
| 98 | return (abs !== root && abs.startsWith(`${root}${path.sep}`)) ? abs : null;
|
|---|
| 99 | }
|
|---|
| 100 |
|
|---|
| 101 | /** Het pad-deel van een originele media-URL, of null als het er niet een van ons is. */
|
|---|
| 102 | function padVanOrigineel(u) {
|
|---|
| 103 | const s = String(u || '');
|
|---|
| 104 | if (s.startsWith('/media/')) return s;
|
|---|
| 105 | try { const x = new URL(s); return x.pathname.startsWith('/media/') ? x.pathname : null; } catch { return null; }
|
|---|
| 106 | }
|
|---|
| 107 |
|
|---|
| 108 | /**
|
|---|
| 109 | * Waar deze bijlage komt te staan, als site-relatief pad.
|
|---|
| 110 | *
|
|---|
| 111 | * Meestal zijn oorspronkelijke plek en bestemming gelijk. Maar een bestand dat
|
|---|
| 112 | * ELDERS werd geserveerd -- gehoste audio ging via /audio/stream/ -- heeft geen
|
|---|
| 113 | * plek onder /media. Zonder een bestemming zou het bestand wel worden
|
|---|
| 114 | * weggeschreven en toch uit de kolommen verdwijnen. Nu krijgt het een eigen hoek,
|
|---|
| 115 | * en verwijzen de kolommen daarheen.
|
|---|
| 116 | */
|
|---|
| 117 | function bestemming(a) {
|
|---|
| 118 | return padVanOrigineel(a && a['shaer:originalUrl']) || `/media/archief/${path.basename(String((a && a.url) || ''))}`;
|
|---|
| 119 | }
|
|---|
| 120 |
|
|---|
| 121 | /**
|
|---|
| 122 | * De audiobibliotheek terugzetten (formaat v2).
|
|---|
| 123 | *
|
|---|
| 124 | * EEN REGEL DIE HIER ALLES BEPAALT: geen bestand, geen track. Dat klinkt
|
|---|
| 125 | * vanzelfsprekend en was het niet. De oude per-post-tak maakte een
|
|---|
| 126 | * audio_tracks-rij aan zodra er metadata was, ook als de bytes ontbraken. Op
|
|---|
| 127 | * soundfabrics.nl leverde dat 13 nummers op die in de lijst stonden en 404'den
|
|---|
| 128 | * bij het afspelen. Dat is erger dan ontbreken: het ziet eruit alsof de
|
|---|
| 129 | * verhuizing gelukt is, dus je gooit de oude instantie weg.
|
|---|
| 130 | *
|
|---|
| 131 | * De bestanden gaan naar AUDIO_ROOT en niet onder MEDIA_ROOT, want daar hoort
|
|---|
| 132 | * gehoste audio: de publieke /media-handler mag er niet bij (routes/audio.js).
|
|---|
| 133 | *
|
|---|
| 134 | * @returns {Array} de schrijfopdrachten; de beller voert ze in zijn transactie uit
|
|---|
| 135 | * (en bij een droogloop dus niet, maar het verslag klopt wel)
|
|---|
| 136 | */
|
|---|
| 137 | /**
|
|---|
| 138 | * Een hoes uit het archief terugzetten. Geeft het nieuwe /media-pad terug, of
|
|---|
| 139 | * null als het bestand er niet in zat: dan liever GEEN cover_url dan een
|
|---|
| 140 | * verwijzing naar niets.
|
|---|
| 141 | */
|
|---|
| 142 | function hoesTerug(files, bestand, werk) {
|
|---|
| 143 | if (!bestand) return null;
|
|---|
| 144 | const bytes = files.get(bestand);
|
|---|
| 145 | if (!bytes || !bytes.length) return null;
|
|---|
| 146 | const naam = path.basename(String(bestand));
|
|---|
| 147 | if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) return null;
|
|---|
| 148 | const urlPad = `/media/archief/${naam}`;
|
|---|
| 149 | const doel = veiligMediaPad(urlPad);
|
|---|
| 150 | if (!doel) return null;
|
|---|
| 151 | werk.push({ soort: 'media', doel, bytes });
|
|---|
| 152 | return urlPad;
|
|---|
| 153 | }
|
|---|
| 154 |
|
|---|
| 155 | function tracksTerug(files, site, rapport) {
|
|---|
| 156 | const buf = files.get('tracks.json');
|
|---|
| 157 | if (!buf) return [];
|
|---|
| 158 | let coll;
|
|---|
| 159 | try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('tracks.json is onleesbaar'); return []; }
|
|---|
| 160 | if (coll['shaer:archive'] !== true) { rapport.waarschuwingen.push('tracks.json: niet gemarkeerd als archief, overgeslagen'); return []; }
|
|---|
| 161 |
|
|---|
| 162 | const werk = [];
|
|---|
| 163 | for (const t of (coll.orderedItems || [])) {
|
|---|
| 164 | const id = String(t.id || '').trim();
|
|---|
| 165 | if (!id) continue;
|
|---|
| 166 | const bestand = t['shaer:file'];
|
|---|
| 167 | const bytes = bestand ? files.get(bestand) : null;
|
|---|
| 168 | if (!bytes || !bytes.length) {
|
|---|
| 169 | rapport.tracksMissing += 1;
|
|---|
| 170 | rapport.waarschuwingen.push(`${t.name || id}: geluidsbestand zit niet in het archief, track niet aangemaakt`);
|
|---|
| 171 | continue; // de hele regel van deze functie
|
|---|
| 172 | }
|
|---|
| 173 | // Naam op de schijf: de hash uit het archief, met zijn extensie. De speler
|
|---|
| 174 | // zoekt op bestandsnaam in AUDIO_ROOT, dus dit is meteen het pad dat werkt.
|
|---|
| 175 | const naam = path.basename(String(bestand));
|
|---|
| 176 | if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) {
|
|---|
| 177 | rapport.waarschuwingen.push(`${t.name || id}: onbruikbare bestandsnaam, overgeslagen`);
|
|---|
| 178 | continue;
|
|---|
| 179 | }
|
|---|
| 180 | const hoes = hoesTerug(files, t['shaer:coverFile'], werk);
|
|---|
| 181 | werk.push({ soort: 'track', doel: path.join(path.resolve(AUDIO_ROOT), naam), bytes, id, t, naam, hoes });
|
|---|
| 182 | rapport.tracks += 1;
|
|---|
| 183 | }
|
|---|
| 184 | return werk;
|
|---|
| 185 | }
|
|---|
| 186 |
|
|---|
| 187 | /** De playlists terug, inclusief hun volgorde: die volgorde IS de playlist. */
|
|---|
| 188 | function playlistsTerug(files, site, rapport, bekendeTracks) {
|
|---|
| 189 | const buf = files.get('playlists.json');
|
|---|
| 190 | if (!buf) return [];
|
|---|
| 191 | let coll;
|
|---|
| 192 | try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('playlists.json is onleesbaar'); return []; }
|
|---|
| 193 | if (coll['shaer:archive'] !== true) return [];
|
|---|
| 194 | const werk = [];
|
|---|
| 195 | for (const p of (coll.orderedItems || [])) {
|
|---|
| 196 | const id = String(p.id || '').trim();
|
|---|
| 197 | if (!id) continue;
|
|---|
| 198 | // Alleen verwijzen naar tracks die er echt gekomen zijn, anders staat er
|
|---|
| 199 | // straks een playlist vol gaten die niemand kan afspelen.
|
|---|
| 200 | const items = (p['shaer:tracks'] || []).filter((x) => bekendeTracks.has(String(x && x.id)));
|
|---|
| 201 | const kwijt = (p['shaer:tracks'] || []).length - items.length;
|
|---|
| 202 | if (kwijt) rapport.waarschuwingen.push(`playlist ${p.name || id}: ${kwijt} nummer(s) ontbreken en zijn eruit gelaten`);
|
|---|
| 203 | const hoes = hoesTerug(files, p['shaer:coverFile'], werk);
|
|---|
| 204 | werk.push({ soort: 'playlist', p, id, items, hoes });
|
|---|
| 205 | rapport.playlists += 1;
|
|---|
| 206 | }
|
|---|
| 207 | return werk;
|
|---|
| 208 | }
|
|---|
| 209 |
|
|---|
| 210 | /**
|
|---|
| 211 | * Volg opnieuw wie je volgde, uit de `following.csv` van een archief.
|
|---|
| 212 | *
|
|---|
| 213 | * BEWUST BUITEN importArchive. Die draait in één transactie en raakt alleen de
|
|---|
| 214 | * database; opnieuw volgen stuurt Follow-activiteiten de deur uit en wacht op
|
|---|
| 215 | * het netwerk. Dat hoort niet in een transactie: een trage peer houdt hem open,
|
|---|
| 216 | * en een rollback neemt verzonden activiteiten niet terug.
|
|---|
| 217 | *
|
|---|
| 218 | * Ook een aparte, expliciete stap omdat een archief inlezen stil is maar negen
|
|---|
| 219 | * mensen aanschrijven niet. Dat mag geen bijwerking zijn van een import.
|
|---|
| 220 | *
|
|---|
| 221 | * `followFn` is injecteerbaar, zodat de test geen netwerk raakt en dit bestand
|
|---|
| 222 | * ActivityPubService niet hoeft te importeren.
|
|---|
| 223 | */
|
|---|
| 224 | export async function importFollowing(site, csvText, { followFn = null } = {}) {
|
|---|
| 225 | const rijen = parseFollowingCsv(csvText);
|
|---|
| 226 | const rapport = { totaal: rijen.length, gevolgd: 0, overgeslagen: 0, mislukt: [] };
|
|---|
| 227 | if (!followFn) return { ...rapport, error: 'no_follow_fn' };
|
|---|
| 228 | for (const r of rijen) {
|
|---|
| 229 | // Jezelf volgen is geen relatie maar een lus. Kan echt gebeuren bij een
|
|---|
| 230 | // archief van een instance die je onder een nieuwe naam opnieuw opzet.
|
|---|
| 231 | if (site && site.slug && r.address.startsWith(`${site.slug}@`)) { rapport.overgeslagen += 1; continue; }
|
|---|
| 232 | try {
|
|---|
| 233 | // De uitgelicht-stand gaat MEE in de Follow zelf: followActor neemt hem
|
|---|
| 234 | // als derde argument en zet auto_boost bij het aanmaken van de rij. Een
|
|---|
| 235 | // aparte UPDATE erna zou een tweede pad zijn naar dezelfde vlag, en dan
|
|---|
| 236 | // kan er precies een halve toestand ontstaan als die faalt.
|
|---|
| 237 | const ok = await followFn(site, r.address, r.featured);
|
|---|
| 238 | if (ok === false) { rapport.mislukt.push({ adres: r.address, reden: 'geweigerd' }); continue; }
|
|---|
| 239 | rapport.gevolgd += 1;
|
|---|
| 240 | } catch (e) {
|
|---|
| 241 | rapport.mislukt.push({ adres: r.address, reden: (e && e.message) || 'onbekend' });
|
|---|
| 242 | }
|
|---|
| 243 | }
|
|---|
| 244 | return rapport;
|
|---|
| 245 | }
|
|---|
| 246 |
|
|---|
| 247 | /**
|
|---|
| 248 | * Zet een archief terug in een site.
|
|---|
| 249 | *
|
|---|
| 250 | * @param {Map<string,Buffer>} files het ingelezen archief
|
|---|
| 251 | * @param {object} opts { slug, dryRun, overwrite, origin }
|
|---|
| 252 | */
|
|---|
| 253 | export function importArchive(files, opts = {}) {
|
|---|
| 254 | const rapport = {
|
|---|
| 255 | formatVersion: null, origin: null, idsBehouden: null,
|
|---|
| 256 | posts: 0, overgeslagen: 0, overschreven: 0,
|
|---|
| 257 | replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
|
|---|
| 258 | tracks: 0, tracksMissing: 0, playlists: 0, linksBijgetrokken: 0,
|
|---|
| 259 | };
|
|---|
| 260 |
|
|---|
| 261 | const manifestBuf = files.get('manifest.json');
|
|---|
| 262 | if (!manifestBuf) throw new Error('geen manifest.json: dit is geen inhoudsarchief');
|
|---|
| 263 | const manifest = JSON.parse(manifestBuf.toString('utf8'));
|
|---|
| 264 | rapport.formatVersion = manifest.formatVersion;
|
|---|
| 265 |
|
|---|
| 266 | // VERSIE EERST, voordat er ook maar iets gelezen wordt.
|
|---|
| 267 | if (!Number.isInteger(manifest.formatVersion)) throw new Error('manifest zonder bruikbare formatVersion');
|
|---|
| 268 | if (manifest.formatVersion > FORMAT_VERSION) {
|
|---|
| 269 | throw new Error(`archiefversie ${manifest.formatVersion} is nieuwer dan deze Klonkt kent (${FORMAT_VERSION}); geweigerd`);
|
|---|
| 270 | }
|
|---|
| 271 |
|
|---|
| 272 | const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(opts.slug);
|
|---|
| 273 | if (!site) {
|
|---|
| 274 | // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
|
|---|
| 275 | // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
|
|---|
| 276 | // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
|
|---|
| 277 | let bestaand = [];
|
|---|
| 278 | try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
|
|---|
| 279 | const wat = opts.slug ? `onbekende site: ${opts.slug}` : 'geen site opgegeven';
|
|---|
| 280 | throw new Error(bestaand.length
|
|---|
| 281 | ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
|
|---|
| 282 | : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
|
|---|
| 283 | }
|
|---|
| 284 | const eigenOrigin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
|---|
| 285 | rapport.origin = manifest.origin || null;
|
|---|
| 286 |
|
|---|
| 287 | // IDENTITEIT. Het INTERNE id blijft altijd (Robins besluit, 14-8).
|
|---|
| 288 | //
|
|---|
| 289 | // Dat is iets anders dan de AP-URI. Die is domeingebonden en wordt hoe dan
|
|---|
| 290 | // ook nieuw: https://nieuw/ap/notes/<id> is een ander adres dan
|
|---|
| 291 | // https://oud/ap/notes/<id>. Je claimt dus niets van een ander door het GUID
|
|---|
| 292 | // te hergebruiken, en je wint dat elke INTERNE verwijzing blijft kloppen:
|
|---|
| 293 | // [[track:]], [[playlist:]] en [[album:]] wijzen na een verhuizing nog naar
|
|---|
| 294 | // het goede ding.
|
|---|
| 295 | //
|
|---|
| 296 | // Voorheen hing dit aan de origin, en alleen voor posts; tracks en playlists
|
|---|
| 297 | // hielden hun id al wel. Die scheve tabel was precies waarom een post uit de
|
|---|
| 298 | // zip met [[track:oud]] naast een nummer uit de pull met een nieuw id kwam te
|
|---|
| 299 | // staan, en je de shorthand als kale tekst in je bericht zag.
|
|---|
| 300 | //
|
|---|
| 301 | // `idsBehouden` gaat hieronder alleen nog over de AP-URI: gelijke origin
|
|---|
| 302 | // betekent dat ook die identiek blijft, en dan valt er niets te vertalen.
|
|---|
| 303 | const idsBehouden = !!(manifest.origin && eigenOrigin && manifest.origin === eigenOrigin);
|
|---|
| 304 | rapport.idsBehouden = idsBehouden;
|
|---|
| 305 | if (!idsBehouden) {
|
|---|
| 306 | rapport.waarschuwingen.push(
|
|---|
| 307 | `origin verschilt (archief ${manifest.origin || '?'} vs deze site ${eigenOrigin || '?'}): de berichten krijgen een nieuw AP-adres. Hun interne id blijft, dus verwijzingen binnen je site blijven kloppen.`,
|
|---|
| 308 | );
|
|---|
| 309 | }
|
|---|
| 310 |
|
|---|
| 311 | const postPaden = [...files.keys()].filter((p) => p.startsWith('posts/') && p.endsWith('.json')).sort();
|
|---|
| 312 | const bestaatId = db.prepare('SELECT 1 FROM posts WHERE id = ?');
|
|---|
| 313 | const bestaatSlug = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?');
|
|---|
| 314 | const idKaart = new Map(); // oud post-id -> nieuw post-id
|
|---|
| 315 |
|
|---|
| 316 | const schrijf = []; // alles eerst uitrekenen, dan in EEN transactie
|
|---|
| 317 |
|
|---|
| 318 | for (const pad of postPaden) {
|
|---|
| 319 | const o = JSON.parse(files.get(pad).toString('utf8'));
|
|---|
| 320 | const oudId = decodeURIComponent(String(o.id || '').split('/ap/notes/')[1] || path.basename(pad, '.json'));
|
|---|
| 321 | // Altijd het id uit het archief. Staat er hier al iets met dat id, dan is
|
|---|
| 322 | // dat hetzelfde object, en dat handelt de botsingscontrole hieronder af.
|
|---|
| 323 | const nieuwId = oudId;
|
|---|
| 324 | idKaart.set(oudId, nieuwId);
|
|---|
| 325 |
|
|---|
| 326 | const botsing = !!bestaatId.get(nieuwId) || !!bestaatSlug.get(site.id, o['shaer:slug']);
|
|---|
| 327 | if (botsing && !opts.overwrite) {
|
|---|
| 328 | // EEN gedocumenteerde regel, geen gok per post: bestaande inhoud wordt niet
|
|---|
| 329 | // overschreven tenzij dat expliciet gevraagd is. Dit is ook wat de import
|
|---|
| 330 | // idempotent maakt.
|
|---|
| 331 | rapport.overgeslagen += 1;
|
|---|
| 332 | continue;
|
|---|
| 333 | }
|
|---|
| 334 | if (botsing) rapport.overschreven += 1;
|
|---|
| 335 |
|
|---|
| 336 | // Media: terug naar hun oorspronkelijke plek onder /media, want de content
|
|---|
| 337 | // van de post verwijst daarnaar. Dat pad is site-relatief, dus het werkt ook
|
|---|
| 338 | // op een ander domein.
|
|---|
| 339 | for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) {
|
|---|
| 340 | if (a['shaer:availability'] === 'missing') {
|
|---|
| 341 | rapport.mediaMissing += 1;
|
|---|
| 342 | rapport.gemist.push({ post: o['shaer:slug'], url: a['shaer:originalUrl'] || a.url });
|
|---|
| 343 | continue;
|
|---|
| 344 | }
|
|---|
| 345 | const bytes = files.get(a.url);
|
|---|
| 346 | if (!bytes) {
|
|---|
| 347 | // Het archief zegt 'included' maar het bestand ontbreekt. Dat is een kapot
|
|---|
| 348 | // archief, geen ontbrekende media -- apart melden, niet stil optellen.
|
|---|
| 349 | rapport.waarschuwingen.push(`archief verwijst naar ${a.url}, dat er niet in zit`);
|
|---|
| 350 | continue;
|
|---|
| 351 | }
|
|---|
| 352 | if (a['shaer:sha256'] && sha256(bytes) !== a['shaer:sha256']) {
|
|---|
| 353 | rapport.waarschuwingen.push(`${a.url}: checksum klopt niet, overgeslagen`);
|
|---|
| 354 | continue;
|
|---|
| 355 | }
|
|---|
| 356 | const doel = veiligMediaPad(bestemming(a));
|
|---|
| 357 | if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
|
|---|
| 358 | schrijf.push({ soort: 'media', doel, bytes });
|
|---|
| 359 | rapport.media += 1;
|
|---|
| 360 | }
|
|---|
| 361 |
|
|---|
| 362 | schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o, oudeUri: o.id || null });
|
|---|
| 363 | rapport.posts += 1;
|
|---|
| 364 | }
|
|---|
| 365 |
|
|---|
| 366 | // Antwoorden: alleen-lezen archief. Nooit opnieuw bezorgd, geen meldingen.
|
|---|
| 367 | for (const pad of [...files.keys()].filter((p) => p.startsWith('replies/')).sort()) {
|
|---|
| 368 | const coll = JSON.parse(files.get(pad).toString('utf8'));
|
|---|
| 369 | if (coll['shaer:archive'] !== true) {
|
|---|
| 370 | rapport.waarschuwingen.push(`${pad}: niet gemarkeerd als archief, overgeslagen`);
|
|---|
| 371 | continue;
|
|---|
| 372 | }
|
|---|
| 373 | const oudId = path.basename(pad, '.json');
|
|---|
| 374 | const postId = idKaart.get(oudId);
|
|---|
| 375 | if (!postId) continue; // post overgeslagen -> antwoorden ook
|
|---|
| 376 | for (const it of (coll.orderedItems || [])) {
|
|---|
| 377 | schrijf.push({ soort: 'reply', postId, it });
|
|---|
| 378 | rapport.replies += 1;
|
|---|
| 379 | }
|
|---|
| 380 | }
|
|---|
| 381 |
|
|---|
| 382 | // De audiobibliotheek. Telt ook in een droogloop mee in het verslag, want
|
|---|
| 383 | // "hoeveel nummers komen er" is precies wat je wilt weten voor je besluit.
|
|---|
| 384 | const trackWerk = tracksTerug(files, site, rapport);
|
|---|
| 385 | const bekendeTracks = new Set(trackWerk.map((w) => w.id));
|
|---|
| 386 | const playlistWerk = playlistsTerug(files, site, rapport, bekendeTracks);
|
|---|
| 387 |
|
|---|
| 388 | if (opts.dryRun) return rapport;
|
|---|
| 389 |
|
|---|
| 390 | // Schrijven pas nu, in EEN transactie: een half ingelezen archief is de ergste
|
|---|
| 391 | // uitkomst, want dan lijkt het gelukt.
|
|---|
| 392 | const insPost = db.prepare(`INSERT OR REPLACE INTO posts
|
|---|
| 393 | (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
|
|---|
| 394 | pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
|
|---|
| 395 | content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server)
|
|---|
| 396 | VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
|
|---|
| 397 | @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
|
|---|
| 398 | @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`);
|
|---|
| 399 | const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
|
|---|
| 400 | (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
|
|---|
| 401 | VALUES ('reply', ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`);
|
|---|
| 402 |
|
|---|
| 403 | db.transaction(() => {
|
|---|
| 404 | for (const s of [...schrijf, ...trackWerk, ...playlistWerk]) {
|
|---|
| 405 | if (s.soort === 'media') {
|
|---|
| 406 | fs.mkdirSync(path.dirname(s.doel), { recursive: true });
|
|---|
| 407 | fs.writeFileSync(s.doel, s.bytes);
|
|---|
| 408 | continue;
|
|---|
| 409 | }
|
|---|
| 410 | if (s.soort === 'reply') {
|
|---|
| 411 | insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
|
|---|
| 412 | s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
|
|---|
| 413 | continue;
|
|---|
| 414 | }
|
|---|
| 415 | if (s.soort === 'track') {
|
|---|
| 416 | // Bestand eerst, dan pas de rijen. Faalt het schrijven, dan gooit dit en
|
|---|
| 417 | // rolt de hele transactie terug: liever geen import dan een track zonder
|
|---|
| 418 | // geluid, want dat is precies de val waar dit uit voortkomt.
|
|---|
| 419 | fs.mkdirSync(path.dirname(s.doel), { recursive: true });
|
|---|
| 420 | fs.writeFileSync(s.doel, s.bytes);
|
|---|
| 421 | const mediaId = randomUUID();
|
|---|
| 422 | db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
|
|---|
| 423 | .run(mediaId, site.id, s.naam, s.t['shaer:mediaType'] || 'audio/mpeg', s.bytes.length, s.doel);
|
|---|
| 424 | const link = (k) => (s.t.url || []).find((u) => String(u).includes(k)) || null;
|
|---|
| 425 | db.prepare(`INSERT OR REPLACE INTO audio_tracks
|
|---|
| 426 | (id, site_id, title, artist, album, duration, media_id, position, credit, license,
|
|---|
| 427 | cover_url, downloadable, fedi_open, link_spotify, link_youtube, link_soundcloud)
|
|---|
| 428 | VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|---|
| 429 | .run(s.id, site.id, s.t.name || 'zonder titel', s.t.artist || null, s.t.album || null,
|
|---|
| 430 | s.t.duration || null, mediaId, s.t.position ?? null, s.t.credit || null, s.t.license || null,
|
|---|
| 431 | s.hoes || null, s.t['shaer:downloadable'] ? 1 : 0, s.t['shaer:fediOpen'] ? 1 : 0,
|
|---|
| 432 | link('spotify'), link('youtube'), link('soundcloud'));
|
|---|
| 433 | continue;
|
|---|
| 434 | }
|
|---|
| 435 | if (s.soort === 'playlist') {
|
|---|
| 436 | db.prepare(`INSERT OR REPLACE INTO playlists (id, site_id, title, artist, year, cover_url, kind)
|
|---|
| 437 | VALUES (?,?,?,?,?,?,?)`)
|
|---|
| 438 | .run(s.id, site.id, s.p.name || 'zonder titel', s.p.artist || null, s.p.year || null,
|
|---|
| 439 | s.hoes || null, s.p['shaer:kind'] || null);
|
|---|
| 440 | db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(s.id);
|
|---|
| 441 | const insPT = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
|
|---|
| 442 | s.items.forEach((it, i) => insPT.run(s.id, String(it.id), it.position ?? i));
|
|---|
| 443 | continue;
|
|---|
| 444 | }
|
|---|
| 445 | const o = s.obj;
|
|---|
| 446 | const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
|
|---|
| 447 | // De rollen uit het archief terug naar de kolommen. Zonder dit staat het
|
|---|
| 448 | // bestand er wel, maar komt de post zonder cover en zonder speler terug --
|
|---|
| 449 | // en dat zie je pas als je alle kolommen vergelijkt.
|
|---|
| 450 | const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
|
|---|
| 451 | const padVan = (a) => (a && a['shaer:availability'] !== 'missing' ? bestemming(a) : (a ? padVanOrigineel(a['shaer:originalUrl']) : null));
|
|---|
| 452 | const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
|
|---|
| 453 | const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
|
|---|
| 454 | const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a));
|
|---|
| 455 | return {
|
|---|
| 456 | url: padVan(a), mediaType: a.mediaType, name: a.name || undefined,
|
|---|
| 457 | poster: poster ? padVan(poster) : undefined,
|
|---|
| 458 | };
|
|---|
| 459 | }).filter((a) => a.url);
|
|---|
| 460 | insPost.run({
|
|---|
| 461 | id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
|
|---|
| 462 | title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
|
|---|
| 463 | status: o['shaer:status'] || 'draft',
|
|---|
| 464 | cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null,
|
|---|
| 465 | cover_video_url: padVan(metRol('coverVideo')),
|
|---|
| 466 | c2s_attachments: c2s.length ? JSON.stringify(c2s) : null,
|
|---|
| 467 | pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
|
|---|
| 468 | tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
|
|---|
| 469 | published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published),
|
|---|
| 470 | noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']),
|
|---|
| 471 | fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
|
|---|
| 472 | language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
|
|---|
| 473 | content_warning: o.summary || null,
|
|---|
| 474 | poll_json: opties ? JSON.stringify({ multiple: Array.isArray(o.anyOf), options: opties.map((x) => ({ name: x.name })), endTime: o.endTime || null, closed: !!o.closed }) : null,
|
|---|
| 475 | quote_uri: o.quoteUrl || null, quote_actor: o['shaer:quoteActor'] || null,
|
|---|
| 476 | ap_visibility: o['shaer:apVisibility'] || null,
|
|---|
| 477 | paid: o['shaer:paid'] ? 1 : 0, paid_min_cents: o['shaer:paidMinCents'] || null,
|
|---|
| 478 | view_count: o['shaer:viewCount'] || 0,
|
|---|
| 479 | });
|
|---|
| 480 | // De per-post audio-tak is weg. Tracks komen sinds v2 uit tracks.json,
|
|---|
| 481 | // dat de HELE bibliotheek draagt in plaats van alleen wat in een bericht
|
|---|
| 482 | // stond. Hier stond bovendien de fout die soundfabrics.nl opleverde: deze
|
|---|
| 483 | // lus maakte een audio_tracks-rij aan ZONDER te kijken of het bestand er
|
|---|
| 484 | // wel was, dus je kreeg 13 nummers die bestonden, in de lijst stonden, en
|
|---|
| 485 | // 404'den zodra je op play drukte. Zie tracksTerug hieronder.
|
|---|
| 486 | }
|
|---|
| 487 |
|
|---|
| 488 | // FEP-1580: een import uit een export is GEEN apart geval. De spec zegt
|
|---|
| 489 | // met zoveel woorden dat objecten uit een geëxporteerde collectie net zo
|
|---|
| 490 | // behandeld moeten worden als objecten die van de bron zijn opgehaald.
|
|---|
| 491 | // Dus vult ook deze weg de vertaaltabel, en werkt de reactie van een derde
|
|---|
| 492 | // op een verhuisd bericht straks net zo goed bij als bij een live ingest.
|
|---|
| 493 | //
|
|---|
| 494 | // Alleen zinnig als de ids VERANDERD zijn: bleven ze gelijk, dan wijst de
|
|---|
| 495 | // oude URI al naar het goede object en valt er niets te vertalen.
|
|---|
| 496 | if (!idsBehouden && eigenOrigin) {
|
|---|
| 497 | for (const s of schrijf) {
|
|---|
| 498 | if (s.soort !== 'post' || !s.oudeUri) continue;
|
|---|
| 499 | Migration.recordMigrated(site.slug, {
|
|---|
| 500 | origin: s.oudeUri,
|
|---|
| 501 | target: `${eigenOrigin}/ap/notes/${encodeURIComponent(s.id)}`,
|
|---|
| 502 | sourceActor: manifest.actor || '',
|
|---|
| 503 | isPublic: !(s.obj && (s.obj['shaer:fanOnly'] || s.obj['shaer:apVisibility'] === 'direct')),
|
|---|
| 504 | });
|
|---|
| 505 | }
|
|---|
| 506 | }
|
|---|
| 507 |
|
|---|
| 508 | // Links naar de BRONPOSTS ombuigen naar hier. Ook bij een zip-import: een
|
|---|
| 509 | // verhuizing is een verhuizing, en een bericht dat naar het oude domein
|
|---|
| 510 | // linkt wordt een dode link zodra dat domein opgezegd wordt. Binnen dezelfde
|
|---|
| 511 | // transactie, want half bijgetrokken is erger dan niet.
|
|---|
| 512 | //
|
|---|
| 513 | // Pas hier, aan het eind: nu staan alle berichten er, dus nu weten we welke
|
|---|
| 514 | // slugs bestaan. Een link naar iets dat we niet hebben blijft met rust.
|
|---|
| 515 | if (manifest.origin && manifest.origin !== eigenOrigin) {
|
|---|
| 516 | Migration.postLinksBijtrekken(site, String(manifest.origin).replace(/\/+$/, ''), rapport);
|
|---|
| 517 | }
|
|---|
| 518 | })();
|
|---|
| 519 |
|
|---|
| 520 | return rapport;
|
|---|
| 521 | }
|
|---|