PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
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 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/class-object-cache.php +636 -34 1.1.01.3.3 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
@@ -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,
@@ -141,9 +147,9 @@
141 147 $port = self::int( $opts, 'redis_port', 6379 );
142 148 $user = self::str( $opts, 'redis_user', '' );
143 149 $pass = self::str( $opts, 'redis_password', '' );
144 150 $db = self::int( $opts, 'redis_database', 0 );
145 - $prefix = self::str( $opts, 'key_prefix', '' );
151 + $prefix = self::effective_salt( $opts );
146 152 $timeout = self::int( $opts, 'connection_timeout', 1 );
147 153 $persist = ! empty( $opts['persistent'] );
148 154
149 155 $lines[] = "define( 'WP_REDIS_HOST', '" . self::esc( $host ) . "' );";
@@ -156,22 +162,18 @@
156 162 if ( '' !== $pass ) {
157 163 $lines[] = "define( 'WP_REDIS_PASSWORD', '" . self::esc( $pass ) . "' );";
158 164 }
159 165 $lines[] = "define( 'WP_REDIS_DATABASE', " . $db . ' );';
160 - if ( '' !== $prefix ) {
161 - $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
162 - }
166 + $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
163 167 $lines[] = "define( 'WP_REDIS_TIMEOUT', " . $timeout . ' );';
164 168 $lines[] = "define( 'WP_REDIS_PERSISTENT', " . ( $persist ? 'true' : 'false' ) . ' );';
165 169 } elseif ( 'memcached' === $backend ) {
166 170 $host = self::str( $opts, 'memcached_host', '127.0.0.1' );
167 171 $port = self::int( $opts, 'memcached_port', 11211 );
168 - $prefix = self::str( $opts, 'key_prefix', '' );
172 + $prefix = self::effective_salt( $opts );
169 173 $lines[] = "global \$memcached_servers;";
170 174 $lines[] = "\$memcached_servers = array( array( '" . self::esc( $host ) . "', " . $port . ' ) );';
171 - if ( '' !== $prefix ) {
172 - $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
173 - }
175 + $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
174 176 } else {
175 177 $lines[] = '// No snippet for backend: ' . $backend;
176 178 }
177 179
@@ -188,8 +190,22 @@
188 190 private const CONFIG_BEGIN = '/* BEGIN xSpeed Object Cache */';
189 191 private const CONFIG_END = '/* END xSpeed Object Cache */';
190 192
191 193 /**
194 + * True when wp-content/object-cache.php exists AND is ours (carries the
195 + * drop-in tag). Lets callers decide whether a re-sync applies without
196 + * exposing the tag itself.
197 + */
198 + public static function is_our_dropin_present(): bool {
199 + $target = WP_CONTENT_DIR . '/object-cache.php';
200 + if ( ! file_exists( $target ) ) {
201 + return false;
202 + }
203 + $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.
204 + return is_string( $contents ) && false !== strpos( $contents, self::DROPIN_TAG );
205 + }
206 +
207 + /**
192 208 * Live connection test against the configured backend. Never throws;
193 209 * returns a structured pass/fail the UI can show before we write anything.
194 210 *
195 211 * @param array $opts Settings array (backend, redis_host, ...).
@@ -279,9 +295,9 @@
279 295 @$redis->del( $probe ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup.
280 296 if ( ! $set || '1' !== (string) $got ) {
281 297 return self::test_result( false, $backend, self::write_denied_message( $opts, $host, $port ), $start );
282 298 }
283 - return self::test_result( true, $backend, "Connected to Redis at {$host}:{$port} (phpredis).", $start );
299 + return self::test_result( true, $backend, self::with_prefix_advisory( "Connected to Redis at {$host}:{$port} (phpredis).", $opts ), $start );
284 300 }
285 301
286 302 // Pure-PHP fallback — our own client, zero dependencies.
287 303 $rc = new Redis_Client( $host, $port, (float) $timeout, false );
@@ -313,14 +329,60 @@
313 329 $rc->close();
314 330 if ( ! $set || '1' !== (string) $got ) {
315 331 return self::test_result( false, $backend, self::write_denied_message( $opts, $host, $port ), $start );
316 332 }
317 - return self::test_result( true, $backend, "Connected to Redis at {$host}:{$port} (built-in client).", $start );
333 + return self::test_result( true, $backend, self::with_prefix_advisory( "Connected to Redis at {$host}:{$port} (built-in client).", $opts ), $start );
318 334 } catch ( \Throwable $e ) {
319 335 return self::test_result( false, $backend, 'Connection error: ' . $e->getMessage() );
320 336 }
321 337 }
322 338
339 + /**
340 + * Redis glob metacharacters that must never appear unescaped in a SCAN
341 + * MATCH pattern. `\` is the escape character itself.
342 + */
343 + private const GLOB_METACHARS = '*?[]\\';
344 +
345 + /**
346 + * Whether a salt contains Redis glob metacharacters.
347 + *
348 + * The salt is interpolated into the drop-in's scoped-flush patterns. The
349 + * drop-in escapes it, so caching and purging are correct either way — but
350 + * an explicit Cache Key Prefix exists to match a host's ACL namespace
351 + * byte-for-byte, and a wildcard in it is almost always a typo rather than
352 + * a real namespace. Reporting it on Test connection is the one place the
353 + * user is already looking at their prefix.
354 + *
355 + * @param string $salt Effective salt.
356 + * @return bool
357 + */
358 + public static function salt_has_glob_metachars( string $salt ): bool {
359 + return strcspn( $salt, self::GLOB_METACHARS ) !== strlen( $salt );
360 + }
361 +
362 + /**
363 + * Append a prefix advisory to an otherwise-successful connection message.
364 + *
365 + * @param string $message Success message.
366 + * @param array $opts Settings array.
367 + * @return string
368 + */
369 + private static function with_prefix_advisory( string $message, array $opts ): string {
370 + // Deliberately the TYPED prefix, not effective_salt(): this advisory
371 + // says "the field you are looking at probably has a typo in it". A
372 + // host-pinned WP_CACHE_KEY_SALT is not editable from this screen and
373 + // purges are correctly scoped regardless (the drop-in escapes it), so
374 + // warning about a host's own namespace would be noise on every ACL
375 + // host. A derived salt is glob-free by construction.
376 + $prefix = self::str( $opts, 'key_prefix', '' );
377 + if ( '' === $prefix || ! self::salt_has_glob_metachars( $prefix ) ) {
378 + return $message;
379 + }
380 + return $message . ' Note: the Cache Key Prefix contains one of * ? [ ] \\.'
381 + . ' Purges stay scoped to this site, but these are wildcard characters'
382 + . ' in Redis — check the prefix matches your host\'s key exactly.';
383 + }
384 +
323 385 private static function test_result( bool $ok, string $backend, string $message, ?float $start = null ): array {
324 386 return array(
325 387 'ok' => $ok,
326 388 'backend' => $backend,
@@ -357,22 +419,141 @@
357 419 }
358 420 }
359 421
360 422 /**
423 + * Resolve the salt that namespaces this site's cache keys.
424 + *
425 + * An explicit Cache Key Prefix always wins — on ACL/namespaced hosts
426 + * (xCloud) it MUST match the host's "Redis Object Cache Key" or writes are
427 + * denied (NOPERM), so we never override what the user typed.
428 + *
429 + * When the field is blank we derive a stable, per-site salt instead of
430 + * falling back to an empty one. An empty salt makes every key look like
431 + * `:{prefix}:{group}:{key}` — identical on every install — so two sites
432 + * sharing one Redis/Memcached server collide. That is not a theoretical
433 + * clash: `blog-details` / `blog-lookup` are how WordPress resolves which
434 + * site a request belongs to, so the second site reads the first site's
435 + * entries and redirects to it.
436 + *
437 + * The derived value is a hash of the site URL plus the DB name/prefix, so
438 + * it is unique per install, stable across requests (no cache churn), and
439 + * safe to embed in wp-config.php.
440 + *
441 + * @param array $opts Settings array.
442 + * @return string Non-empty salt.
443 + */
444 + public static function effective_salt( array $opts ): string {
445 + $prefix = self::str( $opts, 'key_prefix', '' );
446 + if ( '' !== $prefix ) {
447 + return $prefix;
448 + }
449 +
450 + // A salt WE already wrote is authoritative over a fresh derivation.
451 + // The keys in the backend are named after it, so re-deriving a
452 + // different value would orphan every one of them — a needless
453 + // cache-cooling on an install that is already correctly namespaced.
454 + // This matters because derive_salt()'s rule was corrected (see there):
455 + // without this branch, the next wp-config sync would rewrite the block
456 + // with a new salt and throw away a warm cache on every existing site.
457 + if ( defined( 'XSPEED_OC_SALT' ) && '' !== (string) constant( 'XSPEED_OC_SALT' ) ) {
458 + return (string) constant( 'XSPEED_OC_SALT' );
459 + }
460 +
461 + // A salt the HOST pinned in its own wp-config (outside our block) is
462 + // the next authority. On ACL/namespaced Redis the host grants write
463 + // access to that namespace and no other, so replacing it with a
464 + // derived value gets every write denied (NOPERM) and the site silently
465 + // stops caching.
466 + // WP_REDIS_PREFIX is checked alongside WP_CACHE_KEY_SALT and before it,
467 + // matching the order the schema and the drop-in resolve (#398). It is
468 + // the name Redis Object Cache uses and the one managed hosts actually
469 + // write, so honouring only the older alias left the commonest
470 + // ACL-namespaced case deriving a salt the host denies writes to.
471 + foreach ( array( 'WP_REDIS_PREFIX', 'WP_CACHE_KEY_SALT' ) as $name ) {
472 + if ( defined( $name ) && '' !== (string) constant( $name ) ) {
473 + return (string) constant( $name );
474 + }
475 + }
476 +
477 + return self::derive_salt();
478 + }
479 +
480 + /**
481 + * Build a stable per-site salt for installs that left Cache Key Prefix
482 + * blank. Distinct per install: the site URL separates sites sharing a
483 + * database, and DB name + table prefix separate installs sharing a domain
484 + * (e.g. subdirectory installs).
485 + *
486 + * This MUST stay byte-identical to the drop-in's xspeed_oc_salt(), which
487 + * is the harder constraint of the two: the drop-in loads from
488 + * wp-settings.php before `$wpdb` exists, so it can only read constants and
489 + * the `$table_prefix` global that wp-config.php itself assigns. Normally
490 + * the two never both run — enable() writes XSPEED_OC_SALT and both sides
491 + * read that constant — but where wp-config is NOT writable no constant is
492 + * ever written, and then both fallbacks are live at once in different
493 + * processes. Seeding them differently made "Test connection" verify a
494 + * different key space than the cache actually writes to: on ACL/namespaced
495 + * Redis (xCloud) that reports success while writes are refused, or reports
496 + * a failure while caching is fine. (PR #390 QA round 2, issue 2)
497 + *
498 + * Two specific traps this alignment closes:
499 + *
500 + * - WP_HOME / WP_SITEURL are OPTIONAL and absent from a stock
501 + * wp-config.php, so the drop-in's URL part is usually EMPTY while
502 + * get_site_url() always returns a real URL. Using get_site_url() here
503 + * therefore diverged on virtually every default install, not just an
504 + * exotic one — so this reads the same constants, and appends ABSPATH
505 + * on the same condition, rather than reaching for the richer value.
506 + * - `$wpdb->prefix` is PER-BLOG on multisite (`wp_2_` on a sub-site)
507 + * while `$table_prefix` is always the base prefix. The drop-in reads
508 + * the salt once and separates sub-sites with blog_prefix instead, so
509 + * `$table_prefix` is the value that matches; `$wpdb->prefix` would
510 + * hand every sub-site a different salt.
511 + *
512 + * @return string
513 + */
514 + private static function derive_salt(): string {
515 + global $table_prefix;
516 +
517 + $url = '';
518 + if ( defined( 'WP_HOME' ) ) {
519 + $url = (string) WP_HOME;
520 + } elseif ( defined( 'WP_SITEURL' ) ) {
521 + $url = (string) WP_SITEURL;
522 + }
523 +
524 + $parts = array(
525 + $url,
526 + defined( 'DB_NAME' ) ? (string) DB_NAME : '',
527 + isset( $table_prefix ) ? (string) $table_prefix : '',
528 + );
529 + if ( '' === $url ) {
530 + $parts[] = defined( 'ABSPATH' ) ? (string) ABSPATH : '';
531 + }
532 +
533 + $seed = implode( '|', $parts );
534 + if ( '' === trim( $seed, '|' ) ) {
535 + // Nothing identifying available. Mirrors the drop-in's own
536 + // last-resort seed so the two still agree.
537 + $seed = 'xspeed';
538 + }
539 +
540 + return 'xs' . substr( md5( $seed ), 0, 12 );
541 + }
542 +
543 + /**
361 544 * Build a probe key for the write-verification round-trip. It must land in
362 545 * the same key space the drop-in writes to, so an ACL namespace restriction
363 546 * (~<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.
547 + * `{salt}:{prefix}:{group}:{key}`, so prefixing the probe with the same
548 + * salt makes it match the allowed pattern on namespaced hosts (xCloud)
549 + * while staying harmless everywhere else.
367 550 *
368 551 * @param array $opts Settings array.
369 552 * @return string
370 553 */
371 554 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;
555 + return self::effective_salt( $opts ) . ':xspeed-oc-probe';
375 556 }
376 557
377 558 /**
378 559 * Message for a connect-OK-but-write-denied result. Points ACL/namespaced
@@ -453,11 +634,37 @@
453 634 *
454 635 * @return array{ok:bool,message:string,steps:array<string,bool>,detect:array}
455 636 */
456 637 public static function disable(): array {
638 + // A drop-in owned by another plugin is left in place by
639 + // remove_dropin(), which then reports success because nothing of ours
640 + // is there to remove. Reporting "disabled" for that is a lie: the site
641 + // still has someone else's object cache running. Say so instead.
642 + $dropin = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : '';
643 + if ( '' !== $dropin && file_exists( $dropin ) && ! self::is_our_dropin_present() ) {
644 + return array(
645 + 'ok' => false,
646 + 'message' => 'The object-cache drop-in belongs to another plugin, so xSpeed left it alone. Turn its object cache off in that plugin instead.',
647 + 'steps' => array(
648 + 'drop_in' => false,
649 + 'wp_config' => false,
650 + ),
651 + 'detect' => self::detect(),
652 + );
653 + }
654 +
457 655 $dropin_removed = self::remove_dropin();
458 656 $config_removed = self::remove_wp_config();
459 657
658 + /*
659 + * The sidecar is the config on a host where wp-config.php is read-only,
660 + * and it carries the Redis password. Leaving it behind would keep a
661 + * plaintext credential on disk for a feature the admin just switched
662 + * off, and a later re-enable would silently pick up stale credentials
663 + * from a file nothing in this path had touched.
664 + */
665 + self::delete_sidecar();
666 +
460 667 return array(
461 668 'ok' => $dropin_removed,
462 669 'message' => $dropin_removed
463 670 ? 'Object cache disabled. Drop-in removed and wp-config.php cleaned.'
@@ -545,21 +752,299 @@
545 752 /**
546 753 * Write the XSPEED_OC_* constants between our markers in wp-config.php.
547 754 * Idempotent: replaces an existing block. Reversible via remove_wp_config().
548 755 */
549 - public static function write_wp_config( array $opts ): bool {
756 + /** Sidecar holding the config when wp-config.php cannot be written. */
757 + private const SIDECAR_FILE = 'xspeed-object-cache.php';
758 +
759 + /**
760 + * Absolute path of the config sidecar.
761 + *
762 + * Lives beside the drop-in in wp-content/ rather than under
763 + * wp-content/cache/, which a purge empties -- losing the settings on the
764 + * next purge would be a far stranger bug than the one this solves.
765 + */
766 + public static function sidecar_path(): string {
767 + return WP_CONTENT_DIR . '/' . self::SIDECAR_FILE;
768 + }
769 +
770 + /**
771 + * Write the config sidecar. Used when wp-config.php is not writable, which
772 + * is the norm on several managed hosts -- there the panel could otherwise
773 + * only ever tell the user to paste a snippet by hand.
774 + *
775 + * Written as PHP, not JSON: wp-content/ is web-reachable, and a .json here
776 + * would serve the Redis password to anyone who guessed the filename. A PHP
777 + * file with an ABSPATH guard returns nothing when requested directly.
778 + *
779 + * @param array<string,mixed> $opts Effective settings to persist.
780 + */
781 + public static function write_sidecar( array $opts ): bool {
550 782 $fs = self::fs();
783 + if ( ! $fs ) {
784 + return false;
785 + }
786 +
787 + $payload = array();
788 + foreach ( self::SIDECAR_KEYS as $key ) {
789 + if ( array_key_exists( $key, $opts ) ) {
790 + $payload[ $key ] = $opts[ $key ];
791 + }
792 + }
793 +
794 + $body = "<?php\n"
795 + . "/**\n"
796 + . " * xSpeed object-cache configuration.\n"
797 + . " *\n"
798 + . " * Written by xSpeed because wp-config.php is not writable on this host.\n"
799 + . " * The drop-in reads this before WordPress loads. Edit the Object Cache\n"
800 + . " * panel rather than this file -- it is rewritten on every save.\n"
801 + . " */\n"
802 + . "defined( 'ABSPATH' ) || exit;\n\n"
803 + . 'return ' . var_export( $payload, true ) . ";\n";
804 +
805 + /*
806 + * Write to a temp file and rename() into place. The drop-in `include`s
807 + * this file BEFORE WordPress loads, so a reader that catches a
808 + * half-written copy gets a PHP parse error -- a white screen on every
809 + * request, not a degraded cache. rename() within the same directory is
810 + * atomic on every filesystem WordPress supports, so a reader sees
811 + * either the whole old file or the whole new one.
812 + */
813 + $path = self::sidecar_path();
814 + $tmp = $path . '.' . wp_generate_password( 8, false ) . '.tmp';
815 +
816 + if ( ! $fs->put_contents( $tmp, $body, FS_CHMOD_FILE ) ) {
817 + return false;
818 + }
819 + // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- WP_Filesystem has no atomic move; rename() is the whole point here.
820 + if ( ! @rename( $tmp, $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- failure is reported by the return value.
821 + $fs->delete( $tmp );
822 + return false;
823 + }
824 +
825 + /*
826 + * Managed hosts -- the ones this sidecar exists for -- often run
827 + * opcache with validate_timestamps off, where `include` would keep
828 + * returning the previously compiled array however many times we
829 + * rewrite the file. That is the exact panel-says-one-thing,
830 + * runtime-does-another failure this change exists to remove.
831 + */
832 + if ( function_exists( 'opcache_invalidate' ) ) {
833 + @opcache_invalidate( $path, true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- opcache may be disabled or restricted; nothing to do either way.
834 + }
835 +
836 + self::forget_sidecar();
837 + return true;
838 + }
839 +
840 + /**
841 + * Remove the sidecar. Called when wp-config.php becomes writable again, so
842 + * two sources can never disagree about the same setting.
843 + */
844 + public static function delete_sidecar(): bool {
845 + $path = self::sidecar_path();
846 + if ( ! file_exists( $path ) ) {
847 + return true;
848 + }
849 + $fs = self::fs();
850 + $ok = $fs ? (bool) $fs->delete( $path ) : false;
851 + if ( $ok ) {
852 + self::forget_sidecar();
853 + }
854 + return $ok;
855 + }
856 +
857 + /**
858 + * Settings the sidecar carries. Mirrors the fields wp_config_block()
859 + * emits, so the two storage paths describe the same configuration.
860 + */
861 + private const SIDECAR_KEYS = array(
862 + 'backend',
863 + 'redis_host',
864 + 'redis_port',
865 + 'redis_user',
866 + 'redis_password',
867 + 'redis_database',
868 + 'memcached_host',
869 + 'memcached_port',
870 + 'key_prefix',
871 + 'connection_timeout',
872 + 'persistent',
873 + );
874 +
875 + /** Memoized sidecar contents; null until first read. */
876 + private static $sidecar_cache = null;
877 +
878 + /** Forget the memoized sidecar. */
879 + public static function forget_sidecar(): void {
880 + self::$sidecar_cache = null;
881 + }
882 +
883 + /**
884 + * Read the sidecar, or an empty array when there is none.
885 + *
886 + * @return array<string,mixed>
887 + */
888 + public static function read_sidecar(): array {
889 + if ( null !== self::$sidecar_cache ) {
890 + return self::$sidecar_cache;
891 + }
892 + $path = self::sidecar_path();
893 + if ( ! file_exists( $path ) || ! is_readable( $path ) ) {
894 + self::$sidecar_cache = array();
895 + return self::$sidecar_cache;
896 + }
897 + $data = include $path;
898 + self::$sidecar_cache = is_array( $data ) ? $data : array();
899 + return self::$sidecar_cache;
900 + }
901 +
902 + /**
903 + * Host and port of the first server in a `$memcached_servers` global.
904 + *
905 + * Memcached has no constant convention the way Redis has WP_REDIS_*; this
906 + * global IS the convention, and hosts write it in two shapes:
907 + *
908 + * array( array( 'host', 11211 ) ) // W3TC pair form
909 + * array( 'default' => array( 'host:11211' ) ) // Memcached Object Cache
910 + *
911 + * Reading only the first left the second taking the whole "host:port"
912 + * string as the hostname, or missing it entirely because its bucket is
913 + * keyed `default` rather than 0.
914 + *
915 + * The drop-in carries `xspeed_oc_first_memcached_server()`, which must
916 + * behave identically -- it loads before WordPress and cannot call this
917 + * class. ObjectCacheConstantParityTest holds the two together. (#398)
918 + *
919 + * @param mixed $servers The global's value, unvalidated.
920 + * @return array{0:?string,1:?int}|null Host and port, either possibly null.
921 + */
922 + public static function first_memcached_server( $servers ): ?array {
923 + if ( ! is_array( $servers ) || array() === $servers ) {
924 + return null;
925 + }
926 +
927 + $bucket = array_key_exists( 0, $servers ) ? $servers[0] : reset( $servers );
928 +
929 + /*
930 + * A bucket is EITHER a [host, port] pair or a list of server entries.
931 + * Telling them apart by shape, not by nesting depth: descending into
932 + * `array( 'mc.example', 11211 )` yields the host string and drops the
933 + * port on the floor, which is the commonest form there is.
934 + */
935 + $entry = $bucket;
936 + if ( is_array( $bucket ) && isset( $bucket[0] ) && is_array( $bucket[0] ) ) {
937 + $entry = $bucket[0];
938 + }
939 +
940 + if ( is_array( $entry ) ) {
941 + $host = isset( $entry[0] ) && ! is_array( $entry[0] ) ? (string) $entry[0] : null;
942 + $port = isset( $entry[1] ) && ! is_array( $entry[1] ) ? (int) $entry[1] : null;
943 + // A single-element list, array( 'host:port' ), is the keyed form's
944 + // bucket rather than a pair -- fall through to the string parser.
945 + if ( null !== $host && null === $port && is_string( $entry[0] ) && false !== strpos( $entry[0], ':' ) ) {
946 + $entry = $entry[0];
947 + } else {
948 + return ( null === $host && null === $port ) ? null : array( $host, $port );
949 + }
950 + }
951 +
952 + if ( ! is_string( $entry ) || '' === $entry ) {
953 + return null;
954 + }
955 +
956 + // "host:port", or a bare host. Split only the LAST colon, and only when
957 + // what follows is numeric -- a unix socket path is a host with no port.
958 + $at = strrpos( $entry, ':' );
959 + if ( false !== $at && ctype_digit( substr( $entry, $at + 1 ) ) ) {
960 + return array( substr( $entry, 0, $at ), (int) substr( $entry, $at + 1 ) );
961 + }
962 + return array( $entry, null );
963 + }
964 +
965 + /**
966 + * Names of the constants xSpeed itself wrote into wp-config.php.
967 + *
968 + * Ownership is decided by LOCATION, not by name. Our block is fenced by
969 + * CONFIG_BEGIN / CONFIG_END, so a define inside it is one we wrote and a
970 + * define anywhere else belongs to the host -- even when both are called
971 + * `XSPEED_OC_HOST`, which is exactly what a user pasting our own snippet
972 + * by hand produces.
973 + *
974 + * Judging by prefix instead is what made the panel treat xSpeed's own
975 + * values as host-pinned: the field locked, the "manage this here" control
976 + * could not unlock it, and Revert handed the field back to our snapshot
977 + * rather than to the host. (#398)
978 + *
979 + * @return string[] Constant names, empty when the block is absent.
980 + */
981 +
982 + public static function our_constants(): array {
983 + if ( null !== self::$our_constants_cache ) {
984 + return self::$our_constants_cache;
985 + }
986 + $cache = array();
987 +
551 988 $wp_config = ABSPATH . 'wp-config.php';
989 + if ( ! file_exists( $wp_config ) || ! is_readable( $wp_config ) ) {
990 + self::$our_constants_cache = $cache;
991 + return $cache;
992 + }
993 + // 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.
994 + $config = (string) @file_get_contents( $wp_config ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable wp-config just means "we own nothing".
995 + if ( '' === $config ) {
996 + self::$our_constants_cache = $cache;
997 + return $cache;
998 + }
999 +
1000 + $pattern = '/' . preg_quote( self::CONFIG_BEGIN, '/' ) . '(.*?)' . preg_quote( self::CONFIG_END, '/' ) . '/s';
1001 + if ( ! preg_match( $pattern, $config, $m ) ) {
1002 + self::$our_constants_cache = $cache;
1003 + return $cache;
1004 + }
1005 + if ( preg_match_all( "/define\\(\\s*'([A-Z0-9_]+)'/", $m[1], $names ) ) {
1006 + $cache = $names[1];
1007 + }
1008 + self::$our_constants_cache = $cache;
1009 + return $cache;
1010 + }
1011 +
1012 + /**
1013 + * Memoized result of our_constants(); null until the block is first read.
1014 + *
1015 + * @var string[]|null
1016 + */
1017 + private static $our_constants_cache = null;
1018 +
1019 + /**
1020 + * Forget the memoized block scan. Every write that changes the block must
1021 + * call this, or the same request keeps answering from the pre-write copy.
1022 + */
1023 + public static function forget_our_constants(): void {
1024 + self::$our_constants_cache = null;
1025 + }
1026 +
1027 + public static function write_wp_config( array $opts, array $force = array() ): bool {
1028 + $fs = self::fs();
1029 + $wp_config = ABSPATH . 'wp-config.php';
552 1030 if ( ! $fs || ! file_exists( $wp_config ) || ! $fs->is_writable( $wp_config ) ) {
553 - return false;
1031 + /*
1032 + * wp-config.php is read-only on several managed hosts. Fall back to
1033 + * a sidecar in wp-content/ -- writable wherever the drop-in itself
1034 + * could be installed, so the panel keeps working instead of telling
1035 + * the user to paste a snippet by hand. (#398)
1036 + */
1037 + return self::write_sidecar( $opts );
554 1038 }
555 1039
1040 +
556 1041 $config = $fs->get_contents( $wp_config );
557 1042 if ( ! is_string( $config ) ) {
558 1043 return false;
559 1044 }
560 1045
561 - $block = self::wp_config_block( $opts );
1046 + $block = self::wp_config_block( $opts, $force );
562 1047
563 1048 // Replace an existing xSpeed block if present, else insert after <?php.
564 1049 // IMPORTANT: $block is inserted via preg_replace_callback returning it
565 1050 // VERBATIM — never as a preg_replace replacement string. In a
@@ -589,9 +1074,23 @@
589 1074 1
590 1075 );
591 1076 }
592 1077
593 - return (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1078 + $written = (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1079 + if ( $written ) {
1080 + // The block just changed; a memoized scan from earlier in this
1081 + // request would still name the previous set. (#398)
1082 + self::forget_our_constants();
1083 +
1084 + // Only NOW is the block durable, so only now is a sidecar left
1085 + // from an earlier read-only spell safely redundant. Deleting it
1086 + // before the write -- is_writable() is not a promise the write
1087 + // lands; get_contents() can fail, and put_contents() can fail on a
1088 + // full disk or an SELinux denial -- would drop the live config and
1089 + // leave the site on built-in defaults.
1090 + self::delete_sidecar();
1091 + }
1092 + return $written;
594 1093 }
595 1094
596 1095 /**
597 1096 * Strip our wp-config block. Returns true if the block is gone afterward.
@@ -610,9 +1109,17 @@
610 1109 return false;
611 1110 }
612 1111 $pattern = '/' . preg_quote( self::CONFIG_BEGIN, '/' ) . '.*?' . preg_quote( self::CONFIG_END, '/' ) . "\s*/s";
613 1112 $config = preg_replace( $pattern, '', $config );
614 - return (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1113 + $removed = (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1114 + if ( $removed ) {
1115 + // A scan from earlier in this request would still name the
1116 + // constants we just deleted, so origins() would report a field as
1117 + // ours -- editable -- when a host define is now the only source
1118 + // and the field should read as pinned.
1119 + self::forget_our_constants();
1120 + }
1121 + return $removed;
615 1122 }
616 1123
617 1124 /**
618 1125 * The marker-wrapped constants block written into wp-config.php. Uses
@@ -618,37 +1125,132 @@
618 1125 * The marker-wrapped constants block written into wp-config.php. Uses
619 1126 * XSPEED_OC_* names (our drop-in reads these first, then falls back to
620 1127 * WP_REDIS_* for interop).
621 1128 */
622 - private static function wp_config_block( array $opts ): string {
1129 + private static function wp_config_block( array $opts, array $force = array() ): string {
1130 + // Fields the caller has decided we own, whatever pinned_elsewhere()
1131 + // would otherwise say. Used when an admin saved an override: they were
1132 + // told the host's define would stop applying, and this is the write
1133 + // that makes that true. (#398)
1134 + self::$force_fields = $force;
623 1135 $backend = (string) ( $opts['backend'] ?? 'redis' );
624 1136 $lines = array( self::CONFIG_BEGIN );
625 1137 $lines[] = "define( 'XSPEED_OC_BACKEND', '" . self::esc( $backend ) . "' );";
626 1138
627 1139 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 ) . ' );';
1140 + // XSPEED_OC_MC_*, not the Redis pair: one shared name meant enabling
1141 + // Redis overwrote the Memcached host/port. (#398)
1142 + if ( ! self::pinned_elsewhere( 'memcached_host' ) ) {
1143 + $lines[] = "define( 'XSPEED_OC_MC_HOST', '" . self::esc( self::str( $opts, 'memcached_host', '127.0.0.1' ) ) . "' );";
1144 + }
1145 + if ( ! self::pinned_elsewhere( 'memcached_port' ) ) {
1146 + $lines[] = "define( 'XSPEED_OC_MC_PORT', " . self::int( $opts, 'memcached_port', 11211 ) . ' );';
1147 + }
630 1148 } 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 ) . ' );';
1149 + if ( ! self::pinned_elsewhere( 'redis_host' ) ) {
1150 + $lines[] = "define( 'XSPEED_OC_HOST', '" . self::esc( self::str( $opts, 'redis_host', '127.0.0.1' ) ) . "' );";
1151 + }
1152 + if ( ! self::pinned_elsewhere( 'redis_port' ) ) {
1153 + $lines[] = "define( 'XSPEED_OC_PORT', " . self::int( $opts, 'redis_port', 6379 ) . ' );';
1154 + }
633 1155 $user = self::str( $opts, 'redis_user', '' );
634 - if ( '' !== $user ) {
1156 + if ( '' !== $user && ! self::pinned_elsewhere( 'redis_user' ) ) {
635 1157 $lines[] = "define( 'XSPEED_OC_USER', '" . self::esc( $user ) . "' );";
636 1158 }
637 1159 $pass = self::str( $opts, 'redis_password', '' );
638 - if ( '' !== $pass ) {
1160 + if ( '' !== $pass && ! self::pinned_elsewhere( 'redis_password' ) ) {
639 1161 $lines[] = "define( 'XSPEED_OC_PASSWORD', '" . self::esc( $pass ) . "' );";
640 1162 }
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' ) . ' );';
1163 + if ( ! self::pinned_elsewhere( 'redis_database' ) ) {
1164 + $lines[] = "define( 'XSPEED_OC_DATABASE', " . self::int( $opts, 'redis_database', 0 ) . ' );';
1165 + }
1166 + if ( ! self::pinned_elsewhere( 'connection_timeout' ) ) {
1167 + $lines[] = "define( 'XSPEED_OC_TIMEOUT', " . self::int( $opts, 'connection_timeout', 1 ) . ' );';
1168 + }
1169 + if ( ! self::pinned_elsewhere( 'persistent' ) ) {
1170 + $lines[] = "define( 'XSPEED_OC_PERSISTENT', " . ( ! empty( $opts['persistent'] ) ? 'true' : 'false' ) . ' );';
1171 + }
644 1172 }
645 - $prefix = self::str( $opts, 'key_prefix', '' );
646 - if ( '' !== $prefix ) {
647 - $lines[] = "define( 'XSPEED_OC_SALT', '" . self::esc( $prefix ) . "' );";
1173 + // Always emit a salt (#390): a blank Cache Key Prefix derives a per-site
1174 + // value rather than leaving keys unnamespaced, which collides when
1175 + // several sites share one Redis/Memcached server.
1176 + //
1177 + // Unless a foreign define already owns it (#398). Emitting ours would
1178 + // outrank the host's WP_REDIS_PREFIX, and on an ACL/namespaced Redis a
1179 + // prefix that does not match the host's exactly means every write is
1180 + // denied with NOPERM -- so a derived salt there is worse than none.
1181 + // The host's define IS the namespace in that case, and it is already
1182 + // non-empty, so the collision #390 closes cannot reopen.
1183 + if ( ! self::pinned_elsewhere( 'key_prefix' ) ) {
1184 + $lines[] = "define( 'XSPEED_OC_SALT', '" . self::esc( self::effective_salt( $opts ) ) . "' );";
648 1185 }
649 1186 $lines[] = self::CONFIG_END;
1187 + self::$force_fields = array();
650 1188 return implode( "\n", $lines ) . "\n";
1189 + }
1190 +
1191 + /**
1192 + * Fields the current block write owns outright. Set for the duration of one
1193 + * wp_config_block() call; see the $force parameter there.
1194 + *
1195 + * @var string[]
1196 + */
1197 + private static array $force_fields = array();
1198 +
1199 + /**
1200 + * Is this field already pinned by a constant we are not about to write?
1201 + *
1202 + * Enable() resolves settings through Settings_Manager, so on a
1203 + * host-provisioned site those values came FROM wp-config in the first
1204 + * place -- typically WP_REDIS_*. Writing them back out under our own
1205 + * XSPEED_OC_* names, which outrank every alias, would freeze a snapshot:
1206 + * when the host later rotated the password, the site would keep
1207 + * authenticating with our stale copy and silently drop to a
1208 + * non-persistent cache. It would also re-emit a credential as a second
1209 + * plaintext literal, which is the thing sourcing it from a constant
1210 + * avoids. So leave the host's define alone and emit nothing for it. (#398)
1211 + */
1212 + private static function pinned_elsewhere( string $field ): bool {
1213 + if ( in_array( $field, self::$force_fields, true ) ) {
1214 + return false;
1215 + }
1216 + if ( ! class_exists( '\\XSpeed\\Settings_Manager' ) ) {
1217 + return false;
1218 + }
1219 + $module = \XSpeed\Module_Registry::get( 'object-cache' );
1220 + if ( ! $module ) {
1221 + return false;
1222 + }
1223 +
1224 + // An admin who deliberately overrode this field asked us to shadow the
1225 + // host's define -- they were told so in as many words before the field
1226 + // unlocked. Protecting it here would silently drop their value on the
1227 + // next enable, which is the same silent-no-op failure the whole
1228 + // pinned-field contract exists to prevent. (#398)
1229 + if ( \XSpeed\Settings_Manager::is_overridden( 'object-cache', $field ) ) {
1230 + return false;
1231 + }
1232 +
1233 + // Somebody else's define, anywhere in this field's list, is protected --
1234 + // even when our own XSPEED_OC_* copy currently outranks it. Testing only
1235 + // the WINNING constant made an override permanent in a subtler way: on
1236 + // revert we rewrote our copy with the host's value, our copy still
1237 + // outranked theirs, and a later rotation on their side was shadowed
1238 + // forever. Emitting nothing for the field lets the host's define surface
1239 + // again and keep surfacing. (#398)
1240 + return null !== \XSpeed\Settings_Manager::foreign_constant( 'object-cache', $field );
1241 + }
1242 +
1243 + /**
1244 + * Is our marker block present in wp-config.php?
1245 + *
1246 + * Public so a caller can tell "we already manage constants here" from
1247 + * "this site never enabled the object cache" -- rewriting the block is
1248 + * right in the first case and would be an unasked-for file edit in the
1249 + * second. (#398)
1250 + */
1251 + public static function wp_config_has_our_block(): bool {
1252 + return self::wp_config_has_block();
651 1253 }
652 1254
653 1255 private static function wp_config_has_block(): bool {
654 1256 $wp_config = ABSPATH . 'wp-config.php';