PluginProbe
AI / trunk
AI vtrunk
1.3.0 1.2.0 1.1.0 1.0.2 1.0.1 1.0.0 0.9.0 trunk 0.1.1 0.2.0 0.2.1 0.3.0 0.3.1 0.4.0 0.4.1 0.5.0 0.6.0 0.7.0 0.8.0
ai / includes / Admin / Uninstall.php

Uninstall.php in AI trunk, at includes/Admin/Uninstall.php

317 lines 9.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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