← Blog

Joomla

Joomla 3.10 K2-migratie: de J2XML double-encode-val

Twaalf dagen in een Joomla 3.10-naar-Sanity-migratie waren 2.600 CrossRef-backlinks ongemerkt kapot. De boosdoener: een dubbele base64 waar niemand naar had gezocht.

Jacob Molkenboer· Oprichter · A Brand New Company· 18 jun 2026· 11 min
Versleten leren logboek naast koperen sleutel op crème kaart, groen lint in de rug, ijzeren labels op ivoorpapier.

Marieke leidt de redactie van een kleine wetenschappelijke uitgever aan het Rapenburg in Leiden. Drieëntwintig mensen, vier tijdschriften, 9.800 peer-reviewed artikelen die teruggaan tot 1998. Hun Joomla 3.10-site, met K2 onder elk artikel, lag bevroren sinds de dag dat de end-of-life-aankondiging van 3.10 niet meer als dreiging voelde. Het plan: een schone migratie in zes weken naar Sanity en Next.js. Content exporteren, front-end herbouwen, live vóór het najaarsnummer.

Op dag twaalf liep de migratie vast. De artikelen stonden in Sanity. De artikelpagina's renderden. De DOI's bovenaan elk artikel renderden. En elke CrossRef-backlink — de citatiegrafiek waarmee een lezer van het ene paper naar het volgende loopt — was kapot. Alle 2.600.

Dit is het verhaal van wat er mis was en hoe we het vonden.

In de JParameter-blob

K2 was de populairste content-extensie voor Joomla 1.5 tot en met 3.x. In 2026 wordt het nog steeds onderhouden, maar zelden gekozen voor nieuwe projecten. Het datamodel heeft één eigenaardigheid die elke migratie bijt: de kolom params op jos_k2_items is een vrije verzamelbak. K2 zelf gebruikt 'm voor SEO-velden, beelduitlijning en een handvol vlaggen. Wat een third-party-extensie ook aan een artikel wil hangen, belandt er ook in.

De redactie had in 2014 een third-party K2-extensie gekocht om DOI-verwijzingen te beheren. Per artikel werden de uitgaande citaties opgeslagen als veld doi_refs binnen de params-blob van het K2-item. Joomla noemde dat object een JParameter: intern een associatieve array, soms geserialiseerd naar INI, soms naar JSON, en vanaf K2 v2.7 naar een base64-verpakte PHP-serialize()-string. Welk formaat je kreeg, hing af van of het veld in de admin na de 2.7-upgrade nog was aangeraakt.

De JParameter-klasse zelf werd in Joomla 2.5 als deprecated gemarkeerd, ten gunste van JRegistry, dat standaardiseerde op JSON. De params-blob van K2 is nooit naar de nieuwe klasse gemigreerd. De K2-makers bouwden er hun eigen serialisatielaag bovenop. Daarom kan hetzelfde veld drie verschillende formaten bevatten binnen één database, afhankelijk van wanneer elke rij voor het laatst is opgeslagen.

In hun database vonden we alle drie de formaten naast elkaar. Ongeveer 7.200 items gebruikten de moderne base64-verpakte serialize. Ongeveer 2.600 gebruikten het oude INI. Een handvol gebruikte JSON. Niemand in de redactie had hier ooit weet van gehad. Ze hadden gewoon het CMS gebruikt.

De plugin achter de DOI-velden heette K2 DOI Refs. Hij werd tussen 2013 en 2016 verkocht in de Joomla-extensiedirectory en ging zonder definitieve release-notes-pin stil. Het exemplaar van de redactie was v1.6.2, waarbij de v2.7-compatibele opslagmigratie automatisch was toegepast op het moment dat een admin voor het eerst een artikel opnieuw opsloeg op de geüpgrade K2. We vonden de bron van de plugin in de components-folder van de site, ongewijzigd sinds 2015. Achthonderd regels PHP, waarvan er twee voor ons telden: de regels die het citatieveld schreven en lazen.

De J2XML double-encode

