| @@ -14,11 +14,131 @@ | ||
| 14 | 14 | /** |
| 15 | 15 | * Run all uninstall cleanup. Wrapped in a function so locals don't pollute global scope. |
| 16 | 16 | */ |
| 17 | 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 | + */ | |
| 18 | 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_redirect_to_onboarding' ); | |
| 57 | + delete_option( 'xspeed_onboarding_complete' ); | |
| 58 | + // Provenance a host plugin wrote before it activated us, and the profile | |
| 59 | + // that install came up with. | |
| 60 | + delete_option( 'xspeed_installed_by' ); | |
| 61 | + delete_option( 'xspeed_installer' ); | |
| 62 | + delete_option( 'xspeed_install_profile' ); | |
| 63 | + // Written by the copy-vendored Setup that host plugins used to carry | |
| 64 | + // before xSpeed seeded its own conflict-safe profile on activation. We no | |
| 65 | + // longer write it; an older host may have, and it is ours to clean up. | |
| 66 | + delete_option( 'xspeed_setup_snapshot' ); | |
| 19 | 67 | delete_option( 'xspeed_stats' ); |
| 68 | + delete_option( 'xspeed_gc_cursor' ); | |
| 69 | + wp_clear_scheduled_hook( 'xspeed_gc' ); | |
| 20 | 70 | |
| 71 | + global $wpdb; | |
| 72 | + | |
| 73 | + // Hit counters and the Hub attachment flag — ours, and simply never named | |
| 74 | + // here before. xspeed_hit_buffer is BOTH an option and a transient of the | |
| 75 | + // same name (Hit_Counter uses one string for the memory buffer and the | |
| 76 | + // durable counter), so both stores need clearing. | |
| 77 | + delete_option( 'xspeed_hit_buffer' ); | |
| 78 | + delete_transient( 'xspeed_hit_buffer' ); | |
| 79 | + delete_option( 'xspeed_hit_daily' ); | |
| 80 | + delete_option( 'xspeed_hub_site_attached' ); | |
| 81 | + | |
| 82 | + // Usage-tracking state, which lives in the shared WP Insights rows rather | |
| 83 | + // than under our own prefix. Left behind, the consent key survives an | |
| 84 | + // uninstall: a REINSTALL then reads as already opted in before the wizard | |
| 85 | + // has asked anything, and a later deactivation posts a diagnostic payload | |
| 86 | + // for a consent this install never collected (#439). | |
| 87 | + // | |
| 88 | + // The two keyed rows are SHARED with sibling WPDeveloper plugins, so only | |
| 89 | + // our own key comes out and the row itself is deleted only once nothing | |
| 90 | + // else is using it. Deleting them outright would wipe another plugin's | |
| 91 | + // consent state. | |
| 92 | + foreach ( array( 'wpins_allow_tracking', 'wpins_last_track_time' ) as $shared ) { | |
| 93 | + $value = get_option( $shared ); | |
| 94 | + if ( ! is_array( $value ) ) { | |
| 95 | + continue; // Absent, or a shape we did not write — leave it alone. | |
| 96 | + } | |
| 97 | + unset( $value['xspeed'] ); | |
| 98 | + if ( empty( $value ) ) { | |
| 99 | + delete_option( $shared ); | |
| 100 | + } else { | |
| 101 | + update_option( $shared, $value ); | |
| 102 | + } | |
| 103 | + } | |
| 104 | + // The deactivation-feedback payload. Normally consumed-then-deleted by the | |
| 105 | + // tracker's own deactivation send, but that only happens when consent | |
| 106 | + // passes and the send succeeds — a user who deactivates without consent | |
| 107 | + // leaves both rows behind, and they are exactly the orphaned diagnostic | |
| 108 | + // payload #439 describes. | |
| 109 | + delete_option( 'wpins_deactivation_reason_xspeed' ); | |
| 110 | + delete_option( 'wpins_deactivation_details_xspeed' ); | |
| 111 | + // The tracker's recurring send. Cleared on opt-out, but nothing guarantees | |
| 112 | + // an opt-out ever happened before the uninstall. | |
| 113 | + wp_clear_scheduled_hook( 'xspeed_do_weekly_action' ); | |
| 114 | + // Our own WP Insights rows: the site id, the original URL, and the last | |
| 115 | + // payload — whose name embeds the site id. The payload names are | |
| 116 | + // derivable from the id, but a failed earlier uninstall or a renamed row | |
| 117 | + // shape would strand them, so sweep the prefix. `xspeed-pro`'s rows | |
| 118 | + // survive this only because its slug's HYPHEN doesn't match the | |
| 119 | + // underscore in `wpins_xspeed_%` — that separator is load-bearing. | |
| 120 | + delete_option( 'wpins_xspeed_site_id' ); | |
| 121 | + delete_option( 'wpins_xspeed_original_url' ); | |
| 122 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- options API has no prefix delete; uninstall only. | |
| 123 | + $wpins_rows = $wpdb->get_col( | |
| 124 | + $wpdb->prepare( | |
| 125 | + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", | |
| 126 | + $wpdb->esc_like( 'wpins_xspeed_' ) . '%' | |
| 127 | + ) | |
| 128 | + ); | |
| 129 | + foreach ( (array) $wpins_rows as $wpins_row ) { | |
| 130 | + delete_option( $wpins_row ); | |
| 131 | + } | |
| 132 | + | |
| 133 | + // Score history — the plugin's own table, plus the legacy option the | |
| 134 | + // table was migrated from (kept on upgrade so a bad migration is | |
| 135 | + // recoverable; there is nothing to recover on uninstall). | |
| 136 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table, uninstall. | |
| 137 | + $wpdb->query( 'DROP TABLE IF EXISTS ' . $wpdb->prefix . 'xspeed_scores' ); | |
| 138 | + delete_option( 'xspeed_score_schema' ); | |
| 139 | + delete_option( 'xspeed_score_history' ); | |
| 140 | + | |
| 21 | 141 | if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 22 | 142 | require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 23 | 143 | } |
| 24 | 144 | WP_Filesystem(); |
| @@ -31,8 +151,30 @@ | ||
| 31 | 151 | if ( $wp_filesystem->is_dir( $cache_dir ) ) { |
| 32 | 152 | $wp_filesystem->delete( $cache_dir, true ); |
| 33 | 153 | } |
| 34 | 154 | |
| 155 | + // nginx hit log (FBS-82478). It lives under uploads/xspeed/, NOT the | |
| 156 | + // cache dir, precisely so that a pasted nginx `access_log` directive | |
| 157 | + // pointing at it does NOT get its parent directory deleted here — if it | |
| 158 | + // did, `nginx -t` would fail [emerg] and refuse to (re)start, taking | |
| 159 | + // down EVERY vhost on the host until someone manually finds the orphaned | |
| 160 | + // directive. We therefore EMPTY the log file but DELIBERATELY LEAVE THE | |
| 161 | + // DIRECTORY in place, so any still-pasted directive keeps a valid, | |
| 162 | + // openable target after the plugin is gone. (A stray empty dir is | |
| 163 | + // harmless; a broken nginx is not.) Users are also warned in the admin | |
| 164 | + // UI to remove the snippet before uninstalling. | |
| 165 | + $hits_log = WP_CONTENT_DIR . '/uploads/xspeed/hits.log'; | |
| 166 | + if ( function_exists( 'wp_upload_dir' ) ) { | |
| 167 | + $uploads = wp_upload_dir( null, false ); | |
| 168 | + if ( is_array( $uploads ) && empty( $uploads['error'] ) && ! empty( $uploads['basedir'] ) ) { | |
| 169 | + $hits_log = rtrim( (string) $uploads['basedir'], '/' ) . '/xspeed/hits.log'; | |
| 170 | + } | |
| 171 | + } | |
| 172 | + if ( $wp_filesystem->exists( $hits_log ) ) { | |
| 173 | + // Truncate, don't delete the dir — keep the access_log target openable. | |
| 174 | + $wp_filesystem->put_contents( $hits_log, '', FS_CHMOD_FILE ); | |
| 175 | + } | |
| 176 | + | |
| 35 | 177 | // Static-cache tree — separate from the flat-hash cache dir, holds |
| 36 | 178 | // the {host}/{path}/index.html files the .htaccess rewrite block |
| 37 | 179 | // serves directly. Same teardown rules as XSPEED_CACHE_DIR. |
| 38 | 180 | $static_dir = WP_CONTENT_DIR . '/cache/xspeed-static'; |
| @@ -50,21 +192,74 @@ | ||
| 50 | 192 | } |
| 51 | 193 | insert_with_markers( $htaccess, 'xSpeed Static Cache', array() ); |
| 52 | 194 | } |
| 53 | 195 | |
| 54 | - $drop_in = WP_CONTENT_DIR . '/advanced-cache.php'; | |
| 196 | + // Serialize the shared drop-in/config teardown with Cache::toggle(). If | |
| 197 | + // the lock is unavailable, leave shared state and its receipt untouched. | |
| 198 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.PHP.NoSilencedErrors.Discouraged -- flock requires a local handle; failure is fail-closed. | |
| 199 | + $ownership_lock = @fopen( WP_CONTENT_DIR . '/.xspeed-page-cache.lock', 'c+' ); | |
| 200 | + if ( ! is_resource( $ownership_lock ) || ! flock( $ownership_lock, LOCK_EX ) ) { | |
| 201 | + return; | |
| 202 | + } | |
| 203 | + | |
| 204 | + require_once __DIR__ . '/includes/wp-cache-constant.php'; | |
| 205 | + $drop_in = WP_CONTENT_DIR . '/advanced-cache.php'; | |
| 206 | + $dropin_ours = false; | |
| 55 | 207 | if ( $wp_filesystem->exists( $drop_in ) ) { |
| 56 | - $contents = $wp_filesystem->get_contents( $drop_in ); | |
| 57 | - if ( is_string( $contents ) && false !== strpos( $contents, 'XSPEED_DROPIN' ) ) { | |
| 208 | + $contents = $wp_filesystem->get_contents( $drop_in ); | |
| 209 | + $dropin_ours = is_string( $contents ) && xspeed_has_canonical_dropin_signature( $contents ); | |
| 210 | + if ( $dropin_ours ) { | |
| 58 | 211 | $wp_filesystem->delete( $drop_in ); |
| 59 | 212 | } |
| 60 | 213 | } |
| 61 | 214 | |
| 62 | - $wp_config = ABSPATH . 'wp-config.php'; | |
| 63 | - if ( defined( 'WP_CACHE' ) && WP_CACHE && $wp_filesystem->is_writable( $wp_config ) ) { | |
| 215 | + $wp_config = file_exists( ABSPATH . 'wp-config.php' ) ? ABSPATH . 'wp-config.php' : dirname( ABSPATH ) . '/wp-config.php'; | |
| 216 | + // `defined()` alone, not `&& WP_CACHE`: the old truthiness test skipped | |
| 217 | + // the removal whenever the constant was false/0 — exactly the orphan we | |
| 218 | + // are here to clean up — while still entering it for TRUE/1, where the | |
| 219 | + // hardcoded-lowercase regex then matched nothing and reported success. | |
| 220 | + // Strip whatever spelling is there. (#9) | |
| 221 | + // | |
| 222 | + // Gated on the drop-in being ours: if another caching plugin holds | |
| 223 | + // advanced-cache.php, WP_CACHE is the switch that loads THEIR file, and | |
| 224 | + // stripping it on our way out would silently disable their page cache. | |
| 225 | + if ( $wp_filesystem->is_writable( $wp_config ) ) { | |
| 64 | 226 | $config = $wp_filesystem->get_contents( $wp_config ); |
| 65 | 227 | if ( is_string( $config ) ) { |
| 66 | - $config = preg_replace( "/define\\(\\s*['\"]WP_CACHE['\"]\\s*,\\s*true\\s*\\);\\s*\\n?/", '', $config ); | |
| 67 | - $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE ); | |
| 228 | + // Same helper Cache::set_wp_cache_constant() uses — one pattern, | |
| 229 | + // one place. uninstall.php loads no plugin classes, hence a | |
| 230 | + // plain requirable function rather than a method. | |
| 231 | + require_once __DIR__ . '/includes/wp-cache-constant.php'; | |
| 232 | + $receipt = get_option( 'xspeed_page_cache_ownership_receipt', '' ); | |
| 233 | + $marked = xspeed_wp_cache_receipt_matches( $config, $receipt ); | |
| 234 | + /* | |
| 235 | + * A receipt proves WE wrote the line; it does not prove the line | |
| 236 | + * is still ours to remove. If a competitor has since taken over | |
| 237 | + * advanced-cache.php, WP_CACHE is what loads THEIR drop-in — they | |
| 238 | + * had no reason to touch an already-true define, so our receipt | |
| 239 | + * comment is still sitting beside it. Stripping on the receipt | |
| 240 | + * alone silently stopped their live page cache. | |
| 241 | + * | |
| 242 | + * A foreign drop-in therefore vetoes the removal outright, which | |
| 243 | + * is the same rule Cache::set_wp_cache_constant() applies in the | |
| 244 | + * running plugin; only this path was missing it. `$dropin_ours` | |
| 245 | + * covers the file we just deleted, `$marked` the case where there | |
| 246 | + * is no drop-in left at all. | |
| 247 | + */ | |
| 248 | + $foreign_dropin = $wp_filesystem->exists( $drop_in ) && ! $dropin_ours; | |
| 249 | + if ( ! $foreign_dropin && ( $dropin_ours || $marked ) ) { | |
| 250 | + $config = xspeed_strip_wp_cache_define( $config ); | |
| 251 | + $wp_filesystem->put_contents( $wp_config, $config, FS_CHMOD_FILE ); | |
| 252 | + } | |
| 68 | 253 | } |
| 69 | 254 | } |
| 255 | + delete_option( 'xspeed_page_cache_ownership_receipt' ); | |
| 256 | + flock( $ownership_lock, LOCK_UN ); | |
| 257 | + fclose( $ownership_lock ); | |
| 258 | + // Our own lock file, left in wp-content after everything else of ours is | |
| 259 | + // gone. Deleted last, after the handle is closed, so we are not | |
| 260 | + // unlinking a lock we are still inside. That ordering is hygiene, not a | |
| 261 | + // guarantee: a request already past the fopen would hold a handle to an | |
| 262 | + // unlinked inode. Harmless here — by this point the plugin is being | |
| 263 | + // removed and nothing will take the lock again. | |
| 264 | + $wp_filesystem->delete( WP_CONTENT_DIR . '/.xspeed-page-cache.lock' ); | |
| 70 | 265 | } |