source: Klonkt/src/services/ArchiveExportService.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: 28.2 KB
Line 
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
17import fs from 'fs';
18import path from 'path';
19import crypto from 'crypto';
20import db from '../config/database.js';
21import { 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.
30export const FORMAT_VERSION = 2;
31
32/** JSON met gesorteerde sleutels: zonder vaste volgorde is byte-gelijkheid toeval. */
33export 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
46const 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 */
59const 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
67const 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};
73const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
74const mimeOf = (u) => MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
75const 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 */
87function 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. */
105function 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 */
147function 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 */
184function 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. */
260function 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. */
319function 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.
349const 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. */
352function 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 */
368export 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 */
394export 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. */
416function 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 */
440export 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
564const _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})();
569function 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
575export 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. */
612export 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}
Note: See TracBrowser for help on using the repository browser.