source: Klonkt/src/services/ArchiveExportService.js@ 812fea9

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

Elke bijlage zegt waar hij voor was (shaer-pmr, vervolg)

Gevonden bij een oefenherstel: ik vergeleek dertien kolommen, noemde het een
schone rondgang, en vergeleek daarna ALLE kolommen. Toen bleken er drie dingen
weg te vallen.

COVER de bytes zaten in het archief, maar nergens stond dat ze de

cover waren. cover_image_url kwam als null terug, dus de post
stond zonder cover.

C2S-BIJLAGE idem: het audiobestand en zijn poster zaten erin, maar

c2s_attachments werd niet hersteld, dus de speler verdween.

SUB-SECONDE de import zette tijdstempels om naar SQL-notatie en verloor de

milliseconden. Twee posts in dezelfde seconde staan dan in
willekeurige volgorde.

Elke bijlage draagt nu een shaer:role -- cover, coverVideo, inline, c2s, poster,
track -- en een poster wijst met shaer:posterFor naar de bijlage waar hij bij
hoort. De importer bouwt cover_image_url, cover_video_url en c2s_attachments
daaruit terug. Een rol is geen versiering: zonder rol heeft het archief wel de
bytes maar niet het feit dat ze de cover WAREN, en dat zie je pas als je alle
kolommen vergelijkt.

Tijdstempels gaan nu ongewijzigd de database in. Klonkt schrijft zelf in twee
spellingen; het archief normaliseert naar ISO. Het MOMENT overleeft de rondgang,
de spelling niet -- nu ook zo gedocumenteerd in plaats van stilzwijgend.

Rondgang op echte beta-data: geen verschil meer over 29 kolommen, met de tijden
op moment vergeleken in plaats van op tekst. 21 tests. Suite 514/514.

  • Property mode set to 100644
