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