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
This commit is contained in:
2026-09-25 22:16:02 +08:00
parent fc0f018a94
commit b407f235ac
3 changed files with 234 additions and 171 deletions
+62 -171
View File
@@ -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 <id> --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(
'<p>' . esc_html( '2meet Data Optimizer 已停止解除安裝:以下資料只存在 wpdo_* 表,刪除外掛會一併刪除這些表。' ) . '</p>'
. '<ul><li>' . implode( '</li><li>', array_map( 'esc_html', $wpdo_blocked ) ) . '</li></ul>'
. '<p>' . esc_html( '目前沒有把這些資料寫回 WordPress 原生表的自動工具。若只想移除外掛程式,可手動刪除外掛目錄,資料表會保留。' ) . '</p>'
);
}
// ── 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();
}
);