Changeset f894be5 in Klonkt for src/services/MigrationService.js


Ignore:
Timestamp:
08/14/2026 01:20:51 AM (4 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
9f9b45f
Parents:
5a49eba
Message:

De helft van de outbox, hotlinks in de tekst, en een lege images-tab

Robin na de echte fetch: 18 van de 35 berichten binnen, alle plaatjes
hotlinken naar de oude site, en de images-tab is leeg. Drie oorzaken, en de
eerste was de pijnlijkste omdat hij zonder één waarschuwing "klaar" meldde.

DE KALE OUTBOX IS EEN VALSTRIK. Klonkt zet er first EN een kopie van pagina 1
in (Pleroma eiste ooit een first, sindsdien staan ze er allebei). De ingest
zag items op de kale collectie, sloeg first over, vond daarna geen next (dat
veld bestaat alleen op echte pagina's) en dacht klaar te zijn. Precies 18 van
de 35, en "ingest klaar" in het log. Nu wordt de keten ALTIJD via first
gevolgd, en telt de ingest na: meldt de bron meer items dan er verwerkt zijn,
dan staat dat in het verslag.

PLAATJES IN DE TEKST BLEVEN HOTLINKS. De gebakken content draagt absolute
verwijzingen naar de bron, in img en in de href eromheen. Die worden nu
gedownload naar HETZELFDE pad onder onze mediamap en relatief herschreven.
Hetzelfde pad is drie reparaties in een: de verwijzingen in de tekst kloppen,
de zip en de pull convergeren op dezelfde bestanden, en de images-tab (die de
MAP post-images scant, niet de databasetabel) is niet langer leeg.

BESTAANDE POSTS WORDEN GEREPAREERD. Robins 18 staan er al, met hotlinks en
zonder cover. Een tweede ronde herschrijft hun tekst, haalt de bestanden
binnen en vult de cover aan. Overslaan is voortaan alleen voor posts waar
niets aan mankeert.

En onderweg twee eigen missers gevonden en gedicht:

  • de regex voor het herschrijven stond als string in een template literal; \s verloor zijn backslash en werd de LETTER s, dus elke URL met een s erin (post-images!) knapte af. De waarschuwing ".../media/po" gaf hem weg.
  • haalBijlage plakte /media/ voor een pad dat al met /media/ begon.

Gemeten op twee instanties met 28 posts, elk met cover en inline plaatje:
28/28 binnen, 0 hotlinks, 28 covers met bestand, post-images-map 28 stuks, en
de reparatieronde maakt een met opzet kapotgemaakte post weer heel.

Changed files:
src/services/MigrationService.js

  • first wordt altijd gevolgd; natellen tegen totalItems
  • bronMediaPad(): bron-media naar hetzelfde pad hier, met ../-bewaking
  • inhoudMediaBinnen(): alle bron-media in de tekst binnenhalen en relatief maken; idempotent
  • bestaande posts: tekst gerepareerd en cover aangevuld op een tweede ronde
  • de dubbele /media/ en de opgegeten backslash

src/views/pages/admin-migrate.ejs

  • "berichten gerepareerd" in het ophaalverslag

src/services/i18n.js

  • mig.r_posts_updated in nl/en/de

test/fep1580-migration.test.js

  • regressietests: de kale-collectie-valstrik, de verzwegen pagina, en het herschrijven van een URL met een s erin, letter voor letter

remarks: de reden dat mijn eigen proef dit nooit zag: mijn nepbron gaf
orderedItems zonder first, precies de vorm die de bug ontweek. En een post met
een ongeldige published_at sloopt de HELE outbox van de bron (unhandled
rejection in buildOutbox); hier gevonden met kapotte testdata, nog niet
gerepareerd. Suite 997 groen; tegenproef op de paginatie en de backslash, en de
eerste tegenproef op die backslash was VALS (sabotage kwam niet aan), daarna
opnieuw met bewijs.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/MigrationService.js

    r5a49eba rf894be5  
    240240const AFBEELDING = /^image\//i;
    241241
     242/**
     243 * Hoort deze URL bij de bron, en wijst hij onder /media/?
     244 *
     245 * Dan behouden we het PAD. Drie redenen tegelijk:
     246 *   - de gebakken content verwijst relatief of absoluut naar dat pad, en met
     247 *     hetzelfde pad hier klopt elke verwijzing zonder herschrijf-acrobatiek;
     248 *   - de media-bibliotheek (Beheer, Media) scant de MAP post-images, niet de
     249 *     databasetabel. Een bestand onder migrated/<uuid> bestaat wel en is
     250 *     onzichtbaar: Robins lege images-tab;
     251 *   - de zip-import bewaart originele paden al, dus zo convergeren beide
     252 *     routes op dezelfde bestanden.
     253 *
     254 * De ../-bewaking is geen formaliteit: het pad komt van een andere server.
     255 */
     256function bronMediaPad(url, bronOrigin, { mediaRoot, path }) {
     257  try {
     258    const u = new URL(String(url));
     259    if (`${u.protocol}//${u.host}` !== bronOrigin) return null;
     260    if (!u.pathname.startsWith('/media/')) return null;
     261    const rel = decodeURIComponent(u.pathname.slice('/media/'.length));
     262    const abs = path.resolve(mediaRoot, rel);
     263    const root = path.resolve(mediaRoot);
     264    if (abs === root || !abs.startsWith(`${root}${path.sep}`)) return null;
     265    return { rel: `/media/${rel}`, abs };
     266  } catch { return null; }
     267}
     268
    242269/** AS2 geeft de duur als ISO-8601 ("PT212S"), de database wil seconden. */
    243270function duurSeconden(v) {
     
    279306 * moet stranden, ook als die bron "van onszelf" is.
    280307 */
    281 async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes, submap = 'migrated', headers = null }) {
     308async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes, submap = 'migrated', headers = null, doel = null }) {
    282309  // Ondertekend als het moet. Gehoste audio zit achter dezelfde poort als de
    283310  // rest van de bron, en een kale fetch krijgt daar een 403: de bron kan dan
     
    295322  })();
    296323  const naam = `${crypto.randomUUID()}.${ext}`;
     324  // `doel` wint: dan behouden we het pad van de bron (zie bronMediaPad).
    297325  // Zonder submap komt het bestand in de root zelf: dat is wat gehoste audio
    298326  // nodig heeft, want de speler zoekt AUDIO_ROOT + bestandsnaam en kijkt niet
    299327  // in mappen eronder.
    300   const rel = submap ? `${submap}/${naam}` : naam;
    301   const abs = submap ? path.join(mediaRoot, submap, naam) : path.join(mediaRoot, naam);
     328  const rel = doel ? doel.rel : (submap ? `${submap}/${naam}` : naam);
     329  const abs = doel ? doel.abs : (submap ? path.join(mediaRoot, submap, naam) : path.join(mediaRoot, naam));
    302330  fs.mkdirSync(path.dirname(abs), { recursive: true });
    303331  fs.writeFileSync(abs, buf);
    304   return { url: `/media/${rel}`, mediaType: type, size: buf.length, filename: naam, storage_path: abs };
     332  // doel.rel is al een volledig /media/-pad; de submap-variant is dat nog niet.
     333  return { url: doel ? doel.rel : `/media/${rel}`, mediaType: type, size: buf.length, filename: naam, storage_path: abs };
     334}
     335
     336/**
     337 * Alle bron-media in een lap HTML binnenhalen en de verwijzingen relatief maken.
     338 *
     339 * Werkt op ALLE https://bron/media/...-voorkomens, niet alleen op <img src>:
     340 * de gebakken content zet dezelfde URL ook in een href om het plaatje groot te
     341 * openen, en een half herschreven paar (lokaal plaatje, hotlink eromheen) is
     342 * verwarrender dan geen herschrijving.
     343 *
     344 * Idempotent: wat al gedownload is wordt niet opnieuw gehaald, en een tweede
     345 * ronde over dezelfde tekst vindt gewoon niets meer te doen.
     346 */
     347async function inhoudMediaBinnen(html, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes }) {
     348  let inhoud = String(html || '');
     349  if (!inhoud || !bronOrigin) return { inhoud, n: 0 };
     350  // LET OP de dubbele backslash: dit is een STRING die een RegExp wordt. Met een
     351  // enkele \s eet de template literal de backslash op en sluit de klasse de
     352  // LETTER s uit; "post-images" knapte dan af op de s en elke URL met een s
     353  // erin werd half herschreven. Gevonden doordat de waarschuwing ".../media/po"
     354  // meldde, afgekapt precies voor de s.
     355  const patroon = new RegExp(`${bronOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/media/[^"'\\s)<>]+)`, 'g');
     356  const gezien = new Set();
     357  let n = 0;
     358  for (const m of [...inhoud.matchAll(patroon)]) {
     359    const vol = m[0];
     360    if (gezien.has(vol)) continue;
     361    gezien.add(vol);
     362    const doel = bronMediaPad(vol, bronOrigin, { mediaRoot, path });
     363    if (!doel) { rapport.waarschuwingen.push(`onbruikbaar mediapad in tekst: ${vol}`); continue; }
     364    let ok = false;
     365    try { fs.statSync(doel.abs); ok = true; } catch { /* nog niet binnen */ }
     366    if (!ok) {
     367      const g = await haalBijlage(vol, { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
     368      if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`plaatje in tekst niet opgehaald: ${vol}`); continue; }
     369      rapport.media++;
     370      try {
     371        db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
     372          .run(crypto.randomUUID(), site.id, path.basename(doel.abs), g.mediaType, g.size, doel.abs);
     373      } catch { /* administratie */ }
     374    }
     375    inhoud = inhoud.split(vol).join(doel.rel);
     376    n++;
     377  }
     378  return { inhoud, n };
    305379}
    306380
     
    349423  const bronActor = await getJson(site.slug, bron);
    350424  if (!bronActor || !bronActor.id) return { error: 'unreachable' };
     425  // De origin van de bron: alles op deze host onder /media/ is van hem en mag
     426  // naar hetzelfde pad hier. Uit de actor-id, niet uit de invoer.
     427  const bronOrigin = (() => { try { const u = new URL(bronActor.id); return `${u.protocol}//${u.host}`; } catch { return null; } })();
    351428  if (bronActor.movedTo !== me) return { error: 'not_moved_here', movedTo: bronActor.movedTo || null };
    352429
     
    366443
    367444  const rapport = {
    368     bron: bronActor.id, posts: 0, overgeslagen: 0, opnieuw: 0, media: 0, mediaMislukt: 0,
     445    bron: bronActor.id, posts: 0, overgeslagen: 0, opnieuw: 0, postsBijgewerkt: 0, media: 0, mediaMislukt: 0,
    369446    blocks: 0, tracksBinnen: 0, tracksMislukt: 0, tracksBijgewerkt: 0, overgeslagenTracks: 0,
    370447    playlistsBinnen: 0, playlistsMislukt: 0, waarschuwingen: [],
     
    393470    if (!bronActor.outbox) return { ...rapport, error: 'no_outbox' };
    394471    let pagina = await getJson(site.slug, typeof bronActor.outbox === 'string' ? bronActor.outbox : bronActor.outbox.id);
    395     if (pagina && pagina.first && !(pagina.orderedItems || pagina.items)) {
     472    const verwacht = pagina && Number(pagina.totalItems) || null;
     473    // Is er een `first`, dan ALTIJD de paginaketen volgen, ook als de kale
     474    // collectie zelf items draagt. Klonkt zet daar een kopie van pagina 1 in
     475    // (Pleroma eiste een first, en sindsdien staan ze er allebei), maar alleen
     476    // echte pagina's dragen een `next`. Wie op de kale collectie blijft hangen
     477    // verwerkt pagina 1 en denkt dan klaar te zijn: precies 18 van Robins 35
     478    // berichten, zonder één waarschuwing.
     479    if (pagina && pagina.first) {
    396480      pagina = await getJson(site.slug, typeof pagina.first === 'string' ? pagina.first : pagina.first.id);
    397481    }
     
    422506        // opnieuw op, dan komt het gewoon terug: er staat immers niets meer.
    423507        const id = ruwId(o.id) || crypto.randomUUID();
    424         if (db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = ?').get(id, site.id)) {
     508        const bestaand = db.prepare('SELECT id, content, cover_image_url FROM posts WHERE id = ? AND site_id = ?').get(id, site.id);
     509        if (bestaand) {
     510          // Niet alleen overslaan: REPAREREN wat een eerdere ronde liet liggen.
     511          // Robins 18 posts stonden er al, met hotlinks naar de bron in de
     512          // tekst en zonder cover. Een tweede ronde die dat ziet en passeert
     513          // laat je met een site vol verwijzingen naar een domein dat
     514          // opgezegd wordt.
     515          if (safeFetch && fs && path && mediaRoot && bronOrigin && String(bestaand.content || '').includes(bronOrigin)) {
     516            const r2 = await inhoudMediaBinnen(bestaand.content, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes });
     517            if (r2.n) {
     518              db.prepare('UPDATE posts SET content = ? WHERE id = ?').run(r2.inhoud, bestaand.id);
     519              rapport.postsBijgewerkt++;
     520            }
     521          }
     522          if (!bestaand.cover_image_url && safeFetch && fs && path && mediaRoot) {
     523            // De cover alsnog: hij zit als bijlage op de Note.
     524            for (const a of (Array.isArray(o.attachment) ? o.attachment : []).slice(0, 20)) {
     525              const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
     526              if (!u || !AFBEELDING.test(String((a && a.mediaType) || ''))) continue;
     527              const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
     528              const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
     529              if (g) {
     530                db.prepare('UPDATE posts SET cover_image_url = ? WHERE id = ?').run(g.url, bestaand.id);
     531                rapport.media++;
     532                rapport.postsBijgewerkt++;
     533              }
     534              break;
     535            }
     536          }
    425537          rapport.overgeslagen++;
    426538          continue;
     
    433545        const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
    434546        const binnen = [];
     547        let inhoud = o.content || '';
    435548        if (safeFetch && fs && path && mediaRoot) {
    436549          for (const a of bijlagen.slice(0, 20)) {
    437550            const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
    438551            if (!u || !/^https?:\/\//i.test(String(u))) continue;
    439             const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes }).catch(() => null);
     552            const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
     553            const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
    440554            if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`bijlage niet opgehaald: ${u}`); continue; }
    441555            binnen.push({ ...g, naam: (a && a.name) || null, type: (a && a.mediaType) || g.mediaType });
     
    446560            } catch { /* media-rij is administratie, het bestand staat er */ }
    447561          }
     562          // De PLAATJES IN DE TEKST. De gebakken content draagt absolute
     563          // verwijzingen naar de bron (https://oud/media/...), en die bleven
     564          // gewoon staan: elke afbeelding hotlinkte naar een domein dat je gaat
     565          // opzeggen, en je eigen mediamap bleef leeg. Downloaden naar
     566          // HETZELFDE pad en de verwijzing relatief maken; wat niet lukt blijft
     567          // absoluut staan en wordt gemeld, want een lokale 404 is erger dan
     568          // een hotlink.
     569          const r2 = await inhoudMediaBinnen(inhoud, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes });
     570          inhoud = r2.inhoud;
    448571        }
    449572
     
    451574        const rest = binnen.filter((b) => b !== cover);
    452575        // De titel zit in de content, niet in een veld (zie titelUitContent).
    453         const { titel, rest: body } = o.name ? { titel: o.name, rest: o.content || '' } : titelUitContent(o.content || '');
     576        const { titel, rest: body } = o.name ? { titel: o.name, rest: inhoud } : titelUitContent(inhoud);
    454577        // De slug uit de MENSELIJKE url, niet uit de AP-id. Zo houdt het bericht
    455578        // hetzelfde webadres als op de oude instantie, en blijft een link die
     
    488611    }
    489612    if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
     613    // Silently minder ophalen dan de bron zegt te hebben is precies hoe 18 van
     614    // de 35 wekenlang op "klaar" had kunnen staan. Tel na en zeg het.
     615    if (verwacht && gezien < verwacht && gezien < max) {
     616      rapport.waarschuwingen.push(`de bron meldt ${verwacht} items en er zijn er ${gezien} verwerkt; een pagina is mogelijk niet opgehaald, probeer het nog eens`);
     617    }
    490618
    491619    // ── De muziekbibliotheek ──────────────────────────────────────
Note: See TracChangeset for help on using the changeset viewer.