De standaardtool voor het exporteren van Joomla-content is J2XML. Het levert een draagbaar XML-bestand met alle artikelen, categorieën, gebruikers en custom params. Het is écht goede software voor wat het doet, en we hadden 'm op een dozijn eerdere migraties zonder incidenten gebruikt.

J2XML behandelt de K2 params-blob als een ondoorzichtige payload. Lijkt de string op tekst, dan schrijft hij 'm als tekst. Lijkt hij binair of bevat hij tekens die XML zouden breken, dan codeert hij de waarde eerst met base64.

De heuristiek is snel en meestal correct. J2XML controleert of de string tekens bevat buiten de printbare ASCII, of dat de combinatie van deelbaar-door-vier-lengte en een hoge ratio van alfanumeriek-plus-slash-plus-plus-tekens duidt op een ondoorzichtige payload. Bij beide signalen wordt base64-gecodeerd. De adder: een string die al base64 is, slaagt triviaal voor die tweede test.

Dus de 7.200 items waarvan de K2-params als base64-verpakte serialize waren opgeslagen, kregen bij export een tweede base64-laag mee. Het XML op schijf bevatte voor die rijen nu base64(base64(serialize(array))). De oude INI-rijen werden ongewijzigd geëxporteerd.

Onze Sanity-importer decodeerde base64 natuurlijk precies één keer. De PHP-aanroep unserialize() op het resultaat gaf false. De importer schreef weg wat hij had — een lege doi_refs-array — en ging door. Geen error. Geen logregel. De artikelen werden geïmporteerd, de DOI-headers werden geïmporteerd, de CrossRef-backlinks niet.

Dit was de stilstand van twaalf dagen.

Waarschuwing

Elke exporter die "binair ogende" payloads met base64 codeert, codeert alles wat al base64 is dubbel. Diff één record met de hand van bron naar export voordat je de batch vertrouwt — heuristieken liegen geruisloos.

Hoe twaalf dagen eruitzagen

Die twaalf dagen waren niet leeg. Dag één tot en met drie logde de importer de artikel-ingestie als voltooid en gingen we door met categorie-mapping. Dag vier was Sanity Studio-configuratie. Dag vijf en zes waren front-end-rendering in Next.js. De redactie zette een voorbeeldnummer op staging en las de artikelen door op een preview-URL. De bodytekst was prima. De DOI's bovenaan elk artikel waren prima. Niemand klikte op een citatie.

Op dag zeven klikte de hoofdredacteur er één aan en kreeg niets. We logden een bug, gaven de front-end de schuld, en sleten dag acht en negen in de routing-laag. Op dag tien dook één van ons weer Sanity Studio in en zag dat de references-array op elk artikel leeg was. De importer was geruisloos geslaagd. Daarna was het drie dagen greppen in de bron van de importer naar de assignment die dat veld schreef, hem vinden, en niet geloven wat we zagen.

De diff die het openbrak

Op dag twaalf stopten we met staren naar de importer-logs en gingen we terug naar de bron. We pakten één specifiek artikel — een reviewpaper uit 2019 met veertien uitgaande citaties — en dumpten de rauwe params-kolom rechtstreeks uit MySQL. We zochten hetzelfde artikel op in de J2XML-XML-output. En toen diffden we.

De twee dagen ervoor hadden we aangenomen dat de bug in onze importer zat. De Sanity-client, de schema-validatie, de type-coercion in de adapter. Elke uitlezing was negatief; elke smoke-test slaagde. Teruggaan naar de bron betekende toegeven dat de importer niet het probleem was, en dat is psychologisch duur na acht dagen sleutelen.

Het XML-veld was ongeveer 30% langer dan het MySQL-veld. We base64-decodeerden de XML één keer en kregen iets dat nog steeds op base64 leek. Nog een keer decoderen, en we hadden de PHP-serialize-string waarop we hoopten. Door unserialize() halen leverde de array op, inclusief de veertien DOI's.

Het hele onderzoek nam vijfendertig minuten, zodra we ophielden met de importer te vertrouwen en de bytes op schijf begonnen te vertrouwen. Twaalf dagen lang naar de verkeerde laag staren.

