/** * Herstel uit de tijdlijn-cache van een ANDERE Klonkt (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. * * Dit maakt geen posts aan. Het maakt een ARCHIEF in het formaat uit * docs/EXPORT-FORMAT.md, zodat het door dezelfde importer gaat als een gewone * export -- inclusief droogloop, versiecontrole en de regel rond AP-ids. Een * apart herstelpad zou een tweede implementatie zijn van iets dat al bestaat. * * WAT ER PRINCIPIEEL NIET IN ZIT, en dat hoort in de verwachting te staan * voordat iemand eraan begint: * * - ANTWOORDEN van de verloren site zelf. belongsInTimeline() weigert alles * met een inReplyTo, dus die zijn nooit in een tijdlijn-cache beland. * - alles van VOOR het moment dat de bron ging volgen. * - CONCEPTEN. Nooit gefedereerd, dus nergens gecachet. * - de content is de FEDERATIE-projectie: gesaneerd door de sanitizer van de * bron, met de titel in de tekst gebakken en de afbeeldingen uit de body * gehaald. Waar ze in de tekst stonden is niet te herstellen. */ import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import Database from 'better-sqlite3'; import { stableJson, FORMAT_VERSION } from './ArchiveExportService.js'; const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex'); const MIME_BY_EXT = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', }; const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase(); const mimeOf = (u, opgegeven) => opgegeven || MIME_BY_EXT[extOf(u)] || 'application/octet-stream'; const as2TypeOf = (m) => (m.startsWith('video/') ? 'Video' : m.startsWith('audio/') ? 'Audio' : 'Image'); /** * De titel terugvissen uit de tekst. * * buildNote() zet de titel als eerste alinea in de content -- `

...` * -- omdat Mastodon `name` negeert. In de cache staat dus de gefedereerde vorm, * en zonder deze stap komt elke post titelloos terug met zijn titel als vetgedrukte * eerste regel in de body. * * Er is GEEN sluitend signaal. De slug is niet van de titel afgeleid (op echte * data: titel "Back to 1987!", slug "waiting-on-you"), dus we moeten op de vorm * afgaan: een openende alinea die niets anders bevat dan vetgedrukte platte * tekst. Een post die echt zo begint verliest die regel naar zijn titel. Vandaar * dat de beller een lijst terugkrijgt van alles wat is losgetrokken -- dat hoort * een mens na te lopen, niet een script. */ export function splitsTitel(html) { const m = String(html || '').match(/^\s*

\s*([^<>]+)<\/strong>\s*<\/p>/i); if (!m) return { titel: null, rest: html || '' }; const titel = m[1].replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').trim(); if (!titel) return { titel: null, rest: html || '' }; return { titel, rest: String(html).slice(m[0].length) }; } /** * Van een URL naar een bestand op de geredde schijf. * * Twee routes, en de tweede was bijna vergeten: gewone media gaan via /media/ op * MEDIA_ROOT, maar GEHOSTE AUDIO gaat via /audio/stream/ op * AUDIO_DIR -- een andere map. Op echte cachedata van een muzieksite is dat geen * randgeval maar de helft van de bijlagen. */ function schijfPad(url, origin, mediaRoot, audioRoot) { let p = String(url || ''); if (/^https?:/i.test(p)) { try { const u = new URL(p); if (origin && `${u.protocol}//${u.host}` !== origin) return null; p = u.pathname; } catch { return null; } } const onder = (root, rest) => { if (!root) return null; const abs = path.resolve(root, decodeURIComponent(rest)); const r = path.resolve(root); return (abs !== r && abs.startsWith(`${r}${path.sep}`)) ? abs : null; }; if (p.startsWith('/media/')) return onder(mediaRoot, p.slice('/media/'.length)); if (p.startsWith('/audio/stream/')) return onder(audioRoot, p.slice('/audio/stream/'.length)); return null; } const parse = (s, val = null) => { try { return JSON.parse(s) || val; } catch { return val; } }; /** * Bouw een archief uit een of meer tijdlijn-caches. * * @param {object} opts * sources paden naar de database(s) van instances die de verloren site volgen * actorUri de actor van de verloren site, bv. https://boiert.eu/ap/users/boiert * mediaRoot de geredde mediamap van de verloren site (optioneel) * houdTitelInTekst laat de titel staan waar hij staat */ export function recoverFromCache(opts = {}) { const { sources = [], actorUri, mediaRoot = null, houdTitelInTekst = false } = opts; // AUDIO_PATH staat naast MEDIA_PATH, niet erin. Zonder eigen opgave nemen we de // buurmap van de mediamap, want dat is de standaardindeling van storage/. const audioRoot = opts.audioRoot || (mediaRoot ? path.join(path.dirname(path.resolve(mediaRoot)), 'audio') : null); if (!actorUri) throw new Error('actorUri is verplicht: zonder actor weten we niet wiens posts we redden'); const origin = (opts.origin || (() => { try { const u = new URL(actorUri); return `${u.protocol}//${u.host}`; } catch { return ''; } })()).replace(/\/+$/, ''); if (!origin) throw new Error('kan de origin niet afleiden uit de actorUri'); const rapport = { bronnen: [], posts: 0, titels: [], media: 0, mediaMissing: 0, gemist: [], overgeslagen: 0, oudste: null, nieuwste: null, waarschuwingen: [], }; // Beste rij per AP-id. Meerdere bronnen dekken verschillende periodes, en // dezelfde post kan in meer dan een tijdlijn staan; de rijkste versie wint. const beste = new Map(); for (const bron of sources) { let n = 0; let sdb; try { sdb = new Database(bron, { readonly: true, fileMustExist: true }); } catch (e) { rapport.waarschuwingen.push(`${bron}: niet te openen (${e.message})`); continue; } let rijen = []; try { rijen = sdb.prepare('SELECT * FROM ap_timeline WHERE author_uri = ?').all(actorUri); } catch (e) { rapport.waarschuwingen.push(`${bron}: geen bruikbare ap_timeline (${e.message})`); } for (const r of rijen) { // Een boost VAN een ander staat op naam van de oorspronkelijke auteur, dus // author_uri filtert die al weg. Een boost van ONZE post door een ander is // wel van ons -- die houden we, maar zonder de booster. const vorige = beste.get(r.id); if (!vorige || String(r.content || '').length > String(vorige.content || '').length) beste.set(r.id, r); n += 1; } sdb.close(); rapport.bronnen.push({ pad: bron, rijen: n }); } const files = new Map(); const ids = [...beste.keys()].sort(); for (const apId of ids) { const r = beste.get(apId); const postId = decodeURIComponent(String(apId).split('/ap/notes/')[1] || ''); if (!postId) { rapport.overgeslagen += 1; continue; } let slug = postId; try { const u = new URL(r.url || ''); slug = decodeURIComponent(u.pathname.replace(/^\//, '')) || postId; } catch { /* val terug op het id */ } const gesplitst = houdTitelInTekst ? { titel: null, rest: r.content || '' } : splitsTitel(r.content); if (gesplitst.titel) rapport.titels.push({ slug, titel: gesplitst.titel }); // Bijlagen. De cache bewaart alleen URL's; de VOLGORDE is die van buildNote, // waarin de cover voorop gaat. Meer signaal is er niet, dus de eerste krijgt // de rol cover en de rest wordt bijlage. Waar ze in de tekst stonden is bij // het federeren verloren gegaan en komt niet terug. const attachments = []; const lijst = parse(r.media_json, []) || []; lijst.forEach((m, i) => { const url = m && (m.url || m.href); if (!url) return; const mime = mimeOf(url, m.type && String(m.type).includes('/') ? m.type : null); const rol = i === 0 ? 'cover' : 'c2s'; const bestand = schijfPad(url, origin, mediaRoot, audioRoot); let bytes = null; if (bestand) { try { bytes = fs.readFileSync(bestand); } catch { bytes = null; } } if (bytes) { const hash = sha256(bytes); const naam = `media/${hash}${extOf(url) ? `.${extOf(url)}` : ''}`; if (!files.has(naam)) { files.set(naam, bytes); rapport.media += 1; } attachments.push({ type: as2TypeOf(mime), mediaType: mime, name: m.name || m.alt || undefined, url: naam, 'shaer:availability': 'included', 'shaer:originalUrl': /^https?:/i.test(url) ? url : `${origin}${url}`, 'shaer:sha256': hash, 'shaer:role': rol, }); } else { rapport.mediaMissing += 1; rapport.gemist.push({ slug, url }); attachments.push({ type: as2TypeOf(mime), mediaType: mime, name: m.name || m.alt || undefined, url: /^https?:/i.test(url) ? url : `${origin}${url}`, 'shaer:availability': 'missing', 'shaer:originalUrl': /^https?:/i.test(url) ? url : `${origin}${url}`, 'shaer:role': rol, }); } }); const poll = parse(r.poll_json); const quote = parse(r.quote_json); const opties = poll && Array.isArray(poll.options) && poll.options.length >= 2 ? poll.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) })) : null; const obj = { '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }], id: apId, type: opties ? 'Question' : (gesplitst.titel ? 'Article' : 'Note'), attributedTo: actorUri, name: gesplitst.titel || undefined, content: gesplitst.rest, summary: r.cw || undefined, sensitive: r.nsfw ? true : undefined, published: r.published || undefined, url: r.url || `${origin}/${encodeURIComponent(slug)}`, attachment: attachments.length ? attachments : undefined, ...(opties ? (poll.multiple ? { anyOf: opties } : { oneOf: opties }) : {}), endTime: opties ? (poll.endTime || undefined) : undefined, closed: (opties && poll.closed) ? true : undefined, quoteUrl: (quote && (quote.url || quote.uri || quote.id)) || undefined, 'shaer:slug': slug, 'shaer:status': 'published', // alles wat gefedereerd is, was gepubliceerd 'shaer:recoveredFrom': 'timeline-cache', }; files.set(`posts/${postId}.json`, Buffer.from(stableJson(obj), 'utf8')); files.set(`readable/${slug}.md`, Buffer.from( `---\ntitle: ${JSON.stringify(gesplitst.titel || slug)}\nslug: ${JSON.stringify(slug)}\ndate: ${r.published || ''}\nrecovered: timeline-cache\n---\n${gesplitst.rest}\n`, 'utf8')); rapport.posts += 1; if (r.published) { if (!rapport.oudste || r.published < rapport.oudste) rapport.oudste = r.published; if (!rapport.nieuwste || r.published > rapport.nieuwste) rapport.nieuwste = r.published; } } const bestandsHashes = {}; for (const pad of [...files.keys()].sort()) bestandsHashes[pad] = sha256(files.get(pad)); const manifest = { formatVersion: FORMAT_VERSION, generator: `klonkt-recovery/${opts.version || 'dev'}`, exportedAt: opts.exportedAt || new Date().toISOString(), origin, actor: actorUri, site: { slug: opts.slug || '', title: opts.title || '' }, counts: { posts: rapport.posts, replies: 0, media: rapport.media, mediaMissing: rapport.mediaMissing }, files: bestandsHashes, // Zodat niemand dit later voor een gewone export aanziet: dit archief is // gereconstrueerd uit andermans cache en is per definitie onvolledig. 'shaer:recovered': { from: 'timeline-cache', sources: rapport.bronnen.map((b) => path.basename(b.pad)), window: { oldest: rapport.oudste, newest: rapport.nieuwste }, missing: ['replies by this actor', 'posts from before the source followed', 'drafts'], }, }; files.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8')); return { files, manifest, rapport }; }