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

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

Je berichten verhuizen mee: FEP-1580 plus een webinterface ervoor

FEP-7628 verhuist je volgers en zegt zelf dat de inhoud een ander probleem is.
Dat probleem stond open: na een Move bleven je berichten op de oude instantie
staan, en elke reactie van een derde wees naar een URI die verdwijnt zodra dat
domein opgezegd wordt. FEP-1580 regelt dat, status DRAFT.

DE AUTORISATIE IS DE MOVE, NIET EEN CODE. De bronkant behandelt een ondertekend
verzoek namens de doel-actor alsof de bron-actor het zelf deed. Dat mag omdat
moveAccount() no_backreference weigert: moved_to staat er alleen als de
doel-actor ons al in alsoKnownAs had. Beide kanten hebben ooit ja gezegd, dus er
is geen tweede vertrouwensmechanisme nodig. Een typefout komt hier niet binnen,
want die haalt de move zelf niet. Dat dit veilig is leunt op de keyId-binding
uit shaer-xd8i: zonder die controle is "wie tekende dit" te zacht om je hele
geschiedenis aan af te geven.

NIEUWE IDS ZIJN GEEN BUG, DE VERTAALTABEL IS HET ANTWOORD. Een verhuisd bericht
krijgt een eigen URI, want het staat op een ander domein. De migration-collectie
mapt oud naar nieuw en derden lezen die om hun eigen verwijzingen bij te werken.
Zonder die collectie is de draad kapot, met die collectie is het een
verhuisbericht. Niet-publieke items staan er alleen in voor wie ze mocht zien:
een lijst met de URIs van je fan-only posts is een lek, ook zonder de inhoud.

Er gaat geen Create de deur uit. Je volgers hebben die berichten jaren geleden
al gezien; driehonderd posts die als nieuw de tijdlijn in klateren is geen
verhuizing maar spam.

Daarnaast /admin/migrate: exporteren, importeren en ophalen via de
webinterface, zodat verhuizen geen SSH-toegang meer vraagt. Importeren gaat
altijd eerst droog, met een verslag en pas daarna een knop die het echt doet.

Getest op twee draaiende instanties, A verhuisd naar B via de echte
moveAccount. Anoniem zag A 3 van de 4 berichten; ondertekend als de doel-actor
kwamen alle 4 mee, inclusief de fan-only. Titel, webadres en publicatiedatum
blijven staan. Media komt echt over: gedownload, in de mediatabel, B serveert
het. Migration-collectie 4 rijen totaal, 3 publiek.

Changed files:
src/services/ActivityPubService.js

  • isMoveTarget: het hele autorisatiepredicaat van de bronkant
  • outboxAudience en mayReadNote: de doel-actor krijgt onze eigen kijkrechten
  • buildActor adverteert migration en moves, ook leeg (de FEP wijst er apart op dat "niets verhuisd" anders niet te onderscheiden is van "kent dit niet")
  • signedGetJson geexporteerd, de ingest heeft hem nodig
  • isMoveTarget en signedGetJson in de default export (movedLock verstopte zich een dag eerder precies zo)

src/services/ap-core.js

  • FEP-1580-termen in de JSON-LD-context

src/services/ArchiveImportService.js

  • een import uit een zip vult dezelfde vertaaltabel; de spec wil dat een geexporteerde collectie identiek behandeld wordt

src/routes/activitypub.js

  • /ap/users/:slug/migration en /moves
  • de blocked-collectie gaat open voor de doel-actor, want zichtbaarheidsvoorkeuren moeten meeverhuizen

src/config/database.js

  • ap_migration en ap_moves, plus sites.migration_complete

src/server.js

  • /admin/migrate aangesloten

src/views/pages/admin.ejs

  • knop naar Migreren

src/services/i18n.js

  • mig.* in nl/en/de

New file:
src/services/MigrationService.js

  • de doelkant: ingest-routine, migration- en moves-collectie, statusvlag

src/routes/admin-migrate.js

  • exporteren, droog importeren, echt importeren, ophalen bij de oude Klonkt

src/views/pages/admin-migrate.ejs

  • de pagina

test/fep1580-migration.test.js

  • 22 tests over beide rollen, plus de regressietest bij 4101c89

remarks: FEP-8b32 ontbreekt volledig (shaer-j1v0), dus er staat geen
handtekening onder de moves-collectie en we zijn niet naleveringsklaar. Bewust
geen leeg proof-veld: een derde die het controleert wordt dan misleid. De DERDE
rol zit er ook niet in, Klonkt leest nog geen migration-collecties van anderen,
dus andermans verhuizing repareert onze verwijzingen nog niet. Alle betrokken
FEPs zijn DRAFT, ook 7628 die we al volgden; 1580 is vers en de auteur schrijft
zelf dat het een audit verdient.

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

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