From 25de2a13f95c6117ce2e3c8a1a23c76dd4941542 Mon Sep 17 00:00:00 2001 From: David Relich Date: Tue, 8 Sep 2026 15:09:13 +0200 Subject: [PATCH] Harden mojibake repair with Czech-specific serialized value handling --- README.md | 56 +++++++++ tests/repair.php | 163 ++++++++++++++++++++++++ wp-mojibake-repair.php | 276 +++++++++++++++++++++++++++++------------ 3 files changed, 413 insertions(+), 82 deletions(-) create mode 100644 README.md create mode 100644 tests/repair.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..7948832 --- /dev/null +++ b/README.md @@ -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. diff --git a/tests/repair.php b/tests/repair.php new file mode 100644 index 0000000..00505b7 --- /dev/null +++ b/tests/repair.php @@ -0,0 +1,163 @@ +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"; diff --git a/wp-mojibake-repair.php b/wp-mojibake-repair.php index 70fe30d..84853ca 100644 --- a/wp-mojibake-repair.php +++ b/wp-mojibake-repair.php @@ -1,8 +1,8 @@ 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(); - } - 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; - } + $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 ($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 '

Preview

'; echo '

' . number_format_i18n($preview['total']) . ' differing column values found. Only the samples below will be displayed; the confirmed update covers all counted candidates.

'; - echo '

Important: “different” does not prove mojibake. The conversion is not safe to run twice. Correct non-Latin-1 text can also change.

'; - if ($preview['serialized'] > 0) { - echo '

' . number_format_i18n($preview['serialized']) . ' candidate values look PHP-serialized. SQL conversion can invalidate embedded string lengths. Review these samples carefully.

'; + echo '

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.

'; + if ($preview['skipped'] > 0) { + echo '

' . number_format_i18n($preview['skipped']) . ' values were skipped. They contain repair candidates but have malformed or unsupported data (such as serialized objects or references). Already broken settings need recovery from a backup.

'; } foreach ($preview['columns'] as $item) { echo '
'; echo '' . esc_html($item['table'] . '.' . $item['column']) . ' — ' . number_format_i18n($item['count']) . ' differing'; - if ($item['serialized']) { - echo '

Serialized-looking: ' . number_format_i18n($item['serialized']) . '

'; + if ($item['skipped']) { + echo '

Skipped: ' . number_format_i18n($item['skipped']) . '. Example row IDs: ' . esc_html(implode(', ', $item['skipped_ids'])) . '

'; } foreach ($item['samples'] as $sample) { echo '

Row ' . esc_html((string) $sample['row_id']) . '

'; @@ -375,22 +483,26 @@ final class WP_Mojibake_Repair_Temporary } if ($preview['total'] < 1) { - echo '

No update is needed.

'; + echo '

No supported repair candidates were found.

'; return; } echo '

Step 2: Run once

'; wp_nonce_field('wpmr_action', 'wpmr_nonce'); echo ''; - echo ''; + echo ''; echo ''; - 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 '
'; } private static function render_result($result) { echo '

Repair committed successfully. ' . number_format_i18n($result['total']) . ' column values updated.

'; + if (!$result['cache_cleared']) { + echo '

The database repair succeeded, but the WordPress object cache could not be cleared. Clear it through your hosting provider.

'; + } + echo '

Clear any page cache and regenerate your page builder’s CSS before checking the site.

'; echo ''; foreach ($result['rows'] as $row) { echo '';
TableColumnAffected
' . esc_html($row['table']) . '' . esc_html($row['column']) . '' . number_format_i18n($row['affected']) . '