prefix); $schema = self::inspect_schema($plan); $action = isset($_POST['wpmr_action']) ? sanitize_key(wp_unslash($_POST['wpmr_action'])) : ''; $result = null; $preview = null; $error = ''; if ($action !== '') { check_admin_referer('wpmr_action', 'wpmr_nonce'); try { self::require_safe_schema($schema); if ($action === 'preview') { $preview = self::preview($plan); } elseif ($action === 'execute') { if (empty($_POST['confirm_backup'])) { throw new RuntimeException('Confirm that you have a restorable backup.'); } $approval = self::verify_approval( isset($_POST['approval']) ? wp_unslash($_POST['approval']) : '', $plan ); $result = self::execute($plan, $approval); } } catch (Throwable $e) { $error = $e->getMessage(); } } echo '

Mojibake Repair

'; echo '

Temporary repair plugin. Keep your backup. Review the preview carefully. Run once, verify the site, then deactivate and delete this plugin.

'; echo '

Database: ' . esc_html(DB_NAME) . '   Prefix: ' . esc_html($wpdb->prefix) . '

'; self::render_schema($schema); if ($error !== '') { echo '

Stopped: ' . esc_html($error) . '

'; } if ($result !== null) { self::render_result($result); } elseif ($preview !== null) { self::render_preview($preview, $plan); } elseif ($action === '') { echo '

Step 1: Preview

'; echo '

This runs SELECT queries only and shows up to ' . self::SAMPLE_LIMIT . ' changed examples per column.