File size: 17.4 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, rol, extra = {}) => {
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, rol, ...extra });
88 };
89 // De ROL is niet decoratief. Zonder rol staat er in het archief wel een
90 // bestand, maar niet dat het de cover was of bij de speler hoorde -- en dan
91 // komt de post na een herstel zonder cover en zonder speler terug. Gevonden
92 // door bij de oefenherstel ALLE kolommen te vergelijken in plaats van een
93 // handjevol.
94 voegToe(post.cover_image_url, post.cover_alt, 'cover');
95 voegToe(post.cover_video_url, post.cover_alt, 'coverVideo');
96 for (const m of String(post.content || '').matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) voegToe(m[1], null, 'inline');
97 try {
98 for (const a of JSON.parse(post.c2s_attachments || '[]')) {
99 voegToe(a && a.url, a && a.name, 'c2s');
100 // Een audio-bijlage draagt een poster (de omslag die de speler toont). Die
101 // staat in een eigen veld en zou anders stil wegvallen -- op beta viel dat
102 // pas op bij de export van echte data.
103 voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null, 'poster', { posterFor: a && a.url });
104 }
105 } catch { /* kapotte kolom blokkeert de export niet */ }
106 // Gehoste audio: [[track:id]] verwijst naar een audio_tracks-rij met een media-rij eronder.
107 for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
108 try {
109 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]);
110 if (t && t.storage_path) voegToe(`/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`, t.title, 'track');
111 } catch { /* geen audio-tabellen: niets te doen */ }
112 }
113 return uit;
114}
115
116/**
117 * De audio-metadata die alleen in de database staat en nergens anders uit te
118 * halen is: titel, artiest, credit, licentie, externe links.
119 *
120 * `shaer:media` koppelt de track aan zijn bestand in het archief. Zonder die
121 * verwijzing weet een importer wel dát er een track was en hoe hij heette, maar
122 * niet wélk van de bijlagen erbij hoort -- en dan valt [[track:]] bij een
123 * herstel op niets terug.
124 */
125function audioOf(post, attachments) {
126 const uit = [];
127 for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
128 try {
129 const t = db.prepare('SELECT t.*, md.storage_path FROM audio_tracks t LEFT JOIN media md ON md.id = t.media_id WHERE t.id = ?').get(m[1]);
130 if (!t) continue;
131 let bestand;
132 if (t.storage_path) {
133 const rel = `/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`;
134 const bij = attachments.find((a) => String(a['shaer:originalUrl'] || '').endsWith(rel));
135 bestand = bij ? bij.url : undefined;
136 }
137 uit.push({
138 'shaer:ref': `[[track:${t.id}]]`,
139 'shaer:media': bestand,
140 name: t.title, artist: t.artist || undefined, album: t.album || undefined,
141 duration: t.duration || undefined, credit: t.credit || undefined, license: t.license || undefined,
142 url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
143 });
144 } catch { /* idem */ }
145 }
146 return uit.length ? uit : undefined;
147}
148
149/** Eén post als AS2-object volgens het formaat. Bijlagen komen van de beller. */
150function postObject(post, site, origin, attachments) {
151 const heeftTitel = !!(post.title && String(post.title).trim());
152 const published = toISO(post.published_at || post.created_at) || toISO(post.created_at);
153 const updated = toISO(post.updated_at);
154 const poll = (() => {
155 try {
156 const d = JSON.parse(post.poll_json || 'null');
157 if (!d || !Array.isArray(d.options) || d.options.length < 2) return null;
158 const opties = d.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) }));
159 return { multiple: !!d.multiple, opties, endTime: d.endTime || null, closed: !!d.closed };
160 } catch { return null; }
161 })();
162 const tags = [];
163 try {
164 for (const t of String(post.tags || '').split(',').map((x) => x.trim()).filter(Boolean)) {
165 tags.push({ type: 'Hashtag', name: t.startsWith('#') ? t : `#${t}`, href: `${origin}/tag/${encodeURIComponent(t.replace(/^#/, ''))}` });
166 }
167 } catch { /* tags zijn optioneel */ }
168
169 return {
170 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
171 id: `${origin}/ap/notes/${encodeURIComponent(post.id)}`,
172 type: poll ? 'Question' : (heeftTitel ? 'Article' : 'Note'),
173 attributedTo: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
174 name: heeftTitel ? post.title : undefined,
175 content: post.content || '',
176 contentMap: post.language ? { [post.language]: post.content || '' } : undefined,
177 summary: post.content_warning || undefined,
178 sensitive: post.nsfw ? true : undefined,
179 published,
180 updated: (updated && updated !== published) ? updated : undefined,
181 url: `${origin}/${encodeURIComponent(post.slug)}`,
182 attachment: attachments.length ? attachments : undefined,
183 tag: tags.length ? tags : undefined,
184 ...(poll ? (poll.multiple ? { anyOf: poll.opties } : { oneOf: poll.opties }) : {}),
185 endTime: poll ? (poll.endTime || undefined) : undefined,
186 // AS2 kent `closed` op een Question. Zonder dit staat een poll die vroegtijdig
187 // is gesloten na een herstel weer open -- gevonden op echte beta-data.
188 closed: (poll && poll.closed) ? true : undefined,
189 quoteUrl: post.quote_uri || undefined,
190 'shaer:quoteActor': post.quote_actor || undefined,
191 'shaer:slug': post.slug,
192 'shaer:status': post.status || 'draft',
193 'shaer:excerpt': post.excerpt || undefined,
194 'shaer:type': post.type || undefined,
195 'shaer:pinned': post.pinned ? true : undefined,
196 'shaer:noindex': post.noindex ? true : undefined,
197 'shaer:fanOnly': post.fan_only ? true : undefined,
198 'shaer:paid': post.paid ? true : undefined,
199 'shaer:paidMinCents': post.paid ? (post.paid_min_cents || undefined) : undefined,
200 'shaer:apVisibility': post.ap_visibility || undefined,
201 'shaer:publishAt': toISO(post.publish_at) || undefined,
202 'shaer:coverAlt': post.cover_alt || undefined,
203 'shaer:viewCount': post.view_count || undefined,
204 'shaer:audio': audioOf(post, attachments),
205 };
206}
207
208/** De leesbare kopie. Afgeleid, eenrichtingsverkeer -- de importer kijkt hier nooit naar. */
209function readableMarkdown(post, obj) {
210 const fm = [
211 '---',
212 `title: ${JSON.stringify(post.title || post.slug)}`,
213 `slug: ${JSON.stringify(post.slug)}`,
214 `date: ${obj.published || ''}`,
215 `status: ${post.status || 'draft'}`,
216 post.content_warning ? `content_warning: ${JSON.stringify(post.content_warning)}` : null,
217 '---',
218 ].filter((l) => l !== null).join('\n');
219 return `${fm}\n${post.content || ''}\n`;
220}
221
222/**
223 * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
224 *
225 * Bewust geen schrijven naar schijf hier: dat maakt de vorm testbaar zonder
226 * tijdelijke mappen, en de beller bepaalt of het een map of een zip wordt.
227 */
228export function buildArchive(slug, opts = {}) {
229 const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
230 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
231 if (!site) throw new Error(`onbekende site: ${slug}`);
232
233 const bestanden = new Map(); // pad -> Buffer
234 const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0 };
235 const ontbrekend = []; // voor de rapportage van de beller
236
237 // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
238 // is de volgorde van twee posts op dezelfde seconde niet bepaald.
239 const posts = db.prepare(`SELECT * FROM posts WHERE site_id = ?
240 ORDER BY COALESCE(published_at, created_at) ASC, id ASC`).all(site.id);
241
242 for (const post of posts) {
243 const attachments = [];
244 for (const ref of mediaRefsOf(post, origin)) {
245 const schijf = localMediaPath(ref.url, origin);
246 const mime = mimeOf(ref.url);
247 let bytes = null;
248 if (schijf) { try { bytes = fs.readFileSync(schijf); } catch { bytes = null; } }
249 if (bytes) {
250 const hash = sha256(bytes);
251 const naam = `media/${hash}${extOf(ref.url) ? `.${extOf(ref.url)}` : ''}`;
252 if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
253 attachments.push({
254 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
255 url: naam, 'shaer:availability': 'included',
256 'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
257 'shaer:sha256': hash,
258 'shaer:role': ref.rol,
259 'shaer:posterFor': ref.posterFor || undefined,
260 });
261 } else {
262 // De derde staat uit het formaat: we weten DAT het bestond en waar het
263 // stond, maar we hebben de bytes niet. Stil weglaten zou een leugen zijn.
264 tellingen.mediaMissing += 1;
265 const orig = /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`;
266 ontbrekend.push({ post: post.slug, url: orig });
267 attachments.push({
268 type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
269 url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
270 'shaer:role': ref.rol,
271 'shaer:posterFor': ref.posterFor || undefined,
272 });
273 }
274 }
275
276 const obj = postObject(post, site, origin, attachments);
277 bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
278 bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
279 tellingen.posts += 1;
280
281 // Antwoorden van anderen: alleen-lezen archief, nooit opnieuw bezorgd.
282 let replies = [];
283 try {
284 replies = db.prepare(`SELECT * FROM ap_interactions WHERE post_id = ? AND kind = 'reply'
285 ORDER BY COALESCE(published, created_at) ASC, id ASC`).all(post.id);
286 } catch { /* tabel kan ontbreken op een heel oude database */ }
287 if (replies.length) {
288 const coll = {
289 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
290 type: 'OrderedCollection',
291 'shaer:archive': true,
292 'shaer:inReplyTo': obj.id,
293 totalItems: replies.length,
294 orderedItems: replies.map((r) => ({
295 id: r.object_uri || undefined,
296 type: 'Note',
297 attributedTo: r.actor_uri || undefined,
298 inReplyTo: r.parent_uri || obj.id,
299 content: r.content || '',
300 published: toISO(r.published || r.created_at) || undefined,
301 'shaer:actorName': r.actor_name || undefined,
302 'shaer:actorHandle': r.actor_handle || undefined,
303 })),
304 };
305 bestanden.set(`replies/${post.id}.json`, Buffer.from(stableJson(coll), 'utf8'));
306 tellingen.replies += replies.length;
307 }
308 }
309
310 const files = {};
311 for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
312 const manifest = {
313 formatVersion: FORMAT_VERSION,
314 generator: `klonkt/${opts.version || 'dev'}`,
315 exportedAt: opts.exportedAt || new Date().toISOString(),
316 origin,
317 actor: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
318 site: { slug: site.slug, title: site.title || site.slug },
319 counts: tellingen,
320 files,
321 };
322 bestanden.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
323
324 return { files: bestanden, manifest, counts: tellingen, missing: ontbrekend };
325}
326
327// ── Zip, store-only en deterministisch ────────────────────────────
328// Geen nieuwe afhankelijkheid, en zonder compressie is byte-gelijkheid geen
329// kwestie van vertrouwen in de instellingen van een bibliotheek. De mtime is
330// vast (1980-01-01, de nul van het zip-formaat) om dezelfde reden.
331
332const _crcTabel = (() => {
333 const t = new Int32Array(256);
334 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; }
335 return t;
336})();
337function crc32(buf) {
338 let c = 0 ^ -1;
339 for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ _crcTabel[(c ^ buf[i]) & 0xFF];
340 return (c ^ -1) >>> 0;
341}
342
343export function zipArchive(files) {
344 const paden = [...files.keys()].sort();
345 const lokaal = [];
346 const centraal = [];
347 let offset = 0;
348 for (const pad of paden) {
349 const naam = Buffer.from(pad, 'utf8');
350 const data = files.get(pad);
351 const crc = crc32(data);
352 const lh = Buffer.alloc(30);
353 lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6);
354 lh.writeUInt16LE(0, 8); // store, geen compressie
355 lh.writeUInt16LE(0, 10); lh.writeUInt16LE(33, 12); // vaste tijd: 1980-01-01
356 lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22);
357 lh.writeUInt16LE(naam.length, 26); lh.writeUInt16LE(0, 28);
358 lokaal.push(lh, naam, data);
359
360 const ch = Buffer.alloc(46);
361 ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6);
362 ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(0, 10);
363 ch.writeUInt16LE(0, 12); ch.writeUInt16LE(33, 14);
364 ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24);
365 ch.writeUInt16LE(naam.length, 28); ch.writeUInt16LE(0, 30); ch.writeUInt16LE(0, 32);
366 ch.writeUInt16LE(0, 34); ch.writeUInt16LE(0, 36); ch.writeUInt32LE(0, 38);
367 ch.writeUInt32LE(offset, 42);
368 centraal.push(ch, naam);
369 offset += 30 + naam.length + data.length;
370 }
371 const cd = Buffer.concat(centraal);
372 const eocd = Buffer.alloc(22);
373 eocd.writeUInt32LE(0x06054b50, 0);
374 eocd.writeUInt16LE(paden.length, 8); eocd.writeUInt16LE(paden.length, 10);
375 eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
376 return Buffer.concat([...lokaal, cd, eocd]);
377}
378
379/** Schrijf het archief als losse bestanden naar een map. */
380export function writeArchiveDir(files, dir) {
381 for (const pad of [...files.keys()].sort()) {
382 const doel = path.join(dir, pad);
383 fs.mkdirSync(path.dirname(doel), { recursive: true });
384 fs.writeFileSync(doel, files.get(pad));
385 }
386}
Note: See TracBrowser for help on using the repository browser.