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
xspeed / includes / class-object-cache.php

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

1,553 lines 60.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Object_Cache — read-only detector + flusher + wp-config snippet
4 * generator for the persistent object cache.
5 *
6 * We deliberately do NOT install our own object-cache.php drop-in
7 * from this Free release — that's invasive, has many failure modes
8 * (auth, TLS, cluster vs single, Redis vs Predis vs phpredis,
9 * Memcache vs Memcached), and changes how every site reads/writes
10 * persistent state. The Free plugin's role is:
11 *
12 * 1. Tell the user whether a drop-in is currently active and
13 * which backend it appears to be.
14 * 2. Provide a Flush button that calls wp_cache_flush() — which
15 * works regardless of which drop-in is installed.
16 * 3. Save backend-config values (host, port, password, etc.) and
17 * render a wp-config.php snippet the user can paste, so the
18 * flow is "configure here → copy snippet → install drop-in"
19 * without us writing to wp-config ourselves.
20 *
21 * The Pro plugin (or a later Free release once well tested) can
22 * ship a drop-in that consumes these saved values automatically.
23 *
24 * @package XSpeed
25 */
26
27 declare(strict_types=1);
28
29 namespace XSpeed;
30
31 defined( 'ABSPATH' ) || exit;
32
33 final class Object_Cache {
34
35 /**
36 * Inspect the runtime + filesystem for a persistent object cache.
37 *
38 * @return array{
39 * drop_in_installed: bool,
40 * drop_in_is_ours: bool, // installed AND carries our tag
41 * drop_in_path: string,
42 * drop_in_label: string,
43 * backend: string, // redis|memcached|apcu|wp_default|unknown
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
47 * class_available: array<string,bool>
48 * }
49 */
50 public static function detect( bool $with_owner = false ): array {
51 $dropin = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : '';
52 $has_drop_in = '' !== $dropin && file_exists( $dropin );
53 $label = $has_drop_in ? self::sniff_drop_in_label( $dropin ) : '';
54 $ext_in_use = function_exists( 'wp_using_ext_object_cache' ) ? (bool) wp_using_ext_object_cache() : false;
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
71 // Class sniffer — independent of any plugin. Tells us what's
72 // available to actually use, separate from what's wired up.
73 $class_available = array(
74 'Redis' => class_exists( '\\Redis' ),
75 'Memcached' => class_exists( '\\Memcached' ),
76 'Memcache' => class_exists( '\\Memcache' ),
77 'APCu' => function_exists( 'apcu_enabled' ) && @apcu_enabled(),
78 );
79
80 $backend = 'unknown';
81 if ( ! $ext_in_use ) {
82 $backend = 'wp_default';
83 } elseif ( $has_drop_in ) {
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 }
103 }
104 }
105
106 return array(
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(),
113 'drop_in_path' => $dropin,
114 'drop_in_label' => $label,
115 'backend' => $backend,
116 'wp_cache_active' => $ext_in_use,
117 'degraded' => $degraded,
118 'persistent' => $persistent,
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(),
127 );
128 }
129
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 /**
201 * Flush whatever cache backend is wired up. Works against any
202 * compliant drop-in OR the WP default in-memory cache.
203 */
204 public static function flush(): bool {
205 if ( ! function_exists( 'wp_cache_flush' ) ) {
206 return false;
207 }
208 return (bool) wp_cache_flush();
209 }
210
211 /**
212 * Render a paste-into-wp-config.php snippet for the chosen backend
213 * using the supplied settings. The constant names match the
214 * conventions of the widely-used Redis Object Cache + W3TC drop-ins
215 * so users with those installed get a working configuration
216 * without any further translation.
217 */
218 public static function render_config_snippet( array $opts ): string {
219 $backend = (string) ( $opts['backend'] ?? 'redis' );
220 $lines = array( "/* xSpeed object cache config — paste above the \"That's all, stop editing!\" comment in wp-config.php. */" );
221
222 if ( 'redis' === $backend ) {
223 $host = self::str( $opts, 'redis_host', '127.0.0.1' );
224 $port = self::int( $opts, 'redis_port', 6379 );
225 $user = self::str( $opts, 'redis_user', '' );
226 $pass = self::str( $opts, 'redis_password', '' );
227 $db = self::int( $opts, 'redis_database', 0 );
228 $prefix = self::effective_salt( $opts );
229 $timeout = self::int( $opts, 'connection_timeout', 1 );
230 $persist = ! empty( $opts['persistent'] );
231
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 }
248 if ( '' !== $pass ) {
249 $lines[] = "define( 'WP_REDIS_PASSWORD', '" . self::esc( $pass ) . "' );";
250 }
251 $lines[] = "define( 'WP_REDIS_DATABASE', " . $db . ' );';
252 $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
253 $lines[] = "define( 'WP_REDIS_TIMEOUT', " . $timeout . ' );';
254 $lines[] = "define( 'WP_REDIS_PERSISTENT', " . ( $persist ? 'true' : 'false' ) . ' );';
255 } elseif ( 'memcached' === $backend ) {
256 $host = self::str( $opts, 'memcached_host', '127.0.0.1' );
257 $port = self::int( $opts, 'memcached_port', 11211 );
258 $prefix = self::effective_salt( $opts );
259 $lines[] = "global \$memcached_servers;";
260 $lines[] = "\$memcached_servers = array( array( '" . self::esc( $host ) . "', " . $port . ' ) );';
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 );
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 );
1254 } else {
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 );
1263 }
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();
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;
1523 }
1524
1525 private static function sniff_drop_in_label( string $path ): string {
1526 $head = @file_get_contents( $path, false, null, 0, 2048 );
1527 if ( ! is_string( $head ) || '' === $head ) {
1528 return '';
1529 }
1530 // PluginName / Plugin Name in standard WP file header form.
1531 if ( preg_match( '#Plugin Name:\s*([^\r\n]+)#i', $head, $m ) ) {
1532 return trim( $m[1] );
1533 }
1534 // Many drop-ins just put their identity in a comment.
1535 if ( preg_match( '#\*\s*([A-Za-z][A-Za-z0-9 _\-]{2,40}(?:Cache|Redis|Memcached)[^\r\n]*)#i', $head, $m ) ) {
1536 return trim( $m[1] );
1537 }
1538 return basename( $path );
1539 }
1540
1541 private static function str( array $opts, string $key, string $default ): string {
1542 return isset( $opts[ $key ] ) && '' !== $opts[ $key ] ? (string) $opts[ $key ] : $default;
1543 }
1544
1545 private static function int( array $opts, string $key, int $default ): int {
1546 return isset( $opts[ $key ] ) && '' !== $opts[ $key ] ? (int) $opts[ $key ] : $default;
1547 }
1548
1549 private static function esc( string $s ): string {
1550 return str_replace( array( '\\', "'" ), array( '\\\\', "\\'" ), $s );
1551 }
1552 }
1553