source: Klonkt/src/services/ArchiveImportService.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: 15.3 KB
Line 
1/**
2 * Import van een draagbaar inhoudsarchief (shaer-pmr).
3 *
4 * Leest wat docs/EXPORT-FORMAT.md beschrijft. Vier regels uit dat document zijn
5 * geen implementatiekeuze maar eis, en ze staan hier alle vier expliciet:
6 *
7 * VERSIE EERST Een hogere onbekende formatVersion wordt in zijn GEHEEL
8 * geweigerd. Een half begrepen herstel is erger dan geen
9 * herstel, want het ziet eruit alsof het gelukt is.
10 * IDENTITEIT De origin uit het manifest bepaalt of de AP-ids behouden
11 * blijven. Dat is geen vraag aan de gebruiker: een verkeerd
12 * antwoord publiceert objecten onder een id dat je niet beheert.
13 * NIETS STILS Ontbrekende media worden geteld en gemeld.
14 * GEEN UITZENDING Geen Update de fediverse in. Verouderde kopieen elders
15 * rechttrekken is een aparte, bewuste actie.
16 *
17 * `readable/` wordt nooit gelezen. Dat is de hele reden dat het afgeleid is.
18 */
19
20import fs from 'fs';
21import path from 'path';
22import zlib from 'zlib';
23import crypto from 'crypto';
24import { randomUUID } from 'crypto';
25import db from '../config/database.js';
26import { MEDIA_ROOT } from '../config/paths.js';
27import { FORMAT_VERSION } from './ArchiveExportService.js';
28
29const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
30// De tijdstempel gaat er ONGEWIJZIGD in. Omzetten naar SQL-notatie kostte de
31// sub-seconde, en twee posts in dezelfde seconde staan dan in willekeurige
32// volgorde. Klonkt schrijft zelf ook ISO in deze kolommen.
33const tijd = (iso) => (iso && !isNaN(Date.parse(iso)) ? String(iso) : null);
34
35// ── Inlezen ───────────────────────────────────────────────────────
36
37/** Lees een archiefmap in als pad -> Buffer. */
38export function readArchiveDir(dir) {
39 const files = new Map();
40 const loop = (sub) => {
41 for (const naam of fs.readdirSync(path.join(dir, sub), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
42 const rel = sub ? `${sub}/${naam.name}` : naam.name;
43 if (naam.isDirectory()) loop(rel);
44 else files.set(rel, fs.readFileSync(path.join(dir, rel)));
45 }
46 };
47 loop('');
48 return files;
49}
50
51/**
52 * Lees een zip in. Onze eigen export is store-only, maar een archief dat elders
53 * gemaakt is mag deflate gebruiken -- anders is het geen uitwisselformaat.
54 */
55export function readArchiveZip(buf) {
56 const files = new Map();
57 const eocd = (() => {
58 for (let i = buf.length - 22; i >= 0 && i > buf.length - 66000; i--) if (buf.readUInt32LE(i) === 0x06054b50) return i;
59 return -1;
60 })();
61 if (eocd < 0) throw new Error('geen zip: het eind-record ontbreekt');
62 const aantal = buf.readUInt16LE(eocd + 10);
63 let p = buf.readUInt32LE(eocd + 16);
64 for (let n = 0; n < aantal; n++) {
65 if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('beschadigde zip: centrale ingang klopt niet');
66 const methode = buf.readUInt16LE(p + 10);
67 const gecomp = buf.readUInt32LE(p + 20);
68 const naamLen = buf.readUInt16LE(p + 28);
69 const extraLen = buf.readUInt16LE(p + 30);
70 const commentLen = buf.readUInt16LE(p + 32);
71 const lokaalOffset = buf.readUInt32LE(p + 42);
72 const naam = buf.toString('utf8', p + 46, p + 46 + naamLen);
73 const lNaam = buf.readUInt16LE(lokaalOffset + 26);
74 const lExtra = buf.readUInt16LE(lokaalOffset + 28);
75 const start = lokaalOffset + 30 + lNaam + lExtra;
76 const rauw = buf.subarray(start, start + gecomp);
77 if (!naam.endsWith('/')) {
78 files.set(naam, methode === 8 ? zlib.inflateRawSync(rauw) : Buffer.from(rauw));
79 }
80 p += 46 + naamLen + extraLen + commentLen;
81 }
82 return files;
83}
84
85export function readArchive(bron) {
86 const st = fs.statSync(bron);
87 return st.isDirectory() ? readArchiveDir(bron) : readArchiveZip(fs.readFileSync(bron));
88}
89
90// ── Importeren ────────────────────────────────────────────────────
91
92/** Een pad onder MEDIA_ROOT houden. Een archief van elders is invoer, geen vriend. */
93function veiligMediaPad(urlPad) {
94 if (!urlPad || !urlPad.startsWith('/media/')) return null;
95 const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(urlPad.slice('/media/'.length)));
96 const root = path.resolve(MEDIA_ROOT);
97 return (abs !== root && abs.startsWith(`${root}${path.sep}`)) ? abs : null;
98}
99
100/** Het pad-deel van een originele media-URL, of null als het er niet een van ons is. */
101function padVanOrigineel(u) {
102 const s = String(u || '');
103 if (s.startsWith('/media/')) return s;
104 try { const x = new URL(s); return x.pathname.startsWith('/media/') ? x.pathname : null; } catch { return null; }
105}
106
107/**
108 * Zet een archief terug in een site.
109 *
110 * @param {Map<string,Buffer>} files het ingelezen archief
111 * @param {object} opts { slug, dryRun, overwrite, origin }
112 */
113export function importArchive(files, opts = {}) {
114 const rapport = {
115 formatVersion: null, origin: null, idsBehouden: null,
116 posts: 0, overgeslagen: 0, overschreven: 0,
117 replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
118 };
119
120 const manifestBuf = files.get('manifest.json');
121 if (!manifestBuf) throw new Error('geen manifest.json: dit is geen inhoudsarchief');
122 const manifest = JSON.parse(manifestBuf.toString('utf8'));
123 rapport.formatVersion = manifest.formatVersion;
124
125 // VERSIE EERST, voordat er ook maar iets gelezen wordt.
126 if (!Number.isInteger(manifest.formatVersion)) throw new Error('manifest zonder bruikbare formatVersion');
127 if (manifest.formatVersion > FORMAT_VERSION) {
128 throw new Error(`archiefversie ${manifest.formatVersion} is nieuwer dan deze Klonkt kent (${FORMAT_VERSION}); geweigerd`);
129 }
130
131 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(opts.slug);
132 if (!site) throw new Error(`onbekende site: ${opts.slug}`);
133 const eigenOrigin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
134 rapport.origin = manifest.origin || null;
135
136 // IDENTITEIT. Gelijke origin -> de AP-ids blijven, en daarmee vinden de boosts
137 // en antwoorden die er al naar wijzen hun post terug. Anders nieuwe ids, want
138 // een id op andermans domein publiceren is een vervalsingsoppervlak en andere
139 // servers halen het daar toch op.
140 const idsBehouden = !!(manifest.origin && eigenOrigin && manifest.origin === eigenOrigin);
141 rapport.idsBehouden = idsBehouden;
142 if (!idsBehouden) {
143 rapport.waarschuwingen.push(
144 `origin verschilt (archief ${manifest.origin || '?'} vs deze site ${eigenOrigin || '?'}): nieuwe AP-ids, de oude blijven als verwijzing staan`,
145 );
146 }
147
148 const postPaden = [...files.keys()].filter((p) => p.startsWith('posts/') && p.endsWith('.json')).sort();
149 const bestaatId = db.prepare('SELECT 1 FROM posts WHERE id = ?');
150 const bestaatSlug = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?');
151 const idKaart = new Map(); // oud post-id -> nieuw post-id
152
153 const schrijf = []; // alles eerst uitrekenen, dan in EEN transactie
154
155 for (const pad of postPaden) {
156 const o = JSON.parse(files.get(pad).toString('utf8'));
157 const oudId = decodeURIComponent(String(o.id || '').split('/ap/notes/')[1] || path.basename(pad, '.json'));
158 const nieuwId = idsBehouden ? oudId : randomUUID();
159 idKaart.set(oudId, nieuwId);
160
161 const botsing = !!bestaatId.get(nieuwId) || !!bestaatSlug.get(site.id, o['shaer:slug']);
162 if (botsing && !opts.overwrite) {
163 // EEN gedocumenteerde regel, geen gok per post: bestaande inhoud wordt niet
164 // overschreven tenzij dat expliciet gevraagd is. Dit is ook wat de import
165 // idempotent maakt.
166 rapport.overgeslagen += 1;
167 continue;
168 }
169 if (botsing) rapport.overschreven += 1;
170
171 // Media: terug naar hun oorspronkelijke plek onder /media, want de content
172 // van de post verwijst daarnaar. Dat pad is site-relatief, dus het werkt ook
173 // op een ander domein.
174 for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) {
175 if (a['shaer:availability'] === 'missing') {
176 rapport.mediaMissing += 1;
177 rapport.gemist.push({ post: o['shaer:slug'], url: a['shaer:originalUrl'] || a.url });
178 continue;
179 }
180 const bytes = files.get(a.url);
181 if (!bytes) {
182 // Het archief zegt 'included' maar het bestand ontbreekt. Dat is een kapot
183 // archief, geen ontbrekende media -- apart melden, niet stil optellen.
184 rapport.waarschuwingen.push(`archief verwijst naar ${a.url}, dat er niet in zit`);
185 continue;
186 }
187 if (a['shaer:sha256'] && sha256(bytes) !== a['shaer:sha256']) {
188 rapport.waarschuwingen.push(`${a.url}: checksum klopt niet, overgeslagen`);
189 continue;
190 }
191 const doel = veiligMediaPad(padVanOrigineel(a['shaer:originalUrl']))
192 || veiligMediaPad(`/media/archief/${path.basename(a.url)}`);
193 if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
194 schrijf.push({ soort: 'media', doel, bytes });
195 rapport.media += 1;
196 }
197
198 schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o });
199 rapport.posts += 1;
200 }
201
202 // Antwoorden: alleen-lezen archief. Nooit opnieuw bezorgd, geen meldingen.
203 for (const pad of [...files.keys()].filter((p) => p.startsWith('replies/')).sort()) {
204 const coll = JSON.parse(files.get(pad).toString('utf8'));
205 if (coll['shaer:archive'] !== true) {
206 rapport.waarschuwingen.push(`${pad}: niet gemarkeerd als archief, overgeslagen`);
207 continue;
208 }
209 const oudId = path.basename(pad, '.json');
210 const postId = idKaart.get(oudId);
211 if (!postId) continue; // post overgeslagen -> antwoorden ook
212 for (const it of (coll.orderedItems || [])) {
213 schrijf.push({ soort: 'reply', postId, it });
214 rapport.replies += 1;
215 }
216 }
217
218 if (opts.dryRun) return rapport;
219
220 // Schrijven pas nu, in EEN transactie: een half ingelezen archief is de ergste
221 // uitkomst, want dan lijkt het gelukt.
222 const insPost = db.prepare(`INSERT OR REPLACE INTO posts
223 (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
224 pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
225 content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server)
226 VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
227 @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
228 @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`);
229 const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
230 (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
231 VALUES ('reply', ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`);
232
233 db.transaction(() => {
234 for (const s of schrijf) {
235 if (s.soort === 'media') {
236 fs.mkdirSync(path.dirname(s.doel), { recursive: true });
237 fs.writeFileSync(s.doel, s.bytes);
238 continue;
239 }
240 if (s.soort === 'reply') {
241 insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
242 s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
243 continue;
244 }
245 const o = s.obj;
246 const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
247 // De rollen uit het archief terug naar de kolommen. Zonder dit staat het
248 // bestand er wel, maar komt de post zonder cover en zonder speler terug --
249 // en dat zie je pas als je alle kolommen vergelijkt.
250 const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
251 const padVan = (a) => (a ? padVanOrigineel(a['shaer:originalUrl']) : null);
252 const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
253 const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
254 const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a));
255 return {
256 url: padVan(a), mediaType: a.mediaType, name: a.name || undefined,
257 poster: poster ? padVan(poster) : undefined,
258 };
259 }).filter((a) => a.url);
260 insPost.run({
261 id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
262 title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
263 status: o['shaer:status'] || 'draft',
264 cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null,
265 cover_video_url: padVan(metRol('coverVideo')),
266 c2s_attachments: c2s.length ? JSON.stringify(c2s) : null,
267 pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
268 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
269 published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published),
270 noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']),
271 fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
272 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
273 content_warning: o.summary || null,
274 poll_json: opties ? JSON.stringify({ multiple: Array.isArray(o.anyOf), options: opties.map((x) => ({ name: x.name })), endTime: o.endTime || null, closed: !!o.closed }) : null,
275 quote_uri: o.quoteUrl || null, quote_actor: o['shaer:quoteActor'] || null,
276 ap_visibility: o['shaer:apVisibility'] || null,
277 paid: o['shaer:paid'] ? 1 : 0, paid_min_cents: o['shaer:paidMinCents'] || null,
278 view_count: o['shaer:viewCount'] || 0,
279 });
280 // Gehoste audio terug: [[track:]] in de content valt anders op niets terug.
281 for (const t of (o['shaer:audio'] || [])) {
282 const trackId = String(t['shaer:ref'] || '').replace(/^\[\[track:|\]\]$/g, '');
283 if (!trackId) continue;
284 let mediaId = null;
285 const bij = (o.attachment || []).find((a) => a.url === t['shaer:media']);
286 const doel = bij && veiligMediaPad(padVanOrigineel(bij['shaer:originalUrl']));
287 if (doel) {
288 mediaId = randomUUID();
289 try {
290 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
291 .run(mediaId, site.id, path.basename(doel), bij.mediaType || 'audio/mpeg', (files.get(bij.url) || []).length || 0, doel);
292 } catch { mediaId = null; }
293 }
294 try {
295 db.prepare(`INSERT OR REPLACE INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, credit, license, link_spotify, link_youtube, link_soundcloud)
296 VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
297 .run(trackId, site.id, t.name || 'zonder titel', t.artist || null, t.album || null, t.duration || null,
298 mediaId, t.credit || null, t.license || null,
299 ...['spotify', 'youtube', 'soundcloud'].map((k) => (t.url || []).find((u) => String(u).includes(k)) || null));
300 } catch { /* geen audio-tabellen op deze installatie */ }
301 }
302 }
303 })();
304
305 return rapport;
306}
Note: See TracBrowser for help on using the repository browser.