PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 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 All 36 releases
← All changes | includes/class-object-cache.php +1399 -19 1.0.2 → 1.4.2 View file →
@@ -36,21 +36,39 @@
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 + * degraded: bool, // ours is installed but NOT persisting
46 + * persistent: bool, // ours is installed AND persisting
44 47 * class_available: array<string,bool>
45 48 * }
46 49 */
47 - public static function detect(): array {
50 + public static function detect( bool $with_owner = false ): array {
48 51 $dropin = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : '';
49 52 $has_drop_in = '' !== $dropin && file_exists( $dropin );
50 53 $label = $has_drop_in ? self::sniff_drop_in_label( $dropin ) : '';
51 54 $ext_in_use = function_exists( 'wp_using_ext_object_cache' ) ? (bool) wp_using_ext_object_cache() : false;
52 55
56 + // When OUR drop-in is the live one it exposes whether it actually
57 + // connected a persistent backend. A drop-in that's installed but
58 + // degraded reports wp_using_ext_object_cache()=true yet persists
59 + // nothing — the silent failure that makes a site slow. Read the honest
60 + // state straight off the running instance. (FBS-82210)
61 + $degraded = false;
62 + $persistent = false;
63 + if ( $has_drop_in && isset( $GLOBALS['wp_object_cache'] ) && is_object( $GLOBALS['wp_object_cache'] ) ) {
64 + $oc = $GLOBALS['wp_object_cache'];
65 + if ( method_exists( $oc, 'is_persistent' ) ) {
66 + $persistent = (bool) $oc->is_persistent();
67 + $degraded = ! $persistent;
68 + }
69 + }
70 +
53 71 // Class sniffer — independent of any plugin. Tells us what's
54 72 // available to actually use, separate from what's wired up.
55 73 $class_available = array(
56 74 'Redis' => class_exists( '\\Redis' ),
@@ -62,30 +80,125 @@
62 80 $backend = 'unknown';
63 81 if ( ! $ext_in_use ) {
64 82 $backend = 'wp_default';
65 83 } elseif ( $has_drop_in ) {
66 - // Best-effort: the label usually contains the backend name.
67 - $lc = strtolower( $label );
68 - if ( false !== strpos( $lc, 'redis' ) ) {
69 - $backend = 'redis';
70 - } elseif ( false !== strpos( $lc, 'memcached' ) || false !== strpos( $lc, 'memcache' ) ) {
71 - $backend = 'memcached';
72 - } elseif ( false !== strpos( $lc, 'apcu' ) ) {
73 - $backend = 'apcu';
84 + // Authoritative source first: our own drop-in records the chosen
85 + // backend in the XSPEED_OC_BACKEND constant (written to wp-config
86 + // on enable). The drop-in label is the generic
87 + // "XSPEED_OBJECT_CACHE_DROPIN" and does NOT contain the backend
88 + // name, so the label sniff below would always yield "unknown" for
89 + // our drop-in — read the constant instead. (FBS-82111)
90 + if ( defined( 'XSPEED_OC_BACKEND' ) && '' !== (string) constant( 'XSPEED_OC_BACKEND' ) ) {
91 + $backend = strtolower( (string) constant( 'XSPEED_OC_BACKEND' ) );
92 + } else {
93 + // Foreign drop-in (W3TC / Redis Object Cache / …): best-effort
94 + // guess from the label, which usually names the backend.
95 + $lc = strtolower( $label );
96 + if ( false !== strpos( $lc, 'redis' ) ) {
97 + $backend = 'redis';
98 + } elseif ( false !== strpos( $lc, 'memcached' ) || false !== strpos( $lc, 'memcache' ) ) {
99 + $backend = 'memcached';
100 + } elseif ( false !== strpos( $lc, 'apcu' ) ) {
101 + $backend = 'apcu';
102 + }
74 103 }
75 104 }
76 105
77 106 return array(
78 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(),
79 113 'drop_in_path' => $dropin,
80 114 'drop_in_label' => $label,
81 115 'backend' => $backend,
82 116 'wp_cache_active' => $ext_in_use,
117 + 'degraded' => $degraded,
118 + 'persistent' => $persistent,
83 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(),
84 127 );
85 128 }
86 129
87 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 + /**
88 201 * Flush whatever cache backend is wired up. Works against any
89 202 * compliant drop-in OR the WP default in-memory cache.
90 203 */
91 204 public static function flush(): bool {
@@ -108,39 +221,1306 @@
108 221
109 222 if ( 'redis' === $backend ) {
110 223 $host = self::str( $opts, 'redis_host', '127.0.0.1' );
111 224 $port = self::int( $opts, 'redis_port', 6379 );
225 + $user = self::str( $opts, 'redis_user', '' );
112 226 $pass = self::str( $opts, 'redis_password', '' );
113 227 $db = self::int( $opts, 'redis_database', 0 );
114 - $prefix = self::str( $opts, 'key_prefix', '' );
228 + $prefix = self::effective_salt( $opts );
115 229 $timeout = self::int( $opts, 'connection_timeout', 1 );
116 230 $persist = ! empty( $opts['persistent'] );
117 231
118 - $lines[] = "define( 'WP_REDIS_HOST', '" . self::esc( $host ) . "' );";
119 - $lines[] = "define( 'WP_REDIS_PORT', " . $port . ' );';
232 + // A socket is written the way Redis Object Cache expects one,
233 + // scheme plus path, so the snippet works with that plugin too.
234 + // xSpeed's drop-in reads the same pair. (#566)
235 + $socket = Redis_Client::socket_path( $host );
236 + if ( '' !== $socket ) {
237 + $lines[] = "define( 'WP_REDIS_SCHEME', 'unix' );";
238 + $lines[] = "define( 'WP_REDIS_PATH', '" . self::esc( $socket ) . "' );";
239 + } else {
240 + $lines[] = "define( 'WP_REDIS_HOST', '" . self::esc( $host ) . "' );";
241 + $lines[] = "define( 'WP_REDIS_PORT', " . $port . ' );';
242 + }
243 + // Emit the ACL username only when set (Redis 6+). The drop-in
244 + // reads it; an empty user keeps the legacy default-user behavior.
245 + if ( '' !== $user ) {
246 + $lines[] = "define( 'WP_REDIS_USER', '" . self::esc( $user ) . "' );";
247 + }
120 248 if ( '' !== $pass ) {
121 249 $lines[] = "define( 'WP_REDIS_PASSWORD', '" . self::esc( $pass ) . "' );";
122 250 }
123 251 $lines[] = "define( 'WP_REDIS_DATABASE', " . $db . ' );';
124 - if ( '' !== $prefix ) {
125 - $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
126 - }
252 + $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
127 253 $lines[] = "define( 'WP_REDIS_TIMEOUT', " . $timeout . ' );';
128 254 $lines[] = "define( 'WP_REDIS_PERSISTENT', " . ( $persist ? 'true' : 'false' ) . ' );';
129 255 } elseif ( 'memcached' === $backend ) {
130 256 $host = self::str( $opts, 'memcached_host', '127.0.0.1' );
131 257 $port = self::int( $opts, 'memcached_port', 11211 );
132 - $prefix = self::str( $opts, 'key_prefix', '' );
258 + $prefix = self::effective_salt( $opts );
133 259 $lines[] = "global \$memcached_servers;";
134 260 $lines[] = "\$memcached_servers = array( array( '" . self::esc( $host ) . "', " . $port . ' ) );';
135 - if ( '' !== $prefix ) {
136 - $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
261 + $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
262 + } else {
263 + $lines[] = '// No snippet for backend: ' . $backend;
264 + }
265 +
266 + return implode( "\n", $lines ) . "\n";
267 + }
268 +
269 + /**
270 + * Identifier embedded in our drop-in so we can recognise (and safely
271 + * overwrite / remove) only files we installed.
272 + */
273 + private const DROPIN_TAG = 'XSPEED_OBJECT_CACHE_DROPIN';
274 +
275 + /** Markers wrapping the constants we write into wp-config.php. */
276 + private const CONFIG_BEGIN = '/* BEGIN xSpeed Object Cache */';
277 + private const CONFIG_END = '/* END xSpeed Object Cache */';
278 +
279 + /**
280 + * True when wp-content/object-cache.php exists AND is ours (carries the
281 + * drop-in tag). Lets callers decide whether a re-sync applies without
282 + * exposing the tag itself.
283 + */
284 + public static function is_our_dropin_present(): bool {
285 + $target = WP_CONTENT_DIR . '/object-cache.php';
286 + if ( ! file_exists( $target ) ) {
287 + return false;
288 + }
289 + $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.
290 + return is_string( $contents ) && false !== strpos( $contents, self::DROPIN_TAG );
291 + }
292 +
293 + /**
294 + * Live connection test against the configured backend. Never throws;
295 + * returns a structured pass/fail the UI can show before we write anything.
296 + *
297 + * @param array $opts Settings array (backend, redis_host, ...).
298 + * @return array{ok:bool,backend:string,message:string,latency_ms:?float}
299 + */
300 + public static function test_connection( array $opts ): array {
301 + $backend = (string) ( $opts['backend'] ?? 'redis' );
302 + $start = microtime( true );
303 +
304 + try {
305 + if ( 'memcached' === $backend ) {
306 + $host = self::str( $opts, 'memcached_host', '127.0.0.1' );
307 + $port = self::int( $opts, 'memcached_port', 11211 );
308 + $timeout = self::int( $opts, 'connection_timeout', 1 );
309 +
310 + // Prefer the ext/memcached extension (libmemcached).
311 + if ( class_exists( '\\Memcached' ) ) {
312 + $mc = new \Memcached();
313 + $mc->addServer( $host, $port );
314 + $stats = @$mc->getStats();
315 + $ok = is_array( $stats ) && ! empty( array_filter( $stats ) );
316 + return self::test_result(
317 + $ok,
318 + $backend,
319 + $ok ? "Connected to Memcached at {$host}:{$port} (ext/memcached)." : "Could not reach Memcached at {$host}:{$port}.",
320 + $start
321 + );
322 + }
323 +
324 + // Pure-PHP fallback — our own client, zero dependencies.
325 + $mc = new Memcached_Client( $host, $port, (float) $timeout );
326 + if ( ! $mc->connect() ) {
327 + return self::test_result( false, $backend, "Could not connect to Memcached at {$host}:{$port}." );
328 + }
329 + $ver = $mc->version();
330 + $mc->close();
331 + $ok = ( false !== $ver );
332 + return self::test_result(
333 + $ok,
334 + $backend,
335 + $ok ? "Connected to Memcached at {$host}:{$port} (built-in client)." : "Memcached at {$host}:{$port} did not respond.",
336 + $start
337 + );
137 338 }
339 +
340 + // Redis. Prefer the phpredis extension (faster C client); fall back
341 + // to xSpeed's own dependency-free Redis_Client (pure-PHP RESP over a
342 + // socket) so Redis works even without the extension — true
343 + // plug-and-play, no bundled library.
344 + $host = self::str( $opts, 'redis_host', '127.0.0.1' );
345 + $port = self::int( $opts, 'redis_port', 6379 );
346 + $timeout = self::int( $opts, 'connection_timeout', 1 );
347 + $user = self::str( $opts, 'redis_user', '' );
348 + $pass = self::str( $opts, 'redis_password', '' );
349 + $db = self::int( $opts, 'redis_database', 0 );
350 + // A socket path has no port. phpredis opens a socket only when the
351 + // port is below 1, and the messages should name the path the user
352 + // typed rather than "path:6379". (#566)
353 + $socket = Redis_Client::socket_path( $host );
354 + $where = '' !== $socket ? $socket : "{$host}:{$port}";
355 +
356 + if ( class_exists( '\\Redis' ) ) {
357 + $redis = new \Redis();
358 + $connected = '' !== $socket
359 + ? @$redis->connect( $socket, 0, $timeout )
360 + : @$redis->connect( $host, $port, $timeout );
361 + if ( ! $connected ) {
362 + return self::test_result( false, $backend, "Could not connect to Redis at {$where}." );
363 + }
364 + // Redis 6+ ACL: when a username is set, authenticate as that user
365 + // (phpredis ≥ 5.3 accepts ['user'=>..,'pass'=>..]); otherwise keep
366 + // the legacy password-only form that authenticates as `default`.
367 + $auth_ok = self::phpredis_auth( $redis, $user, $pass );
368 + if ( null !== $auth_ok && ! $auth_ok ) {
369 + return self::test_result( false, $backend, '' !== $user ? 'Redis authentication failed — check the Redis user + password (ACL).' : 'Redis authentication failed — check the password.' );
370 + }
371 + if ( $db > 0 && ! @$redis->select( $db ) ) {
372 + return self::test_result( false, $backend, "Could not select Redis database {$db}." );
373 + }
374 + $pong = @$redis->ping();
375 + $ok = ( '+PONG' === $pong || true === $pong || 'PONG' === $pong );
376 + if ( ! $ok ) {
377 + return self::test_result( false, $backend, "Redis at {$where} did not respond to PING.", $start );
378 + }
379 + // Write-verification: PING only proves auth, not that the user can
380 + // STORE data. ACL-namespaced hosts (xCloud) restrict a user to a
381 + // key pattern (~redis:<id>:*); a SET outside it is NOPERM-denied and
382 + // the drop-in's @$redis->set() swallows it — enable() would then
383 + // green-light a cache that silently persists nothing. Do a real
384 + // SET/GET/DEL round-trip on a probe key built with the user's key
385 + // prefix so a namespace restriction is caught here. (FBS-83118 OC-2)
386 + $probe = self::probe_key( $opts );
387 + $set = @$redis->set( $probe, '1', 5 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- NOPERM/denied is the negative answer we report, not a fatal.
388 + $got = @$redis->get( $probe ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- same.
389 + @$redis->del( $probe ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup.
390 + if ( ! $set || '1' !== (string) $got ) {
391 + return self::test_result( false, $backend, self::write_denied_message( $opts, $where ), $start );
392 + }
393 + return self::test_result( true, $backend, self::with_prefix_advisory( "Connected to Redis at {$where} (phpredis).", $opts ), $start );
394 + }
395 +
396 + // Pure-PHP fallback — our own client, zero dependencies.
397 + $rc = new Redis_Client( $host, $port, (float) $timeout, false );
398 + if ( ! $rc->connect() ) {
399 + return self::test_result( false, $backend, "Could not connect to Redis at {$where}." );
400 + }
401 + // Authenticate when a user OR a password is set. Gating on password
402 + // alone skipped auth for the "ACL user + empty password" case, which
403 + // then failed later at PING with a misleading message. (FBS-83118 OC-1)
404 + if ( ( '' !== $pass || '' !== $user ) && false === $rc->auth( $pass, $user ) ) {
405 + $rc->close();
406 + return self::test_result( false, $backend, '' !== $user ? 'Redis authentication failed — check the Redis user + password (ACL).' : 'Redis authentication failed — check the password.' );
407 + }
408 + if ( $db > 0 ) {
409 + $rc->select( $db );
410 + }
411 + $pong = $rc->ping();
412 + $ok = ( is_string( $pong ) && false !== stripos( $pong, 'PONG' ) );
413 + if ( ! $ok ) {
414 + $rc->close();
415 + return self::test_result( false, $backend, "Redis at {$where} did not respond to PING.", $start );
416 + }
417 + // Write-verification round-trip — same rationale as the phpredis path
418 + // above. (FBS-83118 OC-2)
419 + $probe = self::probe_key( $opts );
420 + $set = $rc->set( $probe, '1' );
421 + $got = $rc->get( $probe );
422 + $rc->del( $probe );
423 + $rc->close();
424 + if ( ! $set || '1' !== (string) $got ) {
425 + return self::test_result( false, $backend, self::write_denied_message( $opts, $where ), $start );
426 + }
427 + return self::test_result( true, $backend, self::with_prefix_advisory( "Connected to Redis at {$where} (built-in client).", $opts ), $start );
428 + } catch ( \Throwable $e ) {
429 + return self::test_result( false, $backend, 'Connection error: ' . $e->getMessage() );
430 + }
431 + }
432 +
433 + /**
434 + * Redis glob metacharacters that must never appear unescaped in a SCAN
435 + * MATCH pattern. `\` is the escape character itself.
436 + */
437 + private const GLOB_METACHARS = '*?[]\\';
438 +
439 + /**
440 + * Whether a salt contains Redis glob metacharacters.
441 + *
442 + * The salt is interpolated into the drop-in's scoped-flush patterns. The
443 + * drop-in escapes it, so caching and purging are correct either way — but
444 + * an explicit Cache Key Prefix exists to match a host's ACL namespace
445 + * byte-for-byte, and a wildcard in it is almost always a typo rather than
446 + * a real namespace. Reporting it on Test connection is the one place the
447 + * user is already looking at their prefix.
448 + *
449 + * @param string $salt Effective salt.
450 + * @return bool
451 + */
452 + public static function salt_has_glob_metachars( string $salt ): bool {
453 + return strcspn( $salt, self::GLOB_METACHARS ) !== strlen( $salt );
454 + }
455 +
456 + /**
457 + * Append a prefix advisory to an otherwise-successful connection message.
458 + *
459 + * @param string $message Success message.
460 + * @param array $opts Settings array.
461 + * @return string
462 + */
463 + private static function with_prefix_advisory( string $message, array $opts ): string {
464 + // Deliberately the TYPED prefix, not effective_salt(): this advisory
465 + // says "the field you are looking at probably has a typo in it". A
466 + // host-pinned WP_CACHE_KEY_SALT is not editable from this screen and
467 + // purges are correctly scoped regardless (the drop-in escapes it), so
468 + // warning about a host's own namespace would be noise on every ACL
469 + // host. A derived salt is glob-free by construction.
470 + $prefix = self::str( $opts, 'key_prefix', '' );
471 + if ( '' === $prefix || ! self::salt_has_glob_metachars( $prefix ) ) {
472 + return $message;
473 + }
474 + return $message . ' Note: the Cache key prefix contains one of * ? [ ] \\.'
475 + . ' Purges stay scoped to this site, but these are wildcard characters'
476 + . ' in Redis — check the prefix matches your host\'s key exactly.';
477 + }
478 +
479 + private static function test_result( bool $ok, string $backend, string $message, ?float $start = null ): array {
480 + return array(
481 + 'ok' => $ok,
482 + 'backend' => $backend,
483 + 'message' => $message,
484 + 'latency_ms' => $start ? round( ( microtime( true ) - $start ) * 1000, 2 ) : null,
485 + );
486 + }
487 +
488 + /**
489 + * Authenticate a phpredis connection, honoring Redis 6+ ACL usernames.
490 + *
491 + * Returns null when no auth is needed (empty username AND password) so
492 + * callers can distinguish "didn't try" from "tried and failed". When a
493 + * username is present we pass ['user'=>..,'pass'=>..] which phpredis
494 + * ≥ 5.3 maps to the two-argument AUTH; otherwise the legacy
495 + * password-only form authenticates as the built-in `default` user.
496 + *
497 + * @param \Redis $redis Connected phpredis instance.
498 + * @param string $user ACL username; '' = default user.
499 + * @param string $pass Password.
500 + * @return bool|null true/false on auth attempt, null if none needed.
501 + */
502 + private static function phpredis_auth( $redis, string $user, string $pass ) {
503 + if ( '' === $user && '' === $pass ) {
504 + return null;
505 + }
506 + try {
507 + if ( '' !== $user ) {
508 + return (bool) @$redis->auth( array( 'user' => $user, 'pass' => $pass ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- phpredis throws on bad auth; we report it as a failed test, not a fatal.
509 + }
510 + return (bool) @$redis->auth( $pass ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- same.
511 + } catch ( \Throwable $e ) {
512 + return false;
513 + }
514 + }
515 +
516 + /**
517 + * Resolve the salt that namespaces this site's cache keys.
518 + *
519 + * An explicit Cache Key Prefix always wins — on ACL/namespaced hosts
520 + * (xCloud) it MUST match the host's "Redis Object Cache Key" or writes are
521 + * denied (NOPERM), so we never override what the user typed.
522 + *
523 + * When the field is blank we derive a stable, per-site salt instead of
524 + * falling back to an empty one. An empty salt makes every key look like
525 + * `:{prefix}:{group}:{key}` — identical on every install — so two sites
526 + * sharing one Redis/Memcached server collide. That is not a theoretical
527 + * clash: `blog-details` / `blog-lookup` are how WordPress resolves which
528 + * site a request belongs to, so the second site reads the first site's
529 + * entries and redirects to it.
530 + *
531 + * The derived value is a hash of the site URL plus the DB name/prefix, so
532 + * it is unique per install, stable across requests (no cache churn), and
533 + * safe to embed in wp-config.php.
534 + *
535 + * @param array $opts Settings array.
536 + * @return string Non-empty salt.
537 + */
538 + public static function effective_salt( array $opts ): string {
539 + $prefix = self::str( $opts, 'key_prefix', '' );
540 + if ( '' !== $prefix ) {
541 + return $prefix;
542 + }
543 +
544 + // A salt WE already wrote is authoritative over a fresh derivation.
545 + // The keys in the backend are named after it, so re-deriving a
546 + // different value would orphan every one of them — a needless
547 + // cache-cooling on an install that is already correctly namespaced.
548 + // This matters because derive_salt()'s rule was corrected (see there):
549 + // without this branch, the next wp-config sync would rewrite the block
550 + // with a new salt and throw away a warm cache on every existing site.
551 + if ( defined( 'XSPEED_OC_SALT' ) && '' !== (string) constant( 'XSPEED_OC_SALT' ) ) {
552 + return (string) constant( 'XSPEED_OC_SALT' );
553 + }
554 +
555 + // A salt the HOST pinned in its own wp-config (outside our block) is
556 + // the next authority. On ACL/namespaced Redis the host grants write
557 + // access to that namespace and no other, so replacing it with a
558 + // derived value gets every write denied (NOPERM) and the site silently
559 + // stops caching.
560 + // WP_REDIS_PREFIX is checked alongside WP_CACHE_KEY_SALT and before it,
561 + // matching the order the schema and the drop-in resolve (#398). It is
562 + // the name Redis Object Cache uses and the one managed hosts actually
563 + // write, so honouring only the older alias left the commonest
564 + // ACL-namespaced case deriving a salt the host denies writes to.
565 + foreach ( array( 'WP_REDIS_PREFIX', 'WP_CACHE_KEY_SALT' ) as $name ) {
566 + if ( defined( $name ) && '' !== (string) constant( $name ) ) {
567 + return (string) constant( $name );
568 + }
569 + }
570 +
571 + return self::derive_salt();
572 + }
573 +
574 + /**
575 + * Build a stable per-site salt for installs that left Cache Key Prefix
576 + * blank. Distinct per install: the site URL separates sites sharing a
577 + * database, and DB name + table prefix separate installs sharing a domain
578 + * (e.g. subdirectory installs).
579 + *
580 + * This MUST stay byte-identical to the drop-in's xspeed_oc_salt(), which
581 + * is the harder constraint of the two: the drop-in loads from
582 + * wp-settings.php before `$wpdb` exists, so it can only read constants and
583 + * the `$table_prefix` global that wp-config.php itself assigns. Normally
584 + * the two never both run — enable() writes XSPEED_OC_SALT and both sides
585 + * read that constant — but where wp-config is NOT writable no constant is
586 + * ever written, and then both fallbacks are live at once in different
587 + * processes. Seeding them differently made "Test connection" verify a
588 + * different key space than the cache actually writes to: on ACL/namespaced
589 + * Redis (xCloud) that reports success while writes are refused, or reports
590 + * a failure while caching is fine. (PR #390 QA round 2, issue 2)
591 + *
592 + * Two specific traps this alignment closes:
593 + *
594 + * - WP_HOME / WP_SITEURL are OPTIONAL and absent from a stock
595 + * wp-config.php, so the drop-in's URL part is usually EMPTY while
596 + * get_site_url() always returns a real URL. Using get_site_url() here
597 + * therefore diverged on virtually every default install, not just an
598 + * exotic one — so this reads the same constants, and appends ABSPATH
599 + * on the same condition, rather than reaching for the richer value.
600 + * - `$wpdb->prefix` is PER-BLOG on multisite (`wp_2_` on a sub-site)
601 + * while `$table_prefix` is always the base prefix. The drop-in reads
602 + * the salt once and separates sub-sites with blog_prefix instead, so
603 + * `$table_prefix` is the value that matches; `$wpdb->prefix` would
604 + * hand every sub-site a different salt.
605 + *
606 + * @return string
607 + */
608 + private static function derive_salt(): string {
609 + global $table_prefix;
610 +
611 + $url = '';
612 + if ( defined( 'WP_HOME' ) ) {
613 + $url = (string) WP_HOME;
614 + } elseif ( defined( 'WP_SITEURL' ) ) {
615 + $url = (string) WP_SITEURL;
616 + }
617 +
618 + $parts = array(
619 + $url,
620 + defined( 'DB_NAME' ) ? (string) DB_NAME : '',
621 + isset( $table_prefix ) ? (string) $table_prefix : '',
622 + );
623 + if ( '' === $url ) {
624 + $parts[] = defined( 'ABSPATH' ) ? (string) ABSPATH : '';
625 + }
626 +
627 + $seed = implode( '|', $parts );
628 + if ( '' === trim( $seed, '|' ) ) {
629 + // Nothing identifying available. Mirrors the drop-in's own
630 + // last-resort seed so the two still agree.
631 + $seed = 'xspeed';
632 + }
633 +
634 + return 'xs' . substr( md5( $seed ), 0, 12 );
635 + }
636 +
637 + /**
638 + * Build a probe key for the write-verification round-trip. It must land in
639 + * the same key space the drop-in writes to, so an ACL namespace restriction
640 + * (~<prefix>:*) is exercised. The drop-in salts keys as
641 + * `{salt}:{prefix}:{group}:{key}`, so prefixing the probe with the same
642 + * salt makes it match the allowed pattern on namespaced hosts (xCloud)
643 + * while staying harmless everywhere else.
644 + *
645 + * @param array $opts Settings array.
646 + * @return string
647 + */
648 + private static function probe_key( array $opts ): string {
649 + return self::effective_salt( $opts ) . ':xspeed-oc-probe';
650 + }
651 +
652 + /**
653 + * Message for a connect-OK-but-write-denied result. Points ACL/namespaced
654 + * hosts at the fix (match the key prefix to the host's Redis Object Cache
655 + * Key), which is exactly the xCloud failure mode. (FBS-83118 OC-2)
656 + *
657 + * @param array $opts Settings array.
658 + * @param string $where host:port, or the socket path.
659 + * @return string
660 + */
661 + private static function write_denied_message( array $opts, string $where ): string {
662 + $has_prefix = '' !== self::str( $opts, 'key_prefix', '' );
663 + $hint = $has_prefix
664 + ? 'The Redis user may lack write permission for this key prefix (NOPERM).'
665 + : '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.';
666 + return "Connected to Redis at {$where}, but the cache could not store data. {$hint}";
667 + }
668 +
669 + /**
670 + * Full plug-and-play enable: test → write wp-config constants → install
671 + * drop-in → verify. Reversible via disable(). Returns a structured result
672 + * the REST/UI layer surfaces directly.
673 + *
674 + * @param array $opts Settings array.
675 + * @return array{ok:bool,message:string,steps:array<string,bool>,test:array,detect:array}
676 + */
677 + public static function enable( array $opts, array $args = array() ): array {
678 + /*
679 + * Another plugin's object-cache.php is a switch, not an install: its
680 + * owner has to be out of the picture first or it puts its file back
681 + * (W3TC on the next admin request, LiteSpeed on its next save). The
682 + * panel always refused here while REST, CLI and MCP overwrote the
683 + * file, so every entry point now refuses unless the caller asked for
684 + * the switch. (#686)
685 + */
686 + if ( self::foreign_dropin_present() ) {
687 + if ( empty( $args['takeover'] ) ) {
688 + $owner = Object_Cache_Takeover::owner();
689 + return array(
690 + 'ok' => false,
691 + 'needs_takeover' => true,
692 + 'message' => Object_Cache_Takeover::refusal_message( $owner ),
693 + 'steps' => array(
694 + 'connection' => false,
695 + 'wp_config' => false,
696 + 'drop_in' => false,
697 + 'verified' => false,
698 + ),
699 + 'owner' => $owner,
700 + 'detect' => self::detect( true ),
701 + );
702 + }
703 + return Object_Cache_Takeover::run( $opts );
704 + }
705 +
706 + // Enabling from scratch starts a new session, so an earlier switch's
707 + // record no longer describes anything to put back. Re-running enable
708 + // over our own drop-in (a re-sync, an import) keeps it.
709 + if ( ! self::is_our_dropin_present() ) {
710 + Object_Cache_Takeover::forget();
711 + }
712 + return self::install( $opts );
713 + }
714 +
715 + /**
716 + * Test, write the wp-config block and install our drop-in. The shared
717 + * second half of enable() and of a switch from another plugin; neither
718 + * calls it while a foreign drop-in is in place.
719 + *
720 + * @param array $opts Settings array.
721 + * @param array|null $test A connection test already run moments ago.
722 + * @return array{ok:bool,message:string,steps:array<string,bool>,test:array,detect:array}
723 + */
724 + public static function install( array $opts, ?array $test = null ): array {
725 + $steps = array(
726 + 'connection' => false,
727 + 'wp_config' => false,
728 + 'drop_in' => false,
729 + 'verified' => false,
730 + );
731 +
732 + // 1. Don't write anything until the backend actually answers.
733 + $test = $test ?? self::test_connection( $opts );
734 + if ( ! $test['ok'] ) {
735 + return array(
736 + 'ok' => false,
737 + 'message' => 'Could not enable: ' . $test['message'],
738 + 'steps' => $steps,
739 + 'test' => $test,
740 + 'detect' => self::detect( true ),
741 + );
742 + }
743 + $steps['connection'] = true;
744 +
745 + // Values from an earlier xSpeed session are still in Redis (keys
746 + // have no TTL, `alloptions` included), and the drop-in would read
747 + // them back as current. Only when ours is not already the live cache:
748 + // re-running enable over a working install must not cool it.
749 + if ( ! self::our_dropin_is_live() ) {
750 + self::purge_namespace( $opts );
751 + }
752 +
753 + // 2. Write the XSPEED_OC_* constants into wp-config.php.
754 + $steps['wp_config'] = self::write_wp_config( $opts );
755 +
756 + // 3. Install our drop-in.
757 + $steps['drop_in'] = self::install_dropin();
758 +
759 + // 4. Verify the drop-in is live (best-effort — wp_using_ext_object_cache
760 + // reflects state only after the drop-in loads on the NEXT request, so
761 + // we verify the file landed + constants are present this request).
762 + $detect = self::detect();
763 + $steps['verified'] = $detect['drop_in_installed'] && self::wp_config_has_block();
764 +
765 + $all_ok = $steps['drop_in'] && ( $steps['wp_config'] || self::backend_uses_no_constants( $opts ) );
766 +
767 + return array(
768 + 'ok' => $all_ok,
769 + 'message' => $all_ok
770 + ? 'Object cache enabled. Drop-in installed and configured automatically.'
771 + : ( $steps['drop_in']
772 + ? 'Drop-in installed, but wp-config.php is not writable — add the snippet manually (shown below).'
773 + : 'Could not install the object-cache drop-in (wp-content not writable).' ),
774 + 'steps' => $steps,
775 + 'test' => $test,
776 + 'detect' => $detect,
777 + );
778 + }
779 +
780 + /**
781 + * Full reverse of enable(): remove drop-in + strip our wp-config block.
782 + *
783 + * @return array{ok:bool,message:string,steps:array<string,bool>,detect:array}
784 + */
785 + public static function disable( array $args = array() ): array {
786 + // A drop-in owned by another plugin is left in place by
787 + // remove_dropin(), which then reports success because nothing of ours
788 + // is there to remove. Reporting "disabled" for that is a lie: the site
789 + // still has someone else's object cache running. Say so instead.
790 + $dropin = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : '';
791 + if ( '' !== $dropin && file_exists( $dropin ) && ! self::is_our_dropin_present() ) {
792 + return array(
793 + 'ok' => false,
794 + 'message' => 'The object-cache drop-in belongs to another plugin, so xSpeed left it alone. Turn its object cache off in that plugin instead.',
795 + 'steps' => array(
796 + 'drop_in' => false,
797 + 'wp_config' => false,
798 + ),
799 + 'detect' => self::detect( true ),
800 + );
801 + }
802 +
803 + // Empty our namespace before the drop-in goes, so a later enable
804 + // never reads this session's values back. Our cache stays loaded
805 + // until this request ends, so whatever it writes after this point
806 + // (a restore's own option writes) is flushed again at shutdown.
807 + // (#686)
808 + if ( self::is_our_dropin_present() ) {
809 + if ( self::our_dropin_is_live() ) {
810 + self::flush();
811 + add_action( 'shutdown', array( __CLASS__, 'flush' ), PHP_INT_MAX );
812 + } elseif ( class_exists( __NAMESPACE__ . '\\Settings_Manager' ) ) {
813 + self::purge_namespace( (array) Settings_Manager::get( 'object-cache' ) );
814 + }
815 + }
816 +
817 + $dropin_removed = self::remove_dropin();
818 + $config_removed = self::remove_wp_config();
819 +
820 + /*
821 + * The sidecar is the config on a host where wp-config.php is read-only,
822 + * and it carries the Redis password. Leaving it behind would keep a
823 + * plaintext credential on disk for a feature the admin just switched
824 + * off, and a later re-enable would silently pick up stale credentials
825 + * from a file nothing in this path had touched.
826 + */
827 + self::delete_sidecar();
828 +
829 + $message = $dropin_removed
830 + ? 'Object cache disabled. Drop-in removed and wp-config.php cleaned.'
831 + : 'Could not remove the drop-in — wp-content may not be writable.';
832 +
833 + // Put back the plugin xSpeed switched from, when asked. Without the
834 + // ask, the record describes nothing any more.
835 + $restored = null;
836 + if ( $dropin_removed && ! empty( $args['restore'] ) ) {
837 + $restored = Object_Cache_Takeover::restore();
838 + $message .= ' ' . $restored['message'];
839 + } elseif ( $dropin_removed ) {
840 + // Not put back, but its namespace is cleared all the same, so a
841 + // later manual re-enable does not start from the switch's
842 + // snapshot.
843 + Object_Cache_Takeover::discard();
844 + }
845 +
846 + return array(
847 + // Whether xSpeed's object cache is off. A restore that could not
848 + // complete is reported beside it, in `restored` and the message:
849 + // the disable itself still happened. (#687)
850 + 'ok' => $dropin_removed,
851 + 'restored' => null === $restored ? null : $restored['ok'],
852 + 'message' => $message,
853 + 'steps' => array(
854 + 'drop_in' => $dropin_removed,
855 + 'wp_config' => $config_removed,
856 + 'restored' => null !== $restored && $restored['ok'],
857 + ),
858 + 'detect' => self::detect( true ),
859 + );
860 + }
861 +
862 + /**
863 + * Copy our object-cache.php template into wp-content/. Mirrors
864 + * Cache::install_dropin(): only overwrites our own file, backs up a
865 + * foreign drop-in before replacing it.
866 + */
867 + public static function install_dropin(): bool {
868 + $source = ( defined( 'XSPEED_DIR' ) ? XSPEED_DIR : plugin_dir_path( __DIR__ ) . '../' ) . 'includes/object-cache.php';
869 + $target = WP_CONTENT_DIR . '/object-cache.php';
870 + if ( ! file_exists( $source ) ) {
871 + return false;
872 + }
873 +
874 + $fs = self::fs();
875 + if ( ! $fs ) {
876 + return false;
877 + }
878 +
879 + $source_contents = $fs->get_contents( $source );
880 + if ( ! is_string( $source_contents ) ) {
881 + return false;
882 + }
883 +
884 + if ( file_exists( $target ) ) {
885 + $existing = $fs->get_contents( $target );
886 + $is_xspeed = is_string( $existing ) && false !== strpos( $existing, self::DROPIN_TAG );
887 +
888 + if ( $is_xspeed ) {
889 + if ( $existing === $source_contents ) {
890 + return true;
891 + }
892 + $written = (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
893 + self::invalidate_compiled( $target );
894 + return $written;
895 + }
896 +
897 + // Foreign drop-in — back it up before overwriting.
898 + $upload = wp_upload_dir( null, false );
899 + $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
900 + if ( $basedir ) {
901 + if ( ! file_exists( $basedir ) ) {
902 + wp_mkdir_p( $basedir );
903 + }
904 + $backup = $basedir . '/object-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
905 + $fs->move( $target, $backup, true );
906 + } else {
907 + $fs->delete( $target );
908 + }
909 + }
910 +
911 + $written = (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
912 + self::invalidate_compiled( $target );
913 + return $written;
914 + }
915 +
916 + /**
917 + * Remove our drop-in (only if it's ours). Returns true when no xSpeed
918 + * drop-in remains.
919 + */
920 + public static function remove_dropin(): bool {
921 + $target = WP_CONTENT_DIR . '/object-cache.php';
922 + if ( ! file_exists( $target ) ) {
923 + return true;
924 + }
925 + $fs = self::fs();
926 + if ( ! $fs ) {
927 + return false;
928 + }
929 + $contents = $fs->get_contents( $target );
930 + if ( is_string( $contents ) && false !== strpos( $contents, self::DROPIN_TAG ) ) {
931 + wp_delete_file( $target );
932 + self::invalidate_compiled( $target );
933 + return ! file_exists( $target );
934 + }
935 + // Not ours — leave it, but report success (nothing of ours to remove).
936 + return true;
937 + }
938 +
939 + /**
940 + * Write the XSPEED_OC_* constants between our markers in wp-config.php.
941 + * Idempotent: replaces an existing block. Reversible via remove_wp_config().
942 + */
943 + /** Sidecar holding the config when wp-config.php cannot be written. */
944 + private const SIDECAR_FILE = 'xspeed-object-cache.php';
945 +
946 + /**
947 + * Absolute path of the config sidecar.
948 + *
949 + * Lives beside the drop-in in wp-content/ rather than under
950 + * wp-content/cache/, which a purge empties -- losing the settings on the
951 + * next purge would be a far stranger bug than the one this solves.
952 + */
953 + public static function sidecar_path(): string {
954 + return WP_CONTENT_DIR . '/' . self::SIDECAR_FILE;
955 + }
956 +
957 + /**
958 + * Write the config sidecar. Used when wp-config.php is not writable, which
959 + * is the norm on several managed hosts -- there the panel could otherwise
960 + * only ever tell the user to paste a snippet by hand.
961 + *
962 + * Written as PHP, not JSON: wp-content/ is web-reachable, and a .json here
963 + * would serve the Redis password to anyone who guessed the filename. A PHP
964 + * file with an ABSPATH guard returns nothing when requested directly.
965 + *
966 + * @param array<string,mixed> $opts Effective settings to persist.
967 + */
968 + public static function write_sidecar( array $opts ): bool {
969 + $fs = self::fs();
970 + if ( ! $fs ) {
971 + return false;
972 + }
973 +
974 + $payload = array();
975 + foreach ( self::SIDECAR_KEYS as $key ) {
976 + if ( array_key_exists( $key, $opts ) ) {
977 + $payload[ $key ] = $opts[ $key ];
978 + }
979 + }
980 +
981 + $body = "<?php\n"
982 + . "/**\n"
983 + . " * xSpeed object-cache configuration.\n"
984 + . " *\n"
985 + . " * Written by xSpeed because wp-config.php is not writable on this host.\n"
986 + . " * The drop-in reads this before WordPress loads. Edit the Object Cache\n"
987 + . " * panel rather than this file -- it is rewritten on every save.\n"
988 + . " */\n"
989 + . "defined( 'ABSPATH' ) || exit;\n\n"
990 + . 'return ' . var_export( $payload, true ) . ";\n";
991 +
992 + /*
993 + * Write to a temp file and rename() into place. The drop-in `include`s
994 + * this file BEFORE WordPress loads, so a reader that catches a
995 + * half-written copy gets a PHP parse error -- a white screen on every
996 + * request, not a degraded cache. rename() within the same directory is
997 + * atomic on every filesystem WordPress supports, so a reader sees
998 + * either the whole old file or the whole new one.
999 + */
1000 + $path = self::sidecar_path();
1001 + $tmp = $path . '.' . wp_generate_password( 8, false ) . '.tmp';
1002 +
1003 + if ( ! $fs->put_contents( $tmp, $body, FS_CHMOD_FILE ) ) {
1004 + return false;
1005 + }
1006 + // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- WP_Filesystem has no atomic move; rename() is the whole point here.
1007 + if ( ! @rename( $tmp, $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- failure is reported by the return value.
1008 + $fs->delete( $tmp );
1009 + return false;
1010 + }
1011 +
1012 + /*
1013 + * Managed hosts -- the ones this sidecar exists for -- often run
1014 + * opcache with validate_timestamps off, where `include` would keep
1015 + * returning the previously compiled array however many times we
1016 + * rewrite the file. That is the exact panel-says-one-thing,
1017 + * runtime-does-another failure this change exists to remove.
1018 + */
1019 + if ( function_exists( 'opcache_invalidate' ) ) {
1020 + @opcache_invalidate( $path, true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- opcache may be disabled or restricted; nothing to do either way.
1021 + }
1022 +
1023 + self::forget_sidecar();
1024 + return true;
1025 + }
1026 +
1027 + /**
1028 + * Remove the sidecar. Called when wp-config.php becomes writable again, so
1029 + * two sources can never disagree about the same setting.
1030 + */
1031 + public static function delete_sidecar(): bool {
1032 + $path = self::sidecar_path();
1033 + if ( ! file_exists( $path ) ) {
1034 + return true;
1035 + }
1036 + $fs = self::fs();
1037 + $ok = $fs ? (bool) $fs->delete( $path ) : false;
1038 + if ( $ok ) {
1039 + self::forget_sidecar();
1040 + }
1041 + return $ok;
1042 + }
1043 +
1044 + /**
1045 + * Settings the sidecar carries. Mirrors the fields wp_config_block()
1046 + * emits, so the two storage paths describe the same configuration.
1047 + */
1048 + private const SIDECAR_KEYS = array(
1049 + 'backend',
1050 + 'redis_host',
1051 + 'redis_port',
1052 + 'redis_user',
1053 + 'redis_password',
1054 + 'redis_database',
1055 + 'memcached_host',
1056 + 'memcached_port',
1057 + 'key_prefix',
1058 + 'connection_timeout',
1059 + 'persistent',
1060 + );
1061 +
1062 + /** Memoized sidecar contents; null until first read. */
1063 + private static $sidecar_cache = null;
1064 +
1065 + /** Forget the memoized sidecar. */
1066 + public static function forget_sidecar(): void {
1067 + self::$sidecar_cache = null;
1068 + }
1069 +
1070 + /**
1071 + * Read the sidecar, or an empty array when there is none.
1072 + *
1073 + * @return array<string,mixed>
1074 + */
1075 + public static function read_sidecar(): array {
1076 + if ( null !== self::$sidecar_cache ) {
1077 + return self::$sidecar_cache;
1078 + }
1079 + $path = self::sidecar_path();
1080 + if ( ! file_exists( $path ) || ! is_readable( $path ) ) {
1081 + self::$sidecar_cache = array();
1082 + return self::$sidecar_cache;
1083 + }
1084 + $data = include $path;
1085 + self::$sidecar_cache = is_array( $data ) ? $data : array();
1086 + return self::$sidecar_cache;
1087 + }
1088 +
1089 + /**
1090 + * Host and port of the first server in a `$memcached_servers` global.
1091 + *
1092 + * Memcached has no constant convention the way Redis has WP_REDIS_*; this
1093 + * global IS the convention, and hosts write it in two shapes:
1094 + *
1095 + * array( array( 'host', 11211 ) ) // W3TC pair form
1096 + * array( 'default' => array( 'host:11211' ) ) // Memcached Object Cache
1097 + *
1098 + * Reading only the first left the second taking the whole "host:port"
1099 + * string as the hostname, or missing it entirely because its bucket is
1100 + * keyed `default` rather than 0.
1101 + *
1102 + * The drop-in carries `xspeed_oc_first_memcached_server()`, which must
1103 + * behave identically -- it loads before WordPress and cannot call this
1104 + * class. ObjectCacheConstantParityTest holds the two together. (#398)
1105 + *
1106 + * @param mixed $servers The global's value, unvalidated.
1107 + * @return array{0:?string,1:?int}|null Host and port, either possibly null.
1108 + */
1109 + public static function first_memcached_server( $servers ): ?array {
1110 + if ( ! is_array( $servers ) || array() === $servers ) {
1111 + return null;
1112 + }
1113 +
1114 + $bucket = array_key_exists( 0, $servers ) ? $servers[0] : reset( $servers );
1115 +
1116 + /*
1117 + * A bucket is EITHER a [host, port] pair or a list of server entries.
1118 + * Telling them apart by shape, not by nesting depth: descending into
1119 + * `array( 'mc.example', 11211 )` yields the host string and drops the
1120 + * port on the floor, which is the commonest form there is.
1121 + */
1122 + $entry = $bucket;
1123 + if ( is_array( $bucket ) && isset( $bucket[0] ) && is_array( $bucket[0] ) ) {
1124 + $entry = $bucket[0];
1125 + }
1126 +
1127 + if ( is_array( $entry ) ) {
1128 + $host = isset( $entry[0] ) && ! is_array( $entry[0] ) ? (string) $entry[0] : null;
1129 + $port = isset( $entry[1] ) && ! is_array( $entry[1] ) ? (int) $entry[1] : null;
1130 + // A single-element list, array( 'host:port' ), is the keyed form's
1131 + // bucket rather than a pair -- fall through to the string parser.
1132 + if ( null !== $host && null === $port && is_string( $entry[0] ) && false !== strpos( $entry[0], ':' ) ) {
1133 + $entry = $entry[0];
1134 + } else {
1135 + return ( null === $host && null === $port ) ? null : array( $host, $port );
1136 + }
1137 + }
1138 +
1139 + if ( ! is_string( $entry ) || '' === $entry ) {
1140 + return null;
1141 + }
1142 +
1143 + // "host:port", or a bare host. Split only the LAST colon, and only when
1144 + // what follows is numeric -- a unix socket path is a host with no port.
1145 + $at = strrpos( $entry, ':' );
1146 + if ( false !== $at && ctype_digit( substr( $entry, $at + 1 ) ) ) {
1147 + return array( substr( $entry, 0, $at ), (int) substr( $entry, $at + 1 ) );
1148 + }
1149 + return array( $entry, null );
1150 + }
1151 +
1152 + /**
1153 + * Names of the constants xSpeed itself wrote into wp-config.php.
1154 + *
1155 + * Ownership is decided by LOCATION, not by name. Our block is fenced by
1156 + * CONFIG_BEGIN / CONFIG_END, so a define inside it is one we wrote and a
1157 + * define anywhere else belongs to the host -- even when both are called
1158 + * `XSPEED_OC_HOST`, which is exactly what a user pasting our own snippet
1159 + * by hand produces.
1160 + *
1161 + * Judging by prefix instead is what made the panel treat xSpeed's own
1162 + * values as host-pinned: the field locked, the "manage this here" control
1163 + * could not unlock it, and Revert handed the field back to our snapshot
1164 + * rather than to the host. (#398)
1165 + *
1166 + * @return string[] Constant names, empty when the block is absent.
1167 + */
1168 +
1169 + public static function our_constants(): array {
1170 + if ( null !== self::$our_constants_cache ) {
1171 + return self::$our_constants_cache;
1172 + }
1173 + $cache = array();
1174 +
1175 + $wp_config = ABSPATH . 'wp-config.php';
1176 + if ( ! file_exists( $wp_config ) || ! is_readable( $wp_config ) ) {
1177 + self::$our_constants_cache = $cache;
1178 + return $cache;
1179 + }
1180 + // 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.
1181 + $config = (string) @file_get_contents( $wp_config ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable wp-config just means "we own nothing".
1182 + if ( '' === $config ) {
1183 + self::$our_constants_cache = $cache;
1184 + return $cache;
1185 + }
1186 +
1187 + $pattern = '/' . preg_quote( self::CONFIG_BEGIN, '/' ) . '(.*?)' . preg_quote( self::CONFIG_END, '/' ) . '/s';
1188 + if ( ! preg_match( $pattern, $config, $m ) ) {
1189 + self::$our_constants_cache = $cache;
1190 + return $cache;
1191 + }
1192 + if ( preg_match_all( "/define\\(\\s*'([A-Z0-9_]+)'/", $m[1], $names ) ) {
1193 + $cache = $names[1];
1194 + }
1195 + self::$our_constants_cache = $cache;
1196 + return $cache;
1197 + }
1198 +
1199 + /**
1200 + * Memoized result of our_constants(); null until the block is first read.
1201 + *
1202 + * @var string[]|null
1203 + */
1204 + private static $our_constants_cache = null;
1205 +
1206 + /**
1207 + * Forget the memoized block scan. Every write that changes the block must
1208 + * call this, or the same request keeps answering from the pre-write copy.
1209 + */
1210 + public static function forget_our_constants(): void {
1211 + self::$our_constants_cache = null;
1212 + }
1213 +
1214 + public static function write_wp_config( array $opts, array $force = array() ): bool {
1215 + $fs = self::fs();
1216 + $wp_config = ABSPATH . 'wp-config.php';
1217 + if ( ! $fs || ! file_exists( $wp_config ) || ! $fs->is_writable( $wp_config ) ) {
1218 + /*
1219 + * wp-config.php is read-only on several managed hosts. Fall back to
1220 + * a sidecar in wp-content/ -- writable wherever the drop-in itself
1221 + * could be installed, so the panel keeps working instead of telling
1222 + * the user to paste a snippet by hand. (#398)
1223 + */
1224 + return self::write_sidecar( $opts );
1225 + }
1226 +
1227 +
1228 + $config = $fs->get_contents( $wp_config );
1229 + if ( ! is_string( $config ) ) {
1230 + return false;
1231 + }
1232 +
1233 + $block = self::wp_config_block( $opts, $force );
1234 +
1235 + // Replace an existing xSpeed block if present, else insert after <?php.
1236 + // IMPORTANT: $block is inserted via preg_replace_callback returning it
1237 + // VERBATIM — never as a preg_replace replacement string. In a
1238 + // replacement string, `\` and `$` are special (backref escapes), so a
1239 + // constant value ending in a backslash (e.g. a Redis password or key
1240 + // prefix like "secret\") or containing "$1" would corrupt the output:
1241 + // esc()'s "secret\\" collapses back to "secret\", producing
1242 + // 'secret\' ) — a PHP parse error that white-screens the whole site.
1243 + // The callback form treats $block as literal text. (FBS-82111 Bug 1)
1244 + $pattern = '/' . preg_quote( self::CONFIG_BEGIN, '/' ) . '.*?' . preg_quote( self::CONFIG_END, '/' ) . "\s*/s";
1245 + if ( preg_match( $pattern, $config ) ) {
1246 + $config = preg_replace_callback(
1247 + $pattern,
1248 + static function () use ( $block ) {
1249 + return $block;
1250 + },
1251 + $config,
1252 + 1
1253 + );
138 1254 } else {
139 - $lines[] = '// No snippet for backend: ' . $backend;
1255 + $config = preg_replace_callback(
1256 + '/(<\?php)/',
1257 + static function ( $m ) use ( $block ) {
1258 + return $m[1] . "\n" . $block;
1259 + },
1260 + $config,
1261 + 1
1262 + );
140 1263 }
141 1264
1265 + $written = (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1266 + if ( $written ) {
1267 + self::invalidate_compiled( $wp_config );
1268 + // The block just changed; a memoized scan from earlier in this
1269 + // request would still name the previous set. (#398)
1270 + self::forget_our_constants();
1271 +
1272 + // Only NOW is the block durable, so only now is a sidecar left
1273 + // from an earlier read-only spell safely redundant. Deleting it
1274 + // before the write -- is_writable() is not a promise the write
1275 + // lands; get_contents() can fail, and put_contents() can fail on a
1276 + // full disk or an SELinux denial -- would drop the live config and
1277 + // leave the site on built-in defaults.
1278 + self::delete_sidecar();
1279 + }
1280 + return $written;
1281 + }
1282 +
1283 + /**
1284 + * Strip our wp-config block. Returns true if the block is gone afterward.
1285 + */
1286 + public static function remove_wp_config(): bool {
1287 + $fs = self::fs();
1288 + $wp_config = ABSPATH . 'wp-config.php';
1289 + if ( ! $fs || ! file_exists( $wp_config ) ) {
1290 + return true;
1291 + }
1292 + if ( ! $fs->is_writable( $wp_config ) ) {
1293 + return false;
1294 + }
1295 + $config = $fs->get_contents( $wp_config );
1296 + if ( ! is_string( $config ) ) {
1297 + return false;
1298 + }
1299 + $pattern = '/' . preg_quote( self::CONFIG_BEGIN, '/' ) . '.*?' . preg_quote( self::CONFIG_END, '/' ) . "\s*/s";
1300 + $config = preg_replace( $pattern, '', $config );
1301 + $removed = (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1302 + if ( $removed ) {
1303 + self::invalidate_compiled( $wp_config );
1304 + // A scan from earlier in this request would still name the
1305 + // constants we just deleted, so origins() would report a field as
1306 + // ours -- editable -- when a host define is now the only source
1307 + // and the field should read as pinned.
1308 + self::forget_our_constants();
1309 + }
1310 + return $removed;
1311 + }
1312 +
1313 + /**
1314 + * The marker-wrapped constants block written into wp-config.php. Uses
1315 + * XSPEED_OC_* names (our drop-in reads these first, then falls back to
1316 + * WP_REDIS_* for interop).
1317 + */
1318 + private static function wp_config_block( array $opts, array $force = array() ): string {
1319 + // Fields the caller has decided we own, whatever pinned_elsewhere()
1320 + // would otherwise say. Used when an admin saved an override: they were
1321 + // told the host's define would stop applying, and this is the write
1322 + // that makes that true. (#398)
1323 + self::$force_fields = $force;
1324 + $backend = (string) ( $opts['backend'] ?? 'redis' );
1325 + $lines = array( self::CONFIG_BEGIN );
1326 + $lines[] = "define( 'XSPEED_OC_BACKEND', '" . self::esc( $backend ) . "' );";
1327 +
1328 + if ( 'memcached' === $backend ) {
1329 + // XSPEED_OC_MC_*, not the Redis pair: one shared name meant enabling
1330 + // Redis overwrote the Memcached host/port. (#398)
1331 + if ( ! self::pinned_elsewhere( 'memcached_host' ) ) {
1332 + $lines[] = "define( 'XSPEED_OC_MC_HOST', '" . self::esc( self::str( $opts, 'memcached_host', '127.0.0.1' ) ) . "' );";
1333 + }
1334 + if ( ! self::pinned_elsewhere( 'memcached_port' ) ) {
1335 + $lines[] = "define( 'XSPEED_OC_MC_PORT', " . self::int( $opts, 'memcached_port', 11211 ) . ' );';
1336 + }
1337 + } else {
1338 + if ( ! self::pinned_elsewhere( 'redis_host' ) ) {
1339 + $lines[] = "define( 'XSPEED_OC_HOST', '" . self::esc( self::str( $opts, 'redis_host', '127.0.0.1' ) ) . "' );";
1340 + }
1341 + if ( ! self::pinned_elsewhere( 'redis_port' ) ) {
1342 + $lines[] = "define( 'XSPEED_OC_PORT', " . self::int( $opts, 'redis_port', 6379 ) . ' );';
1343 + }
1344 + $user = self::str( $opts, 'redis_user', '' );
1345 + if ( '' !== $user && ! self::pinned_elsewhere( 'redis_user' ) ) {
1346 + $lines[] = "define( 'XSPEED_OC_USER', '" . self::esc( $user ) . "' );";
1347 + }
1348 + $pass = self::str( $opts, 'redis_password', '' );
1349 + if ( '' !== $pass && ! self::pinned_elsewhere( 'redis_password' ) ) {
1350 + $lines[] = "define( 'XSPEED_OC_PASSWORD', '" . self::esc( $pass ) . "' );";
1351 + }
1352 + if ( ! self::pinned_elsewhere( 'redis_database' ) ) {
1353 + $lines[] = "define( 'XSPEED_OC_DATABASE', " . self::int( $opts, 'redis_database', 0 ) . ' );';
1354 + }
1355 + if ( ! self::pinned_elsewhere( 'connection_timeout' ) ) {
1356 + $lines[] = "define( 'XSPEED_OC_TIMEOUT', " . self::int( $opts, 'connection_timeout', 1 ) . ' );';
1357 + }
1358 + if ( ! self::pinned_elsewhere( 'persistent' ) ) {
1359 + $lines[] = "define( 'XSPEED_OC_PERSISTENT', " . ( ! empty( $opts['persistent'] ) ? 'true' : 'false' ) . ' );';
1360 + }
1361 + }
1362 + // Always emit a salt (#390): a blank Cache Key Prefix derives a per-site
1363 + // value rather than leaving keys unnamespaced, which collides when
1364 + // several sites share one Redis/Memcached server.
1365 + //
1366 + // Unless a foreign define already owns it (#398). Emitting ours would
1367 + // outrank the host's WP_REDIS_PREFIX, and on an ACL/namespaced Redis a
1368 + // prefix that does not match the host's exactly means every write is
1369 + // denied with NOPERM -- so a derived salt there is worse than none.
1370 + // The host's define IS the namespace in that case, and it is already
1371 + // non-empty, so the collision #390 closes cannot reopen.
1372 + if ( ! self::pinned_elsewhere( 'key_prefix' ) ) {
1373 + $lines[] = "define( 'XSPEED_OC_SALT', '" . self::esc( self::effective_salt( $opts ) ) . "' );";
1374 + }
1375 + $lines[] = self::CONFIG_END;
1376 + self::$force_fields = array();
142 1377 return implode( "\n", $lines ) . "\n";
1378 + }
1379 +
1380 + /**
1381 + * Fields the current block write owns outright. Set for the duration of one
1382 + * wp_config_block() call; see the $force parameter there.
1383 + *
1384 + * @var string[]
1385 + */
1386 + private static array $force_fields = array();
1387 +
1388 + /**
1389 + * Is this field already pinned by a constant we are not about to write?
1390 + *
1391 + * Enable() resolves settings through Settings_Manager, so on a
1392 + * host-provisioned site those values came FROM wp-config in the first
1393 + * place -- typically WP_REDIS_*. Writing them back out under our own
1394 + * XSPEED_OC_* names, which outrank every alias, would freeze a snapshot:
1395 + * when the host later rotated the password, the site would keep
1396 + * authenticating with our stale copy and silently drop to a
1397 + * non-persistent cache. It would also re-emit a credential as a second
1398 + * plaintext literal, which is the thing sourcing it from a constant
1399 + * avoids. So leave the host's define alone and emit nothing for it. (#398)
1400 + */
1401 + private static function pinned_elsewhere( string $field ): bool {
1402 + if ( in_array( $field, self::$force_fields, true ) ) {
1403 + return false;
1404 + }
1405 + if ( ! class_exists( '\\XSpeed\\Settings_Manager' ) ) {
1406 + return false;
1407 + }
1408 + $module = \XSpeed\Module_Registry::get( 'object-cache' );
1409 + if ( ! $module ) {
1410 + return false;
1411 + }
1412 +
1413 + // An admin who deliberately overrode this field asked us to shadow the
1414 + // host's define -- they were told so in as many words before the field
1415 + // unlocked. Protecting it here would silently drop their value on the
1416 + // next enable, which is the same silent-no-op failure the whole
1417 + // pinned-field contract exists to prevent. (#398)
1418 + if ( \XSpeed\Settings_Manager::is_overridden( 'object-cache', $field ) ) {
1419 + return false;
1420 + }
1421 +
1422 + // Somebody else's define, anywhere in this field's list, is protected --
1423 + // even when our own XSPEED_OC_* copy currently outranks it. Testing only
1424 + // the WINNING constant made an override permanent in a subtler way: on
1425 + // revert we rewrote our copy with the host's value, our copy still
1426 + // outranked theirs, and a later rotation on their side was shadowed
1427 + // forever. Emitting nothing for the field lets the host's define surface
1428 + // again and keep surfacing. (#398)
1429 + return null !== \XSpeed\Settings_Manager::foreign_constant( 'object-cache', $field );
1430 + }
1431 +
1432 + /**
1433 + * Is our marker block present in wp-config.php?
1434 + *
1435 + * Public so a caller can tell "we already manage constants here" from
1436 + * "this site never enabled the object cache" -- rewriting the block is
1437 + * right in the first case and would be an unasked-for file edit in the
1438 + * second. (#398)
1439 + */
1440 + public static function wp_config_has_our_block(): bool {
1441 + return self::wp_config_has_block();
1442 + }
1443 +
1444 + private static function wp_config_has_block(): bool {
1445 + $wp_config = ABSPATH . 'wp-config.php';
1446 + if ( ! file_exists( $wp_config ) ) {
1447 + return false;
1448 + }
1449 + $fs = self::fs();
1450 + if ( ! $fs ) {
1451 + return false;
1452 + }
1453 + $config = $fs->get_contents( $wp_config );
1454 + return is_string( $config ) && false !== strpos( $config, self::CONFIG_BEGIN );
1455 + }
1456 +
1457 + /**
1458 + * Memcached config goes through $memcached_servers (handled by our drop-in's
1459 + * defaults), so a non-writable wp-config isn't necessarily fatal for it.
1460 + */
1461 + private static function backend_uses_no_constants( array $opts ): bool {
1462 + return false; // both backends currently rely on the constants block
1463 + }
1464 +
1465 + /**
1466 + * Initialised WP_Filesystem handle, or null. Plugin Check-compliant access.
1467 + *
1468 + * Forces the 'direct' transport when PHP can write the WordPress tree
1469 + * itself. Without this, WP_Filesystem() can fall back to the FTP transport
1470 + * (no credentials in a non-interactive context) and fatal in
1471 + * ftp_fget(). We only need 'direct' — these writes target wp-config.php /
1472 + * wp-content, both owned by the PHP user on a normal install.
1473 + */
1474 + /**
1475 + * Drop a file's compiled copy from OPcache after we rewrite or remove it.
1476 + *
1477 + * wp-config.php and object-cache.php are both compiled once and reused.
1478 + * With the default revalidate_freq of 2 seconds, and indefinitely where a
1479 + * host turns validate_timestamps off, requests right after a write still
1480 + * run the old code: the panel read "Off" and "Backend: unknown" just
1481 + * after a switch because the new block's constants were not defined yet,
1482 + * and the old drop-in was still the live cache. (#687)
1483 + *
1484 + * @param string $path Absolute file path.
1485 + */
1486 + public static function invalidate_compiled( string $path ): void {
1487 + if ( function_exists( 'wp_opcache_invalidate' ) ) {
1488 + wp_opcache_invalidate( $path, true );
1489 + } elseif ( function_exists( 'opcache_invalidate' ) ) {
1490 + @opcache_invalidate( $path, true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- opcache may be disabled or restricted; nothing to do either way.
1491 + }
1492 + }
1493 +
1494 + /**
1495 + * The direct filesystem, for Object_Cache_Takeover's copies and deletes.
1496 + *
1497 + * @return \WP_Filesystem_Base|null
1498 + */
1499 + public static function filesystem() {
1500 + return self::fs();
1501 + }
1502 +
1503 + private static function fs() {
1504 + global $wp_filesystem;
1505 + if ( ! function_exists( 'WP_Filesystem' ) ) {
1506 + require_once ABSPATH . 'wp-admin/includes/file.php';
1507 + }
1508 +
1509 + // Pin the method to 'direct' for this call so a missing FTP/SSH config
1510 + // can never trigger the credential-prompt / ftp_*() fatal path. Use a
1511 + // closure on the filter so we don't permanently alter global behaviour.
1512 + $force_direct = static function () {
1513 + return 'direct';
1514 + };
1515 + add_filter( 'filesystem_method', $force_direct, 99 );
1516 + $ok = WP_Filesystem();
1517 + remove_filter( 'filesystem_method', $force_direct, 99 );
1518 +
1519 + if ( ! $ok || ! $wp_filesystem || 'direct' !== $wp_filesystem->method ) {
1520 + return null;
1521 + }
1522 + return $wp_filesystem;
143 1523 }
144 1524
145 1525 private static function sniff_drop_in_label( string $path ): string {
146 1526 $head = @file_get_contents( $path, false, null, 0, 2048 );