source: Klonkt/src/services/ArchiveExportService.js@ 58cfe5f

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

De importer (shaer-pmr)

ArchiveImportService leest het formaat terug, plus een CLI
(scripts/import-archive.mjs) met droogloop en --overwrite. De vier eisen uit het
document staan expliciet in de code:

VERSIE EERST een hogere onbekende formatVersion wordt in zijn GEHEEL

geweigerd; half lezen ziet eruit alsof het gelukt is

IDENTITEIT gelijke origin -> AP-ids behouden, anders nieuwe ids met een

waarschuwing. De importer stelt dat zelf vast; een verkeerd
antwoord publiceert objecten onder een id dat je niet beheert

NIETS STILS ontbrekende media worden geteld en gemeld, en een archief dat

zijn eigen bestand mist is een APARTE melding -- dat is een
kapot archief, geen verdwenen media

GEEN UITZENDING geen Update de fediverse in

Botsingsregel, gedocumenteerd en niet per post gegokt: een bestaande post met
hetzelfde id of dezelfde slug wordt OVERGESLAGEN. Dat maakt de import idempotent
en zorgt dat je nooit per ongeluk vernietigt wat er al staat. --overwrite doet
het wel, expliciet.

Media gaan terug naar hun oorspronkelijke pad onder /media, want de content van
de post wijst daarheen; dat pad is site-relatief en werkt dus ook op een ander
domein. Checksum wordt gecontroleerd voordat er iets wordt weggeschreven, en een
pad buiten MEDIA_ROOT nooit. Gehoste audio krijgt zijn media- en audio_tracks-rij
terug, anders valt [[track:]] op niets terug -- daarvoor draagt shaer:audio nu
een shaer:media-verwijzing, die in het formaat ontbrak.

readable/ wordt nooit gelezen, en daar is een test voor: vul het met onzin en er
verandert niets.

De zip-lezer kent ook deflate. Een lezer die alleen onze eigen store-only zip
aankan is geen uitwisselformaat maar een prive-doosje.

DERDE VONDST UIT ECHTE DATA vandaag: bij de rondgang op beta bleek poll_json.closed
weg te vallen. Een poll die je vroegtijdig sluit met een einddatum in de toekomst
stond na een herstel weer open. AS2 kent daar "closed" voor op een Question; nu
beide kanten op.

Rondgang op echte beta-data: 4 posts, 3 media, 1 antwoordenarchief, ids behouden,
en na de fix geen enkel kolomverschil meer. 19 tests. Suite 512/512.

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

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