source: Klonkt/src/services/ArchiveImportService.js@ 861c228

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

Gebakken links wijzen voortaan naar hier, in beide routes

Robin: de gebakken tracklinks moeten de nieuwe links worden. Terecht, en ik had
ze te makkelijk afgedaan als "geen plaatje, dus niet mijn probleem". Het zijn
tijdbommen: buildNote zet https://oud/<slug>#track-<id> en ?fc=2 in de content,
en zodra dat domein opgezegd wordt staan er dode links in je eigen berichten.

Ze kunnen nu ook echt om, en dat is te danken aan "altijd behouden" van
vanmiddag: slug en track-id zijn hier hetzelfde, dus alleen de host hoeft eraf
en het fragment klopt vanzelf.

DE REGEL: alleen ombuigen voor een slug die hier ECHT bestaat. Wijst een link
naar iets dat we niet hebben, dan blijft hij naar de oude site wijzen. Een
verwijzing naar een pagina die er nog is, is beter dan een 404 op de nieuwe.

Pas aan het EIND van de ronde, want dan pas weten we welke slugs binnen zijn.
Bij een verse ingest zou de helft van de links nog niet te beoordelen zijn.

EN IN BEIDE ROUTES ("het moet wel gebeuren bij migratie direct ook"). Een
zip-import is net zo goed een verhuizing. Daar draait het binnen dezelfde
transactie: half bijgetrokken is erger dan niet.

Changed files:
src/services/MigrationService.js

  • postLinksBijtrekken(), geexporteerd zodat de zip hem ook kan draaien
  • aangeroepen zodra alle berichten binnen zijn

src/services/ArchiveImportService.js

  • dezelfde stap aan het eind van de import, in de transactie
  • rapportveld linksBijgetrokken

src/views/pages/admin-migrate.ejs

  • in het import- en het ophaalverslag

src/services/i18n.js

  • mig.r_links_fixed in nl/en/de

test/fep1580-migration.test.js

  • fragment en querystring blijven heel, een onbekende slug blijft met rust

test/archive-audio.test.js

  • dezelfde eis voor de zip-route

remarks: /media, /audio en /ap worden overgeslagen; die lopen via hun eigen weg.
Suite 1000 groen, tegenproef op allebei de routes.

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

  • Property mode set to 100644
