From b407f235acec0f525709bbcfdb8c616fd03e82b1 Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 25 Sep 2026 22:16:02 +0800 Subject: [PATCH] fix(2meet-data-optimizer): refuse uninstall while data lives only in flat tables - uninstall.php checks every site first and wp_die()s (keeping tables, options and plugin files) on aeav_only modes, misc rows, or zone modules past cutover; a failed bootstrap also refuses - Multisite now checks and cleans every site: network activation is always false at uninstall, so only the main site was being cleaned before - Drop the inline fallback DROP loop, unreachable now that a missing helper refuses instead Verify: UninstallGuardTest (8 tests incl. running uninstall.php single and multisite); unit 602 OK, integration 429 OK PR: PR-008 --- includes/class-tmdo-installer.php | 52 +++++ tests/integration/UninstallGuardTest.php | 120 ++++++++++++ uninstall.php | 233 ++++++----------------- 3 files changed, 234 insertions(+), 171 deletions(-) create mode 100644 tests/integration/UninstallGuardTest.php diff --git a/includes/class-tmdo-installer.php b/includes/class-tmdo-installer.php index b003c07..64c8e0f 100644 --- a/includes/class-tmdo-installer.php +++ b/includes/class-tmdo-installer.php @@ -297,6 +297,58 @@ class TMDO_Installer { update_option( self::MISC_BUCKET_DEFAULT_FLAG, '1', true ); } + /** + * Reasons this site's wpdo_* tables cannot be dropped yet; empty means safe. + * + * Reads the site's own options rather than Mode_Manager / Feature_Flags, + * whose per-request caches do not follow switch_to_blog() during uninstall. + * + * @return string[] + */ + public static function uninstall_blockers(): array { + global $wpdb; + $blockers = array(); + + $modes = get_option( 'wpdo_bridge_modes', array() ); + foreach ( is_array( $modes ) ? $modes : array() as $entity => $mode ) { + if ( 'aeav_only' === $mode ) { + $blockers[] = sprintf( '%1$s 的 bridge 模式是 aeav_only:資料只存在 wpdo_%1$s_* 扁平表。', $entity ); + } + } + + foreach ( array( 'wpdo_term_misc', 'wpdo_comment_misc' ) as $name ) { + if ( self::table_has_rows( $wpdb->prefix . $name ) ) { + $blockers[] = sprintf( '%s 有資料:這些 term / comment meta 只存在 misc 表。', $name ); + } + } + + $features = get_option( 'wpdo_features', array() ); + foreach ( TMDO_Feature_Flags::ZONE_MODULES as $module ) { + $state = is_array( $features ) ? ( $features[ $module ] ?? 'idle' ) : 'idle'; + if ( in_array( $state, array( 'cleanup', 'complete' ), true ) ) { + $blockers[] = sprintf( '區域模組 %1$s 處於 %2$s:讀取已改走 wpdo_* 表。', $module, $state ); + } + } + + return $blockers; + } + + /** + * Whether a table exists and holds at least one row. + * + * @param string $table Full table name. + * @return bool + */ + private static function table_has_rows( string $table ): bool { + global $wpdb; + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + if ( $table !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) ) { + return false; + } + return null !== $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" ); + // phpcs:enable + } + // ── v2.0.0 entity + audit + shadow_diffs schema (PR-2) ───────────────── // These tables are installed only when v2.0.0 upgrade runs (PR-7). // install_v2_tables() is idempotent; safe to call multiple times. diff --git a/tests/integration/UninstallGuardTest.php b/tests/integration/UninstallGuardTest.php new file mode 100644 index 0000000..5027c62 --- /dev/null +++ b/tests/integration/UninstallGuardTest.php @@ -0,0 +1,120 @@ +query( + 'CREATE TABLE IF NOT EXISTS `' . self::TERM_MISC . '` ( + term_id bigint(20) unsigned NOT NULL, + meta_key varchar(191) NOT NULL, + meta_value longtext, + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (term_id, meta_key) + ) DEFAULT CHARACTER SET utf8mb4' + ); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->prefix = 'wp_itest_'; + $wpdb->options = 'wp_itest_options'; + $wpdb->query( 'TRUNCATE TABLE `' . self::TERM_MISC . '`' ); + delete_option( 'wpdo_bridge_modes' ); + delete_option( 'wpdo_features' ); + } + + public function test_clean_site_has_no_blockers(): void { + $this->assertSame( array(), TMDO_Installer::uninstall_blockers() ); + } + + public function test_aeav_only_mode_blocks(): void { + update_option( 'wpdo_bridge_modes', array( 'post' => 'aeav_only', 'user' => 'dual_write' ) ); + + $blockers = TMDO_Installer::uninstall_blockers(); + + $this->assertCount( 1, $blockers ); + $this->assertStringContainsString( 'post', $blockers[0] ); + } + + public function test_modes_that_still_write_native_do_not_block(): void { + update_option( 'wpdo_bridge_modes', array( 'user' => 'dual_write', 'term' => 'shadow_read', 'comment' => 'disabled' ) ); + + $this->assertSame( array(), TMDO_Installer::uninstall_blockers() ); + } + + public function test_misc_rows_block(): void { + global $wpdb; + $wpdb->query( 'INSERT INTO `' . self::TERM_MISC . "` (term_id, meta_key, meta_value) VALUES (1, 'k', 'v')" ); + + $blockers = TMDO_Installer::uninstall_blockers(); + + $this->assertCount( 1, $blockers ); + $this->assertStringContainsString( 'wpdo_term_misc', $blockers[0] ); + } + + public function test_zone_module_past_cutover_blocks(): void { + update_option( 'wpdo_features', array( 'hot_hp_listing' => 'complete', 'cold_hp_vendor' => 'cleanup', 'warm' => 'cutover' ) ); + + $blockers = TMDO_Installer::uninstall_blockers(); + + $this->assertCount( 2, $blockers ); + $this->assertStringContainsString( 'hot_hp_listing', implode( ' ', $blockers ) ); + $this->assertStringContainsString( 'cold_hp_vendor', implode( ' ', $blockers ) ); + } + + public function test_hpct_module_complete_does_not_block(): void { + // HPCT modules live in hpct_* tables, which uninstall never drops. + update_option( 'wpdo_features', array( 'reviews' => 'complete' ) ); + + $this->assertSame( array(), TMDO_Installer::uninstall_blockers() ); + } + + public function test_uninstall_script_refuses_and_keeps_data(): void { + global $wpdb; + update_option( 'wpdo_bridge_modes', array( 'post' => 'aeav_only' ) ); + if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { + define( 'WP_UNINSTALL_PLUGIN', '2meet-data-optimizer/2meet-data-optimizer.php' ); + } + + try { + require TMDO_PATH . 'uninstall.php'; + $this->fail( 'uninstall.php should have stopped with wp_die()' ); + } catch ( RuntimeException $e ) { + $this->assertStringContainsString( 'aeav_only', $e->getMessage() ); + } + + $this->assertSame( array( 'post' => 'aeav_only' ), get_option( 'wpdo_bridge_modes' ) ); + $this->assertSame( self::TERM_MISC, $wpdb->get_var( "SHOW TABLES LIKE '" . self::TERM_MISC . "'" ) ); + } + + public function test_uninstall_script_checks_every_site_on_multisite(): void { + update_option( 'wpdo_bridge_modes', array( 'user' => 'aeav_only' ) ); + if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { + define( 'WP_UNINSTALL_PLUGIN', '2meet-data-optimizer/2meet-data-optimizer.php' ); + } + $GLOBALS['_wp_is_multisite'] = true; + $GLOBALS['_wp_sites'] = array( 1, 3 ); + + try { + require TMDO_PATH . 'uninstall.php'; + $this->fail( 'uninstall.php should have stopped with wp_die()' ); + } catch ( RuntimeException $e ) { + $this->assertStringContainsString( '站台 #1', $e->getMessage() ); + $this->assertStringContainsString( '站台 #3', $e->getMessage() ); + } finally { + unset( $GLOBALS['_wp_is_multisite'], $GLOBALS['_wp_sites'] ); + } + } +} diff --git a/uninstall.php b/uninstall.php index 4fa1c0a..8f7e54f 100644 --- a/uninstall.php +++ b/uninstall.php @@ -5,14 +5,18 @@ * Runs only when the plugin is deleted via the WordPress admin. * Removes all WPDO tables and options. Does NOT remove HPCT tables. * + * Refuses (wp_die, before any snapshot or DROP) while any site still holds data + * that exists only in wpdo_* tables — see TMDO_Installer::uninstall_blockers(). + * wp_die() also stops WordPress from deleting the plugin files. + * * v2.2.0 hardening: take a `pre_uninstall` snapshot BEFORE dropping tables. * The snapshot file under wp-content/uploads/wpdo-backups/ survives plugin * deletion (uploads dir is never auto-purged), so the user can re-install + * `wp wpdo snapshot restore --apply` to recover. * - * v2.14.0 multisite hardening: when network-uninstalled, iterate all sites - * via switch_to_blog so every wp_N_wpdo_* table is dropped (previously only - * the main site got cleaned, leaving orphans on every other site). + * Multisite: every site is checked and cleaned. The plugin may have been active + * per site, and it is always inactive by the time it can be deleted, so network + * activation cannot tell which sites hold wpdo_* tables. * * @package WP_Data_Optimizer */ @@ -44,11 +48,61 @@ foreach ( $bootstrap_files as $f ) { require_once $f; } +// ── Sites to check and clean (0 = single site, no blog switching) ──────── +$wpdo_site_ids = is_multisite() + ? array_map( + 'intval', + get_sites( + array( + 'fields' => 'ids', + 'number' => 0, + ) + ) + ) + : array( 0 ); + +$wpdo_for_each_site = static function ( callable $callback ) use ( $wpdo_site_ids ): void { + foreach ( $wpdo_site_ids as $wpdo_blog_id ) { + if ( $wpdo_blog_id ) { + switch_to_blog( $wpdo_blog_id ); + } + try { + $callback( $wpdo_blog_id ); + } finally { + if ( $wpdo_blog_id ) { + restore_current_blog(); + } + } + } +}; + +// ── Refuse while any site holds data that exists only in wpdo_* tables ─── +if ( ! $bootstrap_ok || ! method_exists( 'TMDO_Installer', 'uninstall_blockers' ) ) { + wp_die( esc_html( '2meet Data Optimizer:無法載入資料安全檢查,為避免資料遺失,已停止解除安裝。' ) ); +} + +$wpdo_blocked = array(); +$wpdo_for_each_site( + static function ( int $blog_id ) use ( &$wpdo_blocked ): void { + foreach ( TMDO_Installer::uninstall_blockers() as $reason ) { + $wpdo_blocked[] = ( $blog_id ? "站台 #{$blog_id}:" : '' ) . $reason; + } + } +); + +if ( $wpdo_blocked ) { + wp_die( + '

' . esc_html( '2meet Data Optimizer 已停止解除安裝:以下資料只存在 wpdo_* 表,刪除外掛會一併刪除這些表。' ) . '

' + . '' + . '

' . esc_html( '目前沒有把這些資料寫回 WordPress 原生表的自動工具。若只想移除外掛程式,可手動刪除外掛目錄,資料表會保留。' ) . '

' + ); +} + // ── v2.2.0 M1.5: pre-uninstall snapshot (main-site context only) ───────── // Snapshot is taken once on the main site — it captures the catalog of all // wpdo_snapshots rows (which is per-site so per-site snapshots survive the // site-level table DROP that happens later). Best-effort. -if ( $bootstrap_ok && class_exists( 'TMDO_Snapshot_Manager' ) ) { +if ( class_exists( 'TMDO_Snapshot_Manager' ) ) { $result = TMDO_Snapshot_Manager::create( 'pre_uninstall', array(), @@ -75,172 +129,9 @@ if ( $bootstrap_ok && class_exists( 'TMDO_Snapshot_Manager' ) ) { } } -// ── v2.14.0: shared cleanup helper ─────────────────────────────────────── -// Falls back to inline DROP loop if the helper class isn't loadable -// (e.g. partially-broken install where includes/class-tmdo-installer.php -// is missing). The inline path is the original v2.13.x logic verbatim. -$has_helper = class_exists( 'TMDO_Installer' ) - && method_exists( 'TMDO_Installer', 'drop_all_tables_for_current_blog' ); - -/** - * Run the cleanup for the current blog (helper-aware). - */ -$run_cleanup = static function () use ( $has_helper ): void { - global $wpdb; - - if ( $has_helper ) { +// ── Drop wpdo_* tables and options on every site ───────────────────────── +$wpdo_for_each_site( + static function (): void { TMDO_Installer::drop_all_tables_for_current_blog(); - return; } - - // ── Fallback inline path (helper unavailable) ───────────────────────── - $tables = array( - $wpdb->prefix . 'wpdo_migrations', - $wpdb->prefix . 'wpdo_errors', - $wpdb->prefix . 'wpdo_benchmarks', - $wpdb->prefix . 'wpdo_warm', - $wpdb->prefix . 'wpdo_archive', - $wpdb->prefix . 'wpdo_audit', - $wpdb->prefix . 'wpdo_shadow_diffs', - $wpdb->prefix . 'wpdo_site_metrics', - $wpdb->prefix . 'wpdo_registry_meta', - $wpdb->prefix . 'wpdo_uni_options', - $wpdb->prefix . 'wpdo_wc_commissions', - $wpdb->prefix . 'wpdo_snapshots', - $wpdb->prefix . 'wpdo_migration_status', - $wpdb->prefix . 'wpdo_user_points_ledger', - $wpdb->prefix . 'wpdo_user_membership', - $wpdb->prefix . 'wpdo_user_activity', - $wpdb->prefix . 'wpdo_user_profile', - $wpdb->prefix . 'wpdo_user_sso', - $wpdb->prefix . 'wpdo_user_core_profile', - $wpdb->prefix . 'wpdo_user_social', - $wpdb->prefix . 'wpdo_user_commerce', - $wpdb->prefix . 'wpdo_user_hp_user', - $wpdb->prefix . 'wpdo_user_admin_prefs', - $wpdb->prefix . 'wpdo_post_wp_core', - $wpdb->prefix . 'wpdo_post_attachment', - $wpdb->prefix . 'wpdo_post_wc_product', - $wpdb->prefix . 'wpdo_post_hp_listing_core', - $wpdb->prefix . 'wpdo_post_hp_request_core', - $wpdb->prefix . 'wpdo_post_hp_vendor_core', - $wpdb->prefix . 'wpdo_post_nav_menu_item', - $wpdb->prefix . 'wpdo_hot_hp_listing', - $wpdb->prefix . 'wpdo_term_hp_taxonomy', - $wpdb->prefix . 'wpdo_comment_hp_review', - $wpdb->prefix . 'wpdo_term_misc', - $wpdb->prefix . 'wpdo_comment_misc', - // v3.0.0: HivePress family integration tables. - $wpdb->prefix . 'wpdo_hot_hp_request', - $wpdb->prefix . 'wpdo_hot_hp_membership', - $wpdb->prefix . 'wpdo_hot_hp_membership_plan', - $wpdb->prefix . 'wpdo_hot_hp_booking', - $wpdb->prefix . 'wpdo_comment_hp_message', - $wpdb->prefix . 'wpdo_comment_hp_favorite', - $wpdb->prefix . 'wpdo_comment_hp_offer', - $wpdb->prefix . 'wpdo_term_hp_listing_tag', - ); - - $hot_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_hot_' ) . '%'; - $cold_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_cold_' ) . '%'; - $entity_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_user_' ) . '%'; - $post_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_post_' ) . '%'; - $term_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_term_' ) . '%'; - $comment_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_comment_' ) . '%'; - - if ( class_exists( 'WP_SQLite_Driver' ) ) { - $dynamic = $wpdb->get_col( - $wpdb->prepare( - "SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s)", - $hot_like, - $cold_like, - $entity_like, - $post_ent_like, - $term_ent_like, - $comment_ent_like - ) - ); - } else { - $dynamic = $wpdb->get_col( - $wpdb->prepare( - 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND (TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s)', - $hot_like, - $cold_like, - $entity_like, - $post_ent_like, - $term_ent_like, - $comment_ent_like - ) - ); - } - - $tables = array_unique( array_merge( $tables, $dynamic ?: array() ) ); - $prefix = $wpdb->prefix . 'wpdo_'; - foreach ( $tables as $table ) { - if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) || strpos( $table, $prefix ) !== 0 ) { - continue; - } - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); - } - - delete_option( 'wpdo_db_version' ); - delete_option( 'wpdo_features' ); - delete_option( 'wpdo_features_shadow' ); - delete_option( 'wpdo_hpct_imported' ); - delete_option( 'wpdo_hook_bus_enabled' ); - delete_option( 'wpdo_v2_features_backup' ); - delete_option( 'wpdo_v2_upgrade_status' ); - delete_option( 'wpdo_v2_upgrade_error' ); - delete_option( 'wpdo_v2_upgraded_at' ); - delete_option( 'wpdo_health_alert' ); - delete_option( 'wpdo_rl_stats' ); - delete_option( 'wpdo_setup_wizard_completed' ); - delete_option( 'wpdo_first_run_at' ); - - wp_clear_scheduled_hook( 'wpdo_warm_cleanup' ); - wp_clear_scheduled_hook( 'wpdo_archive_sweep' ); - wp_clear_scheduled_hook( 'wpdo_errors_gc' ); - wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' ); - wp_clear_scheduled_hook( 'wpdo_daily_health_check' ); - wp_clear_scheduled_hook( 'wpdo_snapshot_prune_daily' ); -}; - -// ── Run cleanup ────────────────────────────────────────────────────────── -// v2.14.0: when network-uninstalling on multisite, iterate every site so each -// wp_N_wpdo_* table set is dropped. Pre-v2.14.0 only the main site got cleaned. -if ( is_multisite() ) { - $is_network_active = function_exists( 'is_plugin_active_for_network' ) - && is_plugin_active_for_network( plugin_basename( __FILE__ ) ); - - if ( $is_network_active ) { - // Iterate all sites in batches. - $offset = 0; - $batch = 100; - do { - $site_ids = get_sites( - array( - 'number' => $batch, - 'offset' => $offset, - 'fields' => 'ids', - ) - ); - foreach ( $site_ids as $wpdo_blog_id ) { - switch_to_blog( (int) $wpdo_blog_id ); - try { - $run_cleanup(); - } finally { - restore_current_blog(); - } - } - $offset += $batch; - $batch_size = count( $site_ids ); - } while ( $batch_size === $batch ); - } else { - // Per-site activation: clean only the current site. - $run_cleanup(); - } -} else { - // Single-site install: run once. - $run_cleanup(); -} +);