| 1 |
<?php |
| 2 |
/** |
| 3 |
* Object_Cache_Takeover — switch the object cache from another plugin to xSpeed. |
| 4 |
* |
| 5 |
* WordPress gives every object-cache plugin the same file, |
| 6 |
* wp-content/object-cache.php. Overwriting it while the owner is still active |
| 7 |
* starts a fight we lose: W3 Total Cache writes its own file back on the next |
| 8 |
* wp-admin request whenever it is missing, and LiteSpeed Cache copies its own |
| 9 |
* over any file whose md5 differs, on its next settings save or update. Every |
| 10 |
* one of them deletes only its OWN file on deactivation. (#686) |
| 11 |
* |
| 12 |
* So the switch takes the owner out of the picture first, and only then |
| 13 |
* installs ours: |
| 14 |
* |
| 15 |
* 1. Identify the owner and refuse up front where we cannot switch safely |
| 16 |
* (a hosting platform's drop-in, a must-use plugin, a plugin we have no |
| 17 |
* safe way to turn off). |
| 18 |
* 2. Test the connection. Nothing changes until it passes. |
| 19 |
* 3. Back up the current file (a copy). |
| 20 |
* 4. Deactivate an object-cache-only plugin, or turn off just the object |
| 21 |
* cache in LiteSpeed / W3TC through their own settings API. |
| 22 |
* 5. Remove a foreign file the owner left behind. |
| 23 |
* 6. Install ours (purge our namespace, wp-config block, drop-in). |
| 24 |
* 7. Verify ours is in place and the owner is still off. |
| 25 |
* 8. Any failure after step 3 puts everything back. |
| 26 |
* |
| 27 |
* A record of the switch lets xSpeed's Disable put the previous plugin back. |
| 28 |
* |
| 29 |
* @package XSpeed |
| 30 |
*/ |
| 31 |
|
| 32 |
declare(strict_types=1); |
| 33 |
|
| 34 |
namespace XSpeed; |
| 35 |
|
| 36 |
defined( 'ABSPATH' ) || exit; |
| 37 |
|
| 38 |
final class Object_Cache_Takeover { |
| 39 |
|
| 40 |
/** Site option recording the last switch, so Disable can restore it. */ |
| 41 |
public const RECORD_OPTION = 'xspeed_oc_takeover'; |
| 42 |
|
| 43 |
/** Deactivate the plugin; its own deactivation removes its file. */ |
| 44 |
public const STRATEGY_DEACTIVATE = 'deactivate'; |
| 45 |
|
| 46 |
/** Turn off only the plugin's object cache; its other features keep running. */ |
| 47 |
public const STRATEGY_FEATURE_OFF = 'feature_off'; |
| 48 |
|
| 49 |
/** Nobody active owns the file (a leftover); back it up and replace it. */ |
| 50 |
public const STRATEGY_REPLACE = 'replace'; |
| 51 |
|
| 52 |
/** Do not switch. The reason says where to turn it off instead. */ |
| 53 |
public const STRATEGY_REFUSE = 'refuse'; |
| 54 |
|
| 55 |
/** |
| 56 |
* Plugins whose only job is the object cache. Deactivating them is the |
| 57 |
* switch, and each one's deactivation removes its own drop-in. |
| 58 |
* |
| 59 |
* Only plugins whose cache namespace we can clear on their own are here. |
| 60 |
* While xSpeed runs, the outgoing plugin's keys go stale (the switch |
| 61 |
* itself leaves `active_plugins` without it in there), and restoring it |
| 62 |
* onto them makes it read itself as inactive. Object Cache Pro, WP Redis |
| 63 |
* and Docket Cache are switched off by hand instead: their own flush |
| 64 |
* empties the whole Redis database, or their key layout is not known. |
| 65 |
* (#687) |
| 66 |
*/ |
| 67 |
private const OBJECT_CACHE_ONLY = array( |
| 68 |
'redis-cache/redis-cache.php' => 'Redis Object Cache', |
| 69 |
); |
| 70 |
|
| 71 |
/** |
| 72 |
* Multi-feature plugins we can switch off the object cache in, leaving |
| 73 |
* their page cache and everything else running. |
| 74 |
*/ |
| 75 |
private const FEATURE_OFF = array( |
| 76 |
'litespeed-cache/litespeed-cache.php' => 'litespeed', |
| 77 |
'w3-total-cache/w3-total-cache.php' => 'w3tc', |
| 78 |
); |
| 79 |
|
| 80 |
/** |
| 81 |
* Plugin URIs the object-cache drop-ins carry in their own header, so a |
| 82 |
* leftover can be named after its plugin even when that plugin is no |
| 83 |
* longer active (the plugin catalog matches page-cache headers only). |
| 84 |
*/ |
| 85 |
private const KNOWN_DROPIN_URIS = array( |
| 86 |
'wordpress.org/plugins/redis-cache' => 'redis-cache/redis-cache.php', |
| 87 |
'objectcache.pro' => 'object-cache-pro/object-cache-pro.php', |
| 88 |
'wordpress.org/plugins/wp-redis' => 'wp-redis/wp-redis.php', |
| 89 |
'github.com/pantheon-systems/wp-redis' => 'wp-redis/wp-redis.php', |
| 90 |
'wordpress.org/plugins/docket-cache' => 'docket-cache/docket-cache.php', |
| 91 |
'docketcache.com' => 'docket-cache/docket-cache.php', |
| 92 |
); |
| 93 |
|
| 94 |
/** @var array|null Per-request memo of owner(). */ |
| 95 |
private static $owner = null; |
| 96 |
|
| 97 |
/** @var array|null The switch whose outgoing namespace is cleared at shutdown. */ |
| 98 |
private static $outgoing = null; |
| 99 |
|
| 100 |
/** |
| 101 |
* Who owns wp-content/object-cache.php, and how a switch would go. |
| 102 |
* |
| 103 |
* @return array{ |
| 104 |
* state: string, // none|ours|foreign |
| 105 |
* label: string, // who it belongs to, for messages |
| 106 |
* plugin: string, // owning plugin basename, or '' |
| 107 |
* active: bool, // owning plugin is active |
| 108 |
* strategy: string, // one of the STRATEGY_* constants ('' unless foreign) |
| 109 |
* reason: string, // why a refuse refuses, '' otherwise |
| 110 |
* plan: string[] // the steps a switch will take, for the confirmation |
| 111 |
* } |
| 112 |
*/ |
| 113 |
public static function owner(): array { |
| 114 |
if ( null !== self::$owner ) { |
| 115 |
return self::$owner; |
| 116 |
} |
| 117 |
|
| 118 |
$path = self::dropin_path(); |
| 119 |
$empty = array( |
| 120 |
'state' => 'none', |
| 121 |
'label' => '', |
| 122 |
'plugin' => '', |
| 123 |
'active' => false, |
| 124 |
'strategy' => '', |
| 125 |
'reason' => '', |
| 126 |
'plan' => array(), |
| 127 |
); |
| 128 |
if ( '' === $path || ( ! file_exists( $path ) && ! is_link( $path ) ) ) { |
| 129 |
return self::$owner = $empty; |
| 130 |
} |
| 131 |
if ( Object_Cache::is_our_dropin_present() ) { |
| 132 |
return self::$owner = array_merge( $empty, array( 'state' => 'ours' ) ); |
| 133 |
} |
| 134 |
|
| 135 |
$contents = (string) @file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- read-only ownership check of a local file; an unreadable one reads as unknown. |
| 136 |
$plugin = self::owner_plugin( $path, $contents ); |
| 137 |
$label = '' !== $plugin ? self::plugin_label( $plugin ) : self::header_label( $contents ); |
| 138 |
$active = '' !== $plugin && self::plugin_active( $plugin ); |
| 139 |
|
| 140 |
$owner = array_merge( |
| 141 |
$empty, |
| 142 |
array( |
| 143 |
'state' => 'foreign', |
| 144 |
'label' => '' !== $label ? $label : 'object-cache.php', |
| 145 |
'plugin' => $plugin, |
| 146 |
'active' => $active, |
| 147 |
) |
| 148 |
); |
| 149 |
|
| 150 |
list( $owner['strategy'], $owner['reason'] ) = self::strategy( $owner ); |
| 151 |
$owner['plan'] = self::plan( $owner ); |
| 152 |
|
| 153 |
return self::$owner = $owner; |
| 154 |
} |
| 155 |
|
| 156 |
/** |
| 157 |
* Run the switch. Callers have already established that a foreign drop-in |
| 158 |
* is present and that the user asked for the switch. |
| 159 |
* |
| 160 |
* @param array $opts Object-cache settings. |
| 161 |
* @return array{ok:bool,message:string,steps:array<string,bool>,test?:array,owner:array,detect:array} |
| 162 |
*/ |
| 163 |
public static function run( array $opts ): array { |
| 164 |
self::$owner = null; |
| 165 |
$owner = self::owner(); |
| 166 |
$steps = array( |
| 167 |
'connection' => false, |
| 168 |
'backup' => false, |
| 169 |
'owner_off' => false, |
| 170 |
'leftover' => false, |
| 171 |
'wp_config' => false, |
| 172 |
'drop_in' => false, |
| 173 |
'verified' => false, |
| 174 |
); |
| 175 |
$fail = static function ( string $message, array $steps, array $extra = array() ) use ( $owner ): array { |
| 176 |
return array_merge( |
| 177 |
array( |
| 178 |
'ok' => false, |
| 179 |
'message' => $message, |
| 180 |
'steps' => $steps, |
| 181 |
'owner' => $owner, |
| 182 |
'detect' => Object_Cache::detect( true ), |
| 183 |
), |
| 184 |
$extra |
| 185 |
); |
| 186 |
}; |
| 187 |
|
| 188 |
if ( 'foreign' !== $owner['state'] ) { |
| 189 |
return $fail( __( 'There is no other object cache to switch from.', 'xspeed' ), $steps ); |
| 190 |
} |
| 191 |
if ( self::STRATEGY_REFUSE === $owner['strategy'] ) { |
| 192 |
return $fail( $owner['reason'], $steps ); |
| 193 |
} |
| 194 |
if ( ! self::can_manage( $owner ) ) { |
| 195 |
return $fail( |
| 196 |
/* translators: %s: plugin name. */ |
| 197 |
sprintf( __( 'You do not have permission to turn off %s.', 'xspeed' ), $owner['label'] ), |
| 198 |
$steps |
| 199 |
); |
| 200 |
} |
| 201 |
|
| 202 |
// 1. Nothing changes until the cache server answers. |
| 203 |
$test = Object_Cache::test_connection( $opts ); |
| 204 |
if ( empty( $test['ok'] ) ) { |
| 205 |
/* translators: %s: connection error. */ |
| 206 |
return $fail( sprintf( __( 'Could not switch: %s', 'xspeed' ), (string) $test['message'] ), $steps, array( 'test' => $test ) ); |
| 207 |
} |
| 208 |
$steps['connection'] = true; |
| 209 |
|
| 210 |
// 2. A copy, so the original stays in place until the owner is off. |
| 211 |
$backup = self::backup(); |
| 212 |
if ( '' === $backup ) { |
| 213 |
return $fail( __( 'Could not back up the current object-cache.php, so nothing was changed.', 'xspeed' ), $steps, array( 'test' => $test ) ); |
| 214 |
} |
| 215 |
$steps['backup'] = true; |
| 216 |
|
| 217 |
$record = array( |
| 218 |
'plugin' => $owner['plugin'], |
| 219 |
'label' => $owner['label'], |
| 220 |
'strategy' => $owner['strategy'], |
| 221 |
'network' => self::network_active( $owner['plugin'] ), |
| 222 |
'backup' => $backup, |
| 223 |
'at' => time(), |
| 224 |
); |
| 225 |
|
| 226 |
// 3. The owner goes first, so it cannot put its file back over ours. |
| 227 |
if ( ! self::owner_off( $record ) ) { |
| 228 |
$problem = self::rollback( $record, $steps ); |
| 229 |
return $fail( |
| 230 |
'' === $problem |
| 231 |
/* translators: %s: plugin name. */ |
| 232 |
? sprintf( __( 'Could not turn off %s, so nothing was changed.', 'xspeed' ), $owner['label'] ) |
| 233 |
/* translators: %s: plugin name. */ |
| 234 |
: sprintf( __( 'Could not turn off %s.', 'xspeed' ), $owner['label'] ) . ' ' . self::put_back_message( $record, $problem ), |
| 235 |
$steps, |
| 236 |
array( 'test' => $test ) |
| 237 |
); |
| 238 |
} |
| 239 |
$steps['owner_off'] = true; |
| 240 |
|
| 241 |
// 4. Owners that do not clean up (a symlink, a plain drop-in) leave |
| 242 |
// their file; we hold a backup of it. |
| 243 |
if ( ! self::remove_foreign() ) { |
| 244 |
$problem = self::rollback( $record, $steps ); |
| 245 |
return $fail( |
| 246 |
'' === $problem |
| 247 |
? __( 'Could not remove the old object-cache.php, so the switch was undone.', 'xspeed' ) |
| 248 |
: __( 'Could not remove the old object-cache.php.', 'xspeed' ) . ' ' . self::put_back_message( $record, $problem ), |
| 249 |
$steps, |
| 250 |
array( 'test' => $test ) |
| 251 |
); |
| 252 |
} |
| 253 |
$steps['leftover'] = true; |
| 254 |
|
| 255 |
// 5. Ours, through the same path a plain enable takes. |
| 256 |
$installed = Object_Cache::install( $opts, $test ); |
| 257 |
$steps['wp_config'] = ! empty( $installed['steps']['wp_config'] ); |
| 258 |
$steps['drop_in'] = ! empty( $installed['steps']['drop_in'] ); |
| 259 |
|
| 260 |
// 6. Ours is on disk and the owner is still off. |
| 261 |
$steps['verified'] = ! empty( $installed['ok'] ) |
| 262 |
&& Object_Cache::is_our_dropin_present() |
| 263 |
&& self::owner_is_off( $record ); |
| 264 |
|
| 265 |
if ( ! $steps['verified'] ) { |
| 266 |
$problem = self::rollback( $record, $steps ); |
| 267 |
$why = empty( $installed['ok'] ) ? ' ' . (string) $installed['message'] : ''; |
| 268 |
return $fail( |
| 269 |
'' === $problem |
| 270 |
? __( 'xSpeed could not take over the object cache, so the previous setup was put back.', 'xspeed' ) . $why |
| 271 |
: __( 'xSpeed could not take over the object cache.', 'xspeed' ) . $why . ' ' . self::put_back_message( $record, $problem ), |
| 272 |
$steps, |
| 273 |
array( 'test' => $test ) |
| 274 |
); |
| 275 |
} |
| 276 |
|
| 277 |
// A leftover is not offered back: its plugin is gone, and the file |
| 278 |
// may load code that went with it. The backup stays in uploads. |
| 279 |
if ( self::STRATEGY_REPLACE === $record['strategy'] ) { |
| 280 |
self::forget(); |
| 281 |
} else { |
| 282 |
update_site_option( self::RECORD_OPTION, $record ); |
| 283 |
} |
| 284 |
self::$owner = null; |
| 285 |
|
| 286 |
/* |
| 287 |
* The previous plugin's object cache is still the one loaded in this |
| 288 |
* request, and in any request already running, so options written |
| 289 |
* from here on (`active_plugins` without it, first of all) land in |
| 290 |
* ITS namespace. Clear that namespace once this request is done -- |
| 291 |
* scoped to its own keys, never its wp_cache_flush(), which empties |
| 292 |
* the whole database for LiteSpeed and Redis Object Cache. Requests |
| 293 |
* still in flight can write after this, so restore and Disable clear |
| 294 |
* it again before the plugin is back; that one is authoritative. |
| 295 |
*/ |
| 296 |
if ( self::STRATEGY_REPLACE !== $record['strategy'] ) { |
| 297 |
self::$outgoing = $record; |
| 298 |
add_action( 'shutdown', array( __CLASS__, 'clean_outgoing_namespace' ), PHP_INT_MAX ); |
| 299 |
} |
| 300 |
|
| 301 |
return array( |
| 302 |
'ok' => true, |
| 303 |
'message' => self::STRATEGY_REPLACE === $record['strategy'] |
| 304 |
? sprintf( |
| 305 |
/* translators: %s: plugin name or file name. */ |
| 306 |
__( 'Replaced the object-cache.php left by %s (a copy is in uploads/xspeed-backups/). xSpeed now handles object caching.', 'xspeed' ), |
| 307 |
$owner['label'] |
| 308 |
) |
| 309 |
: sprintf( |
| 310 |
/* translators: %s: plugin name. */ |
| 311 |
__( 'Object caching switched from %s to xSpeed. Disable can put it back.', 'xspeed' ), |
| 312 |
$owner['label'] |
| 313 |
), |
| 314 |
'steps' => $steps, |
| 315 |
'test' => $test, |
| 316 |
'owner' => $owner, |
| 317 |
'detect' => Object_Cache::detect( true ), |
| 318 |
); |
| 319 |
} |
| 320 |
|
| 321 |
/** |
| 322 |
* Put the plugin xSpeed switched from back in charge. Runs after |
| 323 |
* Object_Cache::disable() has removed our drop-in. |
| 324 |
* |
| 325 |
* @return array{ok:bool,message:string} |
| 326 |
*/ |
| 327 |
public static function restore(): array { |
| 328 |
$record = self::record(); |
| 329 |
if ( null === $record ) { |
| 330 |
return array( |
| 331 |
'ok' => false, |
| 332 |
'message' => __( 'There is no previous object cache to restore.', 'xspeed' ), |
| 333 |
); |
| 334 |
} |
| 335 |
|
| 336 |
$problem = self::put_back( $record ); |
| 337 |
delete_site_option( self::RECORD_OPTION ); |
| 338 |
self::$owner = null; |
| 339 |
|
| 340 |
return array( |
| 341 |
'ok' => '' === $problem, |
| 342 |
'message' => '' === $problem |
| 343 |
/* translators: %s: plugin name. */ |
| 344 |
? sprintf( __( '%s is handling object caching again.', 'xspeed' ), $record['label'] ) |
| 345 |
: self::put_back_message( $record, $problem ), |
| 346 |
); |
| 347 |
} |
| 348 |
|
| 349 |
/** |
| 350 |
* The recorded switch, or null. |
| 351 |
* |
| 352 |
* @return array{plugin:string,label:string,strategy:string,network:bool,backup:string,at:int}|null |
| 353 |
*/ |
| 354 |
public static function record(): ?array { |
| 355 |
$record = get_site_option( self::RECORD_OPTION, null ); |
| 356 |
if ( ! is_array( $record ) || empty( $record['strategy'] ) || empty( $record['label'] ) ) { |
| 357 |
return null; |
| 358 |
} |
| 359 |
return array( |
| 360 |
'plugin' => (string) ( $record['plugin'] ?? '' ), |
| 361 |
'label' => (string) $record['label'], |
| 362 |
'strategy' => (string) $record['strategy'], |
| 363 |
'network' => ! empty( $record['network'] ), |
| 364 |
'backup' => (string) ( $record['backup'] ?? '' ), |
| 365 |
'at' => (int) ( $record['at'] ?? 0 ), |
| 366 |
); |
| 367 |
} |
| 368 |
|
| 369 |
/** |
| 370 |
* Why enable() refused, worded for the owner it found. A leftover is not |
| 371 |
* "handling" anything and there is nothing to put back later. (#687) |
| 372 |
* |
| 373 |
* @param array $owner owner() record. |
| 374 |
* @return string |
| 375 |
*/ |
| 376 |
public static function refusal_message( array $owner ): string { |
| 377 |
if ( self::STRATEGY_REFUSE === $owner['strategy'] ) { |
| 378 |
return $owner['reason']; |
| 379 |
} |
| 380 |
if ( self::STRATEGY_REPLACE === $owner['strategy'] ) { |
| 381 |
return sprintf( |
| 382 |
/* translators: %s: plugin name or file name. */ |
| 383 |
__( 'wp-content/object-cache.php was left by %s, which is not active. Switch to xSpeed to replace it (a copy is kept in uploads/xspeed-backups/).', 'xspeed' ), |
| 384 |
$owner['label'] |
| 385 |
); |
| 386 |
} |
| 387 |
return sprintf( |
| 388 |
/* translators: %s: plugin name. */ |
| 389 |
__( '%s handles object caching on this site. Switch to xSpeed to take it over; Disable can put it back later.', 'xspeed' ), |
| 390 |
$owner['label'] |
| 391 |
); |
| 392 |
} |
| 393 |
|
| 394 |
/** Shutdown after a switch: clear the outgoing plugin's own keys. */ |
| 395 |
public static function clean_outgoing_namespace(): void { |
| 396 |
if ( null !== self::$outgoing ) { |
| 397 |
self::clean_namespace( self::$outgoing ); |
| 398 |
self::$outgoing = null; |
| 399 |
} |
| 400 |
} |
| 401 |
|
| 402 |
/** Forget the recorded switch without acting on it. */ |
| 403 |
public static function forget(): void { |
| 404 |
delete_site_option( self::RECORD_OPTION ); |
| 405 |
} |
| 406 |
|
| 407 |
/** |
| 408 |
* xSpeed was disabled without putting the previous plugin back. Clear its |
| 409 |
* namespace anyway, so turning it on by hand later does not start from |
| 410 |
* the snapshot the switch left there, then forget the switch. |
| 411 |
*/ |
| 412 |
public static function discard(): void { |
| 413 |
$record = self::record(); |
| 414 |
if ( null !== $record ) { |
| 415 |
self::clean_namespace( $record ); |
| 416 |
} |
| 417 |
self::forget(); |
| 418 |
} |
| 419 |
|
| 420 |
/** Drop the per-request memo (tests, and after the file changes). */ |
| 421 |
public static function reset(): void { |
| 422 |
self::$owner = null; |
| 423 |
} |
| 424 |
|
| 425 |
// ─────────────────────────── Ownership ─────────────────────────── |
| 426 |
|
| 427 |
/** |
| 428 |
* Basename of the plugin that owns the drop-in, or ''. |
| 429 |
* |
| 430 |
* Evidence in order of strength: a symlink into a plugin's folder, a |
| 431 |
* byte-identical copy of a template an active plugin ships, the drop-in's |
| 432 |
* Plugin URI matching an active plugin's, then the catalog's tokens. |
| 433 |
* |
| 434 |
* @param string $path Drop-in path. |
| 435 |
* @param string $contents Drop-in contents. |
| 436 |
* @return string |
| 437 |
*/ |
| 438 |
private static function owner_plugin( string $path, string $contents ): string { |
| 439 |
$plugins_dir = defined( 'WP_PLUGIN_DIR' ) ? rtrim( str_replace( '\\', '/', (string) WP_PLUGIN_DIR ), '/' ) : ''; |
| 440 |
|
| 441 |
if ( is_link( $path ) && '' !== $plugins_dir ) { |
| 442 |
$target = realpath( $path ); |
| 443 |
$target = false !== $target ? str_replace( '\\', '/', $target ) : ''; |
| 444 |
if ( '' !== $target && 0 === strpos( $target, $plugins_dir . '/' ) ) { |
| 445 |
$folder = strtok( substr( $target, strlen( $plugins_dir . '/' ) ), '/' ); |
| 446 |
$match = self::plugin_in_folder( (string) $folder ); |
| 447 |
if ( '' !== $match ) { |
| 448 |
return $match; |
| 449 |
} |
| 450 |
} |
| 451 |
} |
| 452 |
|
| 453 |
$uri = self::header_value( $contents, 'Plugin URI' ); |
| 454 |
$hash = '' !== $contents ? md5( $contents ) : ''; |
| 455 |
foreach ( self::active_plugins() as $plugin ) { |
| 456 |
$folder = dirname( $plugin ); |
| 457 |
if ( '.' === $folder || '' === $plugins_dir ) { |
| 458 |
continue; |
| 459 |
} |
| 460 |
foreach ( self::templates( $plugins_dir . '/' . $folder ) as $template ) { |
| 461 |
if ( '' !== $hash && md5_file( $template ) === $hash ) { |
| 462 |
return $plugin; |
| 463 |
} |
| 464 |
} |
| 465 |
if ( '' !== $uri ) { |
| 466 |
$main = self::header_value( (string) @file_get_contents( $plugins_dir . '/' . $plugin, false, null, 0, 8192 ), 'Plugin URI' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- reading a local plugin header. |
| 467 |
if ( '' !== $main && rtrim( $main, '/' ) === rtrim( $uri, '/' ) ) { |
| 468 |
return $plugin; |
| 469 |
} |
| 470 |
} |
| 471 |
} |
| 472 |
|
| 473 |
if ( '' !== $uri ) { |
| 474 |
foreach ( self::KNOWN_DROPIN_URIS as $needle => $plugin ) { |
| 475 |
if ( false !== stripos( $uri, $needle ) ) { |
| 476 |
return $plugin; |
| 477 |
} |
| 478 |
} |
| 479 |
} |
| 480 |
|
| 481 |
if ( class_exists( __NAMESPACE__ . '\\Cache_Plugin_Catalog' ) ) { |
| 482 |
$hit = Cache_Plugin_Catalog::identify_object_dropin( $contents ); |
| 483 |
if ( null !== $hit && 'xspeed/xspeed.php' !== $hit ) { |
| 484 |
return $hit; |
| 485 |
} |
| 486 |
} |
| 487 |
return ''; |
| 488 |
} |
| 489 |
|
| 490 |
/** |
| 491 |
* Files named like an object-cache template inside a plugin folder, |
| 492 |
* three levels deep at most. Only runs while a foreign drop-in exists. |
| 493 |
* |
| 494 |
* @param string $dir Plugin folder. |
| 495 |
* @return string[] |
| 496 |
*/ |
| 497 |
private static function templates( string $dir ): array { |
| 498 |
if ( ! is_dir( $dir ) ) { |
| 499 |
return array(); |
| 500 |
} |
| 501 |
$found = array(); |
| 502 |
foreach ( array( '/', '/*/', '/*/*/' ) as $depth ) { |
| 503 |
foreach ( (array) glob( $dir . $depth . '*object-cache*.php' ) as $file ) { |
| 504 |
if ( is_string( $file ) && is_file( $file ) ) { |
| 505 |
$found[] = $file; |
| 506 |
} |
| 507 |
} |
| 508 |
} |
| 509 |
return $found; |
| 510 |
} |
| 511 |
|
| 512 |
/** |
| 513 |
* Why a switch must not happen, and which way it would go otherwise. |
| 514 |
* |
| 515 |
* @param array $owner Partial owner record. |
| 516 |
* @return array{0:string,1:string} Strategy and reason. |
| 517 |
*/ |
| 518 |
private static function strategy( array $owner ): array { |
| 519 |
$platform = self::platform(); |
| 520 |
if ( '' !== $platform ) { |
| 521 |
return array( |
| 522 |
self::STRATEGY_REFUSE, |
| 523 |
/* translators: %s: hosting platform name. */ |
| 524 |
sprintf( __( '%s manages the object cache on this site and would put its own back. Turn it off in your hosting dashboard if you want xSpeed to handle it.', 'xspeed' ), $platform ), |
| 525 |
); |
| 526 |
} |
| 527 |
|
| 528 |
$plugin = $owner['plugin']; |
| 529 |
if ( '' !== $plugin && $owner['active'] ) { |
| 530 |
if ( isset( self::OBJECT_CACHE_ONLY[ $plugin ] ) ) { |
| 531 |
return array( self::STRATEGY_DEACTIVATE, '' ); |
| 532 |
} |
| 533 |
if ( isset( self::FEATURE_OFF[ $plugin ] ) && self::adapter_available( self::FEATURE_OFF[ $plugin ] ) ) { |
| 534 |
return array( self::STRATEGY_FEATURE_OFF, '' ); |
| 535 |
} |
| 536 |
return array( |
| 537 |
self::STRATEGY_REFUSE, |
| 538 |
/* translators: %s: plugin name. */ |
| 539 |
sprintf( __( 'Turn off the object cache in %s first, then enable it here.', 'xspeed' ), $owner['label'] ), |
| 540 |
); |
| 541 |
} |
| 542 |
|
| 543 |
if ( '' === $plugin ) { |
| 544 |
$mu = self::mu_plugin_owner(); |
| 545 |
if ( '' !== $mu ) { |
| 546 |
return array( |
| 547 |
self::STRATEGY_REFUSE, |
| 548 |
/* translators: %s: must-use plugin file name. */ |
| 549 |
sprintf( __( 'The must-use plugin %s manages object-cache.php and would put it back. Ask your host before switching.', 'xspeed' ), $mu ), |
| 550 |
); |
| 551 |
} |
| 552 |
} |
| 553 |
|
| 554 |
// Owned by nobody active: a file left behind by a plugin that is gone |
| 555 |
// or switched off. |
| 556 |
return array( self::STRATEGY_REPLACE, '' ); |
| 557 |
} |
| 558 |
|
| 559 |
/** |
| 560 |
* The steps a switch will take, worded for the confirmation. |
| 561 |
* |
| 562 |
* @param array $owner Owner record. |
| 563 |
* @return string[] |
| 564 |
*/ |
| 565 |
private static function plan( array $owner ): array { |
| 566 |
if ( self::STRATEGY_REFUSE === $owner['strategy'] ) { |
| 567 |
return array(); |
| 568 |
} |
| 569 |
$plan = array( |
| 570 |
__( 'Test the connection to your cache server. Nothing changes if it fails.', 'xspeed' ), |
| 571 |
__( 'Back up the current object-cache.php to wp-content/uploads/xspeed-backups/.', 'xspeed' ), |
| 572 |
); |
| 573 |
if ( self::STRATEGY_DEACTIVATE === $owner['strategy'] ) { |
| 574 |
/* translators: %s: plugin name. */ |
| 575 |
$plan[] = sprintf( __( 'Deactivate %s. It removes its own object-cache.php as it goes.', 'xspeed' ), $owner['label'] ); |
| 576 |
} elseif ( self::STRATEGY_FEATURE_OFF === $owner['strategy'] ) { |
| 577 |
/* translators: %s: plugin name. */ |
| 578 |
$plan[] = sprintf( __( 'Turn off Object Cache in %s. Its other features keep running.', 'xspeed' ), $owner['label'] ); |
| 579 |
} else { |
| 580 |
/* translators: %s: plugin name or file name. */ |
| 581 |
$plan[] = sprintf( __( 'Remove the object-cache.php left by %s, which is not active.', 'xspeed' ), $owner['label'] ); |
| 582 |
} |
| 583 |
$plan[] = __( "Install xSpeed's object cache and check it is in place.", 'xspeed' ); |
| 584 |
$plan[] = __( 'If any step fails, put everything back as it was.', 'xspeed' ); |
| 585 |
return $plan; |
| 586 |
} |
| 587 |
|
| 588 |
/** |
| 589 |
* A hosting platform that ships its own object cache, or ''. |
| 590 |
* |
| 591 |
* @return string |
| 592 |
*/ |
| 593 |
private static function platform(): string { |
| 594 |
if ( defined( 'WPE_APIKEY' ) || class_exists( 'WpeCommon' ) ) { |
| 595 |
return 'WP Engine'; |
| 596 |
} |
| 597 |
if ( ( defined( 'IS_ATOMIC' ) && constant( 'IS_ATOMIC' ) ) || defined( 'WPCOMSH_VERSION' ) ) { |
| 598 |
return 'WordPress.com'; |
| 599 |
} |
| 600 |
if ( defined( 'IS_PRESSABLE' ) && constant( 'IS_PRESSABLE' ) ) { |
| 601 |
return 'Pressable'; |
| 602 |
} |
| 603 |
if ( defined( 'PANTHEON_ENVIRONMENT' ) || false !== getenv( 'PANTHEON_ENVIRONMENT' ) ) { |
| 604 |
return 'Pantheon'; |
| 605 |
} |
| 606 |
return ''; |
| 607 |
} |
| 608 |
|
| 609 |
/** |
| 610 |
* A must-use plugin that mentions object-cache.php, or ''. Hosts install |
| 611 |
* their object cache that way; deactivating one is not possible. |
| 612 |
* |
| 613 |
* @return string |
| 614 |
*/ |
| 615 |
private static function mu_plugin_owner(): string { |
| 616 |
$dir = defined( 'WPMU_PLUGIN_DIR' ) ? (string) WPMU_PLUGIN_DIR : ''; |
| 617 |
if ( '' === $dir || ! is_dir( $dir ) ) { |
| 618 |
return ''; |
| 619 |
} |
| 620 |
foreach ( (array) glob( $dir . '/*.php' ) as $file ) { |
| 621 |
if ( ! is_string( $file ) || filesize( $file ) > 512000 ) { |
| 622 |
continue; |
| 623 |
} |
| 624 |
$code = (string) @file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- reading a local mu-plugin. |
| 625 |
if ( false !== strpos( $code, 'object-cache.php' ) ) { |
| 626 |
return basename( $file ); |
| 627 |
} |
| 628 |
} |
| 629 |
return ''; |
| 630 |
} |
| 631 |
|
| 632 |
// ───────────────────────── Owner on / off ───────────────────────── |
| 633 |
|
| 634 |
/** |
| 635 |
* Take the owner out of the picture. |
| 636 |
* |
| 637 |
* @param array $record Switch record. |
| 638 |
* @return bool |
| 639 |
*/ |
| 640 |
private static function owner_off( array $record ): bool { |
| 641 |
switch ( $record['strategy'] ) { |
| 642 |
case self::STRATEGY_DEACTIVATE: |
| 643 |
self::load_plugin_api(); |
| 644 |
// Silent is false on purpose: the plugin's deactivation hook is |
| 645 |
// what removes its drop-in (and Redis Object Cache flushes). |
| 646 |
deactivate_plugins( $record['plugin'], false, $record['network'] ); |
| 647 |
return ! self::plugin_active( $record['plugin'] ); |
| 648 |
case self::STRATEGY_FEATURE_OFF: |
| 649 |
return self::adapter( self::FEATURE_OFF[ $record['plugin'] ] ?? '', false ); |
| 650 |
case self::STRATEGY_REPLACE: |
| 651 |
return true; |
| 652 |
} |
| 653 |
return false; |
| 654 |
} |
| 655 |
|
| 656 |
/** |
| 657 |
* Whether the owner is still out of the picture. |
| 658 |
* |
| 659 |
* @param array $record Switch record. |
| 660 |
* @return bool |
| 661 |
*/ |
| 662 |
private static function owner_is_off( array $record ): bool { |
| 663 |
switch ( $record['strategy'] ) { |
| 664 |
case self::STRATEGY_DEACTIVATE: |
| 665 |
return ! self::plugin_active( $record['plugin'] ); |
| 666 |
case self::STRATEGY_FEATURE_OFF: |
| 667 |
return ! self::adapter_enabled( self::FEATURE_OFF[ $record['plugin'] ] ?? '' ); |
| 668 |
} |
| 669 |
return true; |
| 670 |
} |
| 671 |
|
| 672 |
/** |
| 673 |
* Put the owner and its file back. Used by rollback and by restore. |
| 674 |
* |
| 675 |
* Order matters. Ours goes first, so a plugin that writes its drop-in on |
| 676 |
* activation finds the slot empty. Then the owner's namespace is cleared, |
| 677 |
* while nothing can be writing to it: it holds a snapshot from the switch |
| 678 |
* (`active_plugins` without it, from requests that were already running), |
| 679 |
* and a plugin restored onto that reads itself as inactive. Then the |
| 680 |
* owner, and its file only once the owner is back: a drop-in loads its |
| 681 |
* plugin's code, so putting it back for a plugin deleted in the meantime |
| 682 |
* would fatal every request. |
| 683 |
* |
| 684 |
* @param array $record Switch record. |
| 685 |
* @return string '' when everything is back, otherwise what is not: |
| 686 |
* 'ours' (our drop-in would not go), 'owner' (the plugin or |
| 687 |
* its object cache would not come back), 'file' (its |
| 688 |
* drop-in could not be put back). |
| 689 |
*/ |
| 690 |
private static function put_back( array $record ): string { |
| 691 |
if ( Object_Cache::is_our_dropin_present() && ! Object_Cache::remove_dropin() ) { |
| 692 |
return 'ours'; |
| 693 |
} |
| 694 |
|
| 695 |
self::clean_namespace( $record ); |
| 696 |
|
| 697 |
$back = false; |
| 698 |
switch ( $record['strategy'] ) { |
| 699 |
case self::STRATEGY_DEACTIVATE: |
| 700 |
self::load_plugin_api(); |
| 701 |
if ( '' !== $record['plugin'] && ! self::plugin_active( $record['plugin'] ) ) { |
| 702 |
// Silent: this restores the state of moments (or one |
| 703 |
// switch) ago, not a fresh install, so the plugin's |
| 704 |
// first-run side effects (redirects, notices) do not apply. |
| 705 |
$result = activate_plugin( $record['plugin'], '', $record['network'], true ); |
| 706 |
$back = ! is_wp_error( $result ) && self::plugin_active( $record['plugin'] ); |
| 707 |
} else { |
| 708 |
$back = '' !== $record['plugin']; |
| 709 |
} |
| 710 |
break; |
| 711 |
case self::STRATEGY_FEATURE_OFF: |
| 712 |
/* |
| 713 |
* The file goes back BEFORE the setting. LiteSpeed's |
| 714 |
* update_file() copies its drop-in when the file is missing or |
| 715 |
* differs, and every copy reconnects with flushDb(), which |
| 716 |
* empties the whole database. Finding its own file already in |
| 717 |
* place, it skips both. The plugin stayed active throughout, so |
| 718 |
* its code is there for the file to load. |
| 719 |
*/ |
| 720 |
$copied = false; |
| 721 |
if ( self::plugin_active( $record['plugin'] ) && ! file_exists( self::dropin_path() ) ) { |
| 722 |
$copied = self::copy_backup( $record ); |
| 723 |
} |
| 724 |
$back = self::adapter( self::FEATURE_OFF[ $record['plugin'] ] ?? '', true ); |
| 725 |
if ( ! $back && $copied ) { |
| 726 |
wp_delete_file( self::dropin_path() ); |
| 727 |
} |
| 728 |
// W3TC versions its keys; its own flush retires the stale ones |
| 729 |
// and needs its object cache on to reach the engine. |
| 730 |
if ( $back && 'w3tc' === ( self::FEATURE_OFF[ $record['plugin'] ] ?? '' ) && function_exists( 'w3tc_objectcache_flush' ) ) { |
| 731 |
try { |
| 732 |
w3tc_objectcache_flush(); |
| 733 |
} catch ( \Throwable $e ) { |
| 734 |
unset( $e ); |
| 735 |
} |
| 736 |
} |
| 737 |
break; |
| 738 |
case self::STRATEGY_REPLACE: |
| 739 |
// Only a rollback gets here: the switch failed moments ago, so |
| 740 |
// the file it removed is still the right one. |
| 741 |
$back = true; |
| 742 |
break; |
| 743 |
} |
| 744 |
self::$owner = null; |
| 745 |
if ( ! $back ) { |
| 746 |
return 'owner'; |
| 747 |
} |
| 748 |
|
| 749 |
$path = self::dropin_path(); |
| 750 |
if ( ! file_exists( $path ) && ! self::copy_backup( $record ) ) { |
| 751 |
return 'file'; |
| 752 |
} |
| 753 |
clearstatcache( true, $path ); |
| 754 |
// LiteSpeed writes its own drop-in when its object cache comes back |
| 755 |
// on; the others need the copy. Either way it has to be there now. |
| 756 |
return file_exists( $path ) ? '' : 'file'; |
| 757 |
} |
| 758 |
|
| 759 |
/** |
| 760 |
* Copy the switch's backup into wp-content/object-cache.php. |
| 761 |
* |
| 762 |
* @param array $record Switch record. |
| 763 |
* @return bool |
| 764 |
*/ |
| 765 |
private static function copy_backup( array $record ): bool { |
| 766 |
if ( '' === $record['backup'] || ! is_readable( $record['backup'] ) ) { |
| 767 |
return false; |
| 768 |
} |
| 769 |
$fs = Object_Cache::filesystem(); |
| 770 |
$ok = $fs && $fs->copy( $record['backup'], self::dropin_path(), true, FS_CHMOD_FILE ); |
| 771 |
clearstatcache( true, self::dropin_path() ); |
| 772 |
Object_Cache::invalidate_compiled( self::dropin_path() ); |
| 773 |
return $ok && file_exists( self::dropin_path() ); |
| 774 |
} |
| 775 |
|
| 776 |
// ─────────────────────── Outgoing namespace ─────────────────────── |
| 777 |
|
| 778 |
/** |
| 779 |
* Delete the outgoing plugin's cached values, and only those. |
| 780 |
* |
| 781 |
* Never through its wp_cache_flush(): Redis Object Cache and LiteSpeed |
| 782 |
* flush with FLUSHDB, which wipes every other site and app sharing the |
| 783 |
* database. Each known owner's key layout is matched instead. W3TC is |
| 784 |
* handled in put_back(), where its own versioned flush is scoped. |
| 785 |
* |
| 786 |
* @param array $record Switch record. |
| 787 |
* @return int Keys deleted, or -1 when there was nothing we could clear. |
| 788 |
*/ |
| 789 |
public static function clean_namespace( array $record ): int { |
| 790 |
switch ( $record['plugin'] ) { |
| 791 |
case 'redis-cache/redis-cache.php': |
| 792 |
return self::redis_delete( self::redis_object_cache_server(), self::redis_object_cache_patterns() ); |
| 793 |
case 'litespeed-cache/litespeed-cache.php': |
| 794 |
$server = self::litespeed_server(); |
| 795 |
return null === $server ? -1 : self::redis_delete( $server, array( self::glob_escape( self::litespeed_prefix() ) . '*' ) ); |
| 796 |
} |
| 797 |
return -1; |
| 798 |
} |
| 799 |
|
| 800 |
/** |
| 801 |
* Redis Object Cache's key patterns for this site. |
| 802 |
* |
| 803 |
* Its keys are `{salt}{prefix}:{group}:{key}` (fast_build_key()). The |
| 804 |
* salt is WP_REDIS_PREFIX, else the env var, else WP_CACHE_KEY_SALT. |
| 805 |
* The prefix is the table prefix on a single site, and the blog id (or |
| 806 |
* empty for global groups) on multisite. |
| 807 |
* |
| 808 |
* @return string[] |
| 809 |
*/ |
| 810 |
public static function redis_object_cache_patterns(): array { |
| 811 |
$salt = ''; |
| 812 |
if ( defined( 'WP_REDIS_PREFIX' ) ) { |
| 813 |
$salt = (string) constant( 'WP_REDIS_PREFIX' ); |
| 814 |
} elseif ( false !== getenv( 'WP_REDIS_PREFIX' ) ) { |
| 815 |
$salt = (string) getenv( 'WP_REDIS_PREFIX' ); |
| 816 |
} elseif ( defined( 'WP_CACHE_KEY_SALT' ) ) { |
| 817 |
$salt = (string) constant( 'WP_CACHE_KEY_SALT' ); |
| 818 |
} |
| 819 |
$salt = self::glob_escape( trim( $salt ) ); |
| 820 |
|
| 821 |
if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'get_sites' ) ) { |
| 822 |
// With a salt, everything under it is this network's. |
| 823 |
if ( '' !== $salt ) { |
| 824 |
return array( $salt . '*' ); |
| 825 |
} |
| 826 |
$patterns = array( ':*' ); |
| 827 |
foreach ( (array) get_sites( array( 'fields' => 'ids', 'number' => 0 ) ) as $id ) { |
| 828 |
$patterns[] = (int) $id . ':*'; |
| 829 |
} |
| 830 |
return $patterns; |
| 831 |
} |
| 832 |
|
| 833 |
global $table_prefix; |
| 834 |
$prefix = trim( (string) $table_prefix, '_-:$' ); |
| 835 |
return array( $salt . self::glob_escape( $prefix ) . ':*' ); |
| 836 |
} |
| 837 |
|
| 838 |
/** |
| 839 |
* The Redis server Redis Object Cache uses, from its own constants. |
| 840 |
* |
| 841 |
* @return array{host:string,port:int,user:string,password:string,database:int,timeout:float} |
| 842 |
*/ |
| 843 |
private static function redis_object_cache_server(): array { |
| 844 |
$scheme = defined( 'WP_REDIS_SCHEME' ) ? (string) constant( 'WP_REDIS_SCHEME' ) : 'tcp'; |
| 845 |
$host = defined( 'WP_REDIS_HOST' ) ? (string) constant( 'WP_REDIS_HOST' ) : '127.0.0.1'; |
| 846 |
if ( 0 === strcasecmp( $scheme, 'unix' ) && defined( 'WP_REDIS_PATH' ) ) { |
| 847 |
$host = (string) constant( 'WP_REDIS_PATH' ); |
| 848 |
} |
| 849 |
$password = defined( 'WP_REDIS_PASSWORD' ) ? constant( 'WP_REDIS_PASSWORD' ) : ''; |
| 850 |
$user = ''; |
| 851 |
if ( is_array( $password ) ) { |
| 852 |
$parts = array_values( $password ); |
| 853 |
$user = count( $parts ) > 1 ? (string) $parts[0] : ''; |
| 854 |
$password = (string) ( count( $parts ) > 1 ? $parts[1] : ( $parts[0] ?? '' ) ); |
| 855 |
} |
| 856 |
if ( defined( 'WP_REDIS_USERNAME' ) ) { |
| 857 |
$user = (string) constant( 'WP_REDIS_USERNAME' ); |
| 858 |
} |
| 859 |
return array( |
| 860 |
'host' => $host, |
| 861 |
'port' => defined( 'WP_REDIS_PORT' ) ? (int) constant( 'WP_REDIS_PORT' ) : 6379, |
| 862 |
'user' => $user, |
| 863 |
'password' => (string) $password, |
| 864 |
'database' => defined( 'WP_REDIS_DATABASE' ) ? (int) constant( 'WP_REDIS_DATABASE' ) : 0, |
| 865 |
'timeout' => 1.0, |
| 866 |
); |
| 867 |
} |
| 868 |
|
| 869 |
/** |
| 870 |
* LiteSpeed's key prefix: LSOC_PREFIX, which it derives from the path of |
| 871 |
* its own object-cache class unless wp-config sets it. |
| 872 |
* |
| 873 |
* @return string |
| 874 |
*/ |
| 875 |
public static function litespeed_prefix(): string { |
| 876 |
if ( defined( 'LSOC_PREFIX' ) ) { |
| 877 |
return (string) constant( 'LSOC_PREFIX' ); |
| 878 |
} |
| 879 |
$file = ( defined( 'WP_PLUGIN_DIR' ) ? (string) WP_PLUGIN_DIR : '' ) . '/litespeed-cache/src/object-cache-wp.cls.php'; |
| 880 |
$real = realpath( $file ); |
| 881 |
return substr( md5( false !== $real ? $real : $file ), -5 ); |
| 882 |
} |
| 883 |
|
| 884 |
/** |
| 885 |
* The Redis server LiteSpeed's object cache is set to, or null when it |
| 886 |
* uses Memcached (no key enumeration) or its settings cannot be read. |
| 887 |
* |
| 888 |
* @return array|null |
| 889 |
*/ |
| 890 |
private static function litespeed_server(): ?array { |
| 891 |
if ( ! has_filter( 'litespeed_conf' ) ) { |
| 892 |
return null; |
| 893 |
} |
| 894 |
$conf = static function ( string $key ) { |
| 895 |
return apply_filters( 'litespeed_conf', $key ); |
| 896 |
}; |
| 897 |
if ( 1 !== (int) $conf( 'object-kind' ) ) { |
| 898 |
return null; |
| 899 |
} |
| 900 |
return array( |
| 901 |
'host' => (string) $conf( 'object-host' ), |
| 902 |
'port' => (int) $conf( 'object-port' ), |
| 903 |
'user' => (string) $conf( 'object-user' ), |
| 904 |
'password' => (string) $conf( 'object-pswd' ), |
| 905 |
'database' => (int) $conf( 'object-db_id' ), |
| 906 |
'timeout' => 1.0, |
| 907 |
); |
| 908 |
} |
| 909 |
|
| 910 |
/** |
| 911 |
* SCAN-delete keys matching the patterns on one Redis server. phpredis |
| 912 |
* when loaded (it reaches a unix socket as a bare path with port 0), |
| 913 |
* otherwise our own client. |
| 914 |
* |
| 915 |
* @param array $server host, port, user, password, database, timeout. |
| 916 |
* @param string[] $patterns SCAN MATCH patterns, already escaped. |
| 917 |
* @return int Keys deleted, or -1 on a connection failure. |
| 918 |
*/ |
| 919 |
private static function redis_delete( array $server, array $patterns ): int { |
| 920 |
$host = (string) $server['host']; |
| 921 |
if ( '' === $host || array() === $patterns ) { |
| 922 |
return -1; |
| 923 |
} |
| 924 |
$socket = '/' === substr( $host, 0, 1 ) || 0 === stripos( $host, 'unix:' ); |
| 925 |
if ( $socket && 0 === stripos( $host, 'unix:' ) ) { |
| 926 |
$host = '/' . ltrim( substr( $host, 5 ), '/' ); |
| 927 |
} |
| 928 |
$deleted = 0; |
| 929 |
|
| 930 |
try { |
| 931 |
if ( class_exists( '\\Redis' ) ) { |
| 932 |
$redis = new \Redis(); |
| 933 |
if ( ! @$redis->connect( $host, $socket ? 0 : (int) $server['port'], (float) $server['timeout'] ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a dead server is a -1, not a warning on the page. |
| 934 |
return -1; |
| 935 |
} |
| 936 |
if ( '' !== $server['user'] ) { |
| 937 |
@$redis->auth( array( 'user' => $server['user'], 'pass' => $server['password'] ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- same. |
| 938 |
} elseif ( '' !== $server['password'] ) { |
| 939 |
@$redis->auth( $server['password'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- same. |
| 940 |
} |
| 941 |
if ( $server['database'] > 0 ) { |
| 942 |
$redis->select( (int) $server['database'] ); |
| 943 |
} |
| 944 |
$redis->setOption( \Redis::OPT_SCAN, \Redis::SCAN_RETRY ); |
| 945 |
foreach ( $patterns as $pattern ) { |
| 946 |
$it = null; |
| 947 |
do { |
| 948 |
$keys = $redis->scan( $it, $pattern, 500 ); |
| 949 |
if ( is_array( $keys ) && array() !== $keys ) { |
| 950 |
$deleted += (int) $redis->del( $keys ); |
| 951 |
} |
| 952 |
} while ( $it > 0 ); |
| 953 |
} |
| 954 |
$redis->close(); |
| 955 |
return $deleted; |
| 956 |
} |
| 957 |
|
| 958 |
$client = new Redis_Client( $host, (int) $server['port'], (float) $server['timeout'], false ); |
| 959 |
if ( ! $client->connect() ) { |
| 960 |
return -1; |
| 961 |
} |
| 962 |
if ( '' !== $server['password'] || '' !== $server['user'] ) { |
| 963 |
$client->auth( (string) $server['password'], (string) $server['user'] ); |
| 964 |
} |
| 965 |
if ( $server['database'] > 0 ) { |
| 966 |
$client->select( (int) $server['database'] ); |
| 967 |
} |
| 968 |
foreach ( $patterns as $pattern ) { |
| 969 |
$deleted += max( 0, $client->delete_by_pattern( $pattern ) ); |
| 970 |
} |
| 971 |
$client->close(); |
| 972 |
return $deleted; |
| 973 |
} catch ( \Throwable $e ) { |
| 974 |
return -1; |
| 975 |
} |
| 976 |
} |
| 977 |
|
| 978 |
/** |
| 979 |
* Escape Redis glob metacharacters in a literal key segment. |
| 980 |
* |
| 981 |
* @param string $literal Literal text. |
| 982 |
* @return string |
| 983 |
*/ |
| 984 |
private static function glob_escape( string $literal ): string { |
| 985 |
return str_replace( |
| 986 |
array( '\\', '*', '?', '[', ']' ), |
| 987 |
array( '\\\\', '\\*', '\\?', '\\[', '\\]' ), |
| 988 |
$literal |
| 989 |
); |
| 990 |
} |
| 991 |
|
| 992 |
/** |
| 993 |
* Undo a switch that failed part way. |
| 994 |
* |
| 995 |
* @param array $record Switch record. |
| 996 |
* @param array $steps Steps reached. |
| 997 |
* @return string '' when everything is back, else put_back()'s problem. |
| 998 |
*/ |
| 999 |
private static function rollback( array $record, array $steps ): string { |
| 1000 |
if ( ! empty( $steps['wp_config'] ) || ! empty( $steps['drop_in'] ) ) { |
| 1001 |
Object_Cache::remove_wp_config(); |
| 1002 |
} |
| 1003 |
return self::put_back( $record ); |
| 1004 |
} |
| 1005 |
|
| 1006 |
/** |
| 1007 |
* What a failed put-back left behind, worded for the user. A rollback |
| 1008 |
* or restore must never claim the previous setup is back when it is not. |
| 1009 |
* |
| 1010 |
* @param array $record Switch record. |
| 1011 |
* @param string $problem One of the put_back() problem codes. |
| 1012 |
* @return string |
| 1013 |
*/ |
| 1014 |
private static function put_back_message( array $record, string $problem ): string { |
| 1015 |
if ( 'owner' === $problem ) { |
| 1016 |
return sprintf( |
| 1017 |
/* translators: %s: plugin name. */ |
| 1018 |
__( '%s could not be turned back on (is it still installed?), so its object-cache.php was not put back. WordPress is using its built-in cache.', 'xspeed' ), |
| 1019 |
$record['label'] |
| 1020 |
); |
| 1021 |
} |
| 1022 |
if ( 'file' === $problem ) { |
| 1023 |
return sprintf( |
| 1024 |
/* translators: %s: plugin name. */ |
| 1025 |
__( '%s is on again, but its object-cache.php could not be put back (is wp-content writable?), so the site has no object cache. Turn its object cache on again from its settings.', 'xspeed' ), |
| 1026 |
$record['label'] |
| 1027 |
); |
| 1028 |
} |
| 1029 |
return __( "xSpeed's object-cache.php could not be removed, so the previous plugin was not put back. Check that wp-content is writable.", 'xspeed' ); |
| 1030 |
} |
| 1031 |
|
| 1032 |
// ─────────────────────── LiteSpeed / W3TC ─────────────────────── |
| 1033 |
|
| 1034 |
/** |
| 1035 |
* Whether the plugin's own settings API is there to call. |
| 1036 |
* |
| 1037 |
* @param string $adapter litespeed|w3tc. |
| 1038 |
* @return bool |
| 1039 |
*/ |
| 1040 |
private static function adapter_available( string $adapter ): bool { |
| 1041 |
if ( 'litespeed' === $adapter ) { |
| 1042 |
return class_exists( '\\LiteSpeed\\Conf' ) && method_exists( '\\LiteSpeed\\Conf', 'cls' ) && method_exists( '\\LiteSpeed\\Conf', 'update_confs' ); |
| 1043 |
} |
| 1044 |
if ( 'w3tc' === $adapter ) { |
| 1045 |
return class_exists( '\\W3TC\\Dispatcher' ) && method_exists( '\\W3TC\\Dispatcher', 'config' ); |
| 1046 |
} |
| 1047 |
return false; |
| 1048 |
} |
| 1049 |
|
| 1050 |
/** |
| 1051 |
* Whether the plugin's object cache is on. |
| 1052 |
* |
| 1053 |
* @param string $adapter litespeed|w3tc. |
| 1054 |
* @return bool |
| 1055 |
*/ |
| 1056 |
private static function adapter_enabled( string $adapter ): bool { |
| 1057 |
if ( ! self::adapter_available( $adapter ) ) { |
| 1058 |
return false; |
| 1059 |
} |
| 1060 |
try { |
| 1061 |
if ( 'litespeed' === $adapter ) { |
| 1062 |
return (bool) apply_filters( 'litespeed_conf', 'object' ); |
| 1063 |
} |
| 1064 |
return (bool) \W3TC\Dispatcher::config()->get_boolean( 'objectcache.enabled' ); |
| 1065 |
} catch ( \Throwable $e ) { |
| 1066 |
return false; |
| 1067 |
} |
| 1068 |
} |
| 1069 |
|
| 1070 |
/** |
| 1071 |
* Switch the plugin's object cache on or off through its own settings |
| 1072 |
* API, which also adds or removes its drop-in the way it normally would. |
| 1073 |
* |
| 1074 |
* @param string $adapter litespeed|w3tc. |
| 1075 |
* @param bool $on Desired state. |
| 1076 |
* @return bool Whether the plugin now reports that state. |
| 1077 |
*/ |
| 1078 |
private static function adapter( string $adapter, bool $on ): bool { |
| 1079 |
if ( ! self::adapter_available( $adapter ) ) { |
| 1080 |
return false; |
| 1081 |
} |
| 1082 |
try { |
| 1083 |
if ( 'litespeed' === $adapter ) { |
| 1084 |
\LiteSpeed\Conf::cls()->update_confs( array( 'object' => $on ) ); |
| 1085 |
} else { |
| 1086 |
$config = \W3TC\Dispatcher::config(); |
| 1087 |
$config->set( 'objectcache.enabled', $on ); |
| 1088 |
$config->save(); |
| 1089 |
} |
| 1090 |
} catch ( \Throwable $e ) { |
| 1091 |
return false; |
| 1092 |
} |
| 1093 |
return self::adapter_enabled( $adapter ) === $on; |
| 1094 |
} |
| 1095 |
|
| 1096 |
// ──────────────────────────── Files ──────────────────────────── |
| 1097 |
|
| 1098 |
/** @return string wp-content/object-cache.php, or '' before WP is set up. */ |
| 1099 |
private static function dropin_path(): string { |
| 1100 |
return defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : ''; |
| 1101 |
} |
| 1102 |
|
| 1103 |
/** |
| 1104 |
* Copy the current drop-in into uploads/xspeed-backups/. Resolves a |
| 1105 |
* symlink so the backup holds the code, not a dangling link. |
| 1106 |
* |
| 1107 |
* @return string Backup path, or '' on failure. |
| 1108 |
*/ |
| 1109 |
private static function backup(): string { |
| 1110 |
$path = self::dropin_path(); |
| 1111 |
$fs = Object_Cache::filesystem(); |
| 1112 |
if ( ! $fs || '' === $path ) { |
| 1113 |
return ''; |
| 1114 |
} |
| 1115 |
$upload = wp_upload_dir( null, false ); |
| 1116 |
$dir = ! empty( $upload['basedir'] ) ? trailingslashit( (string) $upload['basedir'] ) . 'xspeed-backups' : ''; |
| 1117 |
if ( '' === $dir || ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) ) { |
| 1118 |
return ''; |
| 1119 |
} |
| 1120 |
$source = is_link( $path ) ? (string) realpath( $path ) : $path; |
| 1121 |
$target = $dir . '/object-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak'; |
| 1122 |
if ( '' === $source || ! $fs->copy( $source, $target, true, FS_CHMOD_FILE ) ) { |
| 1123 |
return ''; |
| 1124 |
} |
| 1125 |
return $target; |
| 1126 |
} |
| 1127 |
|
| 1128 |
/** |
| 1129 |
* Remove a foreign drop-in the owner left in place. |
| 1130 |
* |
| 1131 |
* @return bool Whether the slot is now free of foreign files. |
| 1132 |
*/ |
| 1133 |
private static function remove_foreign(): bool { |
| 1134 |
$path = self::dropin_path(); |
| 1135 |
if ( '' === $path || ( ! file_exists( $path ) && ! is_link( $path ) ) ) { |
| 1136 |
return true; |
| 1137 |
} |
| 1138 |
if ( Object_Cache::is_our_dropin_present() ) { |
| 1139 |
return true; |
| 1140 |
} |
| 1141 |
wp_delete_file( $path ); |
| 1142 |
clearstatcache( true, $path ); |
| 1143 |
Object_Cache::invalidate_compiled( $path ); |
| 1144 |
return ! file_exists( $path ) && ! is_link( $path ); |
| 1145 |
} |
| 1146 |
|
| 1147 |
// ─────────────────────────── Plugins ─────────────────────────── |
| 1148 |
|
| 1149 |
/** Make the plugin functions available outside wp-admin (REST, CLI). */ |
| 1150 |
private static function load_plugin_api(): void { |
| 1151 |
if ( ! function_exists( 'is_plugin_active' ) && defined( 'ABSPATH' ) && file_exists( ABSPATH . 'wp-admin/includes/plugin.php' ) ) { |
| 1152 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 1153 |
} |
| 1154 |
} |
| 1155 |
|
| 1156 |
/** @return string[] Active plugin basenames, site and network. */ |
| 1157 |
private static function active_plugins(): array { |
| 1158 |
$active = (array) get_option( 'active_plugins', array() ); |
| 1159 |
if ( function_exists( 'is_multisite' ) && is_multisite() ) { |
| 1160 |
$active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) ); |
| 1161 |
} |
| 1162 |
return array_values( array_unique( array_filter( array_map( 'strval', $active ) ) ) ); |
| 1163 |
} |
| 1164 |
|
| 1165 |
/** |
| 1166 |
* @param string $plugin Plugin basename. |
| 1167 |
* @return bool |
| 1168 |
*/ |
| 1169 |
private static function plugin_active( string $plugin ): bool { |
| 1170 |
return in_array( $plugin, self::active_plugins(), true ); |
| 1171 |
} |
| 1172 |
|
| 1173 |
/** |
| 1174 |
* @param string $plugin Plugin basename. |
| 1175 |
* @return bool |
| 1176 |
*/ |
| 1177 |
private static function network_active( string $plugin ): bool { |
| 1178 |
if ( '' === $plugin || ! function_exists( 'is_multisite' ) || ! is_multisite() ) { |
| 1179 |
return false; |
| 1180 |
} |
| 1181 |
return array_key_exists( $plugin, (array) get_site_option( 'active_sitewide_plugins', array() ) ); |
| 1182 |
} |
| 1183 |
|
| 1184 |
/** |
| 1185 |
* An active plugin living in the given folder, or ''. |
| 1186 |
* |
| 1187 |
* @param string $folder Plugin folder name. |
| 1188 |
* @return string |
| 1189 |
*/ |
| 1190 |
private static function plugin_in_folder( string $folder ): string { |
| 1191 |
foreach ( self::active_plugins() as $plugin ) { |
| 1192 |
if ( dirname( $plugin ) === $folder ) { |
| 1193 |
return $plugin; |
| 1194 |
} |
| 1195 |
} |
| 1196 |
return ''; |
| 1197 |
} |
| 1198 |
|
| 1199 |
/** |
| 1200 |
* Whether the current user may turn the owner off. WP-CLI runs as the |
| 1201 |
* site operator and has no current user. |
| 1202 |
* |
| 1203 |
* @param array $owner Owner record. |
| 1204 |
* @return bool |
| 1205 |
*/ |
| 1206 |
private static function can_manage( array $owner ): bool { |
| 1207 |
if ( defined( 'WP_CLI' ) && WP_CLI ) { |
| 1208 |
return true; |
| 1209 |
} |
| 1210 |
if ( self::STRATEGY_REPLACE === $owner['strategy'] ) { |
| 1211 |
return current_user_can( 'manage_options' ); |
| 1212 |
} |
| 1213 |
return self::network_active( $owner['plugin'] ) |
| 1214 |
? current_user_can( 'manage_network_plugins' ) |
| 1215 |
: current_user_can( 'activate_plugins' ); |
| 1216 |
} |
| 1217 |
|
| 1218 |
/** |
| 1219 |
* Display name for a plugin basename. |
| 1220 |
* |
| 1221 |
* @param string $plugin Plugin basename. |
| 1222 |
* @return string |
| 1223 |
*/ |
| 1224 |
private static function plugin_label( string $plugin ): string { |
| 1225 |
if ( isset( self::OBJECT_CACHE_ONLY[ $plugin ] ) ) { |
| 1226 |
return self::OBJECT_CACHE_ONLY[ $plugin ]; |
| 1227 |
} |
| 1228 |
if ( class_exists( __NAMESPACE__ . '\\Cache_Plugin_Catalog' ) ) { |
| 1229 |
foreach ( Cache_Plugin_Catalog::all() as $file => $entry ) { |
| 1230 |
if ( $file === $plugin && ! empty( $entry['label'] ) ) { |
| 1231 |
return (string) $entry['label']; |
| 1232 |
} |
| 1233 |
} |
| 1234 |
} |
| 1235 |
$dir = defined( 'WP_PLUGIN_DIR' ) ? (string) WP_PLUGIN_DIR : ''; |
| 1236 |
$name = '' !== $dir ? self::header_value( (string) @file_get_contents( $dir . '/' . $plugin, false, null, 0, 8192 ), 'Plugin Name' ) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- reading a local plugin header. |
| 1237 |
return '' !== $name ? $name : $plugin; |
| 1238 |
} |
| 1239 |
|
| 1240 |
/** |
| 1241 |
* Display name from the drop-in's own header. |
| 1242 |
* |
| 1243 |
* @param string $contents Drop-in contents. |
| 1244 |
* @return string |
| 1245 |
*/ |
| 1246 |
private static function header_label( string $contents ): string { |
| 1247 |
return self::header_value( $contents, 'Plugin Name' ); |
| 1248 |
} |
| 1249 |
|
| 1250 |
/** |
| 1251 |
* One WordPress file-header field. |
| 1252 |
* |
| 1253 |
* @param string $contents File contents (the first few KB are enough). |
| 1254 |
* @param string $field Header field name. |
| 1255 |
* @return string |
| 1256 |
*/ |
| 1257 |
private static function header_value( string $contents, string $field ): string { |
| 1258 |
if ( '' === $contents ) { |
| 1259 |
return ''; |
| 1260 |
} |
| 1261 |
if ( preg_match( '/^[ \t\/*#@]*' . preg_quote( $field, '/' ) . ':(.*)$/mi', substr( $contents, 0, 8192 ), $m ) ) { |
| 1262 |
return trim( (string) preg_replace( '/\s*(?:\*\/|\?>).*/', '', $m[1] ) ); |
| 1263 |
} |
| 1264 |
return ''; |
| 1265 |
} |
| 1266 |
} |
| 1267 |
|