PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 All 35 releases
← All changes | includes/class-object-cache.php +1381 -17 1.0.2 → 1.4.0 View file →
@@ -36,21 +36,39 @@
36 36 * Inspect the runtime + filesystem for a persistent object cache.
37 37 *
38 38 * @return array{
39 39 * drop_in_installed: bool,
40 + * drop_in_is_ours: bool, // installed AND carries our tag
40 41 * drop_in_path: string,
41 42 * drop_in_label: string,
42 43 * backend: string, // redis|memcached|apcu|wp_default|unknown
43 44 * wp_cache_active: bool, // wp_using_ext_object_cache
45 + * degraded: bool, // ours is installed but NOT persisting
46 + * persistent: bool, // ours is installed AND persisting
44 47 * class_available: array<string,bool>
45 48 * }
46 49 */
47 - public static function detect(): array {
50 + public static function detect( bool $with_owner = false ): array {
48 51 $dropin = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : '';
49 52 $has_drop_in = '' !== $dropin && file_exists( $dropin );
50 53 $label = $has_drop_in ? self::sniff_drop_in_label( $dropin ) : '';
51 54 $ext_in_use = function_exists( 'wp_using_ext_object_cache' ) ? (bool) wp_using_ext_object_cache() : false;
52 55
56 + // When OUR drop-in is the live one it exposes whether it actually
57 + // connected a persistent backend. A drop-in that's installed but
58 + // degraded reports wp_using_ext_object_cache()=true yet persists
59 + // nothing — the silent failure that makes a site slow. Read the honest
60 + // state straight off the running instance. (FBS-82210)
61 + $degraded = false;
62 + $persistent = false;
63 + if ( $has_drop_in && isset( $GLOBALS['wp_object_cache'] ) && is_object( $GLOBALS['wp_object_cache'] ) ) {
64 + $oc = $GLOBALS['wp_object_cache'];
65 + if ( method_exists( $oc, 'is_persistent' ) ) {
66 + $persistent = (bool) $oc->is_persistent();
67 + $degraded = ! $persistent;
68 + }
69 + }
70 +
53 71 // Class sniffer — independent of any plugin. Tells us what's
54 72 // available to actually use, separate from what's wired up.
55 73 $class_available = array(
56 74 'Redis' => class_exists( '\\Redis' ),
@@ -62,30 +80,125 @@
62 80 $backend = 'unknown';
63 81 if ( ! $ext_in_use ) {
64 82 $backend = 'wp_default';
65 83 } elseif ( $has_drop_in ) {
66 - // Best-effort: the label usually contains the backend name.
67 - $lc = strtolower( $label );
68 - if ( false !== strpos( $lc, 'redis' ) ) {
69 - $backend = 'redis';
70 - } elseif ( false !== strpos( $lc, 'memcached' ) || false !== strpos( $lc, 'memcache' ) ) {
71 - $backend = 'memcached';
72 - } elseif ( false !== strpos( $lc, 'apcu' ) ) {
73 - $backend = 'apcu';
84 + // Authoritative source first: our own drop-in records the chosen
85 + // backend in the XSPEED_OC_BACKEND constant (written to wp-config
86 + // on enable). The drop-in label is the generic
87 + // "XSPEED_OBJECT_CACHE_DROPIN" and does NOT contain the backend
88 + // name, so the label sniff below would always yield "unknown" for
89 + // our drop-in — read the constant instead. (FBS-82111)
90 + if ( defined( 'XSPEED_OC_BACKEND' ) && '' !== (string) constant( 'XSPEED_OC_BACKEND' ) ) {
91 + $backend = strtolower( (string) constant( 'XSPEED_OC_BACKEND' ) );
92 + } else {
93 + // Foreign drop-in (W3TC / Redis Object Cache / …): best-effort
94 + // guess from the label, which usually names the backend.
95 + $lc = strtolower( $label );
96 + if ( false !== strpos( $lc, 'redis' ) ) {
97 + $backend = 'redis';
98 + } elseif ( false !== strpos( $lc, 'memcached' ) || false !== strpos( $lc, 'memcache' ) ) {
99 + $backend = 'memcached';
100 + } elseif ( false !== strpos( $lc, 'apcu' ) ) {
101 + $backend = 'apcu';
102 + }
74 103 }
75 104 }
76 105
77 106 return array(
78 107 'drop_in_installed' => $has_drop_in,
108 + // Whether the installed drop-in is OURS. A foreign one (W3TC,
109 + // Redis Object Cache, LiteSpeed) means the object cache belongs to
110 + // another plugin: we must not offer to configure or disable it,
111 + // and "installed" must not be read as "xSpeed is running".
112 + 'drop_in_is_ours' => $has_drop_in && self::is_our_dropin_present(),
79 113 'drop_in_path' => $dropin,
80 114 'drop_in_label' => $label,
81 115 'backend' => $backend,
82 116 'wp_cache_active' => $ext_in_use,
117 + 'degraded' => $degraded,
118 + 'persistent' => $persistent,
83 119 'class_available' => $class_available,
120 + // Who owns a foreign drop-in and how a switch would go, so every
121 + // surface can offer it (or say why not) the same way. (#686)
122 + // Only on request: it reads plugin folders, and module status
123 + // calls detect() on dashboard loads.
124 + 'owner' => $with_owner && $has_drop_in && ! self::is_our_dropin_present() ? Object_Cache_Takeover::owner() : null,
125 + // The plugin a switch replaced, which Disable can put back.
126 + 'previous_owner' => self::previous_owner(),
84 127 );
85 128 }
86 129
87 130 /**
131 + * Label of the plugin xSpeed switched from, while ours is installed.
132 + *
133 + * @return array{label:string}|null
134 + */
135 + private static function previous_owner(): ?array {
136 + if ( ! self::is_our_dropin_present() ) {
137 + return null;
138 + }
139 + $record = Object_Cache_Takeover::record();
140 + return null === $record ? null : array( 'label' => $record['label'] );
141 + }
142 +
143 + /** A drop-in that is not ours sits in wp-content. */
144 + private static function foreign_dropin_present(): bool {
145 + $dropin = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : '';
146 + return '' !== $dropin && ( file_exists( $dropin ) || is_link( $dropin ) ) && ! self::is_our_dropin_present();
147 + }
148 +
149 + /** Our drop-in is the object cache this request is running on. */
150 + private static function our_dropin_is_live(): bool {
151 + return isset( $GLOBALS['wp_object_cache'] ) && $GLOBALS['wp_object_cache'] instanceof \XSpeed_Object_Cache;
152 + }
153 +
154 + /**
155 + * Delete this site's keys from Redis, the same `salt:*` scope the
156 + * drop-in's flush uses. For when the drop-in is not loaded in this
157 + * request, so wp_cache_flush() would flush someone else's cache or none.
158 + * Memcached has no key enumeration; its namespace generation covers it.
159 + *
160 + * @param array $opts Settings array.
161 + * @return int Keys deleted, or -1 when nothing could be done.
162 + */
163 + public static function purge_namespace( array $opts ): int {
164 + if ( 'redis' !== (string) ( $opts['backend'] ?? 'redis' ) ) {
165 + return -1;
166 + }
167 + $salt = self::effective_salt( $opts );
168 + if ( '' === $salt ) {
169 + return -1;
170 + }
171 + $client = new Redis_Client(
172 + self::str( $opts, 'redis_host', '127.0.0.1' ),
173 + self::int( $opts, 'redis_port', 6379 ),
174 + (float) self::int( $opts, 'connection_timeout', 1 ),
175 + false
176 + );
177 + if ( ! $client->connect() ) {
178 + return -1;
179 + }
180 + $user = self::str( $opts, 'redis_user', '' );
181 + $pass = self::str( $opts, 'redis_password', '' );
182 + if ( ( '' !== $pass || '' !== $user ) && false === $client->auth( $pass, $user ) ) {
183 + $client->close();
184 + return -1;
185 + }
186 + $db = self::int( $opts, 'redis_database', 0 );
187 + if ( $db > 0 ) {
188 + $client->select( $db );
189 + }
190 + $pattern = str_replace(
191 + array( '\\', '*', '?', '[', ']' ),
192 + array( '\\\\', '\\*', '\\?', '\\[', '\\]' ),
193 + $salt
194 + ) . ':*';
195 + $deleted = $client->delete_by_pattern( $pattern );
196 + $client->close();
197 + return $deleted;
198 + }
199 +
200 + /**
88 201 * Flush whatever cache backend is wired up. Works against any
89 202 * compliant drop-in OR the WP default in-memory cache.
90 203 */
91 204 public static function flush(): bool {
@@ -108,39 +221,1290 @@
108 221
109 222 if ( 'redis' === $backend ) {
110 223 $host = self::str( $opts, 'redis_host', '127.0.0.1' );
111 224 $port = self::int( $opts, 'redis_port', 6379 );
225 + $user = self::str( $opts, 'redis_user', '' );
112 226 $pass = self::str( $opts, 'redis_password', '' );
113 227 $db = self::int( $opts, 'redis_database', 0 );
114 - $prefix = self::str( $opts, 'key_prefix', '' );
228 + $prefix = self::effective_salt( $opts );
115 229 $timeout = self::int( $opts, 'connection_timeout', 1 );
116 230 $persist = ! empty( $opts['persistent'] );
117 231
118 232 $lines[] = "define( 'WP_REDIS_HOST', '" . self::esc( $host ) . "' );";
119 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 + }
120 239 if ( '' !== $pass ) {
121 240 $lines[] = "define( 'WP_REDIS_PASSWORD', '" . self::esc( $pass ) . "' );";
122 241 }
123 242 $lines[] = "define( 'WP_REDIS_DATABASE', " . $db . ' );';
124 - if ( '' !== $prefix ) {
125 - $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
126 - }
243 + $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
127 244 $lines[] = "define( 'WP_REDIS_TIMEOUT', " . $timeout . ' );';
128 245 $lines[] = "define( 'WP_REDIS_PERSISTENT', " . ( $persist ? 'true' : 'false' ) . ' );';
129 246 } elseif ( 'memcached' === $backend ) {
130 247 $host = self::str( $opts, 'memcached_host', '127.0.0.1' );
131 248 $port = self::int( $opts, 'memcached_port', 11211 );
132 - $prefix = self::str( $opts, 'key_prefix', '' );
249 + $prefix = self::effective_salt( $opts );
133 250 $lines[] = "global \$memcached_servers;";
134 251 $lines[] = "\$memcached_servers = array( array( '" . self::esc( $host ) . "', " . $port . ' ) );';
135 - if ( '' !== $prefix ) {
136 - $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
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 + );
137 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 + );
138 1238 } else {
139 - $lines[] = '// No snippet for backend: ' . $backend;
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 + );
140 1247 }
141 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();
142 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;
143 1507 }
144 1508
145 1509 private static function sniff_drop_in_label( string $path ): string {
146 1510 $head = @file_get_contents( $path, false, null, 0, 2048 );