PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
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
← All changes | includes/class-object-cache.php +852 -48 1.1.0 → 1.4.0 View file →
@@ -36,8 +36,9 @@
36 36 * Inspect the runtime + filesystem for a persistent object cache.
37 37 *
38 38 * @return array{
39 39 * drop_in_installed: bool,
40 + * drop_in_is_ours: bool, // installed AND carries our tag
40 41 * drop_in_path: string,
41 42 * drop_in_label: string,
42 43 * backend: string, // redis|memcached|apcu|wp_default|unknown
43 44 * wp_cache_active: bool, // wp_using_ext_object_cache
@@ -45,9 +46,9 @@
45 46 * persistent: bool, // ours is installed AND persisting
46 47 * class_available: array<string,bool>
47 48 * }
48 49 */
49 - public static function detect(): array {
50 + public static function detect( bool $with_owner = false ): array {
50 51 $dropin = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : '';
51 52 $has_drop_in = '' !== $dropin && file_exists( $dropin );
52 53 $label = $has_drop_in ? self::sniff_drop_in_label( $dropin ) : '';
53 54 $ext_in_use = function_exists( 'wp_using_ext_object_cache' ) ? (bool) wp_using_ext_object_cache() : false;
@@ -103,8 +104,13 @@
103 104 }
104 105
105 106 return array(
106 107 'drop_in_installed' => $has_drop_in,
108 + // Whether the installed drop-in is OURS. A foreign one (W3TC,
109 + // Redis Object Cache, LiteSpeed) means the object cache belongs to
110 + // another plugin: we must not offer to configure or disable it,
111 + // and "installed" must not be read as "xSpeed is running".
112 + 'drop_in_is_ours' => $has_drop_in && self::is_our_dropin_present(),
107 113 'drop_in_path' => $dropin,
108 114 'drop_in_label' => $label,
109 115 'backend' => $backend,
110 116 'wp_cache_active' => $ext_in_use,
@@ -110,12 +116,89 @@
110 116 'wp_cache_active' => $ext_in_use,
111 117 'degraded' => $degraded,
112 118 'persistent' => $persistent,
113 119 'class_available' => $class_available,
120 + // Who owns a foreign drop-in and how a switch would go, so every
121 + // surface can offer it (or say why not) the same way. (#686)
122 + // Only on request: it reads plugin folders, and module status
123 + // calls detect() on dashboard loads.
124 + 'owner' => $with_owner && $has_drop_in && ! self::is_our_dropin_present() ? Object_Cache_Takeover::owner() : null,
125 + // The plugin a switch replaced, which Disable can put back.
126 + 'previous_owner' => self::previous_owner(),
114 127 );
115 128 }
116 129
117 130 /**
131 + * Label of the plugin xSpeed switched from, while ours is installed.
132 + *
133 + * @return array{label:string}|null
134 + */
135 + private static function previous_owner(): ?array {
136 + if ( ! self::is_our_dropin_present() ) {
137 + return null;
138 + }
139 + $record = Object_Cache_Takeover::record();
140 + return null === $record ? null : array( 'label' => $record['label'] );
141 + }
142 +
143 + /** A drop-in that is not ours sits in wp-content. */
144 + private static function foreign_dropin_present(): bool {
145 + $dropin = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : '';
146 + return '' !== $dropin && ( file_exists( $dropin ) || is_link( $dropin ) ) && ! self::is_our_dropin_present();
147 + }
148 +
149 + /** Our drop-in is the object cache this request is running on. */
150 + private static function our_dropin_is_live(): bool {
151 + return isset( $GLOBALS['wp_object_cache'] ) && $GLOBALS['wp_object_cache'] instanceof \XSpeed_Object_Cache;
152 + }
153 +
154 + /**
155 + * Delete this site's keys from Redis, the same `salt:*` scope the
156 + * drop-in's flush uses. For when the drop-in is not loaded in this
157 + * request, so wp_cache_flush() would flush someone else's cache or none.
158 + * Memcached has no key enumeration; its namespace generation covers it.
159 + *
160 + * @param array $opts Settings array.
161 + * @return int Keys deleted, or -1 when nothing could be done.
162 + */
163 + public static function purge_namespace( array $opts ): int {
164 + if ( 'redis' !== (string) ( $opts['backend'] ?? 'redis' ) ) {
165 + return -1;
166 + }
167 + $salt = self::effective_salt( $opts );
168 + if ( '' === $salt ) {
169 + return -1;
170 + }
171 + $client = new Redis_Client(
172 + self::str( $opts, 'redis_host', '127.0.0.1' ),
173 + self::int( $opts, 'redis_port', 6379 ),
174 + (float) self::int( $opts, 'connection_timeout', 1 ),
175 + false
176 + );
177 + if ( ! $client->connect() ) {
178 + return -1;
179 + }
180 + $user = self::str( $opts, 'redis_user', '' );
181 + $pass = self::str( $opts, 'redis_password', '' );
182 + if ( ( '' !== $pass || '' !== $user ) && false === $client->auth( $pass, $user ) ) {
183 + $client->close();
184 + return -1;
185 + }
186 + $db = self::int( $opts, 'redis_database', 0 );
187 + if ( $db > 0 ) {
188 + $client->select( $db );
189 + }
190 + $pattern = str_replace(
191 + array( '\\', '*', '?', '[', ']' ),
192 + array( '\\\\', '\\*', '\\?', '\\[', '\\]' ),
193 + $salt
194 + ) . ':*';
195 + $deleted = $client->delete_by_pattern( $pattern );
196 + $client->close();
197 + return $deleted;
198 + }
199 +
200 + /**
118 201 * Flush whatever cache backend is wired up. Works against any
119 202 * compliant drop-in OR the WP default in-memory cache.
120 203 */
121 204 public static function flush(): bool {
@@ -141,9 +224,9 @@
141 224 $port = self::int( $opts, 'redis_port', 6379 );
142 225 $user = self::str( $opts, 'redis_user', '' );
143 226 $pass = self::str( $opts, 'redis_password', '' );
144 227 $db = self::int( $opts, 'redis_database', 0 );
145 - $prefix = self::str( $opts, 'key_prefix', '' );
228 + $prefix = self::effective_salt( $opts );
146 229 $timeout = self::int( $opts, 'connection_timeout', 1 );
147 230 $persist = ! empty( $opts['persistent'] );
148 231
149 232 $lines[] = "define( 'WP_REDIS_HOST', '" . self::esc( $host ) . "' );";
@@ -156,22 +239,18 @@
156 239 if ( '' !== $pass ) {
157 240 $lines[] = "define( 'WP_REDIS_PASSWORD', '" . self::esc( $pass ) . "' );";
158 241 }
159 242 $lines[] = "define( 'WP_REDIS_DATABASE', " . $db . ' );';
160 - if ( '' !== $prefix ) {
161 - $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
162 - }
243 + $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
163 244 $lines[] = "define( 'WP_REDIS_TIMEOUT', " . $timeout . ' );';
164 245 $lines[] = "define( 'WP_REDIS_PERSISTENT', " . ( $persist ? 'true' : 'false' ) . ' );';
165 246 } elseif ( 'memcached' === $backend ) {
166 247 $host = self::str( $opts, 'memcached_host', '127.0.0.1' );
167 248 $port = self::int( $opts, 'memcached_port', 11211 );
168 - $prefix = self::str( $opts, 'key_prefix', '' );
249 + $prefix = self::effective_salt( $opts );
169 250 $lines[] = "global \$memcached_servers;";
170 251 $lines[] = "\$memcached_servers = array( array( '" . self::esc( $host ) . "', " . $port . ' ) );';
171 - if ( '' !== $prefix ) {
172 - $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
173 - }
252 + $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
174 253 } else {
175 254 $lines[] = '// No snippet for backend: ' . $backend;
176 255 }
177 256
@@ -188,8 +267,22 @@
188 267 private const CONFIG_BEGIN = '/* BEGIN xSpeed Object Cache */';
189 268 private const CONFIG_END = '/* END xSpeed Object Cache */';
190 269
191 270 /**
271 + * True when wp-content/object-cache.php exists AND is ours (carries the
272 + * drop-in tag). Lets callers decide whether a re-sync applies without
273 + * exposing the tag itself.
274 + */
275 + public static function is_our_dropin_present(): bool {
276 + $target = WP_CONTENT_DIR . '/object-cache.php';
277 + if ( ! file_exists( $target ) ) {
278 + return false;
279 + }
280 + $contents = file_get_contents( $target ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- read-only ours-check; WP_Filesystem may not be initialized this early.
281 + return is_string( $contents ) && false !== strpos( $contents, self::DROPIN_TAG );
282 + }
283 +
284 + /**
192 285 * Live connection test against the configured backend. Never throws;
193 286 * returns a structured pass/fail the UI can show before we write anything.
194 287 *
195 288 * @param array $opts Settings array (backend, redis_host, ...).
@@ -279,9 +372,9 @@
279 372 @$redis->del( $probe ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup.
280 373 if ( ! $set || '1' !== (string) $got ) {
281 374 return self::test_result( false, $backend, self::write_denied_message( $opts, $host, $port ), $start );
282 375 }
283 - return self::test_result( true, $backend, "Connected to Redis at {$host}:{$port} (phpredis).", $start );
376 + return self::test_result( true, $backend, self::with_prefix_advisory( "Connected to Redis at {$host}:{$port} (phpredis).", $opts ), $start );
284 377 }
285 378
286 379 // Pure-PHP fallback — our own client, zero dependencies.
287 380 $rc = new Redis_Client( $host, $port, (float) $timeout, false );
@@ -313,14 +406,60 @@
313 406 $rc->close();
314 407 if ( ! $set || '1' !== (string) $got ) {
315 408 return self::test_result( false, $backend, self::write_denied_message( $opts, $host, $port ), $start );
316 409 }
317 - return self::test_result( true, $backend, "Connected to Redis at {$host}:{$port} (built-in client).", $start );
410 + return self::test_result( true, $backend, self::with_prefix_advisory( "Connected to Redis at {$host}:{$port} (built-in client).", $opts ), $start );
318 411 } catch ( \Throwable $e ) {
319 412 return self::test_result( false, $backend, 'Connection error: ' . $e->getMessage() );
320 413 }
321 414 }
322 415
416 + /**
417 + * Redis glob metacharacters that must never appear unescaped in a SCAN
418 + * MATCH pattern. `\` is the escape character itself.
419 + */
420 + private const GLOB_METACHARS = '*?[]\\';
421 +
422 + /**
423 + * Whether a salt contains Redis glob metacharacters.
424 + *
425 + * The salt is interpolated into the drop-in's scoped-flush patterns. The
426 + * drop-in escapes it, so caching and purging are correct either way — but
427 + * an explicit Cache Key Prefix exists to match a host's ACL namespace
428 + * byte-for-byte, and a wildcard in it is almost always a typo rather than
429 + * a real namespace. Reporting it on Test connection is the one place the
430 + * user is already looking at their prefix.
431 + *
432 + * @param string $salt Effective salt.
433 + * @return bool
434 + */
435 + public static function salt_has_glob_metachars( string $salt ): bool {
436 + return strcspn( $salt, self::GLOB_METACHARS ) !== strlen( $salt );
437 + }
438 +
439 + /**
440 + * Append a prefix advisory to an otherwise-successful connection message.
441 + *
442 + * @param string $message Success message.
443 + * @param array $opts Settings array.
444 + * @return string
445 + */
446 + private static function with_prefix_advisory( string $message, array $opts ): string {
447 + // Deliberately the TYPED prefix, not effective_salt(): this advisory
448 + // says "the field you are looking at probably has a typo in it". A
449 + // host-pinned WP_CACHE_KEY_SALT is not editable from this screen and
450 + // purges are correctly scoped regardless (the drop-in escapes it), so
451 + // warning about a host's own namespace would be noise on every ACL
452 + // host. A derived salt is glob-free by construction.
453 + $prefix = self::str( $opts, 'key_prefix', '' );
454 + if ( '' === $prefix || ! self::salt_has_glob_metachars( $prefix ) ) {
455 + return $message;
456 + }
457 + return $message . ' Note: the Cache key prefix contains one of * ? [ ] \\.'
458 + . ' Purges stay scoped to this site, but these are wildcard characters'
459 + . ' in Redis — check the prefix matches your host\'s key exactly.';
460 + }
461 +
323 462 private static function test_result( bool $ok, string $backend, string $message, ?float $start = null ): array {
324 463 return array(
325 464 'ok' => $ok,
326 465 'backend' => $backend,
@@ -357,22 +496,141 @@
357 496 }
358 497 }
359 498
360 499 /**
500 + * Resolve the salt that namespaces this site's cache keys.
501 + *
502 + * An explicit Cache Key Prefix always wins — on ACL/namespaced hosts
503 + * (xCloud) it MUST match the host's "Redis Object Cache Key" or writes are
504 + * denied (NOPERM), so we never override what the user typed.
505 + *
506 + * When the field is blank we derive a stable, per-site salt instead of
507 + * falling back to an empty one. An empty salt makes every key look like
508 + * `:{prefix}:{group}:{key}` — identical on every install — so two sites
509 + * sharing one Redis/Memcached server collide. That is not a theoretical
510 + * clash: `blog-details` / `blog-lookup` are how WordPress resolves which
511 + * site a request belongs to, so the second site reads the first site's
512 + * entries and redirects to it.
513 + *
514 + * The derived value is a hash of the site URL plus the DB name/prefix, so
515 + * it is unique per install, stable across requests (no cache churn), and
516 + * safe to embed in wp-config.php.
517 + *
518 + * @param array $opts Settings array.
519 + * @return string Non-empty salt.
520 + */
521 + public static function effective_salt( array $opts ): string {
522 + $prefix = self::str( $opts, 'key_prefix', '' );
523 + if ( '' !== $prefix ) {
524 + return $prefix;
525 + }
526 +
527 + // A salt WE already wrote is authoritative over a fresh derivation.
528 + // The keys in the backend are named after it, so re-deriving a
529 + // different value would orphan every one of them — a needless
530 + // cache-cooling on an install that is already correctly namespaced.
531 + // This matters because derive_salt()'s rule was corrected (see there):
532 + // without this branch, the next wp-config sync would rewrite the block
533 + // with a new salt and throw away a warm cache on every existing site.
534 + if ( defined( 'XSPEED_OC_SALT' ) && '' !== (string) constant( 'XSPEED_OC_SALT' ) ) {
535 + return (string) constant( 'XSPEED_OC_SALT' );
536 + }
537 +
538 + // A salt the HOST pinned in its own wp-config (outside our block) is
539 + // the next authority. On ACL/namespaced Redis the host grants write
540 + // access to that namespace and no other, so replacing it with a
541 + // derived value gets every write denied (NOPERM) and the site silently
542 + // stops caching.
543 + // WP_REDIS_PREFIX is checked alongside WP_CACHE_KEY_SALT and before it,
544 + // matching the order the schema and the drop-in resolve (#398). It is
545 + // the name Redis Object Cache uses and the one managed hosts actually
546 + // write, so honouring only the older alias left the commonest
547 + // ACL-namespaced case deriving a salt the host denies writes to.
548 + foreach ( array( 'WP_REDIS_PREFIX', 'WP_CACHE_KEY_SALT' ) as $name ) {
549 + if ( defined( $name ) && '' !== (string) constant( $name ) ) {
550 + return (string) constant( $name );
551 + }
552 + }
553 +
554 + return self::derive_salt();
555 + }
556 +
557 + /**
558 + * Build a stable per-site salt for installs that left Cache Key Prefix
559 + * blank. Distinct per install: the site URL separates sites sharing a
560 + * database, and DB name + table prefix separate installs sharing a domain
561 + * (e.g. subdirectory installs).
562 + *
563 + * This MUST stay byte-identical to the drop-in's xspeed_oc_salt(), which
564 + * is the harder constraint of the two: the drop-in loads from
565 + * wp-settings.php before `$wpdb` exists, so it can only read constants and
566 + * the `$table_prefix` global that wp-config.php itself assigns. Normally
567 + * the two never both run — enable() writes XSPEED_OC_SALT and both sides
568 + * read that constant — but where wp-config is NOT writable no constant is
569 + * ever written, and then both fallbacks are live at once in different
570 + * processes. Seeding them differently made "Test connection" verify a
571 + * different key space than the cache actually writes to: on ACL/namespaced
572 + * Redis (xCloud) that reports success while writes are refused, or reports
573 + * a failure while caching is fine. (PR #390 QA round 2, issue 2)
574 + *
575 + * Two specific traps this alignment closes:
576 + *
577 + * - WP_HOME / WP_SITEURL are OPTIONAL and absent from a stock
578 + * wp-config.php, so the drop-in's URL part is usually EMPTY while
579 + * get_site_url() always returns a real URL. Using get_site_url() here
580 + * therefore diverged on virtually every default install, not just an
581 + * exotic one — so this reads the same constants, and appends ABSPATH
582 + * on the same condition, rather than reaching for the richer value.
583 + * - `$wpdb->prefix` is PER-BLOG on multisite (`wp_2_` on a sub-site)
584 + * while `$table_prefix` is always the base prefix. The drop-in reads
585 + * the salt once and separates sub-sites with blog_prefix instead, so
586 + * `$table_prefix` is the value that matches; `$wpdb->prefix` would
587 + * hand every sub-site a different salt.
588 + *
589 + * @return string
590 + */
591 + private static function derive_salt(): string {
592 + global $table_prefix;
593 +
594 + $url = '';
595 + if ( defined( 'WP_HOME' ) ) {
596 + $url = (string) WP_HOME;
597 + } elseif ( defined( 'WP_SITEURL' ) ) {
598 + $url = (string) WP_SITEURL;
599 + }
600 +
601 + $parts = array(
602 + $url,
603 + defined( 'DB_NAME' ) ? (string) DB_NAME : '',
604 + isset( $table_prefix ) ? (string) $table_prefix : '',
605 + );
606 + if ( '' === $url ) {
607 + $parts[] = defined( 'ABSPATH' ) ? (string) ABSPATH : '';
608 + }
609 +
610 + $seed = implode( '|', $parts );
611 + if ( '' === trim( $seed, '|' ) ) {
612 + // Nothing identifying available. Mirrors the drop-in's own
613 + // last-resort seed so the two still agree.
614 + $seed = 'xspeed';
615 + }
616 +
617 + return 'xs' . substr( md5( $seed ), 0, 12 );
618 + }
619 +
620 + /**
361 621 * Build a probe key for the write-verification round-trip. It must land in
362 622 * the same key space the drop-in writes to, so an ACL namespace restriction
363 623 * (~<prefix>:*) is exercised. The drop-in salts keys as
364 - * `{salt}:{prefix}:{group}:{key}` where the salt is the user's key prefix,
365 - * so prefixing the probe with that value makes it match the allowed pattern
366 - * on namespaced hosts (xCloud) while staying harmless everywhere else.
624 + * `{salt}:{prefix}:{group}:{key}`, so prefixing the probe with the same
625 + * salt makes it match the allowed pattern on namespaced hosts (xCloud)
626 + * while staying harmless everywhere else.
367 627 *
368 628 * @param array $opts Settings array.
369 629 * @return string
370 630 */
371 631 private static function probe_key( array $opts ): string {
372 - $prefix = self::str( $opts, 'key_prefix', '' );
373 - $suffix = 'xspeed-oc-probe';
374 - return '' !== $prefix ? $prefix . ':' . $suffix : $suffix;
632 + return self::effective_salt( $opts ) . ':xspeed-oc-probe';
375 633 }
376 634
377 635 /**
378 636 * Message for a connect-OK-but-write-denied result. Points ACL/namespaced
@@ -387,9 +645,9 @@
387 645 private static function write_denied_message( array $opts, string $host, int $port ): string {
388 646 $has_prefix = '' !== self::str( $opts, 'key_prefix', '' );
389 647 $hint = $has_prefix
390 648 ? 'The Redis user may lack write permission for this key prefix (NOPERM).'
391 - : 'On ACL/namespaced Redis (e.g. xCloud), set Cache Key Prefix to the host\'s "Redis Object Cache Key" so writes land in the permitted namespace.';
649 + : 'On ACL/namespaced Redis (e.g. xCloud), set Cache key prefix to the host\'s "Redis Object Cache Key" so writes land in the permitted namespace.';
392 650 return "Connected to Redis at {$host}:{$port}, but the cache could not store data. {$hint}";
393 651 }
394 652
395 653 /**
@@ -399,9 +657,56 @@
399 657 *
400 658 * @param array $opts Settings array.
401 659 * @return array{ok:bool,message:string,steps:array<string,bool>,test:array,detect:array}
402 660 */
403 - public static function enable( array $opts ): array {
661 + public static function enable( array $opts, array $args = array() ): array {
662 + /*
663 + * Another plugin's object-cache.php is a switch, not an install: its
664 + * owner has to be out of the picture first or it puts its file back
665 + * (W3TC on the next admin request, LiteSpeed on its next save). The
666 + * panel always refused here while REST, CLI and MCP overwrote the
667 + * file, so every entry point now refuses unless the caller asked for
668 + * the switch. (#686)
669 + */
670 + if ( self::foreign_dropin_present() ) {
671 + if ( empty( $args['takeover'] ) ) {
672 + $owner = Object_Cache_Takeover::owner();
673 + return array(
674 + 'ok' => false,
675 + 'needs_takeover' => true,
676 + 'message' => Object_Cache_Takeover::refusal_message( $owner ),
677 + 'steps' => array(
678 + 'connection' => false,
679 + 'wp_config' => false,
680 + 'drop_in' => false,
681 + 'verified' => false,
682 + ),
683 + 'owner' => $owner,
684 + 'detect' => self::detect( true ),
685 + );
686 + }
687 + return Object_Cache_Takeover::run( $opts );
688 + }
689 +
690 + // Enabling from scratch starts a new session, so an earlier switch's
691 + // record no longer describes anything to put back. Re-running enable
692 + // over our own drop-in (a re-sync, an import) keeps it.
693 + if ( ! self::is_our_dropin_present() ) {
694 + Object_Cache_Takeover::forget();
695 + }
696 + return self::install( $opts );
697 + }
698 +
699 + /**
700 + * Test, write the wp-config block and install our drop-in. The shared
701 + * second half of enable() and of a switch from another plugin; neither
702 + * calls it while a foreign drop-in is in place.
703 + *
704 + * @param array $opts Settings array.
705 + * @param array|null $test A connection test already run moments ago.
706 + * @return array{ok:bool,message:string,steps:array<string,bool>,test:array,detect:array}
707 + */
708 + public static function install( array $opts, ?array $test = null ): array {
404 709 $steps = array(
405 710 'connection' => false,
406 711 'wp_config' => false,
407 712 'drop_in' => false,
@@ -408,9 +713,9 @@
408 713 'verified' => false,
409 714 );
410 715
411 716 // 1. Don't write anything until the backend actually answers.
412 - $test = self::test_connection( $opts );
717 + $test = $test ?? self::test_connection( $opts );
413 718 if ( ! $test['ok'] ) {
414 719 return array(
415 720 'ok' => false,
416 721 'message' => 'Could not enable: ' . $test['message'],
@@ -415,13 +720,21 @@
415 720 'ok' => false,
416 721 'message' => 'Could not enable: ' . $test['message'],
417 722 'steps' => $steps,
418 723 'test' => $test,
419 - 'detect' => self::detect(),
724 + 'detect' => self::detect( true ),
420 725 );
421 726 }
422 727 $steps['connection'] = true;
423 728
729 + // Values from an earlier xSpeed session are still in Redis (keys
730 + // have no TTL, `alloptions` included), and the drop-in would read
731 + // them back as current. Only when ours is not already the live cache:
732 + // re-running enable over a working install must not cool it.
733 + if ( ! self::our_dropin_is_live() ) {
734 + self::purge_namespace( $opts );
735 + }
736 +
424 737 // 2. Write the XSPEED_OC_* constants into wp-config.php.
425 738 $steps['wp_config'] = self::write_wp_config( $opts );
426 739
427 740 // 3. Install our drop-in.
@@ -452,22 +765,82 @@
452 765 * Full reverse of enable(): remove drop-in + strip our wp-config block.
453 766 *
454 767 * @return array{ok:bool,message:string,steps:array<string,bool>,detect:array}
455 768 */
456 - public static function disable(): array {
769 + public static function disable( array $args = array() ): array {
770 + // A drop-in owned by another plugin is left in place by
771 + // remove_dropin(), which then reports success because nothing of ours
772 + // is there to remove. Reporting "disabled" for that is a lie: the site
773 + // still has someone else's object cache running. Say so instead.
774 + $dropin = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : '';
775 + if ( '' !== $dropin && file_exists( $dropin ) && ! self::is_our_dropin_present() ) {
776 + return array(
777 + 'ok' => false,
778 + 'message' => 'The object-cache drop-in belongs to another plugin, so xSpeed left it alone. Turn its object cache off in that plugin instead.',
779 + 'steps' => array(
780 + 'drop_in' => false,
781 + 'wp_config' => false,
782 + ),
783 + 'detect' => self::detect( true ),
784 + );
785 + }
786 +
787 + // Empty our namespace before the drop-in goes, so a later enable
788 + // never reads this session's values back. Our cache stays loaded
789 + // until this request ends, so whatever it writes after this point
790 + // (a restore's own option writes) is flushed again at shutdown.
791 + // (#686)
792 + if ( self::is_our_dropin_present() ) {
793 + if ( self::our_dropin_is_live() ) {
794 + self::flush();
795 + add_action( 'shutdown', array( __CLASS__, 'flush' ), PHP_INT_MAX );
796 + } elseif ( class_exists( __NAMESPACE__ . '\\Settings_Manager' ) ) {
797 + self::purge_namespace( (array) Settings_Manager::get( 'object-cache' ) );
798 + }
799 + }
800 +
457 801 $dropin_removed = self::remove_dropin();
458 802 $config_removed = self::remove_wp_config();
459 803
804 + /*
805 + * The sidecar is the config on a host where wp-config.php is read-only,
806 + * and it carries the Redis password. Leaving it behind would keep a
807 + * plaintext credential on disk for a feature the admin just switched
808 + * off, and a later re-enable would silently pick up stale credentials
809 + * from a file nothing in this path had touched.
810 + */
811 + self::delete_sidecar();
812 +
813 + $message = $dropin_removed
814 + ? 'Object cache disabled. Drop-in removed and wp-config.php cleaned.'
815 + : 'Could not remove the drop-in — wp-content may not be writable.';
816 +
817 + // Put back the plugin xSpeed switched from, when asked. Without the
818 + // ask, the record describes nothing any more.
819 + $restored = null;
820 + if ( $dropin_removed && ! empty( $args['restore'] ) ) {
821 + $restored = Object_Cache_Takeover::restore();
822 + $message .= ' ' . $restored['message'];
823 + } elseif ( $dropin_removed ) {
824 + // Not put back, but its namespace is cleared all the same, so a
825 + // later manual re-enable does not start from the switch's
826 + // snapshot.
827 + Object_Cache_Takeover::discard();
828 + }
829 +
460 830 return array(
461 - 'ok' => $dropin_removed,
462 - 'message' => $dropin_removed
463 - ? 'Object cache disabled. Drop-in removed and wp-config.php cleaned.'
464 - : 'Could not remove the drop-in — wp-content may not be writable.',
465 - 'steps' => array(
831 + // Whether xSpeed's object cache is off. A restore that could not
832 + // complete is reported beside it, in `restored` and the message:
833 + // the disable itself still happened. (#687)
834 + 'ok' => $dropin_removed,
835 + 'restored' => null === $restored ? null : $restored['ok'],
836 + 'message' => $message,
837 + 'steps' => array(
466 838 'drop_in' => $dropin_removed,
467 839 'wp_config' => $config_removed,
840 + 'restored' => null !== $restored && $restored['ok'],
468 841 ),
469 - 'detect' => self::detect(),
842 + 'detect' => self::detect( true ),
470 843 );
471 844 }
472 845
473 846 /**
@@ -499,9 +872,11 @@
499 872 if ( $is_xspeed ) {
500 873 if ( $existing === $source_contents ) {
501 874 return true;
502 875 }
503 - return (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
876 + $written = (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
877 + self::invalidate_compiled( $target );
878 + return $written;
504 879 }
505 880
506 881 // Foreign drop-in — back it up before overwriting.
507 882 $upload = wp_upload_dir( null, false );
@@ -516,9 +891,11 @@
516 891 $fs->delete( $target );
517 892 }
518 893 }
519 894
520 - return (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
895 + $written = (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
896 + self::invalidate_compiled( $target );
897 + return $written;
521 898 }
522 899
523 900 /**
524 901 * Remove our drop-in (only if it's ours). Returns true when no xSpeed
@@ -535,8 +912,9 @@
535 912 }
536 913 $contents = $fs->get_contents( $target );
537 914 if ( is_string( $contents ) && false !== strpos( $contents, self::DROPIN_TAG ) ) {
538 915 wp_delete_file( $target );
916 + self::invalidate_compiled( $target );
539 917 return ! file_exists( $target );
540 918 }
541 919 // Not ours — leave it, but report success (nothing of ours to remove).
542 920 return true;
@@ -545,21 +923,299 @@
545 923 /**
546 924 * Write the XSPEED_OC_* constants between our markers in wp-config.php.
547 925 * Idempotent: replaces an existing block. Reversible via remove_wp_config().
548 926 */
549 - public static function write_wp_config( array $opts ): bool {
927 + /** Sidecar holding the config when wp-config.php cannot be written. */
928 + private const SIDECAR_FILE = 'xspeed-object-cache.php';
929 +
930 + /**
931 + * Absolute path of the config sidecar.
932 + *
933 + * Lives beside the drop-in in wp-content/ rather than under
934 + * wp-content/cache/, which a purge empties -- losing the settings on the
935 + * next purge would be a far stranger bug than the one this solves.
936 + */
937 + public static function sidecar_path(): string {
938 + return WP_CONTENT_DIR . '/' . self::SIDECAR_FILE;
939 + }
940 +
941 + /**
942 + * Write the config sidecar. Used when wp-config.php is not writable, which
943 + * is the norm on several managed hosts -- there the panel could otherwise
944 + * only ever tell the user to paste a snippet by hand.
945 + *
946 + * Written as PHP, not JSON: wp-content/ is web-reachable, and a .json here
947 + * would serve the Redis password to anyone who guessed the filename. A PHP
948 + * file with an ABSPATH guard returns nothing when requested directly.
949 + *
950 + * @param array<string,mixed> $opts Effective settings to persist.
951 + */
952 + public static function write_sidecar( array $opts ): bool {
550 953 $fs = self::fs();
954 + if ( ! $fs ) {
955 + return false;
956 + }
957 +
958 + $payload = array();
959 + foreach ( self::SIDECAR_KEYS as $key ) {
960 + if ( array_key_exists( $key, $opts ) ) {
961 + $payload[ $key ] = $opts[ $key ];
962 + }
963 + }
964 +
965 + $body = "<?php\n"
966 + . "/**\n"
967 + . " * xSpeed object-cache configuration.\n"
968 + . " *\n"
969 + . " * Written by xSpeed because wp-config.php is not writable on this host.\n"
970 + . " * The drop-in reads this before WordPress loads. Edit the Object Cache\n"
971 + . " * panel rather than this file -- it is rewritten on every save.\n"
972 + . " */\n"
973 + . "defined( 'ABSPATH' ) || exit;\n\n"
974 + . 'return ' . var_export( $payload, true ) . ";\n";
975 +
976 + /*
977 + * Write to a temp file and rename() into place. The drop-in `include`s
978 + * this file BEFORE WordPress loads, so a reader that catches a
979 + * half-written copy gets a PHP parse error -- a white screen on every
980 + * request, not a degraded cache. rename() within the same directory is
981 + * atomic on every filesystem WordPress supports, so a reader sees
982 + * either the whole old file or the whole new one.
983 + */
984 + $path = self::sidecar_path();
985 + $tmp = $path . '.' . wp_generate_password( 8, false ) . '.tmp';
986 +
987 + if ( ! $fs->put_contents( $tmp, $body, FS_CHMOD_FILE ) ) {
988 + return false;
989 + }
990 + // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- WP_Filesystem has no atomic move; rename() is the whole point here.
991 + if ( ! @rename( $tmp, $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- failure is reported by the return value.
992 + $fs->delete( $tmp );
993 + return false;
994 + }
995 +
996 + /*
997 + * Managed hosts -- the ones this sidecar exists for -- often run
998 + * opcache with validate_timestamps off, where `include` would keep
999 + * returning the previously compiled array however many times we
1000 + * rewrite the file. That is the exact panel-says-one-thing,
1001 + * runtime-does-another failure this change exists to remove.
1002 + */
1003 + if ( function_exists( 'opcache_invalidate' ) ) {
1004 + @opcache_invalidate( $path, true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- opcache may be disabled or restricted; nothing to do either way.
1005 + }
1006 +
1007 + self::forget_sidecar();
1008 + return true;
1009 + }
1010 +
1011 + /**
1012 + * Remove the sidecar. Called when wp-config.php becomes writable again, so
1013 + * two sources can never disagree about the same setting.
1014 + */
1015 + public static function delete_sidecar(): bool {
1016 + $path = self::sidecar_path();
1017 + if ( ! file_exists( $path ) ) {
1018 + return true;
1019 + }
1020 + $fs = self::fs();
1021 + $ok = $fs ? (bool) $fs->delete( $path ) : false;
1022 + if ( $ok ) {
1023 + self::forget_sidecar();
1024 + }
1025 + return $ok;
1026 + }
1027 +
1028 + /**
1029 + * Settings the sidecar carries. Mirrors the fields wp_config_block()
1030 + * emits, so the two storage paths describe the same configuration.
1031 + */
1032 + private const SIDECAR_KEYS = array(
1033 + 'backend',
1034 + 'redis_host',
1035 + 'redis_port',
1036 + 'redis_user',
1037 + 'redis_password',
1038 + 'redis_database',
1039 + 'memcached_host',
1040 + 'memcached_port',
1041 + 'key_prefix',
1042 + 'connection_timeout',
1043 + 'persistent',
1044 + );
1045 +
1046 + /** Memoized sidecar contents; null until first read. */
1047 + private static $sidecar_cache = null;
1048 +
1049 + /** Forget the memoized sidecar. */
1050 + public static function forget_sidecar(): void {
1051 + self::$sidecar_cache = null;
1052 + }
1053 +
1054 + /**
1055 + * Read the sidecar, or an empty array when there is none.
1056 + *
1057 + * @return array<string,mixed>
1058 + */
1059 + public static function read_sidecar(): array {
1060 + if ( null !== self::$sidecar_cache ) {
1061 + return self::$sidecar_cache;
1062 + }
1063 + $path = self::sidecar_path();
1064 + if ( ! file_exists( $path ) || ! is_readable( $path ) ) {
1065 + self::$sidecar_cache = array();
1066 + return self::$sidecar_cache;
1067 + }
1068 + $data = include $path;
1069 + self::$sidecar_cache = is_array( $data ) ? $data : array();
1070 + return self::$sidecar_cache;
1071 + }
1072 +
1073 + /**
1074 + * Host and port of the first server in a `$memcached_servers` global.
1075 + *
1076 + * Memcached has no constant convention the way Redis has WP_REDIS_*; this
1077 + * global IS the convention, and hosts write it in two shapes:
1078 + *
1079 + * array( array( 'host', 11211 ) ) // W3TC pair form
1080 + * array( 'default' => array( 'host:11211' ) ) // Memcached Object Cache
1081 + *
1082 + * Reading only the first left the second taking the whole "host:port"
1083 + * string as the hostname, or missing it entirely because its bucket is
1084 + * keyed `default` rather than 0.
1085 + *
1086 + * The drop-in carries `xspeed_oc_first_memcached_server()`, which must
1087 + * behave identically -- it loads before WordPress and cannot call this
1088 + * class. ObjectCacheConstantParityTest holds the two together. (#398)
1089 + *
1090 + * @param mixed $servers The global's value, unvalidated.
1091 + * @return array{0:?string,1:?int}|null Host and port, either possibly null.
1092 + */
1093 + public static function first_memcached_server( $servers ): ?array {
1094 + if ( ! is_array( $servers ) || array() === $servers ) {
1095 + return null;
1096 + }
1097 +
1098 + $bucket = array_key_exists( 0, $servers ) ? $servers[0] : reset( $servers );
1099 +
1100 + /*
1101 + * A bucket is EITHER a [host, port] pair or a list of server entries.
1102 + * Telling them apart by shape, not by nesting depth: descending into
1103 + * `array( 'mc.example', 11211 )` yields the host string and drops the
1104 + * port on the floor, which is the commonest form there is.
1105 + */
1106 + $entry = $bucket;
1107 + if ( is_array( $bucket ) && isset( $bucket[0] ) && is_array( $bucket[0] ) ) {
1108 + $entry = $bucket[0];
1109 + }
1110 +
1111 + if ( is_array( $entry ) ) {
1112 + $host = isset( $entry[0] ) && ! is_array( $entry[0] ) ? (string) $entry[0] : null;
1113 + $port = isset( $entry[1] ) && ! is_array( $entry[1] ) ? (int) $entry[1] : null;
1114 + // A single-element list, array( 'host:port' ), is the keyed form's
1115 + // bucket rather than a pair -- fall through to the string parser.
1116 + if ( null !== $host && null === $port && is_string( $entry[0] ) && false !== strpos( $entry[0], ':' ) ) {
1117 + $entry = $entry[0];
1118 + } else {
1119 + return ( null === $host && null === $port ) ? null : array( $host, $port );
1120 + }
1121 + }
1122 +
1123 + if ( ! is_string( $entry ) || '' === $entry ) {
1124 + return null;
1125 + }
1126 +
1127 + // "host:port", or a bare host. Split only the LAST colon, and only when
1128 + // what follows is numeric -- a unix socket path is a host with no port.
1129 + $at = strrpos( $entry, ':' );
1130 + if ( false !== $at && ctype_digit( substr( $entry, $at + 1 ) ) ) {
1131 + return array( substr( $entry, 0, $at ), (int) substr( $entry, $at + 1 ) );
1132 + }
1133 + return array( $entry, null );
1134 + }
1135 +
1136 + /**
1137 + * Names of the constants xSpeed itself wrote into wp-config.php.
1138 + *
1139 + * Ownership is decided by LOCATION, not by name. Our block is fenced by
1140 + * CONFIG_BEGIN / CONFIG_END, so a define inside it is one we wrote and a
1141 + * define anywhere else belongs to the host -- even when both are called
1142 + * `XSPEED_OC_HOST`, which is exactly what a user pasting our own snippet
1143 + * by hand produces.
1144 + *
1145 + * Judging by prefix instead is what made the panel treat xSpeed's own
1146 + * values as host-pinned: the field locked, the "manage this here" control
1147 + * could not unlock it, and Revert handed the field back to our snapshot
1148 + * rather than to the host. (#398)
1149 + *
1150 + * @return string[] Constant names, empty when the block is absent.
1151 + */
1152 +
1153 + public static function our_constants(): array {
1154 + if ( null !== self::$our_constants_cache ) {
1155 + return self::$our_constants_cache;
1156 + }
1157 + $cache = array();
1158 +
551 1159 $wp_config = ABSPATH . 'wp-config.php';
1160 + if ( ! file_exists( $wp_config ) || ! is_readable( $wp_config ) ) {
1161 + self::$our_constants_cache = $cache;
1162 + return $cache;
1163 + }
1164 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading our own block; WP_Filesystem is not always initialised on the read path.
1165 + $config = (string) @file_get_contents( $wp_config ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable wp-config just means "we own nothing".
1166 + if ( '' === $config ) {
1167 + self::$our_constants_cache = $cache;
1168 + return $cache;
1169 + }
1170 +
1171 + $pattern = '/' . preg_quote( self::CONFIG_BEGIN, '/' ) . '(.*?)' . preg_quote( self::CONFIG_END, '/' ) . '/s';
1172 + if ( ! preg_match( $pattern, $config, $m ) ) {
1173 + self::$our_constants_cache = $cache;
1174 + return $cache;
1175 + }
1176 + if ( preg_match_all( "/define\\(\\s*'([A-Z0-9_]+)'/", $m[1], $names ) ) {
1177 + $cache = $names[1];
1178 + }
1179 + self::$our_constants_cache = $cache;
1180 + return $cache;
1181 + }
1182 +
1183 + /**
1184 + * Memoized result of our_constants(); null until the block is first read.
1185 + *
1186 + * @var string[]|null
1187 + */
1188 + private static $our_constants_cache = null;
1189 +
1190 + /**
1191 + * Forget the memoized block scan. Every write that changes the block must
1192 + * call this, or the same request keeps answering from the pre-write copy.
1193 + */
1194 + public static function forget_our_constants(): void {
1195 + self::$our_constants_cache = null;
1196 + }
1197 +
1198 + public static function write_wp_config( array $opts, array $force = array() ): bool {
1199 + $fs = self::fs();
1200 + $wp_config = ABSPATH . 'wp-config.php';
552 1201 if ( ! $fs || ! file_exists( $wp_config ) || ! $fs->is_writable( $wp_config ) ) {
553 - return false;
1202 + /*
1203 + * wp-config.php is read-only on several managed hosts. Fall back to
1204 + * a sidecar in wp-content/ -- writable wherever the drop-in itself
1205 + * could be installed, so the panel keeps working instead of telling
1206 + * the user to paste a snippet by hand. (#398)
1207 + */
1208 + return self::write_sidecar( $opts );
554 1209 }
555 1210
1211 +
556 1212 $config = $fs->get_contents( $wp_config );
557 1213 if ( ! is_string( $config ) ) {
558 1214 return false;
559 1215 }
560 1216
561 - $block = self::wp_config_block( $opts );
1217 + $block = self::wp_config_block( $opts, $force );
562 1218
563 1219 // Replace an existing xSpeed block if present, else insert after <?php.
564 1220 // IMPORTANT: $block is inserted via preg_replace_callback returning it
565 1221 // VERBATIM — never as a preg_replace replacement string. In a
@@ -589,9 +1245,24 @@
589 1245 1
590 1246 );
591 1247 }
592 1248
593 - return (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1249 + $written = (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1250 + if ( $written ) {
1251 + self::invalidate_compiled( $wp_config );
1252 + // The block just changed; a memoized scan from earlier in this
1253 + // request would still name the previous set. (#398)
1254 + self::forget_our_constants();
1255 +
1256 + // Only NOW is the block durable, so only now is a sidecar left
1257 + // from an earlier read-only spell safely redundant. Deleting it
1258 + // before the write -- is_writable() is not a promise the write
1259 + // lands; get_contents() can fail, and put_contents() can fail on a
1260 + // full disk or an SELinux denial -- would drop the live config and
1261 + // leave the site on built-in defaults.
1262 + self::delete_sidecar();
1263 + }
1264 + return $written;
594 1265 }
595 1266
596 1267 /**
597 1268 * Strip our wp-config block. Returns true if the block is gone afterward.
@@ -610,9 +1281,18 @@
610 1281 return false;
611 1282 }
612 1283 $pattern = '/' . preg_quote( self::CONFIG_BEGIN, '/' ) . '.*?' . preg_quote( self::CONFIG_END, '/' ) . "\s*/s";
613 1284 $config = preg_replace( $pattern, '', $config );
614 - return (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1285 + $removed = (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1286 + if ( $removed ) {
1287 + self::invalidate_compiled( $wp_config );
1288 + // A scan from earlier in this request would still name the
1289 + // constants we just deleted, so origins() would report a field as
1290 + // ours -- editable -- when a host define is now the only source
1291 + // and the field should read as pinned.
1292 + self::forget_our_constants();
1293 + }
1294 + return $removed;
615 1295 }
616 1296
617 1297 /**
618 1298 * The marker-wrapped constants block written into wp-config.php. Uses
@@ -618,39 +1298,134 @@
618 1298 * The marker-wrapped constants block written into wp-config.php. Uses
619 1299 * XSPEED_OC_* names (our drop-in reads these first, then falls back to
620 1300 * WP_REDIS_* for interop).
621 1301 */
622 - private static function wp_config_block( array $opts ): string {
1302 + private static function wp_config_block( array $opts, array $force = array() ): string {
1303 + // Fields the caller has decided we own, whatever pinned_elsewhere()
1304 + // would otherwise say. Used when an admin saved an override: they were
1305 + // told the host's define would stop applying, and this is the write
1306 + // that makes that true. (#398)
1307 + self::$force_fields = $force;
623 1308 $backend = (string) ( $opts['backend'] ?? 'redis' );
624 1309 $lines = array( self::CONFIG_BEGIN );
625 1310 $lines[] = "define( 'XSPEED_OC_BACKEND', '" . self::esc( $backend ) . "' );";
626 1311
627 1312 if ( 'memcached' === $backend ) {
628 - $lines[] = "define( 'XSPEED_OC_HOST', '" . self::esc( self::str( $opts, 'memcached_host', '127.0.0.1' ) ) . "' );";
629 - $lines[] = "define( 'XSPEED_OC_PORT', " . self::int( $opts, 'memcached_port', 11211 ) . ' );';
1313 + // XSPEED_OC_MC_*, not the Redis pair: one shared name meant enabling
1314 + // Redis overwrote the Memcached host/port. (#398)
1315 + if ( ! self::pinned_elsewhere( 'memcached_host' ) ) {
1316 + $lines[] = "define( 'XSPEED_OC_MC_HOST', '" . self::esc( self::str( $opts, 'memcached_host', '127.0.0.1' ) ) . "' );";
1317 + }
1318 + if ( ! self::pinned_elsewhere( 'memcached_port' ) ) {
1319 + $lines[] = "define( 'XSPEED_OC_MC_PORT', " . self::int( $opts, 'memcached_port', 11211 ) . ' );';
1320 + }
630 1321 } else {
631 - $lines[] = "define( 'XSPEED_OC_HOST', '" . self::esc( self::str( $opts, 'redis_host', '127.0.0.1' ) ) . "' );";
632 - $lines[] = "define( 'XSPEED_OC_PORT', " . self::int( $opts, 'redis_port', 6379 ) . ' );';
1322 + if ( ! self::pinned_elsewhere( 'redis_host' ) ) {
1323 + $lines[] = "define( 'XSPEED_OC_HOST', '" . self::esc( self::str( $opts, 'redis_host', '127.0.0.1' ) ) . "' );";
1324 + }
1325 + if ( ! self::pinned_elsewhere( 'redis_port' ) ) {
1326 + $lines[] = "define( 'XSPEED_OC_PORT', " . self::int( $opts, 'redis_port', 6379 ) . ' );';
1327 + }
633 1328 $user = self::str( $opts, 'redis_user', '' );
634 - if ( '' !== $user ) {
1329 + if ( '' !== $user && ! self::pinned_elsewhere( 'redis_user' ) ) {
635 1330 $lines[] = "define( 'XSPEED_OC_USER', '" . self::esc( $user ) . "' );";
636 1331 }
637 1332 $pass = self::str( $opts, 'redis_password', '' );
638 - if ( '' !== $pass ) {
1333 + if ( '' !== $pass && ! self::pinned_elsewhere( 'redis_password' ) ) {
639 1334 $lines[] = "define( 'XSPEED_OC_PASSWORD', '" . self::esc( $pass ) . "' );";
640 1335 }
641 - $lines[] = "define( 'XSPEED_OC_DATABASE', " . self::int( $opts, 'redis_database', 0 ) . ' );';
642 - $lines[] = "define( 'XSPEED_OC_TIMEOUT', " . self::int( $opts, 'connection_timeout', 1 ) . ' );';
643 - $lines[] = "define( 'XSPEED_OC_PERSISTENT', " . ( ! empty( $opts['persistent'] ) ? 'true' : 'false' ) . ' );';
1336 + if ( ! self::pinned_elsewhere( 'redis_database' ) ) {
1337 + $lines[] = "define( 'XSPEED_OC_DATABASE', " . self::int( $opts, 'redis_database', 0 ) . ' );';
1338 + }
1339 + if ( ! self::pinned_elsewhere( 'connection_timeout' ) ) {
1340 + $lines[] = "define( 'XSPEED_OC_TIMEOUT', " . self::int( $opts, 'connection_timeout', 1 ) . ' );';
1341 + }
1342 + if ( ! self::pinned_elsewhere( 'persistent' ) ) {
1343 + $lines[] = "define( 'XSPEED_OC_PERSISTENT', " . ( ! empty( $opts['persistent'] ) ? 'true' : 'false' ) . ' );';
1344 + }
644 1345 }
645 - $prefix = self::str( $opts, 'key_prefix', '' );
646 - if ( '' !== $prefix ) {
647 - $lines[] = "define( 'XSPEED_OC_SALT', '" . self::esc( $prefix ) . "' );";
1346 + // Always emit a salt (#390): a blank Cache Key Prefix derives a per-site
1347 + // value rather than leaving keys unnamespaced, which collides when
1348 + // several sites share one Redis/Memcached server.
1349 + //
1350 + // Unless a foreign define already owns it (#398). Emitting ours would
1351 + // outrank the host's WP_REDIS_PREFIX, and on an ACL/namespaced Redis a
1352 + // prefix that does not match the host's exactly means every write is
1353 + // denied with NOPERM -- so a derived salt there is worse than none.
1354 + // The host's define IS the namespace in that case, and it is already
1355 + // non-empty, so the collision #390 closes cannot reopen.
1356 + if ( ! self::pinned_elsewhere( 'key_prefix' ) ) {
1357 + $lines[] = "define( 'XSPEED_OC_SALT', '" . self::esc( self::effective_salt( $opts ) ) . "' );";
648 1358 }
649 1359 $lines[] = self::CONFIG_END;
1360 + self::$force_fields = array();
650 1361 return implode( "\n", $lines ) . "\n";
651 1362 }
652 1363
1364 + /**
1365 + * Fields the current block write owns outright. Set for the duration of one
1366 + * wp_config_block() call; see the $force parameter there.
1367 + *
1368 + * @var string[]
1369 + */
1370 + private static array $force_fields = array();
1371 +
1372 + /**
1373 + * Is this field already pinned by a constant we are not about to write?
1374 + *
1375 + * Enable() resolves settings through Settings_Manager, so on a
1376 + * host-provisioned site those values came FROM wp-config in the first
1377 + * place -- typically WP_REDIS_*. Writing them back out under our own
1378 + * XSPEED_OC_* names, which outrank every alias, would freeze a snapshot:
1379 + * when the host later rotated the password, the site would keep
1380 + * authenticating with our stale copy and silently drop to a
1381 + * non-persistent cache. It would also re-emit a credential as a second
1382 + * plaintext literal, which is the thing sourcing it from a constant
1383 + * avoids. So leave the host's define alone and emit nothing for it. (#398)
1384 + */
1385 + private static function pinned_elsewhere( string $field ): bool {
1386 + if ( in_array( $field, self::$force_fields, true ) ) {
1387 + return false;
1388 + }
1389 + if ( ! class_exists( '\\XSpeed\\Settings_Manager' ) ) {
1390 + return false;
1391 + }
1392 + $module = \XSpeed\Module_Registry::get( 'object-cache' );
1393 + if ( ! $module ) {
1394 + return false;
1395 + }
1396 +
1397 + // An admin who deliberately overrode this field asked us to shadow the
1398 + // host's define -- they were told so in as many words before the field
1399 + // unlocked. Protecting it here would silently drop their value on the
1400 + // next enable, which is the same silent-no-op failure the whole
1401 + // pinned-field contract exists to prevent. (#398)
1402 + if ( \XSpeed\Settings_Manager::is_overridden( 'object-cache', $field ) ) {
1403 + return false;
1404 + }
1405 +
1406 + // Somebody else's define, anywhere in this field's list, is protected --
1407 + // even when our own XSPEED_OC_* copy currently outranks it. Testing only
1408 + // the WINNING constant made an override permanent in a subtler way: on
1409 + // revert we rewrote our copy with the host's value, our copy still
1410 + // outranked theirs, and a later rotation on their side was shadowed
1411 + // forever. Emitting nothing for the field lets the host's define surface
1412 + // again and keep surfacing. (#398)
1413 + return null !== \XSpeed\Settings_Manager::foreign_constant( 'object-cache', $field );
1414 + }
1415 +
1416 + /**
1417 + * Is our marker block present in wp-config.php?
1418 + *
1419 + * Public so a caller can tell "we already manage constants here" from
1420 + * "this site never enabled the object cache" -- rewriting the block is
1421 + * right in the first case and would be an unasked-for file edit in the
1422 + * second. (#398)
1423 + */
1424 + public static function wp_config_has_our_block(): bool {
1425 + return self::wp_config_has_block();
1426 + }
1427 +
653 1428 private static function wp_config_has_block(): bool {
654 1429 $wp_config = ABSPATH . 'wp-config.php';
655 1430 if ( ! file_exists( $wp_config ) ) {
656 1431 return false;
@@ -679,8 +1454,37 @@
679 1454 * (no credentials in a non-interactive context) and fatal in
680 1455 * ftp_fget(). We only need 'direct' — these writes target wp-config.php /
681 1456 * wp-content, both owned by the PHP user on a normal install.
682 1457 */
1458 + /**
1459 + * Drop a file's compiled copy from OPcache after we rewrite or remove it.
1460 + *
1461 + * wp-config.php and object-cache.php are both compiled once and reused.
1462 + * With the default revalidate_freq of 2 seconds, and indefinitely where a
1463 + * host turns validate_timestamps off, requests right after a write still
1464 + * run the old code: the panel read "Off" and "Backend: unknown" just
1465 + * after a switch because the new block's constants were not defined yet,
1466 + * and the old drop-in was still the live cache. (#687)
1467 + *
1468 + * @param string $path Absolute file path.
1469 + */
1470 + public static function invalidate_compiled( string $path ): void {
1471 + if ( function_exists( 'wp_opcache_invalidate' ) ) {
1472 + wp_opcache_invalidate( $path, true );
1473 + } elseif ( function_exists( 'opcache_invalidate' ) ) {
1474 + @opcache_invalidate( $path, true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- opcache may be disabled or restricted; nothing to do either way.
1475 + }
1476 + }
1477 +
1478 + /**
1479 + * The direct filesystem, for Object_Cache_Takeover's copies and deletes.
1480 + *
1481 + * @return \WP_Filesystem_Base|null
1482 + */
1483 + public static function filesystem() {
1484 + return self::fs();
1485 + }
1486 +
683 1487 private static function fs() {
684 1488 global $wp_filesystem;
685 1489 if ( ! function_exists( 'WP_Filesystem' ) ) {
686 1490 require_once ABSPATH . 'wp-admin/includes/file.php';