source: Klonkt/src/services/ArchiveImportService.js@ e9ec5e4

main
Last change on this file since e9ec5e4 was e9ec5e4, checked in by Robin <roboburr@…>, 4 weeks ago

Volglijst: Featured is auto_boost, plus de knoppen om hem mee te nemen

Twee dingen: een correctie op de vorige commit, en de ingang die er nog niet was.

DE CORRECTIE. Ik had auto_boost in de kolom "Show boosts" gezet omdat in beide het
woord boost zit. Dat is fout. Bij Mastodon betekent die kolom "toon de reblogs van
deze persoon in mijn tijdlijn", en dat kent Klonkt niet; onze vlag gaat over hun
EIGEN posts in JOUW Cirkel. Dat is de vlag die in de UI al "Uitgelicht" heet
(tl.autoboost, in het Engels letterlijk "Featured"), dus hij hoort in de kolom
Featured en "Show boosts" blijft leeg.

Daarmee vervalt ook de kolom highlighted die ik in de vorige commit toevoegde:
die was een nieuw begrip naast een bestaand begrip met dezelfde naam. Eén ding, één
naam. Een bestand uit Mastodon levert nu featured: false, en dat is juist: hun
kolom 2 mag hier niet als uitgelicht binnenkomen.

DE INGANG. De vorige commit had de functies maar geen knop, en volgens de eigen
regel is een feature pas af als een eindgebruiker hem kan gebruiken. Op /following
staat nu een uitklapblok met een downloadknop en een plakveld.

De importroute geeft followActor mee als followFn, zodat er één pad naar een
volgrelatie blijft. Daar zat een val in: followActor meldt een fout als VELD en
niet als exception, dus zonder vertaling telde een onvindbaar account als geslaagd
mee. Die vertaling zit er nu in.

Changed files:
src/config/database.js

  • kolom highlighted teruggedraaid; commentaar bij auto_boost zegt nu dat het "Uitgelicht"/"Featured" heet

src/services/ArchiveExportService.js

  • auto_boost naar kolom Featured, Show boosts/Notify/Languages blijven leeg
  • parseFollowingCsv leest alleen kolom 5, en levert featured in plaats van twee velden

src/services/ArchiveImportService.js

  • de uitgelicht-stand gaat mee IN de Follow (derde argument), geen tweede UPDATE erna

src/routes/posts.js

  • GET /news/following.csv, met private/no-store want dit is jouw volglijst
  • POST /news/following/import, urlencoded, roept importFollowing aan met followActor

src/services/i18n.js

  • vijf teksten in nl, en en de

src/views/pages/following.ejs

  • het uitklapblok met de downloadknop en het plakveld

test/following-csv.test.js

  • bijgewerkt naar de juiste kolomindeling; de Mastodon-test controleert nu expliciet dat hun "Show boosts: true" NIET als uitgelicht landt

