PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-object-cache.php

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

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