De reparatie

Je kunt dit niet aan de J2XML-kant oplossen zonder J2XML te patchen, en dat wilden we niet in het kritieke pad van het najaarsnummer van een uitgever zetten. We deden het aan de importer-kant, met één detectiepass en één reparatiepass.

Voordat één van beide passes de productie-database aanraakte, draaiden we ze allebei tegen een snapshot-kopie op een lokale MariaDB. De detectiepass is read-only. De reparatiepass schrijft naar een staging-tabel in een apart schema. De originele jos_k2_items-tabel werd nooit aangepast. Als het najaarsnummer terug had moeten rollen naar de Joomla-site, was de brondata ongemoeid gebleven.

<?php
// detect-double-encoded-k2-params.php
// Usage: php detect-double-encoded-k2-params.php

$pdo = new PDO('mysql:host=localhost;dbname=joomla', $user, $pass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);

$rows = $pdo->query(
    "SELECT id, params FROM jos_k2_items WHERE params IS NOT NULL"
);

$double = 0;
$single = 0;
$ini    = 0;

foreach ($rows as $row) {
    $raw  = $row['params'];
    $once = base64_decode($raw, true);

    if ($once === false) {
        $ini++;            // legacy INI rows — leave alone
        continue;
    }

    $twice = base64_decode($once, true);
    if ($twice !== false && @unserialize($twice) !== false) {
        $double++;
        fwrite(STDERR, "double: item {$row['id']}\n");
    } elseif (@unserialize($once) !== false) {
        $single++;
    }
}

printf("double=%d single=%d ini=%d\n", $double, $single, $ini);

De detectiepass vertelde ons dat we 7.184 dubbel-gecodeerde rijen hadden, zestien enkel-gecodeerde rijen waar het veld na de export nog was aangeraakt, en 2.600 INI-rijen. De getallen kwamen vrijwel exact overeen met het gat in de backlink-grafiek.

De reparatiepass was dezelfde lus met een andere body: het juiste aantal keren decoderen per rij, door unserialize halen, het veld doi_refs eruit trekken, en de opgeschoonde waarden naar een kleine staging-tabel schrijven.

function extract_doi_refs(string $serialized): array
{
    $params = @unserialize($serialized);
    if (!is_array($params) || empty($params['doi_refs'])) {
        return [];
    }

    // doi_refs was stored as a newline-joined string in K2 v2.10.
    return array_values(array_filter(array_map(
        fn ($s) => trim($s),
        explode("\n", (string) $params['doi_refs'])
    )));
}

Vanaf de staging-tabel was de Sanity-import een kleine adapter die door de references van elk artikel liep en ze in een typed array op het document zette. Veertig minuten nadat we de detectiepass hadden gedraaid, waren de 2.600 CrossRef-backlinks weer gevuld. Marieke checkte er vijf willekeurig en stopte met checken.

De nieuwe vorm in Sanity

In de nieuwe stack is de DOI-lijst een top-level array op elk artikeldocument. Elke referentie is een klein object met de DOI-string, een optionele pointer naar het geciteerde artikel als het bij dezelfde uitgever staat, en een gecachete titel voor citaties die ergens anders heen wijzen. Die cache zit erin zodat een netwerkstoring tijdens een CrossRef-sync geen citatie kan leegmaken die een lezer net aanklikt.

// schemas/artikel.ts
export default {
  name: 'artikel',
  type: 'document',
  fields: [
    { name: 'title', type: 'string' },
    { name: 'doi',   type: 'string' },
    {
      name: 'references',
      type: 'array',
      of: [{
        type: 'object',
        fields: [
          { name: 'doi',         type: 'string' },
          { name: 'target',      type: 'reference', to: [{ type: 'artikel' }] },
          { name: 'cachedTitle', type: 'string' },
        ],
      }],
    },
  ],
}

