| 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 |
|
|---|
| 27 | import fs from 'fs';
|
|---|
| 28 | import path from 'path';
|
|---|
| 29 | import crypto from 'crypto';
|
|---|
| 30 | import Database from 'better-sqlite3';
|
|---|
| 31 | import { stableJson, FORMAT_VERSION } from './ArchiveExportService.js';
|
|---|
| 32 |
|
|---|
| 33 | const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
|
|---|
| 34 | const 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 | };
|
|---|
| 39 | const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
|
|---|
| 40 | const mimeOf = (u, opgegeven) => opgegeven || MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
|
|---|
| 41 | const 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 | */
|
|---|
| 58 | export 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(/</g, '<').replace(/>/g, '>').replace(/&/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 | */
|
|---|
| 74 | function 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 |
|
|---|
| 94 | const 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 | */
|
|---|
| 105 | export 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 | }
|
|---|