| 1 |
<?php |
| 2 |
/** |
| 3 |
* Handles removal of the plugin's data on uninstall. |
| 4 |
* |
| 5 |
* @package WordPress\AI\Admin |
| 6 |
* @since 1.3.0 |
| 7 |
*/ |
| 8 |
|
| 9 |
declare( strict_types=1 ); |
| 10 |
|
| 11 |
namespace WordPress\AI\Admin; |
| 12 |
|
| 13 |
use WordPress\AI\Experiments\Key_Encryption\Secrets_Bridge; |
| 14 |
use WordPress\AI\Logging\AI_Request_Log_Schema; |
| 15 |
use WordPress\AI\Vendor\Secrets\Secrets_Provider_Encrypted_Options; |
| 16 |
|
| 17 |
// Exit if accessed directly. |
| 18 |
defined( 'ABSPATH' ) || exit; |
| 19 |
|
| 20 |
/** |
| 21 |
* Class - Uninstall. |
| 22 |
* |
| 23 |
* Removes the plugin's custom table, options and scheduled events by default. |
| 24 |
* Developers can opt out by returning false from the |
| 25 |
* "wpai_remove_data_on_uninstall" filter to preserve the plugin's data. |
| 26 |
* |
| 27 |
* @internal |
| 28 |
* |
| 29 |
* @since 1.3.0 |
| 30 |
*/ |
| 31 |
final class Uninstall { |
| 32 |
|
| 33 |
/** |
| 34 |
* Scheduled cron hook used by the request log manager. |
| 35 |
* |
| 36 |
* @since 1.3.0 |
| 37 |
* |
| 38 |
* @var string |
| 39 |
*/ |
| 40 |
private const REQUEST_LOG_CLEANUP_HOOK = 'wpai_request_logs_cleanup'; |
| 41 |
|
| 42 |
/** |
| 43 |
* User meta key set when the connector approval notice is dismissed. |
| 44 |
* |
| 45 |
* @since 1.3.0 |
| 46 |
* |
| 47 |
* @var string |
| 48 |
*/ |
| 49 |
private const CONNECTOR_APPROVAL_NOTICE_META = 'wpai_connector_approval_notice_dismissed'; |
| 50 |
|
| 51 |
/** |
| 52 |
* Runs the uninstall routine. |
| 53 |
* |
| 54 |
* Cleanup happens by default unless a developer opts out via the |
| 55 |
* "wpai_remove_data_on_uninstall" filter. On multisite the filter is |
| 56 |
* evaluated per site so each site keeps control of its own data. |
| 57 |
* |
| 58 |
* @since 1.3.0 |
| 59 |
*/ |
| 60 |
public static function run(): void { |
| 61 |
if ( is_multisite() ) { |
| 62 |
$site_ids = get_sites( |
| 63 |
array( |
| 64 |
'fields' => 'ids', |
| 65 |
'number' => 0, |
| 66 |
) |
| 67 |
); |
| 68 |
|
| 69 |
$cleaned_any_site = false; |
| 70 |
|
| 71 |
foreach ( $site_ids as $site_id ) { |
| 72 |
switch_to_blog( (int) $site_id ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.switch_to_blog_switch_to_blog |
| 73 |
$cleaned_any_site = self::maybe_clean_current_site() || $cleaned_any_site; |
| 74 |
restore_current_blog(); |
| 75 |
} |
| 76 |
|
| 77 |
// Network-level data is shared by every site, so it is cleaned once |
| 78 |
// rather than inside the loop, and only when at least one site opted in. |
| 79 |
if ( $cleaned_any_site ) { |
| 80 |
self::delete_network_transients(); |
| 81 |
} |
| 82 |
|
| 83 |
return; |
| 84 |
} |
| 85 |
|
| 86 |
self::maybe_clean_current_site(); |
| 87 |
} |
| 88 |
|
| 89 |
/** |
| 90 |
* Removes the plugin's data for the current site when opted in. |
| 91 |
* |
| 92 |
* @since 1.3.0 |
| 93 |
* |
| 94 |
* @return bool Whether the data was removed. |
| 95 |
*/ |
| 96 |
private static function maybe_clean_current_site(): bool { |
| 97 |
/** |
| 98 |
* Filters whether the plugin should remove all of its data on uninstall. |
| 99 |
* |
| 100 |
* Removal is enabled by default. Return false to keep the plugin's |
| 101 |
* custom table, options, transients and scheduled events for the current |
| 102 |
* site. On multisite this filter runs once per site. |
| 103 |
* |
| 104 |
* @since 1.3.0 |
| 105 |
* |
| 106 |
* @param bool $remove_data Whether to remove all plugin data. Default true. |
| 107 |
*/ |
| 108 |
if ( ! (bool) apply_filters( 'wpai_remove_data_on_uninstall', true ) ) { |
| 109 |
return false; |
| 110 |
} |
| 111 |
|
| 112 |
self::drop_request_logs_table(); |
| 113 |
self::delete_options(); |
| 114 |
self::delete_meta(); |
| 115 |
self::delete_transients(); |
| 116 |
self::clear_scheduled_events(); |
| 117 |
|
| 118 |
return true; |
| 119 |
} |
| 120 |
|
| 121 |
/** |
| 122 |
* Drops the request logs custom table. |
| 123 |
* |
| 124 |
* @since 1.3.0 |
| 125 |
*/ |
| 126 |
private static function drop_request_logs_table(): void { |
| 127 |
global $wpdb; |
| 128 |
|
| 129 |
$table_name = $wpdb->prefix . AI_Request_Log_Schema::TABLE_NAME; |
| 130 |
|
| 131 |
$wpdb->query( "DROP TABLE IF EXISTS `{$table_name}`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 132 |
} |
| 133 |
|
| 134 |
/** |
| 135 |
* Deletes all of the plugin's options. |
| 136 |
* |
| 137 |
* @since 1.3.0 |
| 138 |
*/ |
| 139 |
private static function delete_options(): void { |
| 140 |
// Option name prefixes owned by the plugin. `wpai_` covers settings, |
| 141 |
// feature toggles, versions and connector approvals; `ai_experiment_` |
| 142 |
// covers options left over from pre-1.0 installs; `_secret_ai/` covers |
| 143 |
// the connector API keys encrypted by the Key Encryption experiment. |
| 144 |
$prefixes = array( |
| 145 |
'wpai_', |
| 146 |
'ai_experiment_', |
| 147 |
self::secrets_option_prefix(), |
| 148 |
); |
| 149 |
|
| 150 |
foreach ( $prefixes as $prefix ) { |
| 151 |
foreach ( self::get_option_names_by_prefix( $prefix ) as $option_name ) { |
| 152 |
delete_option( $option_name ); |
| 153 |
} |
| 154 |
} |
| 155 |
|
| 156 |
// Exact option names that don't share a plugin prefix: legacy pre-1.0 |
| 157 |
// options from the plugin's own AI Credentials screen, which was replaced |
| 158 |
// by the Connectors approach. |
| 159 |
$option_names = array( |
| 160 |
'ai_experiments_enabled', |
| 161 |
'wp_ai_client_provider_credentials', |
| 162 |
); |
| 163 |
|
| 164 |
foreach ( $option_names as $option_name ) { |
| 165 |
delete_option( $option_name ); |
| 166 |
} |
| 167 |
|
| 168 |
self::maybe_delete_secrets_master_key(); |
| 169 |
} |
| 170 |
|
| 171 |
/** |
| 172 |
* Deletes the Secrets master key, but only once nothing else depends on it. |
| 173 |
* |
| 174 |
* The master key option name comes from the vendored Displace Secrets Manager |
| 175 |
* SDK, not from this plugin, so the same option can be in use by the upstream |
| 176 |
* plugin or anything else built on that SDK. Deleting it while other |
| 177 |
* `_secret_*` rows exist would make those secrets permanently undecryptable, |
| 178 |
* so it is only removed when this plugin's secrets were the last ones left. |
| 179 |
* |
| 180 |
* @since 1.3.0 |
| 181 |
*/ |
| 182 |
private static function maybe_delete_secrets_master_key(): void { |
| 183 |
global $wpdb; |
| 184 |
|
| 185 |
$remaining = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 186 |
$wpdb->prepare( |
| 187 |
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s AND option_name != %s LIMIT 1", |
| 188 |
$wpdb->esc_like( Secrets_Provider_Encrypted_Options::OPTION_PREFIX ) . '%', |
| 189 |
Secrets_Provider_Encrypted_Options::MASTER_KEY_OPTION |
| 190 |
) |
| 191 |
); |
| 192 |
|
| 193 |
if ( null !== $remaining ) { |
| 194 |
return; |
| 195 |
} |
| 196 |
|
| 197 |
delete_option( Secrets_Provider_Encrypted_Options::MASTER_KEY_OPTION ); |
| 198 |
} |
| 199 |
|
| 200 |
/** |
| 201 |
* Deletes the plugin's metadata (post, comment and user meta). |
| 202 |
* |
| 203 |
* Only meta owned by the plugin is removed. Meta the plugin writes into but |
| 204 |
* does not own (e.g. core "_wp_attachment_image_alt" or third-party SEO |
| 205 |
* description keys) is left untouched. |
| 206 |
* |
| 207 |
* @since 1.3.0 |
| 208 |
*/ |
| 209 |
private static function delete_meta(): void { |
| 210 |
global $wpdb; |
| 211 |
|
| 212 |
// User meta: connector approval notice dismissal flag. |
| 213 |
$user_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 214 |
$wpdb->prepare( |
| 215 |
"SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s", |
| 216 |
self::CONNECTOR_APPROVAL_NOTICE_META |
| 217 |
) |
| 218 |
); |
| 219 |
|
| 220 |
/** @var list<string> $user_ids */ |
| 221 |
foreach ( $user_ids as $user_id ) { |
| 222 |
delete_user_meta( (int) $user_id, self::CONNECTOR_APPROVAL_NOTICE_META ); |
| 223 |
} |
| 224 |
} |
| 225 |
|
| 226 |
/** |
| 227 |
* Deletes the plugin's transients for the current site. |
| 228 |
* |
| 229 |
* @since 1.3.0 |
| 230 |
*/ |
| 231 |
private static function delete_transients(): void { |
| 232 |
$prefix = '_transient_'; |
| 233 |
|
| 234 |
foreach ( self::get_option_names_by_prefix( $prefix . 'wpai_' ) as $option_name ) { |
| 235 |
// delete_transient() removes the paired timeout row and the cached value. |
| 236 |
delete_transient( substr( $option_name, strlen( $prefix ) ) ); |
| 237 |
} |
| 238 |
|
| 239 |
if ( is_multisite() ) { |
| 240 |
// On multisite, site transients are network-level and live in the |
| 241 |
// sitemeta table, so they are handled by delete_network_transients(). |
| 242 |
return; |
| 243 |
} |
| 244 |
|
| 245 |
$site_prefix = '_site_transient_'; |
| 246 |
|
| 247 |
foreach ( self::get_option_names_by_prefix( $site_prefix . 'wpai_' ) as $option_name ) { |
| 248 |
delete_site_transient( substr( $option_name, strlen( $site_prefix ) ) ); |
| 249 |
} |
| 250 |
} |
| 251 |
|
| 252 |
/** |
| 253 |
* Deletes the plugin's network-level site transients on multisite. |
| 254 |
* |
| 255 |
* @since 1.3.0 |
| 256 |
*/ |
| 257 |
private static function delete_network_transients(): void { |
| 258 |
global $wpdb; |
| 259 |
|
| 260 |
$prefix = '_site_transient_'; |
| 261 |
|
| 262 |
$meta_keys = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 263 |
$wpdb->prepare( |
| 264 |
"SELECT meta_key FROM {$wpdb->sitemeta} WHERE site_id = %d AND meta_key LIKE %s", |
| 265 |
get_current_network_id(), |
| 266 |
$wpdb->esc_like( $prefix . 'wpai_' ) . '%' |
| 267 |
) |
| 268 |
); |
| 269 |
|
| 270 |
/** @var list<string> $meta_keys */ |
| 271 |
foreach ( $meta_keys as $meta_key ) { |
| 272 |
delete_site_transient( substr( $meta_key, strlen( $prefix ) ) ); |
| 273 |
} |
| 274 |
} |
| 275 |
|
| 276 |
/** |
| 277 |
* Clears the plugin's scheduled cron events. |
| 278 |
* |
| 279 |
* @since 1.3.0 |
| 280 |
*/ |
| 281 |
private static function clear_scheduled_events(): void { |
| 282 |
wp_clear_scheduled_hook( self::REQUEST_LOG_CLEANUP_HOOK ); |
| 283 |
} |
| 284 |
|
| 285 |
/** |
| 286 |
* Returns the option name prefix used for this plugin's encrypted secrets. |
| 287 |
* |
| 288 |
* @since 1.3.0 |
| 289 |
* |
| 290 |
* @return string The option name prefix, e.g. "_secret_ai/". |
| 291 |
*/ |
| 292 |
private static function secrets_option_prefix(): string { |
| 293 |
return Secrets_Provider_Encrypted_Options::OPTION_PREFIX . Secrets_Bridge::SECRET_NAMESPACE . '/'; |
| 294 |
} |
| 295 |
|
| 296 |
/** |
| 297 |
* Returns every option name in the current site that starts with a prefix. |
| 298 |
* |
| 299 |
* @since 1.3.0 |
| 300 |
* |
| 301 |
* @param string $prefix Literal option name prefix to match. |
| 302 |
* @return list<string> Matching option names. |
| 303 |
*/ |
| 304 |
private static function get_option_names_by_prefix( string $prefix ): array { |
| 305 |
global $wpdb; |
| 306 |
|
| 307 |
$option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 308 |
$wpdb->prepare( |
| 309 |
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", |
| 310 |
$wpdb->esc_like( $prefix ) . '%' |
| 311 |
) |
| 312 |
); |
| 313 |
|
| 314 |
return array_values( array_map( 'strval', $option_names ) ); |
| 315 |
} |
| 316 |
} |
| 317 |
|