commit 4c623c36eacf3593f9cf37540707afbe634fc965 Author: David Relich Date: Tue Sep 8 13:30:14 2026 +0200 initial commit diff --git a/wp-mojibake-repair.php b/wp-mojibake-repair.php new file mode 100644 index 0000000..70fe30d --- /dev/null +++ b/wp-mojibake-repair.php @@ -0,0 +1,402 @@ +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['counts']); + } + } 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) + { + global $wpdb; + $preview = array('columns' => array(), 'counts' => array(), 'total' => 0, 'serialized' => 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(); + + $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}", + ARRAY_A + ); + self::throw_on_db_error(); + } + + $preview['columns'][] = array( + 'table' => $table, + 'column' => $column, + 'count' => $count, + 'serialized' => $serialized, + 'samples' => $samples, + ); + $preview['counts'][$id] = $count; + $preview['total'] += $count; + $preview['serialized'] += $serialized; + } + } + + return $preview; + } + + private static function execute($plan, $approved_counts) + { + 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(); + } + + $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; + } + } + + if ($wpdb->query('COMMIT') === false) { + self::throw_on_db_error(); + } + } catch (Throwable $e) { + $wpdb->query('ROLLBACK'); + throw $e; + } + + return array('rows' => $rows, 'total' => $total); + } + + private static function conversion($column) + { + $column_sql = self::identifier($column); + return "CONVERT(BINARY CONVERT({$column_sql} USING latin1) USING utf8mb4)"; + } + + private static function difference($column) + { + $column_sql = self::identifier($column); + $converted = self::conversion($column); + return "NOT (BINARY {$column_sql} <=> BINARY {$converted})"; + } + + 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($counts) + { + global $wpdb; + $payload = wp_json_encode(array( + 'user' => get_current_user_id(), + 'database' => DB_NAME, + 'prefix' => $wpdb->prefix, + 'expires' => time() + self::APPROVAL_TTL, + 'counts' => $counts, + )); + $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) + || (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) { + 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 '

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.

'; + } + + 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']) . '

'; + } + 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 update is needed.

'; + 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 must not be run twice.');")); + echo '
'; + } + + private static function render_result($result) + { + echo '

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

'; + 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();