source: Klonkt/src/services/ArchiveExportService.js@ 712b161

main
Last change on this file since 712b161 was 712b161, checked in by roboburr <roboburr@…>, 5 weeks ago

De exporter (shaer-1a6)

ArchiveExportService bouwt het archief uit docs/EXPORT-FORMAT.md, plus een CLI
(scripts/export-archive.mjs) met een droogloop.

Alleen de eigen posts van de site, inclusief concepten. Geen sleutel, sessie,
wachtwoordhash of DM van een ander -- dit is nadrukkelijk niet de storage-zip uit
shaer-190t.

Media worden gesleuteld op de sha256 van hun bytes, zodat twee exports dezelfde
namen opleveren en dubbelen vanzelf samenvallen. Een verdwenen bestand krijgt de
derde staat uit het formaat -- 'missing', met de oorspronkelijke plek erbij -- en
wordt geteld en gemeld, nooit stil weggelaten.

Zip zonder nieuwe afhankelijkheid: store-only, gesorteerde ingangen, vaste mtime.
Zonder compressie is byte-gelijkheid geen kwestie van vertrouwen in de
instellingen van een bibliotheek. Gecontroleerd dat gewone unzip hem uitpakt en
dat de bytes er identiek uitkomen; een zelfgeschreven zip die alleen zichzelf kan
lezen is geen uitwisselformaat.

Twee dingen kwamen pas boven bij het draaien tegen echte data (beta):

  • een audio-bijlage in c2s_attachments draagt naast url ook een POSTER, en die viel er stil uit. Nu meegenomen; media op beta gingen daarmee van 2 naar 3.
  • het [[track:]]-pad raakt op beta geen enkele rij (nul audio_tracks), dus dat heeft nu een eigen test in plaats van de aanname dat het meeliep.

Een verzonnen pad in oude inhoud kan geen willekeurig bestand van de schijf het
archief in trekken: media-URL's worden binnen MEDIA_ROOT gehouden en een URL naar
een andere host wordt nooit van onze eigen schijf gelezen.

De reproduceerbaarheidseis in het formaat sloot exportedAt niet uit en sprak
zichzelf daarmee tegen; dat is rechtgezet.

15 tests, waaronder de acceptatietest uit het document: de moeilijkste post die
Klonkt kan maken (poll, bijlagen, quote, content warning, betaald). Suite 493/493.

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

  • Property mode set to 100644
