source: Klonkt/src/services/ArchiveImportService.js@ a4fea5e

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

De instance-naam is niet de site-slug, en vlagwaarden zijn geen argumenten

Vervolg op 6248497. Het instance-pad werkte, maar export-archive.mjs liz gaf
"onbekende site: liz" -- en liet je vervolgens raden.

## De naam van de instance is niet de slug van de site

De map onder de data-root en de systemd-unit heten liz; de SITE in die database
kan een andere slug hebben. Ze vallen vaak samen en soms niet, en dan stond je met
een foutmelding die niets prijsgaf.

Twee dingen veranderd:

  • de foutmelding zegt nu WAT er wel in die database staat, en als er helemaal geen sites in staan vraagt hij of DATABASE_PATH wel klopt
  • de site-slug is optioneel geworden. Staat er precies een site, dan is de keuze niet dubbelzinnig en hoef je hem niet te weten; hij meldt welke hij nam. Bij meerdere sites stopt hij en noemt ze.

node scripts/export-archive.mjs <instance> [site-slug]

## En een parseerfout die ik er zelf net in had gezet

args.filter(a => !a.startsWith('-')) pikt de WAARDE van een vlag op als
positioneel argument. Met liz --data-root /var/lib/klonkt werd /var/lib/klonkt
de site-slug. Gevonden bij het testen van het bovenstaande, en import-archive.mjs
had hem ook: daar zou het pad naar het archief verschoven zijn.

Nu een gedeelde splitsArgs() die weet welke vlaggen een waarde slikken.

Getoetst met een instance 'liz' waarvan de site 'lizzy' heet -- precies het geval
dat op productie omviel: zonder slug pakt hij lizzy en zegt dat, met de verkeerde
slug noemt hij wat er wel is, en met de juiste plus --out schrijft hij het archief.
Suite 577/577 onder UTC en Europe/Amsterdam.

  • Property mode set to 100644
