source: Klonkt/src/services/ArchiveRecoveryService.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: 12.0 KB
Line 
1/**
2 * Herstel uit de tijdlijn-cache van een ANDERE Klonkt (shaer-l1v).
3 *
4 * De aanleiding: boiert.eu verloor zijn database. De posts staan nog in de
5 * ap_timeline van instances die boiert volgen, en die tabel sleutelt op de
6 * OORSPRONKELIJKE AP-object-URI. De identiteiten overleven dus, en dat is het
7 * verschil tussen herstellen en opnieuw posten: boosts, likes en antwoorden
8 * elders wijzen naar die ids.
9 *
10 * Dit maakt geen posts aan. Het maakt een ARCHIEF in het formaat uit
11 * docs/EXPORT-FORMAT.md, zodat het door dezelfde importer gaat als een gewone
12 * export -- inclusief droogloop, versiecontrole en de regel rond AP-ids. Een
13 * apart herstelpad zou een tweede implementatie zijn van iets dat al bestaat.
14 *
15 * WAT ER PRINCIPIEEL NIET IN ZIT, en dat hoort in de verwachting te staan
16 * voordat iemand eraan begint:
17 *
18 * - ANTWOORDEN van de verloren site zelf. belongsInTimeline() weigert alles
19 * met een inReplyTo, dus die zijn nooit in een tijdlijn-cache beland.
20 * - alles van VOOR het moment dat de bron ging volgen.
21 * - CONCEPTEN. Nooit gefedereerd, dus nergens gecachet.
22 * - de content is de FEDERATIE-projectie: gesaneerd door de sanitizer van de
23 * bron, met de titel in de tekst gebakken en de afbeeldingen uit de body
24 * gehaald. Waar ze in de tekst stonden is niet te herstellen.
25 */
26
27import fs from 'fs';
28import path from 'path';
29import crypto from 'crypto';
30import Database from 'better-sqlite3';
31import { stableJson, FORMAT_VERSION } from './ArchiveExportService.js';
32
33const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
34const MIME_BY_EXT = {
35 jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
36 webp: 'image/webp', avif: 'image/avif', mp4: 'video/mp4', webm: 'video/webm',
37 mov: 'video/quicktime', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg',
38};
39const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
40const mimeOf = (u, opgegeven) => opgegeven || MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
41const as2TypeOf = (m) => (m.startsWith('video/') ? 'Video' : m.startsWith('audio/') ? 'Audio' : 'Image');
42
43/**
44 * De titel terugvissen uit de tekst.
45 *
46 * buildNote() zet de titel als eerste alinea in de content -- `<p><strong>...`
47 * -- omdat Mastodon `name` negeert. In de cache staat dus de gefedereerde vorm,
48 * en zonder deze stap komt elke post titelloos terug met zijn titel als vetgedrukte
49 * eerste regel in de body.
50 *
51 * Er is GEEN sluitend signaal. De slug is niet van de titel afgeleid (op echte
52 * data: titel "Back to 1987!", slug "waiting-on-you"), dus we moeten op de vorm
53 * afgaan: een openende alinea die niets anders bevat dan vetgedrukte platte
54 * tekst. Een post die echt zo begint verliest die regel naar zijn titel. Vandaar
55 * dat de beller een lijst terugkrijgt van alles wat is losgetrokken -- dat hoort
56 * een mens na te lopen, niet een script.
57 */
58export function splitsTitel(html) {
59 const m = String(html || '').match(/^\s*<p>\s*<strong>([^<>]+)<\/strong>\s*<\/p>/i);
60 if (!m) return { titel: null, rest: html || '' };
61 const titel = m[1].replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
62 if (!titel) return { titel: null, rest: html || '' };
63 return { titel, rest: String(html).slice(m[0].length) };
64}
65
66/**
67 * Van een URL naar een bestand op de geredde schijf.
68 *
69 * Twee routes, en de tweede was bijna vergeten: gewone media gaan via /media/ op
70 * MEDIA_ROOT, maar GEHOSTE AUDIO gaat via /audio/stream/<bestandsnaam> op
71 * AUDIO_DIR -- een andere map. Op echte cachedata van een muzieksite is dat geen
72 * randgeval maar de helft van de bijlagen.
73 */
74function schijfPad(url, origin, mediaRoot, audioRoot) {
75 let p = String(url || '');
76 if (/^https?:/i.test(p)) {
77 try {
78 const u = new URL(p);
79 if (origin && `${u.protocol}//${u.host}` !== origin) return null;
80 p = u.pathname;
81 } catch { return null; }
82 }
83 const onder = (root, rest) => {
84 if (!root) return null;
85 const abs = path.resolve(root, decodeURIComponent(rest));
86 const r = path.resolve(root);
87 return (abs !== r && abs.startsWith(`${r}${path.sep}`)) ? abs : null;
88 };
89 if (p.startsWith('/media/')) return onder(mediaRoot, p.slice('/media/'.length));
90 if (p.startsWith('/audio/stream/')) return onder(audioRoot, p.slice('/audio/stream/'.length));
91 return null;
92}
93
94const parse = (s, val = null) => { try { return JSON.parse(s) || val; } catch { return val; } };
95
96/**
97 * Bouw een archief uit een of meer tijdlijn-caches.
98 *
99 * @param {object} opts
100 * sources paden naar de database(s) van instances die de verloren site volgen
101 * actorUri de actor van de verloren site, bv. https://boiert.eu/ap/users/boiert
102 * mediaRoot de geredde mediamap van de verloren site (optioneel)
103 * houdTitelInTekst laat de titel staan waar hij staat
104 */
105export function recoverFromCache(opts = {}) {
106 const { sources = [], actorUri, mediaRoot = null, houdTitelInTekst = false } = opts;
107 // AUDIO_PATH staat naast MEDIA_PATH, niet erin. Zonder eigen opgave nemen we de
108 // buurmap van de mediamap, want dat is de standaardindeling van storage/.
109 const audioRoot = opts.audioRoot || (mediaRoot ? path.join(path.dirname(path.resolve(mediaRoot)), 'audio') : null);
110 if (!actorUri) throw new Error('actorUri is verplicht: zonder actor weten we niet wiens posts we redden');
111 const origin = (opts.origin || (() => { try { const u = new URL(actorUri); return `${u.protocol}//${u.host}`; } catch { return ''; } })()).replace(/\/+$/, '');
112 if (!origin) throw new Error('kan de origin niet afleiden uit de actorUri');
113
114 const rapport = {
115 bronnen: [], posts: 0, titels: [], media: 0, mediaMissing: 0, gemist: [],
116 overgeslagen: 0, oudste: null, nieuwste: null, waarschuwingen: [],
117 };
118
119 // Beste rij per AP-id. Meerdere bronnen dekken verschillende periodes, en
120 // dezelfde post kan in meer dan een tijdlijn staan; de rijkste versie wint.
121 const beste = new Map();
122 for (const bron of sources) {
123 let n = 0;
124 let sdb;
125 try { sdb = new Database(bron, { readonly: true, fileMustExist: true }); }
126 catch (e) { rapport.waarschuwingen.push(`${bron}: niet te openen (${e.message})`); continue; }
127 let rijen = [];
128 try { rijen = sdb.prepare('SELECT * FROM ap_timeline WHERE author_uri = ?').all(actorUri); }
129 catch (e) { rapport.waarschuwingen.push(`${bron}: geen bruikbare ap_timeline (${e.message})`); }
130 for (const r of rijen) {
131 // Een boost VAN een ander staat op naam van de oorspronkelijke auteur, dus
132 // author_uri filtert die al weg. Een boost van ONZE post door een ander is
133 // wel van ons -- die houden we, maar zonder de booster.
134 const vorige = beste.get(r.id);
135 if (!vorige || String(r.content || '').length > String(vorige.content || '').length) beste.set(r.id, r);
136 n += 1;
137 }
138 sdb.close();
139 rapport.bronnen.push({ pad: bron, rijen: n });
140 }
141
142 const files = new Map();
143 const ids = [...beste.keys()].sort();
144
145 for (const apId of ids) {
146 const r = beste.get(apId);
147 const postId = decodeURIComponent(String(apId).split('/ap/notes/')[1] || '');
148 if (!postId) { rapport.overgeslagen += 1; continue; }
149 let slug = postId;
150 try { const u = new URL(r.url || ''); slug = decodeURIComponent(u.pathname.replace(/^\//, '')) || postId; } catch { /* val terug op het id */ }
151
152 const gesplitst = houdTitelInTekst ? { titel: null, rest: r.content || '' } : splitsTitel(r.content);
153 if (gesplitst.titel) rapport.titels.push({ slug, titel: gesplitst.titel });
154
155 // Bijlagen. De cache bewaart alleen URL's; de VOLGORDE is die van buildNote,
156 // waarin de cover voorop gaat. Meer signaal is er niet, dus de eerste krijgt
157 // de rol cover en de rest wordt bijlage. Waar ze in de tekst stonden is bij
158 // het federeren verloren gegaan en komt niet terug.
159 const attachments = [];
160 const lijst = parse(r.media_json, []) || [];
161 lijst.forEach((m, i) => {
162 const url = m && (m.url || m.href);
163 if (!url) return;
164 const mime = mimeOf(url, m.type && String(m.type).includes('/') ? m.type : null);
165 const rol = i === 0 ? 'cover' : 'c2s';
166 const bestand = schijfPad(url, origin, mediaRoot, audioRoot);
167 let bytes = null;
168 if (bestand) { try { bytes = fs.readFileSync(bestand); } catch { bytes = null; } }
169 if (bytes) {
170 const hash = sha256(bytes);
171 const naam = `media/${hash}${extOf(url) ? `.${extOf(url)}` : ''}`;
172 if (!files.has(naam)) { files.set(naam, bytes); rapport.media += 1; }
173 attachments.push({
174 type: as2TypeOf(mime), mediaType: mime, name: m.name || m.alt || undefined,
175 url: naam, 'shaer:availability': 'included',
176 'shaer:originalUrl': /^https?:/i.test(url) ? url : `${origin}${url}`,
177 'shaer:sha256': hash, 'shaer:role': rol,
178 });
179 } else {
180 rapport.mediaMissing += 1;
181 rapport.gemist.push({ slug, url });
182 attachments.push({
183 type: as2TypeOf(mime), mediaType: mime, name: m.name || m.alt || undefined,
184 url: /^https?:/i.test(url) ? url : `${origin}${url}`,
185 'shaer:availability': 'missing',
186 'shaer:originalUrl': /^https?:/i.test(url) ? url : `${origin}${url}`,
187 'shaer:role': rol,
188 });
189 }
190 });
191
192 const poll = parse(r.poll_json);
193 const quote = parse(r.quote_json);
194 const opties = poll && Array.isArray(poll.options) && poll.options.length >= 2
195 ? poll.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) })) : null;
196
197 const obj = {
198 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
199 id: apId,
200 type: opties ? 'Question' : (gesplitst.titel ? 'Article' : 'Note'),
201 attributedTo: actorUri,
202 name: gesplitst.titel || undefined,
203 content: gesplitst.rest,
204 summary: r.cw || undefined,
205 sensitive: r.nsfw ? true : undefined,
206 published: r.published || undefined,
207 url: r.url || `${origin}/${encodeURIComponent(slug)}`,
208 attachment: attachments.length ? attachments : undefined,
209 ...(opties ? (poll.multiple ? { anyOf: opties } : { oneOf: opties }) : {}),
210 endTime: opties ? (poll.endTime || undefined) : undefined,
211 closed: (opties && poll.closed) ? true : undefined,
212 quoteUrl: (quote && (quote.url || quote.uri || quote.id)) || undefined,
213 'shaer:slug': slug,
214 'shaer:status': 'published', // alles wat gefedereerd is, was gepubliceerd
215 'shaer:recoveredFrom': 'timeline-cache',
216 };
217 files.set(`posts/${postId}.json`, Buffer.from(stableJson(obj), 'utf8'));
218 files.set(`readable/${slug}.md`, Buffer.from(
219 `---\ntitle: ${JSON.stringify(gesplitst.titel || slug)}\nslug: ${JSON.stringify(slug)}\ndate: ${r.published || ''}\nrecovered: timeline-cache\n---\n${gesplitst.rest}\n`, 'utf8'));
220 rapport.posts += 1;
221 if (r.published) {
222 if (!rapport.oudste || r.published < rapport.oudste) rapport.oudste = r.published;
223 if (!rapport.nieuwste || r.published > rapport.nieuwste) rapport.nieuwste = r.published;
224 }
225 }
226
227 const bestandsHashes = {};
228 for (const pad of [...files.keys()].sort()) bestandsHashes[pad] = sha256(files.get(pad));
229 const manifest = {
230 formatVersion: FORMAT_VERSION,
231 generator: `klonkt-recovery/${opts.version || 'dev'}`,
232 exportedAt: opts.exportedAt || new Date().toISOString(),
233 origin,
234 actor: actorUri,
235 site: { slug: opts.slug || '', title: opts.title || '' },
236 counts: { posts: rapport.posts, replies: 0, media: rapport.media, mediaMissing: rapport.mediaMissing },
237 files: bestandsHashes,
238 // Zodat niemand dit later voor een gewone export aanziet: dit archief is
239 // gereconstrueerd uit andermans cache en is per definitie onvolledig.
240 'shaer:recovered': {
241 from: 'timeline-cache',
242 sources: rapport.bronnen.map((b) => path.basename(b.pad)),
243 window: { oldest: rapport.oudste, newest: rapport.nieuwste },
244 missing: ['replies by this actor', 'posts from before the source followed', 'drafts'],
245 },
246 };
247 files.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
248
249 return { files, manifest, rapport };
250}
Note: See TracBrowser for help on using the repository browser.