prefix . 'guardforge_2fa'; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix + a literal. $wpdb->query( "DROP TABLE IF EXISTS {$guardforge_2fa_table}" ); // The per-user salt that made the stored secret unreadable on its own. Useless // without the table, and pointless to keep after it. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- uninstall: one prepared DELETE of this plugin's own usermeta keys (a guardforge_ key or its 2FA salt) rather than a call per user; the key goes through prepare. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->usermeta} WHERE meta_key = %s", '_guardforge_2fa_salt' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching // Half-finished enrolments and freshly-minted recovery codes live in // transients; both carry the secret in plain form for the few minutes they // last, and neither survives the plugin. // Both spellings: the `gf_` pair was renamed in 1.3.1 under the four-character // prefix rule, and a site that upgraded and uninstalled within the ten minutes // a transient lasts would otherwise leave the older half behind. foreach ( [ 'guardforge_2fa_pending_', 'guardforge_2fa_codes_', 'guardforge_captcha_', 'gf_2fa_pending_', 'gf_2fa_codes_', 'gf_captcha_', ] as $guardforge_prefix ) { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- uninstall: one prepared DELETE of this plugin's own rows in the options table (guardforge_ options and our transients, by full-slug prefix) rather than a call per row; the pattern goes through prepare and esc_like. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", '_transient_' . $wpdb->esc_like( $guardforge_prefix ) . '%', '_transient_timeout_' . $wpdb->esc_like( $guardforge_prefix ) . '%' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching } // 2. ALWAYS: stop the crons. A scheduled hook whose callback no longer exists // is a wp-cron entry that wakes the site up to do nothing, on every run, // forever. foreach ( [ 'guardforge_integrity_cron', 'guardforge_log_gc_cron', 'guardforge_vulndb_cron', 'guardforge_score_cron', 'guardforge_cases_cron' ] as $guardforge_hook ) { $guardforge_ts = wp_next_scheduled( $guardforge_hook ); while ( $guardforge_ts ) { wp_unschedule_event( $guardforge_ts, $guardforge_hook ); $guardforge_ts = wp_next_scheduled( $guardforge_hook ); } } // 3. ALWAYS: credential material that is not the TOTP table. $guardforge_options = get_option( 'guardforge_options', [] ); if ( is_array( $guardforge_options ) ) { // Rewritten rather than dropped, so the operator's history preference — // read just below — survives the scrub. $guardforge_scrubbed = $guardforge_options; foreach ( [ 'cf_api_token', 'alert_channels' ] as $guardforge_secret ) { unset( $guardforge_scrubbed[ $guardforge_secret ] ); } if ( $guardforge_scrubbed !== $guardforge_options ) { update_option( 'guardforge_options', $guardforge_scrubbed, false ); } } // The licence key plus the encrypted site and trial tokens. delete_option( 'guardforge_license_data' ); // Shared site state — the Pro stamp always, the rest only when this was the last Forge product // 'forge_license_last_pro_at' (the Pro-confirmation stamp every Forge plugin // reads and re-sets) goes every time: a neighbour that still holds a licence // re-sets it on its next check, and one that does not must not inherit up to a // day of grace from this one. Everything else the suite keeps on the site — the // account binding, the Connect errors, the statistics options, the queue table // and its events, the credits and updater caches — belongs to the whole suite // and goes only with the last Forge product; while one is left, this product // takes just its own entry in the Connect error list. The exact list, and the // rule, are Forge_Queue::settle_site_on_uninstall's. Above the keep-data // gate: that preference is about this product's content, not the suite's state. // A sibling's older copy of the class has no such method: then only the stamp // goes, and the queue is left to its own helper, as before. if (method_exists('Forge_Queue', 'settle_site_on_uninstall')) { Forge_Queue::settle_site_on_uninstall('guardforge', $guardforge_alone); } else { delete_option('forge_license_last_pro_at'); if (method_exists('Forge_Queue', 'uninstall_shared_queue')) { Forge_Queue::uninstall_shared_queue('guardforge'); } } // 4. Opt-in: the history and the settings. if ( ! is_array( $guardforge_options ) || empty( $guardforge_options['delete_data_on_uninstall'] ) ) { return; } foreach ( [ $wpdb->prefix . 'guardforge_login_log', $wpdb->prefix . 'guardforge_integrity', $wpdb->prefix . 'guardforge_audit_log', $wpdb->prefix . 'guardforge_vulndb', $wpdb->prefix . 'guardforge_score_history', $wpdb->prefix . 'guardforge_event_explain', $wpdb->prefix . 'guardforge_cases', $wpdb->prefix . 'guardforge_case_events', $wpdb->prefix . 'guardforge_trusted_baseline', ] as $guardforge_table ) { // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are $wpdb->prefix + a literal. $wpdb->query( "DROP TABLE IF EXISTS {$guardforge_table}" ); } // Every guardforge_* option, by pattern rather than a hand-kept list that goes // stale the first time somebody adds a setting. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- uninstall: one prepared DELETE of this plugin's own rows in the options table (guardforge_ options and our transients, by full-slug prefix) rather than a call per row; the pattern goes through prepare and esc_like. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'guardforge_' ) . '%' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching foreach ( [ 'gf_', 'guardforge_' ] as $guardforge_prefix ) { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- uninstall: one prepared DELETE of this plugin's own rows in the options table (guardforge_ options and our transients, by full-slug prefix) rather than a call per row; the pattern goes through prepare and esc_like. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", '_transient_' . $wpdb->esc_like( $guardforge_prefix ) . '%', '_transient_timeout_' . $wpdb->esc_like( $guardforge_prefix ) . '%' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching } // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- uninstall: one prepared DELETE of this plugin's own usermeta keys (a guardforge_ key or its 2FA salt) rather than a call per user; the key goes through prepare. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->usermeta} WHERE meta_key LIKE %s", $wpdb->esc_like( 'guardforge_' ) . '%' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching }; // Every site of the network (one call on a single site); a sibling's older Forge_Queue // cannot walk the network, and then only the current site is settled, as before. if (method_exists('Forge_Queue', 'for_each_site')) { Forge_Queue::for_each_site($guardforge_uninstall_site); } else { $guardforge_uninstall_site(0); } // Network-wide, once: the updater's site transients, licence rows kept for the network, the // suite's user meta (one table for all sites). Only the last Forge product removes them. if (method_exists('Forge_Queue', 'settle_network_on_uninstall')) { Forge_Queue::settle_network_on_uninstall($guardforge_alone); }