PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 All 35 releases
xspeed / includes / class-object-cache.php

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

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