De CrossRef-sync is nu een nachtelijke Next.js route handler die de CrossRef REST API aanroept en alleen documenten bijwerkt waarvan de referenties verlopen zijn. Hij draait in ongeveer negentig seconden over het hele corpus en stuurt voor elke wijziging een gestructureerde logregel uit. Er is geen stille fallback. Als een DOI niet kan worden opgelost, markeert de build het artikel in Sanity Studio en ziet de redactie het voordat een lezer dat doet.

Wat we meenemen

Drie gewoonten kwamen uit dit project waarvan we ze nu op elke Joomla-migratie toepassen. Eén: vertrouw nooit de encoding-heuristiek van een exporter. Diff één record met de hand van bron naar export voordat je de batch vertrouwt, ook bij tools die je eerder hebt gebruikt. Twee: sample drie records verspreid over de publicatie-data, want oude CMS'en stapelen formaatgeneraties op en de oudste artikelen hebben vaak de schoonste data. Drie: draai de importer eerst tegen een known-good record en daarna tegen een known-broken record. Pas daarna laat je 'm los op de long tail.

Dit is ook de derde Joomla 3.x-migratie waarbij de fout in de export-tool zat en niet in de brondata. Het patroon is consistent: een volwassen exporter handelt 95% van de gevallen af met een stille heuristiek, de resterende 5% faalt op een manier die geen error oplevert en elke smoke-test overleeft. De verdediging is geen betere tool. Het is een kleine, langzame audit op byte-niveau van één record, voordat er iets gebatcht wordt.

De andere les is ouder en moeilijker op te schrijven. De redactie wist niet dat hun citatie-metadata in de JParameter-blob van een third-party-plugin leefde. Daar hadden ze geen reden voor. Het migratieplan dat we op dag één schreven, wist het ook niet, want we hadden het schema gelezen en niet de data. Een schema vertelt je wat is toegestaan. De data vertelt je wat waar is. Dat zijn niet dezelfde documenten.

Toen we de artikel-pipeline voor de Leidse uitgever herbouwden, liepen we vast op de stille faal van geneste base64 in een geserialiseerde PHP-blob. We losten het op door een detectiepass toe te voegen die pogingen tot unserialize op elke diepte telt voordat een document naar Sanity wordt geschreven. Diezelfde truc heeft ons sindsdien op twee Drupal-migraties gered, en staat nu bovenaan ons draaiboek voor elke legacy migratie.

Heb je dit jaar nog een oude Joomla- of K2-site in de planning voor migratie? Pak één record met rijke custom data, dump de rauwe rij uit MySQL en draai base64_decode twee keer op de relevante blob. Als ook de tweede decode slaagt en het resultaat deserialiseert, heb je deze bug. Plan een extra dag in voordat je de cutover inroostert.

Kern

Bij elke legacy-CMS-migratie: diff één record met de hand van bron naar export voordat je de batch vertrouwt — encoding-heuristieken liegen geruisloos.

FAQ

Krijgt elke J2XML-export te maken met deze double-encode-bug?

Alleen als het bronveld al base64-gecodeerd is. De meeste Joomla-core-velden zijn platte tekst. K2-params vanaf v2.7 en custom fields die door andere extensies in base64 worden verpakt, zijn de gebruikelijke slachtoffers.

Waarom J2XML niet patchen en de export opnieuw draaien?

We hadden een deadline en een bevroren productie-database. De exporter patchen had een nieuwe rondrit betekend, plus regressietesten van de hele export. Aan de importer-kant repareren was sneller en omkeerbaar.

Hoe zie ik in een blob het verschil tussen enkele en dubbele base64?

Decodeer één keer. Is het resultaat leesbare UTF-8 met een INI-fragment, JSON of een PHP-serialize-string, dan had je enkel. Ziet het er nog steeds als base64 uit — juiste tekenset, lengte deelbaar door vier — decodeer dan opnieuw en check.

Kan een Joomla 3.10-site in 2026 op 3.10 blijven?

Technisch wel, maar 3.10 is sinds augustus 2023 end-of-life. Er komen geen security-patches meer. Als er een relevante CVE uitkomt, sta je alleen. Migreren is het enige veilige antwoord.

joomlamigrationlegacy sitesphpmysqlcase study

Iets bouwen?

Start een project