source: Klonkt/src/services/ArchiveImportService.js@ 7aa3140

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

Herstel uit andermans tijdlijn-cache (shaer-l1v)

De aanleiding: boiert.eu verloor zijn database. De posts staan nog in de
ap_timeline van instances die boiert volgen, en die tabel sleutelt op de
OORSPRONKELIJKE AP-object-URI. De identiteiten overleven dus, en dat is het
verschil tussen herstellen en opnieuw posten -- boosts, likes en antwoorden
elders wijzen naar die ids.

Er is bewust GEEN apart herstelpad. recover-from-cache.mjs maakt een gewoon
archief in het formaat uit docs/EXPORT-FORMAT.md, dat door dezelfde importer gaat
als een export: droogloop, versiecontrole, en dezelfde regel rond het behouden
van AP-ids. Een tweede implementatie zou uiteenlopen met de eerste.

Meerdere bronnen mogen samen: verschillende volgers dekken verschillende
periodes, en per AP-id wint de rijkste versie.

Titels moeten terug uit de TEKST, want buildNote bakt ze daar in (Mastodon
negeert name). Er is geen sluitend signaal: op echte data heet een post "Back
to 1987!" met slug "waiting-on-you", dus de slug is niet van de titel afgeleid.
We gaan op de vorm af -- een openende alinea met alleen vetgedrukte platte tekst
-- en het script drukt AF wat het lostrok, zodat een mens het naloopt. --houd-titel
laat alles staan.

Gevonden door tegen echte cachedata te draaien (24 posts van sound-fabrics uit de
beta-cache): een deel van de bijlagen loopt niet via /media/ maar via
/audio/stream/<bestandsnaam>, en dat is AUDIO_DIR, een andere map. Zonder dat pad
blijft op een muzieksite de halve catalogus liggen. De importer had hetzelfde
gat aan zijn kant: zo'n bestand werd wel weggeschreven maar verdween uit de
kolommen, omdat er geen bestemming onder /media voor was. Nu krijgt het er een.

Het manifest draagt shaer:recovered met de bronnen, het tijdvenster en wat er
principieel ontbreekt -- eigen antwoorden, alles van voor het volgmoment, en
concepten. Anders ziet iemand dit over een jaar aan voor een gewone export en
denkt hij dat de site compleet is.

14 tests, waaronder de hele ketting: cache -> archief -> importer -> posts met
behouden ids, titel, cover en gesloten poll. Suite 528/528.

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