PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
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 1.1.2 All 35 releases
xspeed / includes / class-object-cache-takeover.php

class-object-cache-takeover.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.1, at includes/class-object-cache-takeover.php

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