remarks: 921 groen in UTC en Europe/Amsterdam. Server gestart en de drie routes
nagelopen: alle drie bestaan en sturen naar de login, geen serverfouten. De template
apart gerenderd in nl/en/de, geen onvertaalde sleutels. De pagina zelf achter de
login niet visueel gezien; daar is een account voor nodig. Onderweg per ongeluk
.claude/launch.json overschreven (die zette PUBLIC_BASE_URL) en weer hersteld.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 18.3 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, parseFollowingCsv } from './ArchiveExportService.js';
28
29const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
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);
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
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
120/**
121 * Volg opnieuw wie je volgde, uit de `following.csv` van een archief.
122 *
123 * BEWUST BUITEN importArchive. Die draait in één transactie en raakt alleen de
124 * database; opnieuw volgen stuurt Follow-activiteiten de deur uit en wacht op
125 * het netwerk. Dat hoort niet in een transactie: een trage peer houdt hem open,
126 * en een rollback neemt verzonden activiteiten niet terug.
127 *
128 * Ook een aparte, expliciete stap omdat een archief inlezen stil is maar negen
129 * mensen aanschrijven niet. Dat mag geen bijwerking zijn van een import.
130 *
131 * `followFn` is injecteerbaar, zodat de test geen netwerk raakt en dit bestand
132 * ActivityPubService niet hoeft te importeren.
133 */
134export async function importFollowing(site, csvText, { followFn = null } = {}) {
135 const rijen = parseFollowingCsv(csvText);
136 const rapport = { totaal: rijen.length, gevolgd: 0, overgeslagen: 0, mislukt: [] };
137 if (!followFn) return { ...rapport, error: 'no_follow_fn' };
138 for (const r of rijen) {
139 // Jezelf volgen is geen relatie maar een lus. Kan echt gebeuren bij een
140 // archief van een instance die je onder een nieuwe naam opnieuw opzet.
141 if (site && site.slug && r.address.startsWith(`${site.slug}@`)) { rapport.overgeslagen += 1; continue; }
142 try {
143 // De uitgelicht-stand gaat MEE in de Follow zelf: followActor neemt hem
144 // als derde argument en zet auto_boost bij het aanmaken van de rij. Een
145 // aparte UPDATE erna zou een tweede pad zijn naar dezelfde vlag, en dan
146 // kan er precies een halve toestand ontstaan als die faalt.
147 const ok = await followFn(site, r.address, r.featured);
148 if (ok === false) { rapport.mislukt.push({ adres: r.address, reden: 'geweigerd' }); continue; }
149 rapport.gevolgd += 1;
150 } catch (e) {
151 rapport.mislukt.push({ adres: r.address, reden: (e && e.message) || 'onbekend' });
152 }
153 }
154 return rapport;
155}
156
157/**
158 * Zet een archief terug in een site.
159 *
160 * @param {Map<string,Buffer>} files het ingelezen archief
161 * @param {object} opts { slug, dryRun, overwrite, origin }
162 */
163export function importArchive(files, opts = {}) {
164 const rapport = {
165 formatVersion: null, origin: null, idsBehouden: null,
166 posts: 0, overgeslagen: 0, overschreven: 0,
167 replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
168 };
169
170 const manifestBuf = files.get('manifest.json');
171 if (!manifestBuf) throw new Error('geen manifest.json: dit is geen inhoudsarchief');
172 const manifest = JSON.parse(manifestBuf.toString('utf8'));
173 rapport.formatVersion = manifest.formatVersion;
174
175 // VERSIE EERST, voordat er ook maar iets gelezen wordt.
176 if (!Number.isInteger(manifest.formatVersion)) throw new Error('manifest zonder bruikbare formatVersion');
177 if (manifest.formatVersion > FORMAT_VERSION) {
178 throw new Error(`archiefversie ${manifest.formatVersion} is nieuwer dan deze Klonkt kent (${FORMAT_VERSION}); geweigerd`);
179 }
180
181 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(opts.slug);
182 if (!site) {
183 // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
184 // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
185 // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
186 let bestaand = [];
187 try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
188 const wat = opts.slug ? `onbekende site: ${opts.slug}` : 'geen site opgegeven';
189 throw new Error(bestaand.length
190 ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
191 : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
192 }
193 const eigenOrigin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
194 rapport.origin = manifest.origin || null;
195
196 // IDENTITEIT. Gelijke origin -> de AP-ids blijven, en daarmee vinden de boosts
197 // en antwoorden die er al naar wijzen hun post terug. Anders nieuwe ids, want
198 // een id op andermans domein publiceren is een vervalsingsoppervlak en andere
199 // servers halen het daar toch op.
200 const idsBehouden = !!(manifest.origin && eigenOrigin && manifest.origin === eigenOrigin);
201 rapport.idsBehouden = idsBehouden;
202 if (!idsBehouden) {
203 rapport.waarschuwingen.push(
204 `origin verschilt (archief ${manifest.origin || '?'} vs deze site ${eigenOrigin || '?'}): nieuwe AP-ids, de oude blijven als verwijzing staan`,
205 );
206 }
207
208 const postPaden = [...files.keys()].filter((p) => p.startsWith('posts/') && p.endsWith('.json')).sort();
209 const bestaatId = db.prepare('SELECT 1 FROM posts WHERE id = ?');
210 const bestaatSlug = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?');
211 const idKaart = new Map(); // oud post-id -> nieuw post-id
212
213 const schrijf = []; // alles eerst uitrekenen, dan in EEN transactie
214
215 for (const pad of postPaden) {
216 const o = JSON.parse(files.get(pad).toString('utf8'));
217 const oudId = decodeURIComponent(String(o.id || '').split('/ap/notes/')[1] || path.basename(pad, '.json'));
218 const nieuwId = idsBehouden ? oudId : randomUUID();
219 idKaart.set(oudId, nieuwId);
220
221 const botsing = !!bestaatId.get(nieuwId) || !!bestaatSlug.get(site.id, o['shaer:slug']);
222 if (botsing && !opts.overwrite) {
223 // EEN gedocumenteerde regel, geen gok per post: bestaande inhoud wordt niet
224 // overschreven tenzij dat expliciet gevraagd is. Dit is ook wat de import
225 // idempotent maakt.
226 rapport.overgeslagen += 1;
227 continue;
228 }
229 if (botsing) rapport.overschreven += 1;
230
231 // Media: terug naar hun oorspronkelijke plek onder /media, want de content
232 // van de post verwijst daarnaar. Dat pad is site-relatief, dus het werkt ook
233 // op een ander domein.
234 for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) {
235 if (a['shaer:availability'] === 'missing') {
236 rapport.mediaMissing += 1;
237 rapport.gemist.push({ post: o['shaer:slug'], url: a['shaer:originalUrl'] || a.url });
238 continue;
239 }
240 const bytes = files.get(a.url);
241 if (!bytes) {
242 // Het archief zegt 'included' maar het bestand ontbreekt. Dat is een kapot
243 // archief, geen ontbrekende media -- apart melden, niet stil optellen.
244 rapport.waarschuwingen.push(`archief verwijst naar ${a.url}, dat er niet in zit`);
245 continue;
246 }
247 if (a['shaer:sha256'] && sha256(bytes) !== a['shaer:sha256']) {
248 rapport.waarschuwingen.push(`${a.url}: checksum klopt niet, overgeslagen`);
249 continue;
250 }
251 const doel = veiligMediaPad(bestemming(a));
252 if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
253 schrijf.push({ soort: 'media', doel, bytes });
254 rapport.media += 1;
255 }
256
257 schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o });
258 rapport.posts += 1;
259 }
260
261 // Antwoorden: alleen-lezen archief. Nooit opnieuw bezorgd, geen meldingen.
262 for (const pad of [...files.keys()].filter((p) => p.startsWith('replies/')).sort()) {
263 const coll = JSON.parse(files.get(pad).toString('utf8'));
264 if (coll['shaer:archive'] !== true) {
265 rapport.waarschuwingen.push(`${pad}: niet gemarkeerd als archief, overgeslagen`);
266 continue;
267 }
268 const oudId = path.basename(pad, '.json');
269 const postId = idKaart.get(oudId);
270 if (!postId) continue; // post overgeslagen -> antwoorden ook
271 for (const it of (coll.orderedItems || [])) {
272 schrijf.push({ soort: 'reply', postId, it });
273 rapport.replies += 1;
274 }
275 }
276
277 if (opts.dryRun) return rapport;
278
279 // Schrijven pas nu, in EEN transactie: een half ingelezen archief is de ergste
280 // uitkomst, want dan lijkt het gelukt.
281 const insPost = db.prepare(`INSERT OR REPLACE INTO posts
282 (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
283 pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
284 content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server)
285 VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
286 @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
287 @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`);
288 const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
289 (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
290 VALUES ('reply', ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`);
291
292 db.transaction(() => {
293 for (const s of schrijf) {
294 if (s.soort === 'media') {
295 fs.mkdirSync(path.dirname(s.doel), { recursive: true });
296 fs.writeFileSync(s.doel, s.bytes);
297 continue;
298 }
299 if (s.soort === 'reply') {
300 insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
301 s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
302 continue;
303 }
304 const o = s.obj;
305 const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
306 // De rollen uit het archief terug naar de kolommen. Zonder dit staat het
307 // bestand er wel, maar komt de post zonder cover en zonder speler terug --
308 // en dat zie je pas als je alle kolommen vergelijkt.
309 const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
310 const padVan = (a) => (a && a['shaer:availability'] !== 'missing' ? bestemming(a) : (a ? padVanOrigineel(a['shaer:originalUrl']) : null));
311 const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
312 const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
313 const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a));
314 return {
315 url: padVan(a), mediaType: a.mediaType, name: a.name || undefined,
316 poster: poster ? padVan(poster) : undefined,
317 };
318 }).filter((a) => a.url);
319 insPost.run({
320 id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
321 title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
322 status: o['shaer:status'] || 'draft',
323 cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null,
324 cover_video_url: padVan(metRol('coverVideo')),
325 c2s_attachments: c2s.length ? JSON.stringify(c2s) : null,
326 pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
327 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
328 published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published),
329 noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']),
330 fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
331 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
332 content_warning: o.summary || null,
333 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,
334 quote_uri: o.quoteUrl || null, quote_actor: o['shaer:quoteActor'] || null,
335 ap_visibility: o['shaer:apVisibility'] || null,
336 paid: o['shaer:paid'] ? 1 : 0, paid_min_cents: o['shaer:paidMinCents'] || null,
337 view_count: o['shaer:viewCount'] || 0,
338 });
339 // Gehoste audio terug: [[track:]] in de content valt anders op niets terug.
340 for (const t of (o['shaer:audio'] || [])) {
341 const trackId = String(t['shaer:ref'] || '').replace(/^\[\[track:|\]\]$/g, '');
342 if (!trackId) continue;
343 let mediaId = null;
344 const bij = (o.attachment || []).find((a) => a.url === t['shaer:media']);
345 const doel = bij && veiligMediaPad(bestemming(bij));
346 if (doel) {
347 mediaId = randomUUID();
348 try {
349 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
350 .run(mediaId, site.id, path.basename(doel), bij.mediaType || 'audio/mpeg', (files.get(bij.url) || []).length || 0, doel);
351 } catch { mediaId = null; }
352 }
353 try {
354 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)
355 VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
356 .run(trackId, site.id, t.name || 'zonder titel', t.artist || null, t.album || null, t.duration || null,
357 mediaId, t.credit || null, t.license || null,
358 ...['spotify', 'youtube', 'soundcloud'].map((k) => (t.url || []).find((u) => String(u).includes(k)) || null));
359 } catch { /* geen audio-tabellen op deze installatie */ }
360 }
361 }
362 })();
363
364 return rapport;
365}
Note: See TracBrowser for help on using the repository browser.