Harden mojibake repair with Czech-specific serialized value handling

This commit is contained in:
2026-09-08 15:09:13 +02:00
parent 4c623c36ea
commit 25de2a13f9
3 changed files with 413 additions and 82 deletions
+56
View File
@@ -0,0 +1,56 @@
# Mojibake Repair (Temporary)
Version 1.1.0 replaces the original whole-column SQL conversion with targeted
replacements for Czech letters misdecoded as ISO-8859-1 or Windows-1252.
## Why version 1.0.0 could break fonts
The original conversion also changed `options` and metadata. Theme and builder
settings can contain PHP-serialized strings with embedded **byte lengths**.
Changing the text without updating those lengths makes the settings unreadable;
a theme can then fall back to default typography. Converting entire values could
also damage already-correct Unicode in mixed content. WordPress documents this
serialization risk in its [migration guide](https://developer.wordpress.org/advanced-administration/upgrade/migrating/).
## Sites already repaired with version 1.0.0
1. Deactivate version 1.0.0 and save a copy of the current database.
2. Use a pre-repair backup to compare and recover affected theme/builder settings,
preferably on staging first. A full database restore can discard newer content
and orders, so choose the recovery scope using the site's actual changes.
3. Run the new version's preview against the restored data and review the changes.
The new version prevents this serialization damage; it does not reconstruct
settings already corrupted by the previous conversion.
4. After repair, clear page caches and regenerate builder CSS if the site uses it.
Verify fonts and content on the site, then deactivate and remove the plugin.
## Behavior and limits
- Repairs recognized Czech mojibake sequences without transcoding whole values.
Other corruption patterns and JSON `\uXXXX` escapes are outside this repair's
scope. A sequence intentionally shown as a mojibake example will also match,
so review the preview.
- Preserves CSS, JSON escaping, serialized keys and scalar formatting. Repairs
serialized array/string values, including nested serialized strings, and
recalculates their byte lengths without instantiating PHP objects.
- Leaves an entire candidate value unchanged if it contains malformed or
unsupported serialization, objects, references, excessive nesting, or invalid
UTF-8. The preview reports skipped counts and example row IDs. This is not a
general audit of already-corrupted settings: values without a recognized
mojibake sequence are left untouched without further inspection.
- Reads in batches of 200. Execution uses an InnoDB transaction and checks the
exact candidate IDs and values against the signed preview. Changes since
preview or write conflicts cause rollback. Old-version approvals are rejected.
- Clears the WordPress object cache after commit. External page caches and
generated builder styles may still need to be refreshed separately.
- Scans the current site's fixed core-table plan. Large databases may exceed
hosting request limits; batching bounds the rows loaded at once, not the total
request duration or transaction size.
## Local verification
Run `php -l wp-mojibake-repair.php` and `php tests/repair.php`.
The regression script requires PDO SQLite and iconv. It checks Czech text,
serialized typography settings, preserved CSS/JSON, skipped unsafe values,
batching, preview approvals, rollback, and cache invalidation. Database-flow
checks use a SQLite adapter; they do not replace verification on WordPress/MySQL.
+163
View File
@@ -0,0 +1,163 @@
<?php
// Run with: php tests/repair.php (PHP with PDO SQLite and iconv).
define('ABSPATH', __DIR__);
define('ARRAY_A', 'ARRAY_A');
define('DB_NAME', 'repair_test');
function add_action(...$args) {}
function get_current_user_id() { return 7; }
function wp_json_encode($value) { return json_encode($value); }
function wp_salt($scheme) { return 'local-test-salt'; }
function wp_cache_flush() { $GLOBALS['flushes']++; return true; }
require dirname(__DIR__) . '/wp-mojibake-repair.php';
function call_repair($method, ...$args) {
static $methods = array();
if (!isset($methods[$method])) {
$methods[$method] = new ReflectionMethod('WP_Mojibake_Repair_Temporary', $method);
if (PHP_VERSION_ID < 80100) {
$methods[$method]->setAccessible(true);
}
}
return $methods[$method]->invokeArgs(null, $args);
}
$checks = 0;
function same($expected, $actual, $label) {
$GLOBALS['checks']++;
if ($expected !== $actual) {
throw new RuntimeException($label . '\nExpected: ' . var_export($expected, true) . '\nActual: ' . var_export($actual, true));
}
}
function refused($callback, $label) {
try { $callback(); } catch (RuntimeException $e) { $GLOBALS['checks']++; return; }
throw new RuntimeException($label . ': expected refusal');
}
function fix($value) { return call_repair('repair_value', $value); }
function broken($value) { return iconv('ISO-8859-1', 'UTF-8', $value); }
$czech = 'Příliš žluťoučký kůň úpěl ďábelské ódy. ÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ';
same($czech, fix(broken($czech)), 'Latin-1 mojibake, all Czech letters');
// These letters exercise printable Windows-1252 mappings including ™, ¾ and œ.
$windows = 'ěňřšťůžČĎĚŇŘŠŤŮŽ';
same($windows, fix(iconv('Windows-1252', 'UTF-8', $windows)), 'Windows-1252 mojibake');
$correct = $czech . ' 日本語 😀 € “quoted” — ß ö ñ';
same($correct, fix($correct), 'Correct multilingual Unicode is byte-for-byte unchanged');
same($correct . ' Příliš', fix($correct . ' ' . broken('Příliš')), 'Mixed clean and damaged text');
same($czech, fix(fix(broken($czech))), 'Second pass preserves repaired text');
$css = 'body{font-family:"Open Sans",sans-serif;font-weight:600;background:url("/a\\b.woff2")}/* ';
same($css . 'Příliš */', fix($css . broken('Příliš') . ' */'), 'CSS bytes stay intact');
$json = '{"font_family":"Open Sans","url":"https:\/\/example.test\/font.woff2","unicode":"\\u010d","text":"' . broken('Příliš') . '"}';
same(str_replace(broken('Příliš'), 'Příliš', $json), fix($json), 'JSON escaping stays intact');
$settings = array('font_family' => 'Open Sans', 'font_weight' => 600, 'label' => broken('Příliš'), 'nested' => array('css' => $css . '*/', 'json' => $json), 'enabled' => true, 'empty' => null, 'scale' => 1.25);
$expected = $settings;
$expected['label'] = 'Příliš';
$expected['nested']['json'] = fix($json);
same(false, @unserialize(call_repair('repair_text', serialize($settings))), 'Replacing serialized text without fixing lengths breaks settings');
$repaired = fix(serialize($settings));
same($expected, unserialize($repaired), 'Serialized font settings remain readable with correct values');
same(serialize($expected), $repaired, 'Serialized byte lengths are recalculated');
same($repaired, fix($repaired), 'Serialized settings survive repeat repair');
$nested = serialize(array('inner' => serialize(array('label' => broken('kůň')))));
same(array('label' => 'kůň'), unserialize(unserialize(fix($nested))['inner']), 'Nested serialization lengths');
same(serialize(array(broken('č') => 'č')), fix(serialize(array(broken('č') => broken('č')))), 'Serialized keys stay unchanged');
$safe = 'a:2:{s:1:"x";d:1.2500;s:1:"y";' . serialize(broken('č')) . '}';
same(str_replace(serialize(broken('č')), serialize('č'), $safe), fix($safe), 'Scalar formatting stays intact');
$malformed = 'a:1:{s:5:"label";s:999:"' . broken('Příliš') . '";}';
refused(function () use ($malformed) { fix($malformed); }, 'Already broken serialization');
refused(function () { fix(serialize(broken('č')) . 'trailing'); }, 'Trailing serialized data');
refused(function () { fix(' ' . serialize(broken('č'))); }, 'Noncanonical serialized wrapper');
refused(function () { fix(broken('č') . "\xff"); }, 'Invalid UTF-8 candidate');
class RepairTestObject {
public $label;
public function __wakeup() { throw new LogicException('Must never instantiate objects'); }
}
$object = new RepairTestObject();
$object->label = broken('č');
refused(function () use ($object) { fix(serialize($object)); }, 'Serialized objects skipped without hooks');
refused(function () use ($object) { fix(serialize(array('label' => broken('č'), 'object' => $object))); }, 'Whole row skipped for nested objects');
$reference = broken('č');
$references = array(&$reference, &$reference);
refused(function () use ($references) { fix(serialize($references)); }, 'References skipped');
$deep = broken('č');
for ($i = 0; $i < 70; $i++) { $deep = array($deep); }
refused(function () use ($deep) { fix(serialize($deep)); }, 'Depth bounded');
// Exercise the scan, exact-value approvals and transaction flow with SQLite.
// This adapter is not a substitute for verification on a WordPress/MySQL site.
class RepairTestDB {
public $prefix = 'wp_';
public $last_error = '';
public $db;
public $fail_update = false;
public $conflict = false;
public function __construct() {
$this->db = new PDO('sqlite::memory:');
$this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->db->exec('CREATE TABLE wp_options (option_id INTEGER PRIMARY KEY, option_value TEXT)');
}
public function prepare($sql, ...$args) {
return preg_replace_callback('/%[sd]/', function ($match) use (&$args) {
$value = array_shift($args);
return $match[0] === '%d' ? (string) (int) $value : $this->db->quote($value);
}, $sql);
}
public function get_results($sql, $format) { return $this->db->query($sql)->fetchAll(PDO::FETCH_ASSOC); }
public function query($sql) {
if ($sql === 'START TRANSACTION') { return $this->db->beginTransaction() ? 0 : false; }
if ($sql === 'COMMIT') { return $this->db->commit() ? 0 : false; }
if ($sql === 'ROLLBACK') { return $this->db->rollBack() ? 0 : false; }
if ($this->fail_update) { $this->last_error = 'simulated write failure'; return false; }
if ($this->conflict) { return 0; }
// SQLite text equality already compares bytes with its default collation.
return $this->db->exec(str_replace('BINARY ', '', $sql));
}
public function put($id, $text) {
$this->db->prepare('INSERT OR REPLACE INTO wp_options VALUES (?, ?)')->execute(array($id, $text));
}
public function all() { return $this->db->query('SELECT * FROM wp_options ORDER BY option_id')->fetchAll(PDO::FETCH_ASSOC); }
}
$wpdb = new RepairTestDB();
$GLOBALS['flushes'] = 0;
$plan = array('wp_options' => array('key' => 'option_id', 'columns' => array('option_value')));
for ($i = 0; $i < 405; $i++) { $wpdb->put($i, serialize($settings)); }
$wpdb->put(500, $malformed);
$wpdb->put(501, $correct);
$wpdb->put(502, null);
$before = $wpdb->all();
$preview = call_repair('preview', $plan);
same($before, $wpdb->all(), 'Preview is read-only');
same(405, $preview['total'], 'Batches include all rows including ID zero');
same(1, $preview['skipped'], 'Malformed row reported separately');
same(array('500'), $preview['columns'][0]['skipped_ids'], 'Skipped ID shown');
same(5, count($preview['columns'][0]['samples']), 'Sample limit');
$token = call_repair('make_approval', $preview);
$approval = call_repair('verify_approval', $token, $plan);
refused(function () use ($token, $plan) { call_repair('verify_approval', 'x' . $token, $plan); }, 'Tampered approval');
$old = array('user' => 7, 'database' => DB_NAME, 'prefix' => 'wp_', 'expires' => time() + 600, 'counts' => $preview['counts']);
$encoded = rtrim(strtr(base64_encode(json_encode($old)), '+/', '-_'), '=');
$old_token = $encoded . '.' . hash_hmac('sha256', $encoded, wp_salt('auth'));
refused(function () use ($old_token, $plan) { call_repair('verify_approval', $old_token, $plan); }, 'Old blanket-conversion approval rejected');
$wpdb->put(0, broken('Jiný text'));
$changed = $wpdb->all();
refused(function () use ($plan, $approval) { call_repair('execute', $plan, $approval); }, 'Same count but changed values rejected');
same($changed, $wpdb->all(), 'Every write rolled back for stale preview');
same(0, $GLOBALS['flushes'], 'No cache flush after rollback');
$wpdb->put(0, serialize($settings));
$wpdb->conflict = true;
refused(function () use ($plan, $approval) { call_repair('execute', $plan, $approval); }, 'Concurrent write conflict');
same($before, $wpdb->all(), 'Conflict leaves database unchanged');
$wpdb->conflict = false;
$wpdb->fail_update = true;
refused(function () use ($plan, $approval) { call_repair('execute', $plan, $approval); }, 'Database error rolls back');
same($before, $wpdb->all(), 'Failed update leaves database unchanged');
$wpdb->fail_update = false;
$wpdb->last_error = '';
$result = call_repair('execute', $plan, $approval);
same(405, $result['total'], 'Approved updates committed');
same(1, $GLOBALS['flushes'], 'Object cache cleared after commit');
$after = $wpdb->all();
same($expected, unserialize($after[0]['option_value']), 'Database font settings remain readable');
same($malformed, $after[405]['option_value'], 'Malformed row untouched');
same($correct, $after[406]['option_value'], 'Clean row untouched');
same(null, $after[407]['option_value'], 'NULL untouched');
same(0, call_repair('preview', $plan)['total'], 'Second preview has no repair candidates');
echo 'Passed ' . $checks . " checks.\n";
+193 -81
View File
@@ -1,8 +1,8 @@
<?php
/**
* Plugin Name: Mojibake Repair (Temporary)
* Description: Previews and repairs the proven UTF-8/Latin-1 mojibake problem in standard WordPress core text columns.
* Version: 1.0.0
* Description: Previews and repairs Czech UTF-8 mojibake while preserving serialized WordPress settings.
* Version: 1.1.0
* Requires PHP: 7.4
*/
@@ -14,6 +14,8 @@ final class WP_Mojibake_Repair_Temporary
const SAMPLE_LIMIT = 5;
const SAMPLE_LENGTH = 300;
const APPROVAL_TTL = 600;
const REPAIR_VERSION = 2;
const BATCH_SIZE = 200;
public static function init()
{
@@ -61,7 +63,7 @@ final class WP_Mojibake_Repair_Temporary
isset($_POST['approval']) ? wp_unslash($_POST['approval']) : '',
$plan
);
$result = self::execute($plan, $approval['counts']);
$result = self::execute($plan, $approval);
}
} catch (Throwable $e) {
$error = $e->getMessage();
@@ -164,116 +166,217 @@ final class WP_Mojibake_Repair_Temporary
}
}
private static function preview($plan)
private static function preview($plan, $write = false)
{
global $wpdb;
$preview = array('columns' => array(), 'counts' => array(), 'total' => 0, 'serialized' => 0);
$preview = array('columns' => array(), 'counts' => array(), 'fingerprints' => array(), 'total' => 0, 'skipped' => 0);
foreach ($plan as $table => $definition) {
foreach ($definition['columns'] as $column) {
$table_sql = self::identifier($table);
$key_sql = self::identifier($definition['key']);
$column_sql = self::identifier($column);
$converted = self::conversion($column);
$diff = self::difference($column);
$id = $table . '.' . $column;
$count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table_sql} WHERE {$diff}");
self::throw_on_db_error();
$serialized = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$table_sql} WHERE {$diff} AND (LEFT({$column_sql},2) IN ('a:','O:','s:','i:','d:','b:','C:') OR {$column_sql}='N;')"
);
self::throw_on_db_error();
$count = 0;
$skipped = 0;
$samples = array();
if ($count > 0) {
$limit = self::SAMPLE_LIMIT;
$length = self::SAMPLE_LENGTH;
$samples = $wpdb->get_results(
"SELECT {$key_sql} AS row_id, LEFT({$column_sql},{$length}) AS before_value, LEFT({$converted},{$length}) AS after_value " .
"FROM {$table_sql} WHERE {$diff} ORDER BY {$key_sql} ASC LIMIT {$limit}",
$skipped_ids = array();
$cursor = null;
$hash = hash_init('sha256');
do {
$where = $cursor === null ? '' : $wpdb->prepare("WHERE {$key_sql} > %d", $cursor);
$limit = self::BATCH_SIZE;
$batch = $wpdb->get_results(
"SELECT {$key_sql} AS row_id, {$column_sql} AS before_value FROM {$table_sql} {$where} ORDER BY {$key_sql} ASC LIMIT {$limit}",
ARRAY_A
);
self::throw_on_db_error();
foreach ($batch as $row) {
$cursor = $row['row_id'];
if ($row['before_value'] === null) {
continue;
}
$before = (string) $row['before_value'];
try {
$after = self::repair_value($before);
} catch (RuntimeException $e) {
$skipped++;
if (count($skipped_ids) < self::SAMPLE_LIMIT) {
$skipped_ids[] = (string) $cursor;
}
continue;
}
if ($after === $before) {
continue;
}
// Length framing makes the approval cover exact IDs and full values.
foreach (array((string) $cursor, $before, $after) as $part) {
hash_update($hash, strlen($part) . ':' . $part);
}
$count++;
if (count($samples) < self::SAMPLE_LIMIT) {
$samples[] = array('row_id' => $cursor, 'before_value' => self::sample($before), 'after_value' => self::sample($after));
}
if ($write) {
// Bypass option/meta APIs to preserve JSON escaping and serialization.
// Refuse to overwrite a value changed since it was read.
$affected = $wpdb->query($wpdb->prepare(
"UPDATE {$table_sql} SET {$column_sql} = %s WHERE {$key_sql} = %d AND BINARY {$column_sql} = BINARY %s",
$after,
$cursor,
$before
));
self::throw_on_db_error();
if ($affected !== 1) {
throw new RuntimeException('A value changed while updating ' . $id . '. Generate a fresh preview.');
}
}
}
} while (count($batch) === self::BATCH_SIZE);
$preview['columns'][] = array(
'table' => $table,
'column' => $column,
'count' => $count,
'serialized' => $serialized,
'samples' => $samples,
'table' => $table, 'column' => $column, 'count' => $count,
'skipped' => $skipped, 'skipped_ids' => $skipped_ids, 'samples' => $samples,
);
$preview['counts'][$id] = $count;
$preview['fingerprints'][$id] = hash_final($hash);
$preview['total'] += $count;
$preview['serialized'] += $serialized;
$preview['skipped'] += $skipped;
}
}
return $preview;
}
private static function execute($plan, $approved_counts)
private static function sample($value)
{
// Avoid cutting a UTF-8 character in the preview (no mbstring required).
preg_match('/\A.{0,' . self::SAMPLE_LENGTH . '}/us', $value, $match);
return isset($match[0]) ? $match[0] : '';
}
private static function execute($plan, $approval)
{
global $wpdb;
$current = self::preview($plan);
if ($current['counts'] !== $approved_counts) {
throw new RuntimeException('The candidate counts changed after preview. Generate a fresh preview.');
}
if ($wpdb->query('START TRANSACTION') === false) {
self::throw_on_db_error();
throw new RuntimeException('Could not start the repair transaction.');
}
$rows = array();
$total = 0;
try {
foreach ($plan as $table => $definition) {
foreach ($definition['columns'] as $column) {
$id = $table . '.' . $column;
$expected = isset($approved_counts[$id]) ? (int) $approved_counts[$id] : -1;
$table_sql = self::identifier($table);
$column_sql = self::identifier($column);
$converted = self::conversion($column);
$diff = self::difference($column);
$affected = $wpdb->query("UPDATE {$table_sql} SET {$column_sql}={$converted} WHERE {$diff}");
if ($affected === false) {
self::throw_on_db_error();
$current = self::preview($plan, true);
if ($current['counts'] !== $approval['counts'] || $current['fingerprints'] !== $approval['fingerprints']) {
throw new RuntimeException('Candidate values changed after preview. All changes were rolled back. Generate a fresh preview.');
}
if ((int) $affected !== $expected) {
throw new RuntimeException('Affected-row count changed while updating ' . $id . '.');
}
$rows[] = array('table' => $table, 'column' => $column, 'affected' => (int) $affected);
$total += (int) $affected;
}
}
if ($wpdb->query('COMMIT') === false) {
self::throw_on_db_error();
throw new RuntimeException('Could not commit the repair transaction.');
}
} catch (Throwable $e) {
$wpdb->query('ROLLBACK');
throw $e;
}
return array('rows' => $rows, 'total' => $total);
// SQL writes bypass WordPress cache invalidation, including alloptions.
$cache_cleared = wp_cache_flush();
$rows = array();
foreach ($current['columns'] as $item) {
$rows[] = array('table' => $item['table'], 'column' => $item['column'], 'affected' => $item['count']);
}
return array('rows' => $rows, 'total' => $current['total'], 'cache_cleared' => $cache_cleared);
}
private static function conversion($column)
private static function repair_text($value)
{
$column_sql = self::identifier($column);
return "CONVERT(BINARY CONVERT({$column_sql} USING latin1) USING utf8mb4)";
static $map = null;
if ($map === null) {
$map = array();
// The two common misdecodings: ISO-8859-1 and Windows-1252.
$windows = array(
0x80 => '€', 0x82 => '‚', 0x83 => 'ƒ', 0x84 => '„', 0x85 => '…',
0x86 => '†', 0x87 => '‡', 0x88 => 'ˆ', 0x89 => '‰', 0x8a => 'Š',
0x8b => '‹', 0x8c => 'Œ', 0x8e => 'Ž', 0x91 => '‘', 0x92 => '’',
0x93 => '“', 0x94 => '”', 0x95 => '•', 0x96 => '–', 0x97 => '—',
0x98 => '˜', 0x99 => '™', 0x9a => 'š', 0x9b => '›', 0x9c => 'œ',
0x9e => 'ž', 0x9f => 'Ÿ',
);
foreach (preg_split('//u', 'áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ', -1, PREG_SPLIT_NO_EMPTY) as $letter) {
$latin = '';
$cp1252 = '';
foreach (unpack('C*', $letter) as $byte) {
$encoded = $byte < 128 ? chr($byte) : chr(0xc0 | ($byte >> 6)) . chr(0x80 | ($byte & 0x3f));
$latin .= $encoded;
$cp1252 .= isset($windows[$byte]) ? $windows[$byte] : $encoded;
}
$map[$latin] = $letter;
$map[$cp1252] = $letter;
}
}
// Never transcode the whole value: valid Unicode, CSS and JSON stay intact.
return strtr($value, $map);
}
private static function difference($column)
private static function repair_value($value, $depth = 0)
{
$column_sql = self::identifier($column);
$converted = self::conversion($column);
return "NOT (BINARY {$column_sql} <=> BINARY {$converted})";
if (self::repair_text($value) === $value) {
return $value;
}
if ($depth > 64 || preg_match('//u', $value) !== 1) {
throw new RuntimeException('Unsupported depth or invalid UTF-8.');
}
if (!preg_match('/\A(?:[aObisdCREr]:|N;)/', ltrim($value))) {
return self::repair_text($value);
}
$offset = 0;
$result = self::repair_serialized($value, $offset, $depth + 1);
if ($offset !== strlen($value)) {
throw new RuntimeException('Malformed or unsupported serialized value.');
}
return $result;
}
private static function repair_serialized($value, &$offset, $depth, $key = false)
{
if ($depth > 64) {
throw new RuntimeException('Serialized value is too deeply nested.');
}
$start = $offset;
$type = substr($value, $offset, 1);
if ($key && $type !== 's' && $type !== 'i') {
throw new RuntimeException('Unsupported serialized key.');
}
if ($type === 's' && preg_match('/\Gs:([0-9]+):"/', $value, $match, 0, $offset)) {
$offset += strlen($match[0]);
$length = (int) $match[1];
if ($length > strlen($value) - $offset - 2 || substr($value, $offset + $length, 2) !== '";') {
throw new RuntimeException('Serialized string length is invalid. Restore this value from backup.');
}
$text = substr($value, $offset, $length);
$offset += $length + 2;
// Keys are identifiers, and changing them could collide or break settings.
$fixed = $key ? $text : self::repair_value($text, $depth + 1);
return $fixed === $text ? substr($value, $start, $offset - $start) : 's:' . strlen($fixed) . ':"' . $fixed . '";';
}
if ($type === 'a' && preg_match('/\Ga:([0-9]+):\{/', $value, $match, 0, $offset)) {
$offset += strlen($match[0]);
$count = (int) $match[1];
if ($count > strlen($value) - $offset) {
throw new RuntimeException('Invalid serialized array length.');
}
$result = $match[0];
for ($i = 0; $i < $count; $i++) {
$result .= self::repair_serialized($value, $offset, $depth + 1, true);
$result .= self::repair_serialized($value, $offset, $depth + 1);
}
if (substr($value, $offset, 1) !== '}') {
throw new RuntimeException('Malformed serialized array.');
}
$offset++;
return $result . '}';
}
if (preg_match('/\G(?:N;|b:[01];|i:-?[0-9]+;|d:(?:-?INF|NAN|[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[Ee][+-]?[0-9]+)?);)/', $value, $match, 0, $offset)) {
$offset += strlen($match[0]);
return $match[0];
}
// Never instantiate objects or rewrite references/custom serialization.
throw new RuntimeException('Malformed or unsupported serialized value; left unchanged.');
}
private static function identifier($name)
@@ -292,7 +395,7 @@ final class WP_Mojibake_Repair_Temporary
}
}
private static function make_approval($counts)
private static function make_approval($preview)
{
global $wpdb;
$payload = wp_json_encode(array(
@@ -300,7 +403,9 @@ final class WP_Mojibake_Repair_Temporary
'database' => DB_NAME,
'prefix' => $wpdb->prefix,
'expires' => time() + self::APPROVAL_TTL,
'counts' => $counts,
'version' => self::REPAIR_VERSION,
'counts' => $preview['counts'],
'fingerprints' => $preview['fingerprints'],
));
$encoded = rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
return $encoded . '.' . hash_hmac('sha256', $encoded, wp_salt('auth'));
@@ -319,6 +424,9 @@ final class WP_Mojibake_Repair_Temporary
$payload = json_decode(base64_decode(strtr($encoded, '-_', '+/'), true), true);
if (!is_array($payload)
|| !isset($payload['version'], $payload['user'], $payload['database'], $payload['prefix'], $payload['expires'], $payload['fingerprints'])
|| $payload['version'] !== self::REPAIR_VERSION
|| !is_array($payload['fingerprints'])
|| (int) $payload['user'] !== get_current_user_id()
|| (string) $payload['database'] !== DB_NAME
|| (string) $payload['prefix'] !== $wpdb->prefix
@@ -334,7 +442,7 @@ final class WP_Mojibake_Repair_Temporary
$expected_ids[] = $table . '.' . $column;
}
}
if (array_keys($payload['counts']) !== $expected_ids) {
if (array_keys($payload['counts']) !== $expected_ids || array_keys($payload['fingerprints']) !== $expected_ids) {
throw new RuntimeException('The preview approval does not match the fixed repair plan.');
}
@@ -356,16 +464,16 @@ final class WP_Mojibake_Repair_Temporary
{
echo '<h2>Preview</h2>';
echo '<p><strong>' . number_format_i18n($preview['total']) . '</strong> differing column values found. Only the samples below will be displayed; the confirmed update covers all counted candidates.</p>';
echo '<div class="notice notice-warning"><p><strong>Important:</strong> “different” does not prove mojibake. The conversion is not safe to run twice. Correct non-Latin-1 text can also change.</p></div>';
if ($preview['serialized'] > 0) {
echo '<div class="notice notice-error"><p><strong>' . number_format_i18n($preview['serialized']) . ' candidate values look PHP-serialized.</strong> SQL conversion can invalidate embedded string lengths. Review these samples carefully.</p></div>';
echo '<p>Only recognized Czech mojibake sequences are replaced. Correct Unicode is preserved, and serialized string lengths are updated. Review the samples to confirm that these replacements match your content.</p>';
if ($preview['skipped'] > 0) {
echo '<div class="notice notice-warning"><p><strong>' . number_format_i18n($preview['skipped']) . ' values were skipped.</strong> They contain repair candidates but have malformed or unsupported data (such as serialized objects or references). Already broken settings need recovery from a backup.</p></div>';
}
foreach ($preview['columns'] as $item) {
echo '<details style="margin:12px 0;padding:10px;background:#fff;border:1px solid #ccd0d4"' . ($item['count'] ? ' open' : '') . '>';
echo '<summary><code>' . esc_html($item['table'] . '.' . $item['column']) . '</code> — ' . number_format_i18n($item['count']) . ' differing</summary>';
if ($item['serialized']) {
echo '<p><strong>Serialized-looking: ' . number_format_i18n($item['serialized']) . '</strong></p>';
if ($item['skipped']) {
echo '<p><strong>Skipped: ' . number_format_i18n($item['skipped']) . '</strong>. Example row IDs: ' . esc_html(implode(', ', $item['skipped_ids'])) . '</p>';
}
foreach ($item['samples'] as $sample) {
echo '<p><strong>Row ' . esc_html((string) $sample['row_id']) . '</strong></p>';
@@ -375,22 +483,26 @@ final class WP_Mojibake_Repair_Temporary
}
if ($preview['total'] < 1) {
echo '<div class="notice notice-success"><p>No update is needed.</p></div>';
echo '<div class="notice notice-success"><p>No supported repair candidates were found.</p></div>';
return;
}
echo '<h2>Step 2: Run once</h2><form method="post">';
wp_nonce_field('wpmr_action', 'wpmr_nonce');
echo '<input type="hidden" name="wpmr_action" value="execute">';
echo '<input type="hidden" name="approval" value="' . esc_attr(self::make_approval($preview['counts'])) . '">';
echo '<input type="hidden" name="approval" value="' . esc_attr(self::make_approval($preview)) . '">';
echo '<label><input type="checkbox" name="confirm_backup" value="1" required> I have a restorable backup and approve updating every counted candidate above.</label>';
submit_button('Run repair once', 'primary', 'submit', false, array('onclick' => "return confirm('Run the repair now? This must not be run twice.');"));
submit_button('Run repair once', 'primary', 'submit', false, array('onclick' => "return confirm('Run the repair now? This updates all approved candidates.');"));
echo '</form>';
}
private static function render_result($result)
{
echo '<div class="notice notice-success"><p><strong>Repair committed successfully.</strong> ' . number_format_i18n($result['total']) . ' column values updated.</p></div>';
if (!$result['cache_cleared']) {
echo '<div class="notice notice-warning"><p>The database repair succeeded, but the WordPress object cache could not be cleared. Clear it through your hosting provider.</p></div>';
}
echo '<p>Clear any page cache and regenerate your page builder’s CSS before checking the site.</p>';
echo '<table class="widefat striped" style="max-width:900px"><thead><tr><th>Table</th><th>Column</th><th>Affected</th></tr></thead><tbody>';
foreach ($result['rows'] as $row) {
echo '<tr><td><code>' . esc_html($row['table']) . '</code></td><td><code>' . esc_html($row['column']) . '</code></td><td>' . number_format_i18n($row['affected']) . '</td></tr>';