File size: 26.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, AUDIO_ROOT } from '../config/paths.js';
27import { FORMAT_VERSION, parseFollowingCsv } from './ArchiveExportService.js';
28import * as Migration from './MigrationService.js';
29
30const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
31// De tijdstempel gaat er ONGEWIJZIGD in. Omzetten naar SQL-notatie kostte de
32// sub-seconde, en twee posts in dezelfde seconde staan dan in willekeurige
33// volgorde. Klonkt schrijft zelf ook ISO in deze kolommen.
34const tijd = (iso) => (iso && !isNaN(Date.parse(iso)) ? String(iso) : null);
35
36// ── Inlezen ───────────────────────────────────────────────────────
37
38/** Lees een archiefmap in als pad -> Buffer. */
39export function readArchiveDir(dir) {
40 const files = new Map();
41 const loop = (sub) => {
42 for (const naam of fs.readdirSync(path.join(dir, sub), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
43 const rel = sub ? `${sub}/${naam.name}` : naam.name;
44 if (naam.isDirectory()) loop(rel);
45 else files.set(rel, fs.readFileSync(path.join(dir, rel)));
46 }
47 };
48 loop('');
49 return files;
50}
51
52/**
53 * Lees een zip in. Onze eigen export is store-only, maar een archief dat elders
54 * gemaakt is mag deflate gebruiken -- anders is het geen uitwisselformaat.
55 */
56export function readArchiveZip(buf) {
57 const files = new Map();
58 const eocd = (() => {
59 for (let i = buf.length - 22; i >= 0 && i > buf.length - 66000; i--) if (buf.readUInt32LE(i) === 0x06054b50) return i;
60 return -1;
61 })();
62 if (eocd < 0) throw new Error('geen zip: het eind-record ontbreekt');
63 const aantal = buf.readUInt16LE(eocd + 10);
64 let p = buf.readUInt32LE(eocd + 16);
65 for (let n = 0; n < aantal; n++) {
66 if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('beschadigde zip: centrale ingang klopt niet');
67 const methode = buf.readUInt16LE(p + 10);
68 const gecomp = buf.readUInt32LE(p + 20);
69 const naamLen = buf.readUInt16LE(p + 28);
70 const extraLen = buf.readUInt16LE(p + 30);
71 const commentLen = buf.readUInt16LE(p + 32);
72 const lokaalOffset = buf.readUInt32LE(p + 42);
73 const naam = buf.toString('utf8', p + 46, p + 46 + naamLen);
74 const lNaam = buf.readUInt16LE(lokaalOffset + 26);
75 const lExtra = buf.readUInt16LE(lokaalOffset + 28);
76 const start = lokaalOffset + 30 + lNaam + lExtra;
77 const rauw = buf.subarray(start, start + gecomp);
78 if (!naam.endsWith('/')) {
79 files.set(naam, methode === 8 ? zlib.inflateRawSync(rauw) : Buffer.from(rauw));
80 }
81 p += 46 + naamLen + extraLen + commentLen;
82 }
83 return files;
84}
85
86export function readArchive(bron) {
87 const st = fs.statSync(bron);
88 return st.isDirectory() ? readArchiveDir(bron) : readArchiveZip(fs.readFileSync(bron));
89}
90
91// ── Importeren ────────────────────────────────────────────────────
92
93/** Een pad onder MEDIA_ROOT houden. Een archief van elders is invoer, geen vriend. */
94function veiligMediaPad(urlPad) {
95 if (!urlPad || !urlPad.startsWith('/media/')) return null;
96 const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(urlPad.slice('/media/'.length)));
97 const root = path.resolve(MEDIA_ROOT);
98 return (abs !== root && abs.startsWith(`${root}${path.sep}`)) ? abs : null;
99}
100
101/** Het pad-deel van een originele media-URL, of null als het er niet een van ons is. */
102function padVanOrigineel(u) {
103 const s = String(u || '');
104 if (s.startsWith('/media/')) return s;
105 try { const x = new URL(s); return x.pathname.startsWith('/media/') ? x.pathname : null; } catch { return null; }
106}
107
108/**
109 * Waar deze bijlage komt te staan, als site-relatief pad.
110 *
111 * Meestal zijn oorspronkelijke plek en bestemming gelijk. Maar een bestand dat
112 * ELDERS werd geserveerd -- gehoste audio ging via /audio/stream/ -- heeft geen
113 * plek onder /media. Zonder een bestemming zou het bestand wel worden
114 * weggeschreven en toch uit de kolommen verdwijnen. Nu krijgt het een eigen hoek,
115 * en verwijzen de kolommen daarheen.
116 */
117function bestemming(a) {
118 return padVanOrigineel(a && a['shaer:originalUrl']) || `/media/archief/${path.basename(String((a && a.url) || ''))}`;
119}
120
121/**
122 * De audiobibliotheek terugzetten (formaat v2).
123 *
124 * EEN REGEL DIE HIER ALLES BEPAALT: geen bestand, geen track. Dat klinkt
125 * vanzelfsprekend en was het niet. De oude per-post-tak maakte een
126 * audio_tracks-rij aan zodra er metadata was, ook als de bytes ontbraken. Op
127 * soundfabrics.nl leverde dat 13 nummers op die in de lijst stonden en 404'den
128 * bij het afspelen. Dat is erger dan ontbreken: het ziet eruit alsof de
129 * verhuizing gelukt is, dus je gooit de oude instantie weg.
130 *
131 * De bestanden gaan naar AUDIO_ROOT en niet onder MEDIA_ROOT, want daar hoort
132 * gehoste audio: de publieke /media-handler mag er niet bij (routes/audio.js).
133 *
134 * @returns {Array} de schrijfopdrachten; de beller voert ze in zijn transactie uit
135 * (en bij een droogloop dus niet, maar het verslag klopt wel)
136 */
137/**
138 * Een hoes uit het archief terugzetten. Geeft het nieuwe /media-pad terug, of
139 * null als het bestand er niet in zat: dan liever GEEN cover_url dan een
140 * verwijzing naar niets.
141 */
142function hoesTerug(files, bestand, werk) {
143 if (!bestand) return null;
144 const bytes = files.get(bestand);
145 if (!bytes || !bytes.length) return null;
146 const naam = path.basename(String(bestand));
147 if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) return null;
148 const urlPad = `/media/archief/${naam}`;
149 const doel = veiligMediaPad(urlPad);
150 if (!doel) return null;
151 werk.push({ soort: 'media', doel, bytes });
152 return urlPad;
153}
154
155function tracksTerug(files, site, rapport) {
156 const buf = files.get('tracks.json');
157 if (!buf) return [];
158 let coll;
159 try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('tracks.json is onleesbaar'); return []; }
160 if (coll['shaer:archive'] !== true) { rapport.waarschuwingen.push('tracks.json: niet gemarkeerd als archief, overgeslagen'); return []; }
161
162 const werk = [];
163 for (const t of (coll.orderedItems || [])) {
164 const id = String(t.id || '').trim();
165 if (!id) continue;
166 const bestand = t['shaer:file'];
167 const bytes = bestand ? files.get(bestand) : null;
168 if (!bytes || !bytes.length) {
169 rapport.tracksMissing += 1;
170 rapport.waarschuwingen.push(`${t.name || id}: geluidsbestand zit niet in het archief, track niet aangemaakt`);
171 continue; // de hele regel van deze functie
172 }
173 // Naam op de schijf: de hash uit het archief, met zijn extensie. De speler
174 // zoekt op bestandsnaam in AUDIO_ROOT, dus dit is meteen het pad dat werkt.
175 const naam = path.basename(String(bestand));
176 if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) {
177 rapport.waarschuwingen.push(`${t.name || id}: onbruikbare bestandsnaam, overgeslagen`);
178 continue;
179 }
180 const hoes = hoesTerug(files, t['shaer:coverFile'], werk);
181 werk.push({ soort: 'track', doel: path.join(path.resolve(AUDIO_ROOT), naam), bytes, id, t, naam, hoes });
182 rapport.tracks += 1;
183 }
184 return werk;
185}
186
187/** De playlists terug, inclusief hun volgorde: die volgorde IS de playlist. */
188function playlistsTerug(files, site, rapport, bekendeTracks) {
189 const buf = files.get('playlists.json');
190 if (!buf) return [];
191 let coll;
192 try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('playlists.json is onleesbaar'); return []; }
193 if (coll['shaer:archive'] !== true) return [];
194 const werk = [];
195 for (const p of (coll.orderedItems || [])) {
196 const id = String(p.id || '').trim();
197 if (!id) continue;
198 // Alleen verwijzen naar tracks die er echt gekomen zijn, anders staat er
199 // straks een playlist vol gaten die niemand kan afspelen.
200 const items = (p['shaer:tracks'] || []).filter((x) => bekendeTracks.has(String(x && x.id)));
201 const kwijt = (p['shaer:tracks'] || []).length - items.length;
202 if (kwijt) rapport.waarschuwingen.push(`playlist ${p.name || id}: ${kwijt} nummer(s) ontbreken en zijn eruit gelaten`);
203 const hoes = hoesTerug(files, p['shaer:coverFile'], werk);
204 werk.push({ soort: 'playlist', p, id, items, hoes });
205 rapport.playlists += 1;
206 }
207 return werk;
208}
209
210/**
211 * Volg opnieuw wie je volgde, uit de `following.csv` van een archief.
212 *
213 * BEWUST BUITEN importArchive. Die draait in één transactie en raakt alleen de
214 * database; opnieuw volgen stuurt Follow-activiteiten de deur uit en wacht op
215 * het netwerk. Dat hoort niet in een transactie: een trage peer houdt hem open,
216 * en een rollback neemt verzonden activiteiten niet terug.
217 *
218 * Ook een aparte, expliciete stap omdat een archief inlezen stil is maar negen
219 * mensen aanschrijven niet. Dat mag geen bijwerking zijn van een import.
220 *
221 * `followFn` is injecteerbaar, zodat de test geen netwerk raakt en dit bestand
222 * ActivityPubService niet hoeft te importeren.
223 */
224export async function importFollowing(site, csvText, { followFn = null } = {}) {
225 const rijen = parseFollowingCsv(csvText);
226 const rapport = { totaal: rijen.length, gevolgd: 0, overgeslagen: 0, mislukt: [] };
227 if (!followFn) return { ...rapport, error: 'no_follow_fn' };
228 for (const r of rijen) {
229 // Jezelf volgen is geen relatie maar een lus. Kan echt gebeuren bij een
230 // archief van een instance die je onder een nieuwe naam opnieuw opzet.
231 if (site && site.slug && r.address.startsWith(`${site.slug}@`)) { rapport.overgeslagen += 1; continue; }
232 try {
233 // De uitgelicht-stand gaat MEE in de Follow zelf: followActor neemt hem
234 // als derde argument en zet auto_boost bij het aanmaken van de rij. Een
235 // aparte UPDATE erna zou een tweede pad zijn naar dezelfde vlag, en dan
236 // kan er precies een halve toestand ontstaan als die faalt.
237 const ok = await followFn(site, r.address, r.featured);
238 if (ok === false) { rapport.mislukt.push({ adres: r.address, reden: 'geweigerd' }); continue; }
239 rapport.gevolgd += 1;
240 } catch (e) {
241 rapport.mislukt.push({ adres: r.address, reden: (e && e.message) || 'onbekend' });
242 }
243 }
244 return rapport;
245}
246
247/**
248 * Zet een archief terug in een site.
249 *
250 * @param {Map<string,Buffer>} files het ingelezen archief
251 * @param {object} opts { slug, dryRun, overwrite, origin }
252 */
253export function importArchive(files, opts = {}) {
254 const rapport = {
255 formatVersion: null, origin: null, idsBehouden: null,
256 posts: 0, overgeslagen: 0, overschreven: 0,
257 replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
258 tracks: 0, tracksMissing: 0, playlists: 0, linksBijgetrokken: 0,
259 };
260
261 const manifestBuf = files.get('manifest.json');
262 if (!manifestBuf) throw new Error('geen manifest.json: dit is geen inhoudsarchief');
263 const manifest = JSON.parse(manifestBuf.toString('utf8'));
264 rapport.formatVersion = manifest.formatVersion;
265
266 // VERSIE EERST, voordat er ook maar iets gelezen wordt.
267 if (!Number.isInteger(manifest.formatVersion)) throw new Error('manifest zonder bruikbare formatVersion');
268 if (manifest.formatVersion > FORMAT_VERSION) {
269 throw new Error(`archiefversie ${manifest.formatVersion} is nieuwer dan deze Klonkt kent (${FORMAT_VERSION}); geweigerd`);
270 }
271
272 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(opts.slug);
273 if (!site) {
274 // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
275 // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
276 // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
277 let bestaand = [];
278 try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
279 const wat = opts.slug ? `onbekende site: ${opts.slug}` : 'geen site opgegeven';
280 throw new Error(bestaand.length
281 ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
282 : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
283 }
284 const eigenOrigin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
285 rapport.origin = manifest.origin || null;
286
287 // IDENTITEIT. Het INTERNE id blijft altijd (Robins besluit, 14-8).
288 //
289 // Dat is iets anders dan de AP-URI. Die is domeingebonden en wordt hoe dan
290 // ook nieuw: https://nieuw/ap/notes/<id> is een ander adres dan
291 // https://oud/ap/notes/<id>. Je claimt dus niets van een ander door het GUID
292 // te hergebruiken, en je wint dat elke INTERNE verwijzing blijft kloppen:
293 // [[track:]], [[playlist:]] en [[album:]] wijzen na een verhuizing nog naar
294 // het goede ding.
295 //
296 // Voorheen hing dit aan de origin, en alleen voor posts; tracks en playlists
297 // hielden hun id al wel. Die scheve tabel was precies waarom een post uit de
298 // zip met [[track:oud]] naast een nummer uit de pull met een nieuw id kwam te
299 // staan, en je de shorthand als kale tekst in je bericht zag.
300 //
301 // `idsBehouden` gaat hieronder alleen nog over de AP-URI: gelijke origin
302 // betekent dat ook die identiek blijft, en dan valt er niets te vertalen.
303 const idsBehouden = !!(manifest.origin && eigenOrigin && manifest.origin === eigenOrigin);
304 rapport.idsBehouden = idsBehouden;
305 if (!idsBehouden) {
306 rapport.waarschuwingen.push(
307 `origin verschilt (archief ${manifest.origin || '?'} vs deze site ${eigenOrigin || '?'}): de berichten krijgen een nieuw AP-adres. Hun interne id blijft, dus verwijzingen binnen je site blijven kloppen.`,
308 );
309 }
310
311 const postPaden = [...files.keys()].filter((p) => p.startsWith('posts/') && p.endsWith('.json')).sort();
312 const bestaatId = db.prepare('SELECT 1 FROM posts WHERE id = ?');
313 const bestaatSlug = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?');
314 const idKaart = new Map(); // oud post-id -> nieuw post-id
315
316 const schrijf = []; // alles eerst uitrekenen, dan in EEN transactie
317
318 for (const pad of postPaden) {
319 const o = JSON.parse(files.get(pad).toString('utf8'));
320 const oudId = decodeURIComponent(String(o.id || '').split('/ap/notes/')[1] || path.basename(pad, '.json'));
321 // Altijd het id uit het archief. Staat er hier al iets met dat id, dan is
322 // dat hetzelfde object, en dat handelt de botsingscontrole hieronder af.
323 const nieuwId = oudId;
324 idKaart.set(oudId, nieuwId);
325
326 const botsing = !!bestaatId.get(nieuwId) || !!bestaatSlug.get(site.id, o['shaer:slug']);
327 if (botsing && !opts.overwrite) {
328 // EEN gedocumenteerde regel, geen gok per post: bestaande inhoud wordt niet
329 // overschreven tenzij dat expliciet gevraagd is. Dit is ook wat de import
330 // idempotent maakt.
331 rapport.overgeslagen += 1;
332 continue;
333 }
334 if (botsing) rapport.overschreven += 1;
335
336 // Media: terug naar hun oorspronkelijke plek onder /media, want de content
337 // van de post verwijst daarnaar. Dat pad is site-relatief, dus het werkt ook
338 // op een ander domein.
339 for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) {
340 if (a['shaer:availability'] === 'missing') {
341 rapport.mediaMissing += 1;
342 rapport.gemist.push({ post: o['shaer:slug'], url: a['shaer:originalUrl'] || a.url });
343 continue;
344 }
345 const bytes = files.get(a.url);
346 if (!bytes) {
347 // Het archief zegt 'included' maar het bestand ontbreekt. Dat is een kapot
348 // archief, geen ontbrekende media -- apart melden, niet stil optellen.
349 rapport.waarschuwingen.push(`archief verwijst naar ${a.url}, dat er niet in zit`);
350 continue;
351 }
352 if (a['shaer:sha256'] && sha256(bytes) !== a['shaer:sha256']) {
353 rapport.waarschuwingen.push(`${a.url}: checksum klopt niet, overgeslagen`);
354 continue;
355 }
356 const doel = veiligMediaPad(bestemming(a));
357 if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
358 schrijf.push({ soort: 'media', doel, bytes });
359 rapport.media += 1;
360 }
361
362 schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o, oudeUri: o.id || null });
363 rapport.posts += 1;
364 }
365
366 // Antwoorden: alleen-lezen archief. Nooit opnieuw bezorgd, geen meldingen.
367 for (const pad of [...files.keys()].filter((p) => p.startsWith('replies/')).sort()) {
368 const coll = JSON.parse(files.get(pad).toString('utf8'));
369 if (coll['shaer:archive'] !== true) {
370 rapport.waarschuwingen.push(`${pad}: niet gemarkeerd als archief, overgeslagen`);
371 continue;
372 }
373 const oudId = path.basename(pad, '.json');
374 const postId = idKaart.get(oudId);
375 if (!postId) continue; // post overgeslagen -> antwoorden ook
376 for (const it of (coll.orderedItems || [])) {
377 schrijf.push({ soort: 'reply', postId, it });
378 rapport.replies += 1;
379 }
380 }
381
382 // De audiobibliotheek. Telt ook in een droogloop mee in het verslag, want
383 // "hoeveel nummers komen er" is precies wat je wilt weten voor je besluit.
384 const trackWerk = tracksTerug(files, site, rapport);
385 const bekendeTracks = new Set(trackWerk.map((w) => w.id));
386 const playlistWerk = playlistsTerug(files, site, rapport, bekendeTracks);
387
388 if (opts.dryRun) return rapport;
389
390 // Schrijven pas nu, in EEN transactie: een half ingelezen archief is de ergste
391 // uitkomst, want dan lijkt het gelukt.
392 const insPost = db.prepare(`INSERT OR REPLACE INTO posts
393 (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
394 pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
395 content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server)
396 VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
397 @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
398 @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`);
399 const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
400 (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
401 VALUES ('reply', ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`);
402
403 db.transaction(() => {
404 for (const s of [...schrijf, ...trackWerk, ...playlistWerk]) {
405 if (s.soort === 'media') {
406 fs.mkdirSync(path.dirname(s.doel), { recursive: true });
407 fs.writeFileSync(s.doel, s.bytes);
408 continue;
409 }
410 if (s.soort === 'reply') {
411 insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
412 s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
413 continue;
414 }
415 if (s.soort === 'track') {
416 // Bestand eerst, dan pas de rijen. Faalt het schrijven, dan gooit dit en
417 // rolt de hele transactie terug: liever geen import dan een track zonder
418 // geluid, want dat is precies de val waar dit uit voortkomt.
419 fs.mkdirSync(path.dirname(s.doel), { recursive: true });
420 fs.writeFileSync(s.doel, s.bytes);
421 const mediaId = randomUUID();
422 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
423 .run(mediaId, site.id, s.naam, s.t['shaer:mediaType'] || 'audio/mpeg', s.bytes.length, s.doel);
424 const link = (k) => (s.t.url || []).find((u) => String(u).includes(k)) || null;
425 db.prepare(`INSERT OR REPLACE INTO audio_tracks
426 (id, site_id, title, artist, album, duration, media_id, position, credit, license,
427 cover_url, downloadable, fedi_open, link_spotify, link_youtube, link_soundcloud)
428 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
429 .run(s.id, site.id, s.t.name || 'zonder titel', s.t.artist || null, s.t.album || null,
430 s.t.duration || null, mediaId, s.t.position ?? null, s.t.credit || null, s.t.license || null,
431 s.hoes || null, s.t['shaer:downloadable'] ? 1 : 0, s.t['shaer:fediOpen'] ? 1 : 0,
432 link('spotify'), link('youtube'), link('soundcloud'));
433 continue;
434 }
435 if (s.soort === 'playlist') {
436 db.prepare(`INSERT OR REPLACE INTO playlists (id, site_id, title, artist, year, cover_url, kind)
437 VALUES (?,?,?,?,?,?,?)`)
438 .run(s.id, site.id, s.p.name || 'zonder titel', s.p.artist || null, s.p.year || null,
439 s.hoes || null, s.p['shaer:kind'] || null);
440 db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(s.id);
441 const insPT = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
442 s.items.forEach((it, i) => insPT.run(s.id, String(it.id), it.position ?? i));
443 continue;
444 }
445 const o = s.obj;
446 const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
447 // De rollen uit het archief terug naar de kolommen. Zonder dit staat het
448 // bestand er wel, maar komt de post zonder cover en zonder speler terug --
449 // en dat zie je pas als je alle kolommen vergelijkt.
450 const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
451 const padVan = (a) => (a && a['shaer:availability'] !== 'missing' ? bestemming(a) : (a ? padVanOrigineel(a['shaer:originalUrl']) : null));
452 const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
453 const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
454 const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a));
455 return {
456 url: padVan(a), mediaType: a.mediaType, name: a.name || undefined,
457 poster: poster ? padVan(poster) : undefined,
458 };
459 }).filter((a) => a.url);
460 insPost.run({
461 id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
462 title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
463 status: o['shaer:status'] || 'draft',
464 cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null,
465 cover_video_url: padVan(metRol('coverVideo')),
466 c2s_attachments: c2s.length ? JSON.stringify(c2s) : null,
467 pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
468 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
469 published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published),
470 noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']),
471 fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
472 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
473 content_warning: o.summary || null,
474 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,
475 quote_uri: o.quoteUrl || null, quote_actor: o['shaer:quoteActor'] || null,
476 ap_visibility: o['shaer:apVisibility'] || null,
477 paid: o['shaer:paid'] ? 1 : 0, paid_min_cents: o['shaer:paidMinCents'] || null,
478 view_count: o['shaer:viewCount'] || 0,
479 });
480 // De per-post audio-tak is weg. Tracks komen sinds v2 uit tracks.json,
481 // dat de HELE bibliotheek draagt in plaats van alleen wat in een bericht
482 // stond. Hier stond bovendien de fout die soundfabrics.nl opleverde: deze
483 // lus maakte een audio_tracks-rij aan ZONDER te kijken of het bestand er
484 // wel was, dus je kreeg 13 nummers die bestonden, in de lijst stonden, en
485 // 404'den zodra je op play drukte. Zie tracksTerug hieronder.
486 }
487
488 // FEP-1580: een import uit een export is GEEN apart geval. De spec zegt
489 // met zoveel woorden dat objecten uit een geëxporteerde collectie net zo
490 // behandeld moeten worden als objecten die van de bron zijn opgehaald.
491 // Dus vult ook deze weg de vertaaltabel, en werkt de reactie van een derde
492 // op een verhuisd bericht straks net zo goed bij als bij een live ingest.
493 //
494 // Alleen zinnig als de ids VERANDERD zijn: bleven ze gelijk, dan wijst de
495 // oude URI al naar het goede object en valt er niets te vertalen.
496 if (!idsBehouden && eigenOrigin) {
497 for (const s of schrijf) {
498 if (s.soort !== 'post' || !s.oudeUri) continue;
499 Migration.recordMigrated(site.slug, {
500 origin: s.oudeUri,
501 target: `${eigenOrigin}/ap/notes/${encodeURIComponent(s.id)}`,
502 sourceActor: manifest.actor || '',
503 isPublic: !(s.obj && (s.obj['shaer:fanOnly'] || s.obj['shaer:apVisibility'] === 'direct')),
504 });
505 }
506 }
507
508 // Links naar de BRONPOSTS ombuigen naar hier. Ook bij een zip-import: een
509 // verhuizing is een verhuizing, en een bericht dat naar het oude domein
510 // linkt wordt een dode link zodra dat domein opgezegd wordt. Binnen dezelfde
511 // transactie, want half bijgetrokken is erger dan niet.
512 //
513 // Pas hier, aan het eind: nu staan alle berichten er, dus nu weten we welke
514 // slugs bestaan. Een link naar iets dat we niet hebben blijft met rust.
515 if (manifest.origin && manifest.origin !== eigenOrigin) {
516 Migration.postLinksBijtrekken(site, String(manifest.origin).replace(/\/+$/, ''), rapport);
517 }
518 })();
519
520 return rapport;
521}
Note: See TracBrowser for help on using the repository browser.