403 lines
18 KiB
PHP
403 lines
18 KiB
PHP
<?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
|
|
* Requires PHP: 7.4
|
|
*/
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
final class WP_Mojibake_Repair_Temporary
|
|
{
|
|
const PAGE = 'wp-mojibake-repair';
|
|
const SAMPLE_LIMIT = 5;
|
|
const SAMPLE_LENGTH = 300;
|
|
const APPROVAL_TTL = 600;
|
|
|
|
public static function init()
|
|
{
|
|
add_action('admin_menu', array(__CLASS__, 'add_page'));
|
|
}
|
|
|
|
public static function add_page()
|
|
{
|
|
add_management_page(
|
|
'Mojibake Repair',
|
|
'Mojibake Repair',
|
|
'manage_options',
|
|
self::PAGE,
|
|
array(__CLASS__, 'render_page')
|
|
);
|
|
}
|
|
|
|
public static function render_page()
|
|
{
|
|
if (!current_user_can('manage_options')) {
|
|
wp_die('You are not allowed to run this repair.');
|
|
}
|
|
|
|
global $wpdb;
|
|
|
|
$plan = self::plan($wpdb->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 '<div class="wrap"><h1>Mojibake Repair</h1>';
|
|
echo '<div class="notice notice-error"><p><strong>Temporary repair plugin.</strong> Keep your backup. Review the preview carefully. Run once, verify the site, then deactivate and delete this plugin.</p></div>';
|
|
echo '<p><strong>Database:</strong> <code>' . esc_html(DB_NAME) . '</code> <strong>Prefix:</strong> <code>' . esc_html($wpdb->prefix) . '</code></p>';
|
|
|
|
self::render_schema($schema);
|
|
|
|
if ($error !== '') {
|
|
echo '<div class="notice notice-error"><p><strong>Stopped:</strong> ' . esc_html($error) . '</p></div>';
|
|
}
|
|
|
|
if ($result !== null) {
|
|
self::render_result($result);
|
|
} elseif ($preview !== null) {
|
|
self::render_preview($preview, $plan);
|
|
} elseif ($action === '') {
|
|
echo '<h2>Step 1: Preview</h2>';
|
|
echo '<p>This runs SELECT queries only and shows up to ' . self::SAMPLE_LIMIT . ' changed examples per column.</p>';
|
|
echo '<form method="post">';
|
|
wp_nonce_field('wpmr_action', 'wpmr_nonce');
|
|
echo '<input type="hidden" name="wpmr_action" value="preview">';
|
|
submit_button('Generate read-only preview', 'primary', 'submit', false);
|
|
echo '</form>';
|
|
}
|
|
|
|
echo '</div>';
|
|
}
|
|
|
|
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 '<table class="widefat striped" style="max-width:1100px"><thead><tr><th>Core table</th><th>Columns</th><th>Status</th></tr></thead><tbody>';
|
|
foreach ($schema as $table => $status) {
|
|
$state = $status['ready'] ? 'Ready (' . $status['engine'] . ')' : 'Blocked / missing (' . ($status['engine'] ?: 'not found') . ')';
|
|
echo '<tr><td><code>' . esc_html($table) . '</code></td><td><code>' . esc_html(implode(', ', $status['columns'])) . '</code></td><td>' . esc_html($state) . '</td></tr>';
|
|
}
|
|
echo '</tbody></table>';
|
|
}
|
|
|
|
private static function render_preview($preview, $plan)
|
|
{
|
|
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>';
|
|
}
|
|
|
|
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>';
|
|
}
|
|
foreach ($item['samples'] as $sample) {
|
|
echo '<p><strong>Row ' . esc_html((string) $sample['row_id']) . '</strong></p>';
|
|
echo '<table class="widefat"><tr><th style="width:50%">Before</th><th>After</th></tr><tr><td><pre style="white-space:pre-wrap">' . esc_html((string) $sample['before_value']) . '</pre></td><td><pre style="white-space:pre-wrap">' . esc_html((string) $sample['after_value']) . '</pre></td></tr></table>';
|
|
}
|
|
echo '</details>';
|
|
}
|
|
|
|
if ($preview['total'] < 1) {
|
|
echo '<div class="notice notice-success"><p>No update is needed.</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 '<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.');"));
|
|
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>';
|
|
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>';
|
|
}
|
|
echo '</tbody></table><div class="notice notice-error"><p><strong>Now verify the site, then deactivate and delete this plugin.</strong></p></div>';
|
|
}
|
|
}
|
|
|
|
WP_Mojibake_Repair_Temporary::init();
|