'; echo '
'; wp_nonce_field('wpmr_action', 'wpmr_nonce'); echo ''; submit_button('Generate read-only preview', 'primary', 'submit', false); echo '
'; } echo '
'; } private static function plan($prefix) { if (!preg_match('/\A[A-Za-z0-9_]+\z/', $prefix)) { throw new RuntimeException('The WordPress table prefix is not a safe SQL identifier.'); } return array( $prefix . 'posts' => array('key' => 'ID', 'columns' => array('post_content', 'post_title', 'post_excerpt')), $prefix . 'postmeta' => array('key' => 'meta_id', 'columns' => array('meta_value')), $prefix . 'options' => array('key' => 'option_id', 'columns' => array('option_value')), $prefix . 'comments' => array('key' => 'comment_ID', 'columns' => array('comment_author', 'comment_content')), $prefix . 'commentmeta' => array('key' => 'meta_id', 'columns' => array('meta_value')), $prefix . 'terms' => array('key' => 'term_id', 'columns' => array('name', 'slug')), $prefix . 'term_taxonomy'=> array('key' => 'term_taxonomy_id', 'columns' => array('description')), $prefix . 'users' => array('key' => 'ID', 'columns' => array('display_name')), $prefix . 'usermeta' => array('key' => 'umeta_id', 'columns' => array('meta_value')), ); } private static function inspect_schema($plan) { global $wpdb; $schema = array(); foreach ($plan as $table => $definition) { $row = $wpdb->get_row($wpdb->prepare( 'SELECT TABLE_TYPE, ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s', DB_NAME, $table ), ARRAY_A); $columns = $wpdb->get_col($wpdb->prepare( 'SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s', DB_NAME, $table )); $required = array_merge(array($definition['key']), $definition['columns']); $missing = array_values(array_diff($required, $columns)); $exists = is_array($row) && isset($row['TABLE_TYPE']) && $row['TABLE_TYPE'] === 'BASE TABLE'; $engine = $exists && isset($row['ENGINE']) ? $row['ENGINE'] : ''; $schema[$table] = array( 'columns' => $definition['columns'], 'exists' => $exists, 'engine' => $engine, 'missing' => $missing, 'ready' => $exists && empty($missing) && strcasecmp($engine, 'InnoDB') === 0, ); } return $schema; } private static function require_safe_schema($schema) { foreach ($schema as $table => $status) { if (!$status['exists']) { throw new RuntimeException($table . ' is missing or is not a base table.'); } if (!empty($status['missing'])) { throw new RuntimeException($table . ' is missing: ' . implode(', ', $status['missing']) . '.'); } if (strcasecmp($status['engine'], 'InnoDB') !== 0) { throw new RuntimeException($table . ' uses ' . $status['engine'] . ', not InnoDB. Repair was blocked because rollback cannot be guaranteed.'); } } } private static function preview($plan, $write = false) { global $wpdb; $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); $id = $table . '.' . $column; $count = 0; $skipped = 0; $samples = array(); $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, 'skipped' => $skipped, 'skipped_ids' => $skipped_ids, 'samples' => $samples, ); $preview['counts'][$id] = $count; $preview['fingerprints'][$id] = hash_final($hash); $preview['total'] += $count; $preview['skipped'] += $skipped; } } return $preview; } 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; if ($wpdb->query('START TRANSACTION') === false) { throw new RuntimeException('Could not start the repair transaction.'); } try { $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) { throw new RuntimeException('Could not commit the repair transaction.'); } } catch (Throwable $e) { $wpdb->query('ROLLBACK'); throw $e; } // 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 repair_text($value) { 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 repair_value($value, $depth = 0) { 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) { if (!preg_match('/\A[A-Za-z0-9_]+\z/', $name)) { throw new RuntimeException('Unsafe SQL identifier refused.'); } return '`' . $name . '`'; } private static function throw_on_db_error() { global $wpdb; if ($wpdb->last_error !== '') { throw new RuntimeException('Database error: ' . $wpdb->last_error); } } private static function make_approval($preview) { global $wpdb; $payload = wp_json_encode(array( 'user' => get_current_user_id(), 'database' => DB_NAME, 'prefix' => $wpdb->prefix, 'expires' => time() + self::APPROVAL_TTL, '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')); } private static function verify_approval($token, $plan) { global $wpdb; $parts = explode('.', $token, 2); if (count($parts) !== 2 || !hash_equals(hash_hmac('sha256', $parts[0], wp_salt('auth')), $parts[1])) { throw new RuntimeException('The preview approval is invalid. Generate a fresh preview.'); } $padding = strlen($parts[0]) % 4; $encoded = $parts[0] . ($padding ? str_repeat('=', 4 - $padding) : ''); $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 || (int) $payload['expires'] < time() || !isset($payload['counts']) || !is_array($payload['counts'])) { throw new RuntimeException('The preview approval expired or does not match this site. Generate a fresh preview.'); } $expected_ids = array(); foreach ($plan as $table => $definition) { foreach ($definition['columns'] as $column) { $expected_ids[] = $table . '.' . $column; } } 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.'); } $payload['counts'] = array_map('intval', $payload['counts']); return $payload; } private static function render_schema($schema) { echo ''; foreach ($schema as $table => $status) { $state = $status['ready'] ? 'Ready (' . $status['engine'] . ')' : 'Blocked / missing (' . ($status['engine'] ?: 'not found') . ')'; echo ''; } echo '
Core tableColumnsStatus
' . esc_html($table) . '' . esc_html(implode(', ', $status['columns'])) . '' . esc_html($state) . '
'; } private static function render_preview($preview, $plan) { 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 '

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['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']) . '

'; echo '
BeforeAfter
' . esc_html((string) $sample['before_value']) . '
' . esc_html((string) $sample['after_value']) . '
'; } echo '
'; } if ($preview['total'] < 1) { echo '

No supported repair candidates were found.

'; return; } echo '

Step 2: Run once

'; wp_nonce_field('wpmr_action', 'wpmr_nonce'); echo ''; echo ''; echo ''; 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 ''; } echo '
TableColumnAffected
' . esc_html($row['table']) . '' . esc_html($row['column']) . '' . number_format_i18n($row['affected']) . '

Now verify the site, then deactivate and delete this plugin.

'; } } WP_Mojibake_Repair_Temporary::init();