| 1 | /**
|
|---|
| 2 | * Export van een draagbaar inhoudsarchief (shaer-1a6).
|
|---|
| 3 | *
|
|---|
| 4 | * Bouwt precies wat docs/EXPORT-FORMAT.md beschrijft. Lees dat eerst; hier staat
|
|---|
| 5 | * alleen wat de code doet, niet waarom het formaat zo is.
|
|---|
| 6 | *
|
|---|
| 7 | * Twee dingen zijn geen implementatiedetail maar eis:
|
|---|
| 8 | *
|
|---|
| 9 | * REPRODUCEERBAAR Twee exports van ongewijzigde inhoud horen byte-voor-byte
|
|---|
| 10 | * gelijk te zijn, anders is een diff of een checksum nutteloos.
|
|---|
| 11 | * Vandaar gesorteerde sleutels, vaste volgorde, geen tijdstip
|
|---|
| 12 | * in de postbestanden en een vaste mtime in de zip.
|
|---|
| 13 | * GEEN CREDENTIALS Dit is niet de storage-zip uit shaer-190t. Hier komt geen
|
|---|
| 14 | * sleutel, sessie, hash of DM van een ander in.
|
|---|
| 15 | */
|
|---|
| 16 |
|
|---|
| 17 | import fs from 'fs';
|
|---|
| 18 | import path from 'path';
|
|---|
| 19 | import crypto from 'crypto';
|
|---|
| 20 | import db from '../config/database.js';
|
|---|
| 21 | import { MEDIA_ROOT, resolveAudioPath } from '../config/paths.js';
|
|---|
| 22 |
|
|---|
| 23 | // v2: audio zit er eindelijk echt in. Tot v1 kon dat niet: gehoste audio staat
|
|---|
| 24 | // BUITEN MEDIA_ROOT (eigen gated route, zie routes/audio.js), en het archief
|
|---|
| 25 | // droeg alleen bestanden onder media/. De exporter rekende er met path.relative
|
|---|
| 26 | // een /media/../audio/x.mp3 van, en de importer weigerde dat pad terecht. Er
|
|---|
| 27 | // stond dus wel een track in de database van de nieuwe site, maar nooit een
|
|---|
| 28 | // bestand. v2 heeft een eigen audio/-gebied, exporteert de HELE bibliotheek in
|
|---|
| 29 | // plaats van alleen wat in een bericht staat, en neemt de playlists mee.
|
|---|
| 30 | export const FORMAT_VERSION = 2;
|
|---|
| 31 |
|
|---|
| 32 | /** JSON met gesorteerde sleutels: zonder vaste volgorde is byte-gelijkheid toeval. */
|
|---|
| 33 | export function stableJson(value) {
|
|---|
| 34 | const sorteer = (v) => {
|
|---|
| 35 | if (Array.isArray(v)) return v.map(sorteer);
|
|---|
| 36 | if (v && typeof v === 'object') {
|
|---|
| 37 | const uit = {};
|
|---|
| 38 | for (const k of Object.keys(v).sort()) if (v[k] !== undefined) uit[k] = sorteer(v[k]);
|
|---|
| 39 | return uit;
|
|---|
| 40 | }
|
|---|
| 41 | return v;
|
|---|
| 42 | };
|
|---|
| 43 | return `${JSON.stringify(sorteer(value), null, 2)}\n`;
|
|---|
| 44 | }
|
|---|
| 45 |
|
|---|
| 46 | const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
|
|---|
| 47 | /**
|
|---|
| 48 | * Naar ISO 8601 in UTC.
|
|---|
| 49 | *
|
|---|
| 50 | * SQLite schrijft CURRENT_TIMESTAMP als "2026-07-01 12:56:10" -- in UTC, maar
|
|---|
| 51 | * ZONDER zone erbij. Date.parse leest die vorm als LOKALE tijd, en dan schuift
|
|---|
| 52 | * elk tijdstempel in het archief mee met de tijdzone van de machine die de export
|
|---|
| 53 | * draait. Op een server in Amsterdam is dat twee uur, en dat merk je pas als je
|
|---|
| 54 | * ergens anders importeert.
|
|---|
| 55 | *
|
|---|
| 56 | * Gevonden doordat Bart vroeg of dit wel naar UTC normaliseert. De testmachine
|
|---|
| 57 | * draait op UTC, dus geen enkele test kon het zien.
|
|---|
| 58 | */
|
|---|
| 59 | const toISO = (d) => {
|
|---|
| 60 | if (!d) return null;
|
|---|
| 61 | const s = String(d).trim();
|
|---|
| 62 | const zonderZone = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(s);
|
|---|
| 63 | const t = Date.parse(zonderZone ? `${s.replace(' ', 'T')}Z` : s);
|
|---|
| 64 | return isNaN(t) ? null : new Date(t).toISOString();
|
|---|
| 65 | };
|
|---|
| 66 |
|
|---|
| 67 | const MIME_BY_EXT = {
|
|---|
| 68 | jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
|
|---|
| 69 | webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml',
|
|---|
| 70 | mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
|
|---|
| 71 | mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac',
|
|---|
| 72 | };
|
|---|
| 73 | const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
|
|---|
| 74 | const mimeOf = (u) => MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
|
|---|
| 75 | const as2TypeOf = (mime) => (mime.startsWith('video/') ? 'Video' : mime.startsWith('audio/') ? 'Audio' : 'Image');
|
|---|
| 76 |
|
|---|
| 77 | /**
|
|---|
| 78 | * Van een media-URL naar een bestand op schijf.
|
|---|
| 79 | *
|
|---|
| 80 | * /media is een kale express.static op MEDIA_ROOT, dus het URL-pad IS het pad
|
|---|
| 81 | * onder die map. Een absolute URL naar onze eigen origin telt net zo goed als
|
|---|
| 82 | * een pad -- de content slaat allebei op.
|
|---|
| 83 | *
|
|---|
| 84 | * De ../-controle is geen formaliteit: een verzonnen pad in oude inhoud zou
|
|---|
| 85 | * anders een willekeurig bestand van de schijf het archief in trekken.
|
|---|
| 86 | */
|
|---|
| 87 | function localMediaPath(url, origin) {
|
|---|
| 88 | let p = String(url || '');
|
|---|
| 89 | if (!p) return null;
|
|---|
| 90 | if (/^https?:/i.test(p)) {
|
|---|
| 91 | try {
|
|---|
| 92 | const u = new URL(p);
|
|---|
| 93 | if (`${u.protocol}//${u.host}` !== origin) return null; // andermans host: nooit van onze schijf
|
|---|
| 94 | p = u.pathname;
|
|---|
| 95 | } catch { return null; }
|
|---|
| 96 | }
|
|---|
| 97 | if (!p.startsWith('/media/')) return null;
|
|---|
| 98 | const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(p.slice('/media/'.length)));
|
|---|
| 99 | const root = path.resolve(MEDIA_ROOT);
|
|---|
| 100 | if (abs !== root && !abs.startsWith(`${root}${path.sep}`)) return null;
|
|---|
| 101 | return abs;
|
|---|
| 102 | }
|
|---|
| 103 |
|
|---|
| 104 | /** Alle media waar een post naar wijst, in vaste volgorde en zonder dubbelen. */
|
|---|
| 105 | function mediaRefsOf(post, origin) {
|
|---|
| 106 | const uit = [];
|
|---|
| 107 | const zie = new Set();
|
|---|
| 108 | const voegToe = (url, name, rol, extra = {}) => {
|
|---|
| 109 | const u = String(url || '').trim();
|
|---|
| 110 | if (!u || zie.has(u)) return;
|
|---|
| 111 | zie.add(u);
|
|---|
| 112 | uit.push({ url: u, name: name || null, rol, ...extra });
|
|---|
| 113 | };
|
|---|
| 114 | // De ROL is niet decoratief. Zonder rol staat er in het archief wel een
|
|---|
| 115 | // bestand, maar niet dat het de cover was of bij de speler hoorde -- en dan
|
|---|
| 116 | // komt de post na een herstel zonder cover en zonder speler terug. Gevonden
|
|---|
| 117 | // door bij de oefenherstel ALLE kolommen te vergelijken in plaats van een
|
|---|
| 118 | // handjevol.
|
|---|
| 119 | voegToe(post.cover_image_url, post.cover_alt, 'cover');
|
|---|
| 120 | voegToe(post.cover_video_url, post.cover_alt, 'coverVideo');
|
|---|
| 121 | for (const m of String(post.content || '').matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) voegToe(m[1], null, 'inline');
|
|---|
| 122 | try {
|
|---|
| 123 | for (const a of JSON.parse(post.c2s_attachments || '[]')) {
|
|---|
| 124 | voegToe(a && a.url, a && a.name, 'c2s');
|
|---|
| 125 | // Een audio-bijlage draagt een poster (de omslag die de speler toont). Die
|
|---|
| 126 | // staat in een eigen veld en zou anders stil wegvallen -- op beta viel dat
|
|---|
| 127 | // pas op bij de export van echte data.
|
|---|
| 128 | voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null, 'poster', { posterFor: a && a.url });
|
|---|
| 129 | }
|
|---|
| 130 | } catch { /* kapotte kolom blokkeert de export niet */ }
|
|---|
| 131 | // Gehoste audio staat hier NIET meer bij. Die leeft buiten MEDIA_ROOT en gaat
|
|---|
| 132 | // sinds v2 via het audio/-gebied (zie audioBibliotheek). De oude regel rekende
|
|---|
| 133 | // met path.relative een pad naar buiten MEDIA_ROOT uit, en dat kon nooit
|
|---|
| 134 | // aankomen: de importer weigert zo'n pad, terecht.
|
|---|
| 135 | return uit;
|
|---|
| 136 | }
|
|---|
| 137 |
|
|---|
| 138 | /**
|
|---|
| 139 | * De audio-metadata die alleen in de database staat en nergens anders uit te
|
|---|
| 140 | * halen is: titel, artiest, credit, licentie, externe links.
|
|---|
| 141 | *
|
|---|
| 142 | * `shaer:media` koppelt de track aan zijn bestand in het archief. Zonder die
|
|---|
| 143 | * verwijzing weet een importer wel dát er een track was en hoe hij heette, maar
|
|---|
| 144 | * niet wélk van de bijlagen erbij hoort -- en dan valt [[track:]] bij een
|
|---|
| 145 | * herstel op niets terug.
|
|---|
| 146 | */
|
|---|
| 147 | function audioOf(post, audioKaart) {
|
|---|
| 148 | const uit = [];
|
|---|
| 149 | for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
|
|---|
| 150 | try {
|
|---|
| 151 | const t = db.prepare('SELECT * FROM audio_tracks WHERE id = ?').get(m[1]);
|
|---|
| 152 | if (!t) continue;
|
|---|
| 153 | // Sinds v2 wijst dit naar het audio/-gebied. Staat de track er niet in
|
|---|
| 154 | // (bestand onvindbaar), dan blijft het veld LEEG in plaats van naar een
|
|---|
| 155 | // bijlage te wijzen die er niet is.
|
|---|
| 156 | const bestand = audioKaart.get(t.id) || undefined;
|
|---|
| 157 | uit.push({
|
|---|
| 158 | 'shaer:ref': `[[track:${t.id}]]`,
|
|---|
| 159 | 'shaer:media': bestand,
|
|---|
| 160 | name: t.title, artist: t.artist || undefined, album: t.album || undefined,
|
|---|
| 161 | duration: t.duration || undefined, credit: t.credit || undefined, license: t.license || undefined,
|
|---|
| 162 | url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
|
|---|
| 163 | });
|
|---|
| 164 | } catch { /* idem */ }
|
|---|
| 165 | }
|
|---|
| 166 | return uit.length ? uit : undefined;
|
|---|
| 167 | }
|
|---|
| 168 |
|
|---|
| 169 | /**
|
|---|
| 170 | * De HELE audiobibliotheek, plus de playlists.
|
|---|
| 171 | *
|
|---|
| 172 | * Tot v1 ging alleen mee wat met [[track:]] in een bericht stond. Op
|
|---|
| 173 | * sound-fabrics.com waren dat er 14 van de 140, en de 11 playlists gingen
|
|---|
| 174 | * helemaal niet mee. Een verhuizing die je bibliotheek achterlaat is geen
|
|---|
| 175 | * verhuizing.
|
|---|
| 176 | *
|
|---|
| 177 | * Het bestand wordt gezocht met resolveAudioPath, dus op DEZELFDE manier als de
|
|---|
| 178 | * speler het zoekt. Dat verschil was de stille moordenaar: 124 van de 139
|
|---|
| 179 | * storage_paths waren verouderd na een dataverhuizing, de site speelde gewoon
|
|---|
| 180 | * door, en de export liet ze weg zonder dat iemand het merkte.
|
|---|
| 181 | *
|
|---|
| 182 | * @returns {Map<string,string>} trackId -> pad in het archief
|
|---|
| 183 | */
|
|---|
| 184 | function audioBibliotheek(site, bestanden, tellingen, ontbrekend) {
|
|---|
| 185 | const kaart = new Map();
|
|---|
| 186 | let tracks = [];
|
|---|
| 187 | try {
|
|---|
| 188 | tracks = db.prepare(`SELECT t.*, m.storage_path, m.mime_type FROM audio_tracks t
|
|---|
| 189 | LEFT JOIN media m ON m.id = t.media_id
|
|---|
| 190 | WHERE t.site_id = ?
|
|---|
| 191 | ORDER BY COALESCE(t.position, 999999), t.created_at, t.id`).all(site.id);
|
|---|
| 192 | } catch { return kaart; } // installatie zonder audio-tabellen
|
|---|
| 193 | if (!tracks.length) return kaart;
|
|---|
| 194 |
|
|---|
| 195 | const items = [];
|
|---|
| 196 | for (const t of tracks) {
|
|---|
| 197 | const schijf = resolveAudioPath(t.storage_path, fs);
|
|---|
| 198 | let naam = null;
|
|---|
| 199 | let hash = null;
|
|---|
| 200 | if (schijf) {
|
|---|
| 201 | try {
|
|---|
| 202 | const bytes = fs.readFileSync(schijf);
|
|---|
| 203 | hash = sha256(bytes);
|
|---|
| 204 | const ext = (path.extname(schijf).slice(1) || 'mp3').toLowerCase();
|
|---|
| 205 | naam = `audio/${hash}.${ext}`;
|
|---|
| 206 | if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.audio += 1; }
|
|---|
| 207 | kaart.set(t.id, naam);
|
|---|
| 208 | } catch { naam = null; } // onleesbaar telt als ontbrekend, niet als stilte
|
|---|
| 209 | }
|
|---|
| 210 | if (!naam) {
|
|---|
| 211 | tellingen.audioMissing += 1;
|
|---|
| 212 | ontbrekend.push({ track: t.title || t.id, url: t.storage_path || '(geen mediarij)' });
|
|---|
| 213 | }
|
|---|
| 214 | items.push({
|
|---|
| 215 | id: t.id, name: t.title || '', artist: t.artist || undefined, album: t.album || undefined,
|
|---|
| 216 | duration: t.duration || undefined, position: t.position ?? undefined,
|
|---|
| 217 | credit: t.credit || undefined, license: t.license || undefined,
|
|---|
| 218 | 'shaer:coverUrl': t.cover_url || undefined,
|
|---|
| 219 | 'shaer:downloadable': t.downloadable ? 1 : 0,
|
|---|
| 220 | 'shaer:fediOpen': t.fedi_open ? 1 : 0,
|
|---|
| 221 | 'shaer:mediaType': t.mime_type || 'audio/mpeg',
|
|---|
| 222 | 'shaer:file': naam || undefined,
|
|---|
| 223 | 'shaer:sha256': hash || undefined,
|
|---|
| 224 | // Derde staat, net als bij media: we weten DAT het bestond en waar het
|
|---|
| 225 | // stond. Stil weglaten zou een leugen zijn, en de importer moet hierop
|
|---|
| 226 | // kunnen weigeren in plaats van een track zonder bestand aan te maken.
|
|---|
| 227 | 'shaer:availability': naam ? 'included' : 'missing',
|
|---|
| 228 | 'shaer:originalPath': naam ? undefined : (t.storage_path || undefined),
|
|---|
| 229 | url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
|
|---|
| 230 | });
|
|---|
| 231 | }
|
|---|
| 232 | bestanden.set('tracks.json', Buffer.from(stableJson({
|
|---|
| 233 | '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
|
|---|
| 234 | type: 'OrderedCollection', 'shaer:archive': true, totalItems: items.length, orderedItems: items,
|
|---|
| 235 | }), 'utf8'));
|
|---|
| 236 | tellingen.tracks = items.length;
|
|---|
| 237 |
|
|---|
| 238 | // Playlists: de volgorde IS de playlist, dus die moet expliciet mee.
|
|---|
| 239 | try {
|
|---|
| 240 | const pls = db.prepare('SELECT * FROM playlists WHERE site_id = ? ORDER BY created_at, id').all(site.id);
|
|---|
| 241 | if (pls.length) {
|
|---|
| 242 | const lijst = pls.map((p) => ({
|
|---|
| 243 | id: p.id, name: p.title || '', artist: p.artist || undefined, year: p.year || undefined,
|
|---|
| 244 | 'shaer:kind': p.kind || undefined, 'shaer:coverUrl': p.cover_url || undefined,
|
|---|
| 245 | 'shaer:tracks': db.prepare('SELECT track_id, position FROM playlist_tracks WHERE playlist_id = ? ORDER BY position')
|
|---|
| 246 | .all(p.id).map((r) => ({ id: r.track_id, position: r.position })),
|
|---|
| 247 | }));
|
|---|
| 248 | bestanden.set('playlists.json', Buffer.from(stableJson({
|
|---|
| 249 | '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
|
|---|
| 250 | type: 'OrderedCollection', 'shaer:archive': true, totalItems: lijst.length, orderedItems: lijst,
|
|---|
| 251 | }), 'utf8'));
|
|---|
| 252 | tellingen.playlists = lijst.length;
|
|---|
| 253 | }
|
|---|
| 254 | } catch { /* geen playlist-tabellen */ }
|
|---|
| 255 |
|
|---|
| 256 | return kaart;
|
|---|
| 257 | }
|
|---|
| 258 |
|
|---|
| 259 | /** Eén post als AS2-object volgens het formaat. Bijlagen komen van de beller. */
|
|---|
| 260 | function postObject(post, site, origin, attachments, audioKaart) {
|
|---|
| 261 | const heeftTitel = !!(post.title && String(post.title).trim());
|
|---|
| 262 | const published = toISO(post.published_at || post.created_at) || toISO(post.created_at);
|
|---|
| 263 | const updated = toISO(post.updated_at);
|
|---|
| 264 | const poll = (() => {
|
|---|
| 265 | try {
|
|---|
| 266 | const d = JSON.parse(post.poll_json || 'null');
|
|---|
| 267 | if (!d || !Array.isArray(d.options) || d.options.length < 2) return null;
|
|---|
| 268 | const opties = d.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) }));
|
|---|
| 269 | return { multiple: !!d.multiple, opties, endTime: d.endTime || null, closed: !!d.closed };
|
|---|
| 270 | } catch { return null; }
|
|---|
| 271 | })();
|
|---|
| 272 | const tags = [];
|
|---|
| 273 | try {
|
|---|
| 274 | for (const t of String(post.tags || '').split(',').map((x) => x.trim()).filter(Boolean)) {
|
|---|
| 275 | tags.push({ type: 'Hashtag', name: t.startsWith('#') ? t : `#${t}`, href: `${origin}/tag/${encodeURIComponent(t.replace(/^#/, ''))}` });
|
|---|
| 276 | }
|
|---|
| 277 | } catch { /* tags zijn optioneel */ }
|
|---|
| 278 |
|
|---|
| 279 | return {
|
|---|
| 280 | '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
|
|---|
| 281 | id: `${origin}/ap/notes/${encodeURIComponent(post.id)}`,
|
|---|
| 282 | type: poll ? 'Question' : (heeftTitel ? 'Article' : 'Note'),
|
|---|
| 283 | attributedTo: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
|
|---|
| 284 | name: heeftTitel ? post.title : undefined,
|
|---|
| 285 | content: post.content || '',
|
|---|
| 286 | contentMap: post.language ? { [post.language]: post.content || '' } : undefined,
|
|---|
| 287 | summary: post.content_warning || undefined,
|
|---|
| 288 | sensitive: post.nsfw ? true : undefined,
|
|---|
| 289 | published,
|
|---|
| 290 | updated: (updated && updated !== published) ? updated : undefined,
|
|---|
| 291 | url: `${origin}/${encodeURIComponent(post.slug)}`,
|
|---|
| 292 | attachment: attachments.length ? attachments : undefined,
|
|---|
| 293 | tag: tags.length ? tags : undefined,
|
|---|
| 294 | ...(poll ? (poll.multiple ? { anyOf: poll.opties } : { oneOf: poll.opties }) : {}),
|
|---|
| 295 | endTime: poll ? (poll.endTime || undefined) : undefined,
|
|---|
| 296 | // AS2 kent `closed` op een Question. Zonder dit staat een poll die vroegtijdig
|
|---|
| 297 | // is gesloten na een herstel weer open -- gevonden op echte beta-data.
|
|---|
| 298 | closed: (poll && poll.closed) ? true : undefined,
|
|---|
| 299 | quoteUrl: post.quote_uri || undefined,
|
|---|
| 300 | 'shaer:quoteActor': post.quote_actor || undefined,
|
|---|
| 301 | 'shaer:slug': post.slug,
|
|---|
| 302 | 'shaer:status': post.status || 'draft',
|
|---|
| 303 | 'shaer:excerpt': post.excerpt || undefined,
|
|---|
| 304 | 'shaer:type': post.type || undefined,
|
|---|
| 305 | 'shaer:pinned': post.pinned ? true : undefined,
|
|---|
| 306 | 'shaer:noindex': post.noindex ? true : undefined,
|
|---|
| 307 | 'shaer:fanOnly': post.fan_only ? true : undefined,
|
|---|
| 308 | 'shaer:paid': post.paid ? true : undefined,
|
|---|
| 309 | 'shaer:paidMinCents': post.paid ? (post.paid_min_cents || undefined) : undefined,
|
|---|
| 310 | 'shaer:apVisibility': post.ap_visibility || undefined,
|
|---|
| 311 | 'shaer:publishAt': toISO(post.publish_at) || undefined,
|
|---|
| 312 | 'shaer:coverAlt': post.cover_alt || undefined,
|
|---|
| 313 | 'shaer:viewCount': post.view_count || undefined,
|
|---|
| 314 | 'shaer:audio': audioOf(post, audioKaart),
|
|---|
| 315 | };
|
|---|
| 316 | }
|
|---|
| 317 |
|
|---|
| 318 | /** De leesbare kopie. Afgeleid, eenrichtingsverkeer -- de importer kijkt hier nooit naar. */
|
|---|
| 319 | function readableMarkdown(post, obj) {
|
|---|
| 320 | const fm = [
|
|---|
| 321 | '---',
|
|---|
| 322 | `title: ${JSON.stringify(post.title || post.slug)}`,
|
|---|
| 323 | `slug: ${JSON.stringify(post.slug)}`,
|
|---|
| 324 | `date: ${obj.published || ''}`,
|
|---|
| 325 | `status: ${post.status || 'draft'}`,
|
|---|
| 326 | post.content_warning ? `content_warning: ${JSON.stringify(post.content_warning)}` : null,
|
|---|
| 327 | '---',
|
|---|
| 328 | ].filter((l) => l !== null).join('\n');
|
|---|
| 329 | return `${fm}\n${post.content || ''}\n`;
|
|---|
| 330 | }
|
|---|
| 331 |
|
|---|
| 332 | // ── Wie je volgt, als CSV ─────────────────────────────────────────
|
|---|
| 333 | //
|
|---|
| 334 | // Kolomvorm van Mastodon, zodat deze lijst ook DAAR te importeren is en die van
|
|---|
| 335 | // daar hier. Dat is het hele punt van een verhuisformaat: het moet ook werken
|
|---|
| 336 | // als je naar iets anders vertrekt dan waar je vandaan kwam.
|
|---|
| 337 | //
|
|---|
| 338 | // Account address,Show boosts,Notify on new posts,Languages,Featured
|
|---|
| 339 | //
|
|---|
| 340 | // `Featured` is onze kolom en draagt `auto_boost`: het vinkje dat in de UI
|
|---|
| 341 | // "Uitgelicht" heet (tl.autoboost) en hun posts in jouw Cirkel laat meelopen.
|
|---|
| 342 | //
|
|---|
| 343 | // `Show boosts` blijft LEEG. Dat is bij Mastodon "toon de reblogs van deze
|
|---|
| 344 | // persoon in mijn tijdlijn", en dat kent Klonkt niet. De verleiding is groot om
|
|---|
| 345 | // er auto_boost in te schrijven omdat in beide het woord boost zit, maar het is
|
|---|
| 346 | // een ander ding: dat van ons gaat over hun eigen posts in JOUW Cirkel, niet
|
|---|
| 347 | // over andermans posts die zij doorgeven. `Notify` en `Languages` kent Klonkt
|
|---|
| 348 | // evenmin. Die drie staan er omdat Mastodon de POSITIES telt.
|
|---|
| 349 | const CSV_KOP = 'Account address,Show boosts,Notify on new posts,Languages,Featured';
|
|---|
| 350 |
|
|---|
| 351 | /** Een veld dat een komma, aanhalingsteken of nieuwe regel bevat moet geciteerd. */
|
|---|
| 352 | function csvVeld(v) {
|
|---|
| 353 | const s = String(v == null ? '' : v);
|
|---|
| 354 | return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|---|
| 355 | }
|
|---|
| 356 |
|
|---|
| 357 | /**
|
|---|
| 358 | * De volglijst van een site als CSV, of null als er niets te melden valt.
|
|---|
| 359 | *
|
|---|
| 360 | * Alleen `accepted`: een openstaand verzoek is geen relatie, en het opnieuw
|
|---|
| 361 | * versturen ervan op de nieuwe plek zou een tweede verzoek zijn bij iemand die
|
|---|
| 362 | * de eerste misschien bewust liet liggen.
|
|---|
| 363 | *
|
|---|
| 364 | * Het adres is de handle zonder de leidende @, want zo schrijft Mastodon hem.
|
|---|
| 365 | * Ontbreekt de handle, dan valt hij terug op de actor-URI: die is altijd te
|
|---|
| 366 | * herleiden, ook als de webfinger-naam ooit verloren ging.
|
|---|
| 367 | */
|
|---|
| 368 | export function followingCsv(slug) {
|
|---|
| 369 | let rijen = [];
|
|---|
| 370 | try {
|
|---|
| 371 | rijen = db.prepare(`SELECT actor_uri, handle, auto_boost FROM ap_following
|
|---|
| 372 | WHERE slug = ? AND status = 'accepted'
|
|---|
| 373 | ORDER BY handle IS NULL, handle, actor_uri`).all(slug);
|
|---|
| 374 | } catch { return null; } // oude database zonder de kolom
|
|---|
| 375 | if (!rijen.length) return null;
|
|---|
| 376 | const regels = rijen.map((r) => [
|
|---|
| 377 | csvVeld((r.handle || r.actor_uri || '').replace(/^@/, '')),
|
|---|
| 378 | '', // Show boosts: niet van ons
|
|---|
| 379 | '', // Notify on new posts: idem
|
|---|
| 380 | '', // Languages: idem
|
|---|
| 381 | r.auto_boost ? 'true' : 'false', // Featured: het vinkje "Uitgelicht"
|
|---|
| 382 | ].join(','));
|
|---|
| 383 | return `${CSV_KOP}\n${regels.join('\n')}\n`;
|
|---|
| 384 | }
|
|---|
| 385 |
|
|---|
| 386 | /**
|
|---|
| 387 | * Lees zo'n CSV terug. Puur, zodat de vorm te toetsen is zonder database.
|
|---|
| 388 | *
|
|---|
| 389 | * Vergeeflijk met opzet: een bestand uit Mastodon heeft vier kolommen en geen
|
|---|
| 390 | * `Featured`, een handgemaakt bestand heeft misschien alleen adressen. Beide
|
|---|
| 391 | * moeten werken, want anders is het geen uitwisselformaat maar een eigen
|
|---|
| 392 | * bestandje dat toevallig op een CSV lijkt.
|
|---|
| 393 | */
|
|---|
| 394 | export function parseFollowingCsv(text) {
|
|---|
| 395 | const uit = [];
|
|---|
| 396 | const regels = String(text || '').split(/\r?\n/).filter((r) => r.trim());
|
|---|
| 397 | if (!regels.length) return uit;
|
|---|
| 398 | // Een kopregel herkennen we aan het eerste veld; anders is regel 1 al data.
|
|---|
| 399 | const start = /^\s*"?account address"?\s*(,|$)/i.test(regels[0]) ? 1 : 0;
|
|---|
| 400 | for (const regel of regels.slice(start)) {
|
|---|
| 401 | const velden = splitsCsvRegel(regel);
|
|---|
| 402 | const adres = (velden[0] || '').trim().replace(/^@/, '');
|
|---|
| 403 | if (!adres) continue;
|
|---|
| 404 | uit.push({
|
|---|
| 405 | address: adres,
|
|---|
| 406 | // Alleen kolom 5. Een bestand uit Mastodon heeft die niet en levert dus
|
|---|
| 407 | // `false`, en dat is juist: hun "Show boosts" in kolom 2 gaat over iets
|
|---|
| 408 | // anders en mag hier niet als uitgelicht binnenkomen.
|
|---|
| 409 | featured: /^(true|1|yes)$/i.test((velden[4] || '').trim()),
|
|---|
| 410 | });
|
|---|
| 411 | }
|
|---|
| 412 | return uit;
|
|---|
| 413 | }
|
|---|
| 414 |
|
|---|
| 415 | /** Eén CSV-regel, met respect voor geciteerde velden en verdubbelde aanhalingstekens. */
|
|---|
| 416 | function splitsCsvRegel(regel) {
|
|---|
| 417 | const velden = [];
|
|---|
| 418 | let veld = '';
|
|---|
| 419 | let inCitaat = false;
|
|---|
| 420 | for (let i = 0; i < regel.length; i++) {
|
|---|
| 421 | const c = regel[i];
|
|---|
| 422 | if (inCitaat) {
|
|---|
| 423 | if (c === '"') {
|
|---|
| 424 | if (regel[i + 1] === '"') { veld += '"'; i++; } else inCitaat = false;
|
|---|
| 425 | } else veld += c;
|
|---|
| 426 | } else if (c === '"') inCitaat = true;
|
|---|
| 427 | else if (c === ',') { velden.push(veld); veld = ''; }
|
|---|
| 428 | else veld += c;
|
|---|
| 429 | }
|
|---|
| 430 | velden.push(veld);
|
|---|
| 431 | return velden;
|
|---|
| 432 | }
|
|---|
| 433 |
|
|---|
| 434 | /**
|
|---|
| 435 | * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
|
|---|
| 436 | *
|
|---|
| 437 | * Bewust geen schrijven naar schijf hier: dat maakt de vorm testbaar zonder
|
|---|
| 438 | * tijdelijke mappen, en de beller bepaalt of het een map of een zip wordt.
|
|---|
| 439 | */
|
|---|
| 440 | export function buildArchive(slug, opts = {}) {
|
|---|
| 441 | const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
|---|
| 442 | const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
|
|---|
| 443 | if (!site) {
|
|---|
| 444 | // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
|
|---|
| 445 | // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
|
|---|
| 446 | // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
|
|---|
| 447 | let bestaand = [];
|
|---|
| 448 | try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
|
|---|
| 449 | const wat = slug ? `onbekende site: ${slug}` : 'geen site opgegeven';
|
|---|
| 450 | throw new Error(bestaand.length
|
|---|
| 451 | ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
|
|---|
| 452 | : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
|
|---|
| 453 | }
|
|---|
| 454 |
|
|---|
| 455 | const bestanden = new Map(); // pad -> Buffer
|
|---|
| 456 | const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0, audio: 0, audioMissing: 0, tracks: 0, playlists: 0 };
|
|---|
| 457 | const ontbrekend = []; // voor de rapportage van de beller
|
|---|
| 458 |
|
|---|
| 459 | // De audiobibliotheek EERST. De posts verwijzen ernaar met [[track:]], dus de
|
|---|
| 460 | // kaart moet klaar zijn voor de eerste post gebouwd wordt.
|
|---|
| 461 | const audioKaart = audioBibliotheek(site, bestanden, tellingen, ontbrekend);
|
|---|
| 462 |
|
|---|
| 463 | // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
|
|---|
| 464 | // is de volgorde van twee posts op dezelfde seconde niet bepaald.
|
|---|
| 465 | const posts = db.prepare(`SELECT * FROM posts WHERE site_id = ?
|
|---|
| 466 | ORDER BY COALESCE(published_at, created_at) ASC, id ASC`).all(site.id);
|
|---|
| 467 |
|
|---|
| 468 | for (const post of posts) {
|
|---|
| 469 | const attachments = [];
|
|---|
| 470 | for (const ref of mediaRefsOf(post, origin)) {
|
|---|
| 471 | const schijf = localMediaPath(ref.url, origin);
|
|---|
| 472 | const mime = mimeOf(ref.url);
|
|---|
| 473 | let bytes = null;
|
|---|
| 474 | if (schijf) { try { bytes = fs.readFileSync(schijf); } catch { bytes = null; } }
|
|---|
| 475 | if (bytes) {
|
|---|
| 476 | const hash = sha256(bytes);
|
|---|
| 477 | const naam = `media/${hash}${extOf(ref.url) ? `.${extOf(ref.url)}` : ''}`;
|
|---|
| 478 | if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
|
|---|
| 479 | attachments.push({
|
|---|
| 480 | type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
|
|---|
| 481 | url: naam, 'shaer:availability': 'included',
|
|---|
| 482 | 'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
|
|---|
| 483 | 'shaer:sha256': hash,
|
|---|
| 484 | 'shaer:role': ref.rol,
|
|---|
| 485 | 'shaer:posterFor': ref.posterFor || undefined,
|
|---|
| 486 | });
|
|---|
| 487 | } else {
|
|---|
| 488 | // De derde staat uit het formaat: we weten DAT het bestond en waar het
|
|---|
| 489 | // stond, maar we hebben de bytes niet. Stil weglaten zou een leugen zijn.
|
|---|
| 490 | tellingen.mediaMissing += 1;
|
|---|
| 491 | const orig = /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`;
|
|---|
| 492 | ontbrekend.push({ post: post.slug, url: orig });
|
|---|
| 493 | attachments.push({
|
|---|
| 494 | type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
|
|---|
| 495 | url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
|
|---|
| 496 | 'shaer:role': ref.rol,
|
|---|
| 497 | 'shaer:posterFor': ref.posterFor || undefined,
|
|---|
| 498 | });
|
|---|
| 499 | }
|
|---|
| 500 | }
|
|---|
| 501 |
|
|---|
| 502 | const obj = postObject(post, site, origin, attachments, audioKaart);
|
|---|
| 503 | bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
|
|---|
| 504 | bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
|
|---|
| 505 | tellingen.posts += 1;
|
|---|
| 506 |
|
|---|
| 507 | // Antwoorden van anderen: alleen-lezen archief, nooit opnieuw bezorgd.
|
|---|
| 508 | let replies = [];
|
|---|
| 509 | try {
|
|---|
| 510 | replies = db.prepare(`SELECT * FROM ap_interactions WHERE post_id = ? AND kind = 'reply'
|
|---|
| 511 | ORDER BY COALESCE(published, created_at) ASC, id ASC`).all(post.id);
|
|---|
| 512 | } catch { /* tabel kan ontbreken op een heel oude database */ }
|
|---|
| 513 | if (replies.length) {
|
|---|
| 514 | const coll = {
|
|---|
| 515 | '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
|
|---|
| 516 | type: 'OrderedCollection',
|
|---|
| 517 | 'shaer:archive': true,
|
|---|
| 518 | 'shaer:inReplyTo': obj.id,
|
|---|
| 519 | totalItems: replies.length,
|
|---|
| 520 | orderedItems: replies.map((r) => ({
|
|---|
| 521 | id: r.object_uri || undefined,
|
|---|
| 522 | type: 'Note',
|
|---|
| 523 | attributedTo: r.actor_uri || undefined,
|
|---|
| 524 | inReplyTo: r.parent_uri || obj.id,
|
|---|
| 525 | content: r.content || '',
|
|---|
| 526 | published: toISO(r.published || r.created_at) || undefined,
|
|---|
| 527 | 'shaer:actorName': r.actor_name || undefined,
|
|---|
| 528 | 'shaer:actorHandle': r.actor_handle || undefined,
|
|---|
| 529 | })),
|
|---|
| 530 | };
|
|---|
| 531 | bestanden.set(`replies/${post.id}.json`, Buffer.from(stableJson(coll), 'utf8'));
|
|---|
| 532 | tellingen.replies += replies.length;
|
|---|
| 533 | }
|
|---|
| 534 | }
|
|---|
| 535 |
|
|---|
| 536 | // Wie je volgt. Dit ontbrak, en daarmee was een "verhuizing" halfslachtig: de
|
|---|
| 537 | // Move vertelt je VOLGERS waar je heen ging, maar niets vertelde JOU wie jij
|
|---|
| 538 | // volgde. Die lijst stond alleen in de oude database, en die laat je achter.
|
|---|
| 539 | const volgCsv = followingCsv(slug);
|
|---|
| 540 | if (volgCsv) { bestanden.set('following.csv', Buffer.from(volgCsv, 'utf8')); tellingen.following = volgCsv.trim().split('\n').length - 1; }
|
|---|
| 541 |
|
|---|
| 542 | const files = {};
|
|---|
| 543 | for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
|
|---|
| 544 | const manifest = {
|
|---|
| 545 | formatVersion: FORMAT_VERSION,
|
|---|
| 546 | generator: `klonkt/${opts.version || 'dev'}`,
|
|---|
| 547 | exportedAt: opts.exportedAt || new Date().toISOString(),
|
|---|
| 548 | origin,
|
|---|
| 549 | actor: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
|
|---|
| 550 | site: { slug: site.slug, title: site.title || site.slug },
|
|---|
| 551 | counts: tellingen,
|
|---|
| 552 | files,
|
|---|
| 553 | };
|
|---|
| 554 | bestanden.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
|
|---|
| 555 |
|
|---|
| 556 | return { files: bestanden, manifest, counts: tellingen, missing: ontbrekend };
|
|---|
| 557 | }
|
|---|
| 558 |
|
|---|
| 559 | // ── Zip, store-only en deterministisch ────────────────────────────
|
|---|
| 560 | // Geen nieuwe afhankelijkheid, en zonder compressie is byte-gelijkheid geen
|
|---|
| 561 | // kwestie van vertrouwen in de instellingen van een bibliotheek. De mtime is
|
|---|
| 562 | // vast (1980-01-01, de nul van het zip-formaat) om dezelfde reden.
|
|---|
| 563 |
|
|---|
| 564 | const _crcTabel = (() => {
|
|---|
| 565 | const t = new Int32Array(256);
|
|---|
| 566 | for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c; }
|
|---|
| 567 | return t;
|
|---|
| 568 | })();
|
|---|
| 569 | function crc32(buf) {
|
|---|
| 570 | let c = 0 ^ -1;
|
|---|
| 571 | for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ _crcTabel[(c ^ buf[i]) & 0xFF];
|
|---|
| 572 | return (c ^ -1) >>> 0;
|
|---|
| 573 | }
|
|---|
| 574 |
|
|---|
| 575 | export function zipArchive(files) {
|
|---|
| 576 | const paden = [...files.keys()].sort();
|
|---|
| 577 | const lokaal = [];
|
|---|
| 578 | const centraal = [];
|
|---|
| 579 | let offset = 0;
|
|---|
| 580 | for (const pad of paden) {
|
|---|
| 581 | const naam = Buffer.from(pad, 'utf8');
|
|---|
| 582 | const data = files.get(pad);
|
|---|
| 583 | const crc = crc32(data);
|
|---|
| 584 | const lh = Buffer.alloc(30);
|
|---|
| 585 | lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6);
|
|---|
| 586 | lh.writeUInt16LE(0, 8); // store, geen compressie
|
|---|
| 587 | lh.writeUInt16LE(0, 10); lh.writeUInt16LE(33, 12); // vaste tijd: 1980-01-01
|
|---|
| 588 | lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22);
|
|---|
| 589 | lh.writeUInt16LE(naam.length, 26); lh.writeUInt16LE(0, 28);
|
|---|
| 590 | lokaal.push(lh, naam, data);
|
|---|
| 591 |
|
|---|
| 592 | const ch = Buffer.alloc(46);
|
|---|
| 593 | ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6);
|
|---|
| 594 | ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(0, 10);
|
|---|
| 595 | ch.writeUInt16LE(0, 12); ch.writeUInt16LE(33, 14);
|
|---|
| 596 | ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24);
|
|---|
| 597 | ch.writeUInt16LE(naam.length, 28); ch.writeUInt16LE(0, 30); ch.writeUInt16LE(0, 32);
|
|---|
| 598 | ch.writeUInt16LE(0, 34); ch.writeUInt16LE(0, 36); ch.writeUInt32LE(0, 38);
|
|---|
| 599 | ch.writeUInt32LE(offset, 42);
|
|---|
| 600 | centraal.push(ch, naam);
|
|---|
| 601 | offset += 30 + naam.length + data.length;
|
|---|
| 602 | }
|
|---|
| 603 | const cd = Buffer.concat(centraal);
|
|---|
| 604 | const eocd = Buffer.alloc(22);
|
|---|
| 605 | eocd.writeUInt32LE(0x06054b50, 0);
|
|---|
| 606 | eocd.writeUInt16LE(paden.length, 8); eocd.writeUInt16LE(paden.length, 10);
|
|---|
| 607 | eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
|
|---|
| 608 | return Buffer.concat([...lokaal, cd, eocd]);
|
|---|
| 609 | }
|
|---|
| 610 |
|
|---|
| 611 | /** Schrijf het archief als losse bestanden naar een map. */
|
|---|
| 612 | export function writeArchiveDir(files, dir) {
|
|---|
| 613 | for (const pad of [...files.keys()].sort()) {
|
|---|
| 614 | const doel = path.join(dir, pad);
|
|---|
| 615 | fs.mkdirSync(path.dirname(doel), { recursive: true });
|
|---|
| 616 | fs.writeFileSync(doel, files.get(pad));
|
|---|
| 617 | }
|
|---|
| 618 | }
|
|---|