File size: 16.4 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 * Waar deze bijlage komt te staan, als site-relatief pad.
109 *
110 * Meestal zijn oorspronkelijke plek en bestemming gelijk. Maar een bestand dat
111 * ELDERS werd geserveerd -- gehoste audio ging via /audio/stream/ -- heeft geen
112 * plek onder /media. Zonder een bestemming zou het bestand wel worden
113 * weggeschreven en toch uit de kolommen verdwijnen. Nu krijgt het een eigen hoek,
114 * en verwijzen de kolommen daarheen.
115 */
116function bestemming(a) {
117 return padVanOrigineel(a && a['shaer:originalUrl']) || `/media/archief/${path.basename(String((a && a.url) || ''))}`;
118}
119
120/**
121 * Zet een archief terug in een site.
122 *
123 * @param {Map<string,Buffer>} files het ingelezen archief
124 * @param {object} opts { slug, dryRun, overwrite, origin }
125 */
126export function importArchive(files, opts = {}) {
127 const rapport = {
128 formatVersion: null, origin: null, idsBehouden: null,
129 posts: 0, overgeslagen: 0, overschreven: 0,
130 replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
131 };
132
133 const manifestBuf = files.get('manifest.json');
134 if (!manifestBuf) throw new Error('geen manifest.json: dit is geen inhoudsarchief');
135 const manifest = JSON.parse(manifestBuf.toString('utf8'));
136 rapport.formatVersion = manifest.formatVersion;
137
138 // VERSIE EERST, voordat er ook maar iets gelezen wordt.
139 if (!Number.isInteger(manifest.formatVersion)) throw new Error('manifest zonder bruikbare formatVersion');
140 if (manifest.formatVersion > FORMAT_VERSION) {
141 throw new Error(`archiefversie ${manifest.formatVersion} is nieuwer dan deze Klonkt kent (${FORMAT_VERSION}); geweigerd`);
142 }
143
144 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(opts.slug);
145 if (!site) {
146 // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
147 // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
148 // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
149 let bestaand = [];
150 try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
151 const wat = opts.slug ? `onbekende site: ${opts.slug}` : 'geen site opgegeven';
152 throw new Error(bestaand.length
153 ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
154 : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
155 }
156 const eigenOrigin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
157 rapport.origin = manifest.origin || null;
158
159 // IDENTITEIT. Gelijke origin -> de AP-ids blijven, en daarmee vinden de boosts
160 // en antwoorden die er al naar wijzen hun post terug. Anders nieuwe ids, want
161 // een id op andermans domein publiceren is een vervalsingsoppervlak en andere
162 // servers halen het daar toch op.
163 const idsBehouden = !!(manifest.origin && eigenOrigin && manifest.origin === eigenOrigin);
164 rapport.idsBehouden = idsBehouden;
165 if (!idsBehouden) {
166 rapport.waarschuwingen.push(
167 `origin verschilt (archief ${manifest.origin || '?'} vs deze site ${eigenOrigin || '?'}): nieuwe AP-ids, de oude blijven als verwijzing staan`,
168 );
169 }
170
171 const postPaden = [...files.keys()].filter((p) => p.startsWith('posts/') && p.endsWith('.json')).sort();
172 const bestaatId = db.prepare('SELECT 1 FROM posts WHERE id = ?');
173 const bestaatSlug = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?');
174 const idKaart = new Map(); // oud post-id -> nieuw post-id
175
176 const schrijf = []; // alles eerst uitrekenen, dan in EEN transactie
177
178 for (const pad of postPaden) {
179 const o = JSON.parse(files.get(pad).toString('utf8'));
180 const oudId = decodeURIComponent(String(o.id || '').split('/ap/notes/')[1] || path.basename(pad, '.json'));
181 const nieuwId = idsBehouden ? oudId : randomUUID();
182 idKaart.set(oudId, nieuwId);
183
184 const botsing = !!bestaatId.get(nieuwId) || !!bestaatSlug.get(site.id, o['shaer:slug']);
185 if (botsing && !opts.overwrite) {
186 // EEN gedocumenteerde regel, geen gok per post: bestaande inhoud wordt niet
187 // overschreven tenzij dat expliciet gevraagd is. Dit is ook wat de import
188 // idempotent maakt.
189 rapport.overgeslagen += 1;
190 continue;
191 }
192 if (botsing) rapport.overschreven += 1;
193
194 // Media: terug naar hun oorspronkelijke plek onder /media, want de content
195 // van de post verwijst daarnaar. Dat pad is site-relatief, dus het werkt ook
196 // op een ander domein.
197 for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) {
198 if (a['shaer:availability'] === 'missing') {
199 rapport.mediaMissing += 1;
200 rapport.gemist.push({ post: o['shaer:slug'], url: a['shaer:originalUrl'] || a.url });
201 continue;
202 }
203 const bytes = files.get(a.url);
204 if (!bytes) {
205 // Het archief zegt 'included' maar het bestand ontbreekt. Dat is een kapot
206 // archief, geen ontbrekende media -- apart melden, niet stil optellen.
207 rapport.waarschuwingen.push(`archief verwijst naar ${a.url}, dat er niet in zit`);
208 continue;
209 }
210 if (a['shaer:sha256'] && sha256(bytes) !== a['shaer:sha256']) {
211 rapport.waarschuwingen.push(`${a.url}: checksum klopt niet, overgeslagen`);
212 continue;
213 }
214 const doel = veiligMediaPad(bestemming(a));
215 if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
216 schrijf.push({ soort: 'media', doel, bytes });
217 rapport.media += 1;
218 }
219
220 schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o });
221 rapport.posts += 1;
222 }
223
224 // Antwoorden: alleen-lezen archief. Nooit opnieuw bezorgd, geen meldingen.
225 for (const pad of [...files.keys()].filter((p) => p.startsWith('replies/')).sort()) {
226 const coll = JSON.parse(files.get(pad).toString('utf8'));
227 if (coll['shaer:archive'] !== true) {
228 rapport.waarschuwingen.push(`${pad}: niet gemarkeerd als archief, overgeslagen`);
229 continue;
230 }
231 const oudId = path.basename(pad, '.json');
232 const postId = idKaart.get(oudId);
233 if (!postId) continue; // post overgeslagen -> antwoorden ook
234 for (const it of (coll.orderedItems || [])) {
235 schrijf.push({ soort: 'reply', postId, it });
236 rapport.replies += 1;
237 }
238 }
239
240 if (opts.dryRun) return rapport;
241
242 // Schrijven pas nu, in EEN transactie: een half ingelezen archief is de ergste
243 // uitkomst, want dan lijkt het gelukt.
244 const insPost = db.prepare(`INSERT OR REPLACE INTO posts
245 (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
246 pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
247 content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server)
248 VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
249 @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
250 @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`);
251 const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
252 (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
253 VALUES ('reply', ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`);
254
255 db.transaction(() => {
256 for (const s of schrijf) {
257 if (s.soort === 'media') {
258 fs.mkdirSync(path.dirname(s.doel), { recursive: true });
259 fs.writeFileSync(s.doel, s.bytes);
260 continue;
261 }
262 if (s.soort === 'reply') {
263 insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
264 s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
265 continue;
266 }
267 const o = s.obj;
268 const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
269 // De rollen uit het archief terug naar de kolommen. Zonder dit staat het
270 // bestand er wel, maar komt de post zonder cover en zonder speler terug --
271 // en dat zie je pas als je alle kolommen vergelijkt.
272 const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
273 const padVan = (a) => (a && a['shaer:availability'] !== 'missing' ? bestemming(a) : (a ? padVanOrigineel(a['shaer:originalUrl']) : null));
274 const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
275 const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
276 const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a));
277 return {
278 url: padVan(a), mediaType: a.mediaType, name: a.name || undefined,
279 poster: poster ? padVan(poster) : undefined,
280 };
281 }).filter((a) => a.url);
282 insPost.run({
283 id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
284 title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
285 status: o['shaer:status'] || 'draft',
286 cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null,
287 cover_video_url: padVan(metRol('coverVideo')),
288 c2s_attachments: c2s.length ? JSON.stringify(c2s) : null,
289 pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
290 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
291 published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published),
292 noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']),
293 fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
294 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
295 content_warning: o.summary || null,
296 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,
297 quote_uri: o.quoteUrl || null, quote_actor: o['shaer:quoteActor'] || null,
298 ap_visibility: o['shaer:apVisibility'] || null,
299 paid: o['shaer:paid'] ? 1 : 0, paid_min_cents: o['shaer:paidMinCents'] || null,
300 view_count: o['shaer:viewCount'] || 0,
301 });
302 // Gehoste audio terug: [[track:]] in de content valt anders op niets terug.
303 for (const t of (o['shaer:audio'] || [])) {
304 const trackId = String(t['shaer:ref'] || '').replace(/^\[\[track:|\]\]$/g, '');
305 if (!trackId) continue;
306 let mediaId = null;
307 const bij = (o.attachment || []).find((a) => a.url === t['shaer:media']);
308 const doel = bij && veiligMediaPad(bestemming(bij));
309 if (doel) {
310 mediaId = randomUUID();
311 try {
312 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
313 .run(mediaId, site.id, path.basename(doel), bij.mediaType || 'audio/mpeg', (files.get(bij.url) || []).length || 0, doel);
314 } catch { mediaId = null; }
315 }
316 try {
317 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)
318 VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
319 .run(trackId, site.id, t.name || 'zonder titel', t.artist || null, t.album || null, t.duration || null,
320 mediaId, t.credit || null, t.license || null,
321 ...['spotify', 'youtube', 'soundcloud'].map((k) => (t.url || []).find((u) => String(u).includes(k)) || null));
322 } catch { /* geen audio-tabellen op deze installatie */ }
323 }
324 }
325 })();
326
327 return rapport;
328}
Note: See TracBrowser for help on using the repository browser.