PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 All 36 releases
xspeed / uninstall.php

uninstall.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.2, at uninstall.php

283 lines 13.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Uninstall handler — deletes options, cache, and drop-in.
4 *
5 * @package XSpeed
6 */
7
8 if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
9 exit;
10 }
11
12 xspeed_uninstall_cleanup();
13
14 /**
15 * Run all uninstall cleanup. Wrapped in a function so locals don't pollute global scope.
16 */
17 function xspeed_uninstall_cleanup() {
18 /*
19 * Everything here is ours to delete. `wpdeveloper_xspeed_offer` is NOT — it
20 * is the site's answer about whether it wants this plugin, written by
21 * whichever WPDeveloper plugin offered it. Removing xSpeed is itself an
22 * answer — the siblings read it as one, from that row plus the absent plugin
23 * files. Deleting it here would make every one of them offer xSpeed again to
24 * the user who just took it off.
25 * See docs/guides/installing-from-another-plugin.md.
26 */
27 delete_option( 'xspeed_options' );
28
29 /*
30 * Per-module rows, for the modules THIS plugin ships. The slugs are read
31 * out of the module files rather than kept as a list here, so a module
32 * added later cannot leave its row behind; and only Free's, because
33 * xspeed-pro keeps its own rows under the same prefix and a Free uninstall
34 * is not a decision about Pro's settings.
35 *
36 * These have to go. The conflict-safe profile writes an explicit `false`
37 * into every one of them when another plugin owns the page cache, and
38 * seeding on the next install only fills ABSENT keys — so a row that
39 * survived uninstall kept every switch off on a site that had since been
40 * cleared, with no way back but the dashboard (PR #295 review).
41 */
42 foreach ( glob( __DIR__ . '/includes/modules/*/*Module.php' ) ?: array() as $module_file ) {
43 $source = @file_get_contents( $module_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- own plugin file, uninstall.
44 if ( is_string( $source ) && preg_match( "/const\s+SLUG\s*=\s*'([^']+)'/", $source, $m ) ) {
45 delete_option( 'xspeed_module_' . $m[1] );
46 }
47 }
48 delete_option( 'xspeed_data_version' );
49 delete_option( 'xspeed_activity_log' );
50 delete_option( 'xspeed_server_type' );
51 delete_option( 'xspeed_oc_dropin_synced' );
52 delete_option( 'xspeed_oc_generation' );
53 delete_option( 'xspeed_oc_sync_attempts' );
54 delete_option( 'xspeed_overridden_constants' );
55 delete_option( 'xspeed_last_mobile_separate' );
56 delete_option( 'xspeed_rules_inputs' );
57 // Per-user, so it needs the meta sweep rather than delete_option: every
58 // admin who ever confirmed they pasted the server rules has one.
59 if ( function_exists( 'delete_metadata' ) ) {
60 delete_metadata( 'user', 0, 'xspeed_nginx_rules_copied', '', true );
61 }
62 delete_option( 'xspeed_redirect_to_onboarding' );
63 delete_option( 'xspeed_preloader_firewall_block' );
64 delete_option( 'xspeed_onboarding_complete' );
65 // Provenance a host plugin wrote before it activated us, and the profile
66 // that install came up with.
67 delete_option( 'xspeed_installed_by' );
68 delete_option( 'xspeed_installer' );
69 delete_option( 'xspeed_install_profile' );
70 // Written by the copy-vendored Setup that host plugins used to carry
71 // before xSpeed seeded its own conflict-safe profile on activation. We no
72 // longer write it; an older host may have, and it is ours to clean up.
73 delete_option( 'xspeed_setup_snapshot' );
74 delete_option( 'xspeed_stats' );
75 // Where the scheduled database clean stopped. Kept here, above the
76 // filesystem early returns below, so a site whose filesystem cannot be
77 // reached still loses it.
78 delete_option( 'xspeed_db_clean_cursor' );
79 delete_option( 'xspeed_gc_cursor' );
80 delete_option( 'xspeed_mcp_rl_gen' );
81 wp_clear_scheduled_hook( 'xspeed_gc' );
82 delete_option( 'xspeed_cache_off_notice_dismissed' );
83
84 global $wpdb;
85
86 // Hit counters and the Hub attachment flag — ours, and simply never named
87 // here before. xspeed_hit_buffer is BOTH an option and a transient of the
88 // same name (Hit_Counter uses one string for the memory buffer and the
89 // durable counter), so both stores need clearing.
90 delete_option( 'xspeed_hit_buffer' );
91 delete_transient( 'xspeed_hit_buffer' );
92 delete_option( 'xspeed_hit_daily' );
93 delete_option( 'xspeed_hub_site_attached' );
94
95 // Usage-tracking state, which lives in the shared WP Insights rows rather
96 // than under our own prefix. Left behind, the consent key survives an
97 // uninstall: a REINSTALL then reads as already opted in before the wizard
98 // has asked anything, and a later deactivation posts a diagnostic payload
99 // for a consent this install never collected (#439).
100 //
101 // The two keyed rows are SHARED with sibling WPDeveloper plugins, so only
102 // our own key comes out and the row itself is deleted only once nothing
103 // else is using it. Deleting them outright would wipe another plugin's
104 // consent state.
105 foreach ( array( 'wpins_allow_tracking', 'wpins_last_track_time' ) as $shared ) {
106 $value = get_option( $shared );
107 if ( ! is_array( $value ) ) {
108 continue; // Absent, or a shape we did not write — leave it alone.
109 }
110 unset( $value['xspeed'] );
111 if ( empty( $value ) ) {
112 delete_option( $shared );
113 } else {
114 update_option( $shared, $value );
115 }
116 }
117 // The deactivation-feedback payload. Normally consumed-then-deleted by the
118 // tracker's own deactivation send, but that only happens when consent
119 // passes and the send succeeds — a user who deactivates without consent
120 // leaves both rows behind, and they are exactly the orphaned diagnostic
121 // payload #439 describes.
122 delete_option( 'wpins_deactivation_reason_xspeed' );
123 delete_option( 'wpins_deactivation_details_xspeed' );
124 // The tracker's recurring send. Cleared on opt-out, but nothing guarantees
125 // an opt-out ever happened before the uninstall.
126 wp_clear_scheduled_hook( 'xspeed_do_weekly_action' );
127 // Health's edge-mode probe: its pending run and its stored verdict.
128 wp_clear_scheduled_hook( 'xspeed_edge_mode_probe_refresh' );
129 delete_transient( 'xspeed_edge_mode_probe' );
130 delete_transient( 'xspeed_edge_mode_probe_token' );
131 // Our own WP Insights rows: the site id, the original URL, and the last
132 // payload — whose name embeds the site id. The payload names are
133 // derivable from the id, but a failed earlier uninstall or a renamed row
134 // shape would strand them, so sweep the prefix. `xspeed-pro`'s rows
135 // survive this only because its slug's HYPHEN doesn't match the
136 // underscore in `wpins_xspeed_%` — that separator is load-bearing.
137 delete_option( 'wpins_xspeed_site_id' );
138 delete_option( 'wpins_xspeed_original_url' );
139 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- options API has no prefix delete; uninstall only.
140 $wpins_rows = $wpdb->get_col(
141 $wpdb->prepare(
142 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
143 $wpdb->esc_like( 'wpins_xspeed_' ) . '%'
144 )
145 );
146 foreach ( (array) $wpins_rows as $wpins_row ) {
147 delete_option( $wpins_row );
148 }
149
150 // Score history — the plugin's own table, plus the legacy option the
151 // table was migrated from (kept on upgrade so a bad migration is
152 // recoverable; there is nothing to recover on uninstall).
153 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table, uninstall.
154 $wpdb->query( 'DROP TABLE IF EXISTS ' . $wpdb->prefix . 'xspeed_scores' );
155 delete_option( 'xspeed_score_schema' );
156 delete_option( 'xspeed_score_history' );
157
158 if ( ! function_exists( 'WP_Filesystem' ) ) {
159 require_once ABSPATH . 'wp-admin/includes/file.php';
160 }
161 WP_Filesystem();
162 global $wp_filesystem;
163 if ( ! $wp_filesystem ) {
164 return;
165 }
166
167 $cache_dir = WP_CONTENT_DIR . '/cache/xspeed';
168 if ( $wp_filesystem->is_dir( $cache_dir ) ) {
169 $wp_filesystem->delete( $cache_dir, true );
170 }
171
172 // nginx hit log (FBS-82478). It lives under uploads/xspeed/, NOT the
173 // cache dir, precisely so that a pasted nginx `access_log` directive
174 // pointing at it does NOT get its parent directory deleted here — if it
175 // did, `nginx -t` would fail [emerg] and refuse to (re)start, taking
176 // down EVERY vhost on the host until someone manually finds the orphaned
177 // directive. We therefore EMPTY the log file but DELIBERATELY LEAVE THE
178 // DIRECTORY in place, so any still-pasted directive keeps a valid,
179 // openable target after the plugin is gone. (A stray empty dir is
180 // harmless; a broken nginx is not.) Users are also warned in the admin
181 // UI to remove the snippet before uninstalling.
182 $hits_log = WP_CONTENT_DIR . '/uploads/xspeed/hits.log';
183 if ( function_exists( 'wp_upload_dir' ) ) {
184 $uploads = wp_upload_dir( null, false );
185 if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) {
186 $hits_log = rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed/hits.log';
187 }
188 }
189 if ( $wp_filesystem->exists( $hits_log ) ) {
190 // Truncate, don't delete the dir — keep the access_log target openable.
191 $wp_filesystem->put_contents( $hits_log, '', FS_CHMOD_FILE );
192 }
193
194 // Static-cache tree — separate from the flat-hash cache dir, holds
195 // the {host}/{path}/index.html files the .htaccess rewrite block
196 // serves directly. Same teardown rules as XSPEED_CACHE_DIR.
197 $static_dir = WP_CONTENT_DIR . '/cache/xspeed-static';
198 if ( $wp_filesystem->is_dir( $static_dir ) ) {
199 $wp_filesystem->delete( $static_dir, true );
200 }
201
202 // Rewrite block in site-root .htaccess. WP's marker helpers handle
203 // the "remove only our block" semantics — passing an empty array
204 // strips the markers in place.
205 $htaccess = ABSPATH . '.htaccess';
206 if ( $wp_filesystem->exists( $htaccess ) && $wp_filesystem->is_writable( $htaccess ) ) {
207 if ( ! function_exists( 'insert_with_markers' ) ) {
208 require_once ABSPATH . 'wp-admin/includes/misc.php';
209 }
210 insert_with_markers( $htaccess, 'xSpeed Static Cache', array() );
211 }
212
213 // Serialize the shared drop-in/config teardown with Cache::toggle(). If
214 // the lock is unavailable, leave shared state and its receipt untouched.
215 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.PHP.NoSilencedErrors.Discouraged -- flock requires a local handle; failure is fail-closed.
216 $ownership_lock = @fopen( WP_CONTENT_DIR . '/.xspeed-page-cache.lock', 'c+' );
217 if ( ! is_resource( $ownership_lock ) || ! flock( $ownership_lock, LOCK_EX ) ) {
218 return;
219 }
220
221 require_once __DIR__ . '/includes/wp-cache-constant.php';
222 $drop_in = WP_CONTENT_DIR . '/advanced-cache.php';
223 $dropin_ours = false;
224 if ( $wp_filesystem->exists( $drop_in ) ) {
225 $contents = $wp_filesystem->get_contents( $drop_in );
226 $dropin_ours = is_string( $contents ) && xspeed_has_canonical_dropin_signature( $contents );
227 if ( $dropin_ours ) {
228 $wp_filesystem->delete( $drop_in );
229 }
230 }
231
232 $wp_config = file_exists( ABSPATH . 'wp-config.php' ) ? ABSPATH . 'wp-config.php' : dirname( ABSPATH ) . '/wp-config.php';
233 // `defined()` alone, not `&& WP_CACHE`: the old truthiness test skipped
234 // the removal whenever the constant was false/0 — exactly the orphan we
235 // are here to clean up — while still entering it for TRUE/1, where the
236 // hardcoded-lowercase regex then matched nothing and reported success.
237 // Strip whatever spelling is there. (#9)
238 //
239 // Gated on the drop-in being ours: if another caching plugin holds
240 // advanced-cache.php, WP_CACHE is the switch that loads THEIR file, and
241 // stripping it on our way out would silently disable their page cache.
242 if ( $wp_filesystem->is_writable( $wp_config ) ) {
243 $config = $wp_filesystem->get_contents( $wp_config );
244 if ( is_string( $config ) ) {
245 // Same helper Cache::set_wp_cache_constant() uses — one pattern,
246 // one place. uninstall.php loads no plugin classes, hence a
247 // plain requirable function rather than a method.
248 require_once __DIR__ . '/includes/wp-cache-constant.php';
249 $receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' );
250 $marked = xspeed_wp_cache_receipt_matches( $config, $receipt );
251 /*
252 * A receipt proves WE wrote the line; it does not prove the line
253 * is still ours to remove. If a competitor has since taken over
254 * advanced-cache.php, WP_CACHE is what loads THEIR drop-in — they
255 * had no reason to touch an already-true define, so our receipt
256 * comment is still sitting beside it. Stripping on the receipt
257 * alone silently stopped their live page cache.
258 *
259 * A foreign drop-in therefore vetoes the removal outright, which
260 * is the same rule Cache::set_wp_cache_constant() applies in the
261 * running plugin; only this path was missing it. `$dropin_ours`
262 * covers the file we just deleted, `$marked` the case where there
263 * is no drop-in left at all.
264 */
265 $foreign_dropin = $wp_filesystem->exists( $drop_in ) && ! $dropin_ours;
266 if ( ! $foreign_dropin && ( $dropin_ours || $marked ) ) {
267 $config = xspeed_strip_wp_cache_define( $config );
268 $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE );
269 }
270 }
271 }
272 delete_option( 'xspeed_page_cache_ownership_receipt' );
273 flock( $ownership_lock, LOCK_UN );
274 fclose( $ownership_lock );
275 // Our own lock file, left in wp-content after everything else of ours is
276 // gone. Deleted last, after the handle is closed, so we are not
277 // unlinking a lock we are still inside. That ordering is hygiene, not a
278 // guarantee: a request already past the fopen would hold a handle to an
279 // unlinked inode. Harmless here — by this point the plugin is being
280 // removed and nothing will take the lock again.
281 $wp_filesystem->delete( WP_CONTENT_DIR . '/.xspeed-page-cache.lock' );
282 }
283