File size: 15.8 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 } from '../config/paths.js';
22
23export const FORMAT_VERSION = 1;
24
25/** JSON met gesorteerde sleutels: zonder vaste volgorde is byte-gelijkheid toeval. */
26function stableJson(value) {
27 const sorteer = (v) => {
28 if (Array.isArray(v)) return v.map(sorteer);
29 if (v && typeof v === 'object') {
30 const uit = {};
31 for (const k of Object.keys(v).sort()) if (v[k] !== undefined) uit[k] = sorteer(v[k]);
32 return uit;
33 }
34 return v;
35 };
36 return `${JSON.stringify(sorteer(value), null, 2)}\n`;
37}
38
39const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
40const toISO = (d) => { const t = Date.parse(d); return isNaN(t) ? null : new Date(t).toISOString(); };
41
42const MIME_BY_EXT = {
43 jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
44 webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml',
45 mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
46 mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac',
47};
48const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
49const mimeOf = (u) => MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
50const as2TypeOf = (mime) => (mime.startsWith('video/') ? 'Video' : mime.startsWith('audio/') ? 'Audio' : 'Image');
51
52/**
53 * Van een media-URL naar een bestand op schijf.
54 *
55 * /media is een kale express.static op MEDIA_ROOT, dus het URL-pad IS het pad
56 * onder die map. Een absolute URL naar onze eigen origin telt net zo goed als
57 * een pad -- de content slaat allebei op.
58 *
59 * De ../-controle is geen formaliteit: een verzonnen pad in oude inhoud zou
60 * anders een willekeurig bestand van de schijf het archief in trekken.
61 */
62function localMediaPath(url, origin) {
63 let p = String(url || '');
64 if (!p) return null;
65 if (/^https?:/i.test(p)) {
66 try {
67 const u = new URL(p);
68 if (`${u.protocol}//${u.host}` !== origin) return null; // andermans host: nooit van onze schijf
69 p = u.pathname;
70 } catch { return null; }
71 }
72 if (!p.startsWith('/media/')) return null;
73 const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(p.slice('/media/'.length)));
74 const root = path.resolve(MEDIA_ROOT);
75 if (abs !== root && !abs.startsWith(`${root}${path.sep}`)) return null;
76 return abs;
77}
78
79/** Alle media waar een post naar wijst, in vaste volgorde en zonder dubbelen. */
80function mediaRefsOf(post, origin) {
81 const uit = [];
82 const zie = new Set();
83 const voegToe = (url, name) => {
84 const u = String(url || '').trim();
85 if (!u || zie.has(u)) return;
86 zie.add(u);
87 uit.push({ url: u, name: name || null });
88 };
89 voegToe(post.cover_image_url, post.cover_alt);
90 voegToe(post.cover_video_url, post.cover_alt);
91 for (const m of String(post.content || '').matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) voegToe(m[1]);
92 try {
93 for (const a of JSON.parse(post.c2s_attachments || '[]')) {
94 voegToe(a && a.url, a && a.name);
95 // Een audio-bijlage draagt een poster (de omslag die de speler toont). Die
96 // staat in een eigen veld en zou anders stil wegvallen -- op beta viel dat
97 // pas op bij de export van echte data.
98 voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null);
99 }
100 } catch { /* kapotte kolom blokkeert de export niet */ }
101 // Gehoste audio: [[track:id]] verwijst naar een audio_tracks-rij met een media-rij eronder.
102 for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
103 try {
104 const t = db.prepare('SELECT t.title, m.storage_path FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id WHERE t.id = ?').get(m[1]);
105 if (t && t.storage_path) voegToe(`/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`, t.title);
106 } catch { /* geen audio-tabellen: niets te doen */ }
107 }
108 return uit;
109}
110
111/** De audio-metadata die alleen in de database staat en nergens anders uit te halen is. */
112function audioOf(post) {
113 const uit = [];
114 for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
115 try {
116 const t = db.prepare('SELECT * FROM audio_tracks WHERE id = ?').get(m[1]);
117 if (!t) continue;
118 uit.push({
119 'shaer:ref': `[[track:${t.id}]]`,
120 name: t.title, artist: t.artist || undefined, album: t.album || undefined,
121 duration: t.duration || undefined, credit: t.credit || undefined, license: t.license || undefined,
122 url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
123 });
124 } catch { /* idem */ }
125 }
126 return uit.length ? uit : undefined;
127}
128
129/** Eén post als AS2-object volgens het formaat. Bijlagen komen van de beller. */
130function postObject(post, site, origin, attachments) {
131 const heeftTitel = !!(post.title && String(post.title).trim());
132 const published = toISO(post.published_at || post.created_at) || toISO(post.created_at);
133 const updated = toISO(post.updated_at);
134 const poll = (() => {
135 try {
136 const d = JSON.parse(post.poll_json || 'null');
137 if (!d || !Array.isArray(d.options) || d.options.length < 2) return null;
138 const opties = d.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) }));
139 return { multiple: !!d.multiple, opties, endTime: d.endTime || null };
140 } catch { return null; }
141 })();
142 const tags = [];
143 try {
144 for (const t of String(post.tags || '').split(',').map((x) => x.trim()).filter(Boolean)) {
145 tags.push({ type: 'Hashtag', name: t.startsWith('#') ? t : `#${t}`, href: `${origin}/tag/${encodeURIComponent(t.replace(/^#/, ''))}` });
146 }
147 } catch { /* tags zijn optioneel */ }
148
149 return {
150 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
151 id: `${origin}/ap/notes/${encodeURIComponent(post.id)}`,
152 type: poll ? 'Question' : (heeftTitel ? 'Article' : 'Note'),
153 attributedTo: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
154 name: heeftTitel ? post.title : undefined,
155 content: post.content || '',
156 contentMap: post.language ? { [post.language]: post.content || '' } : undefined,
157 summary: post.content_warning || undefined,
158 sensitive: post.nsfw ? true : undefined,
159 published,
160 updated: (updated && updated !== published) ? updated : undefined,
161 url: `${origin}/${encodeURIComponent(post.slug)}`,
162 attachment: attachments.length ? attachments : undefined,
163 tag: tags.length ? tags : undefined,
164 ...(poll ? (poll.multiple ? { anyOf: poll.opties } : { oneOf: poll.opties }) : {}),
165 endTime: poll ? (poll.endTime || undefined) : undefined,
166 quoteUrl: post.quote_uri || undefined,
167 'shaer:quoteActor': post.quote_actor || undefined,
168 'shaer:slug': post.slug,
169 'shaer:status': post.status || 'draft',
170 'shaer:excerpt': post.excerpt || undefined,
171 'shaer:type': post.type || undefined,
172 'shaer:pinned': post.pinned ? true : undefined,
173 'shaer:noindex': post.noindex ? true : undefined,
174 'shaer:fanOnly': post.fan_only ? true : undefined,
175 'shaer:paid': post.paid ? true : undefined,
176 'shaer:paidMinCents': post.paid ? (post.paid_min_cents || undefined) : undefined,
177 'shaer:apVisibility': post.ap_visibility || undefined,
178 'shaer:publishAt': toISO(post.publish_at) || undefined,
179 'shaer:coverAlt': post.cover_alt || undefined,
180 'shaer:viewCount': post.view_count || undefined,
181 'shaer:audio': audioOf(post),
182 };
183}
184
185/** De leesbare kopie. Afgeleid, eenrichtingsverkeer -- de importer kijkt hier nooit naar. */
186function readableMarkdown(post, obj) {
187 const fm = [
188 '---',
189 `title: ${JSON.stringify(post.title || post.slug)}`,
190 `slug: ${JSON.stringify(post.slug)}`,
191 `date: ${obj.published || ''}`,
192 `status: ${post.status || 'draft'}`,
193 post.content_warning ? `content_warning: ${JSON.stringify(post.content_warning)}` : null,
194 '---',
195 ].filter((l) => l !== null).join('\n');
196 return `${fm}\n${post.content || ''}\n`;
197}
198
199/**
200 * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
201 *
202 * Bewust geen schrijven naar schijf hier: dat maakt de vorm testbaar zonder
203 * tijdelijke mappen, en de beller bepaalt of het een map of een zip wordt.
204 */
205export function buildArchive(slug, opts = {}) {
206 const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
207 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
208 if (!site) throw new Error(`onbekende site: ${slug}`);
209
210 const bestanden = new Map(); // pad -> Buffer
211 const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0 };
212 const ontbrekend = []; // voor de rapportage van de beller
213
214 // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
215 // is de volgorde van twee posts op dezelfde seconde niet bepaald.
216 const posts = db.prepare(`SELECT * FROM posts WHERE site_id = ?
217 ORDER BY COALESCE(published_at, created_at) ASC, id ASC`).all(site.id);
218
219 for (const post of posts) {
220 const attachments = [];
221 for (const ref of mediaRefsOf(post, origin)) {
222 const schijf = localMediaPath(ref.url, origin);
223 const mime = mimeOf(ref.url);
224 let bytes = null;
225 if (schijf) { try { bytes = fs.readFileSync(schijf); } catch { bytes = null; } }
226 if (bytes) {
227 const hash = sha256(bytes);
228 const naam = `media/${hash}${extOf(ref.url) ? `.${extOf(ref.url)}` : ''}`;
229 if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
230 attachments.push({
231 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
232 url: naam, 'shaer:availability': 'included',
233 'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
234 'shaer:sha256': hash,
235 });
236 } else {
237 // De derde staat uit het formaat: we weten DAT het bestond en waar het
238 // stond, maar we hebben de bytes niet. Stil weglaten zou een leugen zijn.
239 tellingen.mediaMissing += 1;
240 const orig = /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`;
241 ontbrekend.push({ post: post.slug, url: orig });
242 attachments.push({
243 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
244 url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
245 });
246 }
247 }
248
249 const obj = postObject(post, site, origin, attachments);
250 bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
251 bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
252 tellingen.posts += 1;
253
254 // Antwoorden van anderen: alleen-lezen archief, nooit opnieuw bezorgd.
255 let replies = [];
256 try {
257 replies = db.prepare(`SELECT * FROM ap_interactions WHERE post_id = ? AND kind = 'reply'
258 ORDER BY COALESCE(published, created_at) ASC, id ASC`).all(post.id);
259 } catch { /* tabel kan ontbreken op een heel oude database */ }
260 if (replies.length) {
261 const coll = {
262 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
263 type: 'OrderedCollection',
264 'shaer:archive': true,
265 'shaer:inReplyTo': obj.id,
266 totalItems: replies.length,
267 orderedItems: replies.map((r) => ({
268 id: r.object_uri || undefined,
269 type: 'Note',
270 attributedTo: r.actor_uri || undefined,
271 inReplyTo: r.parent_uri || obj.id,
272 content: r.content || '',
273 published: toISO(r.published || r.created_at) || undefined,
274 'shaer:actorName': r.actor_name || undefined,
275 'shaer:actorHandle': r.actor_handle || undefined,
276 })),
277 };
278 bestanden.set(`replies/${post.id}.json`, Buffer.from(stableJson(coll), 'utf8'));
279 tellingen.replies += replies.length;
280 }
281 }
282
283 const files = {};
284 for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
285 const manifest = {
286 formatVersion: FORMAT_VERSION,
287 generator: `klonkt/${opts.version || 'dev'}`,
288 exportedAt: opts.exportedAt || new Date().toISOString(),
289 origin,
290 actor: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
291 site: { slug: site.slug, title: site.title || site.slug },
292 counts: tellingen,
293 files,
294 };
295 bestanden.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
296
297 return { files: bestanden, manifest, counts: tellingen, missing: ontbrekend };
298}
299
300// ── Zip, store-only en deterministisch ────────────────────────────
301// Geen nieuwe afhankelijkheid, en zonder compressie is byte-gelijkheid geen
302// kwestie van vertrouwen in de instellingen van een bibliotheek. De mtime is
303// vast (1980-01-01, de nul van het zip-formaat) om dezelfde reden.
304
305const _crcTabel = (() => {
306 const t = new Int32Array(256);
307 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; }
308 return t;
309})();
310function crc32(buf) {
311 let c = 0 ^ -1;
312 for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ _crcTabel[(c ^ buf[i]) & 0xFF];
313 return (c ^ -1) >>> 0;
314}
315
316export function zipArchive(files) {
317 const paden = [...files.keys()].sort();
318 const lokaal = [];
319 const centraal = [];
320 let offset = 0;
321 for (const pad of paden) {
322 const naam = Buffer.from(pad, 'utf8');
323 const data = files.get(pad);
324 const crc = crc32(data);
325 const lh = Buffer.alloc(30);
326 lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6);
327 lh.writeUInt16LE(0, 8); // store, geen compressie
328 lh.writeUInt16LE(0, 10); lh.writeUInt16LE(33, 12); // vaste tijd: 1980-01-01
329 lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22);
330 lh.writeUInt16LE(naam.length, 26); lh.writeUInt16LE(0, 28);
331 lokaal.push(lh, naam, data);
332
333 const ch = Buffer.alloc(46);
334 ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6);
335 ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(0, 10);
336 ch.writeUInt16LE(0, 12); ch.writeUInt16LE(33, 14);
337 ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24);
338 ch.writeUInt16LE(naam.length, 28); ch.writeUInt16LE(0, 30); ch.writeUInt16LE(0, 32);
339 ch.writeUInt16LE(0, 34); ch.writeUInt16LE(0, 36); ch.writeUInt32LE(0, 38);
340 ch.writeUInt32LE(offset, 42);
341 centraal.push(ch, naam);
342 offset += 30 + naam.length + data.length;
343 }
344 const cd = Buffer.concat(centraal);
345 const eocd = Buffer.alloc(22);
346 eocd.writeUInt32LE(0x06054b50, 0);
347 eocd.writeUInt16LE(paden.length, 8); eocd.writeUInt16LE(paden.length, 10);
348 eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
349 return Buffer.concat([...lokaal, cd, eocd]);
350}
351
352/** Schrijf het archief als losse bestanden naar een map. */
353export function writeArchiveDir(files, dir) {
354 for (const pad of [...files.keys()].sort()) {
355 const doel = path.join(dir, pad);
356 fs.mkdirSync(path.dirname(doel), { recursive: true });
357 fs.writeFileSync(doel, files.get(pad));
358 }
359}
Note: See TracBrowser for help on using the repository browser.