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.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 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/class-object-cache.php +1178 -16 1.0.21.3.2 View file →
@@ -36,12 +36,15 @@
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 50 public static function detect(): array {
@@ -49,8 +52,23 @@
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,25 +80,43 @@
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,
84 120 );
85 121 }
86 122
@@ -108,39 +144,1165 @@
108 144
109 145 if ( 'redis' === $backend ) {
110 146 $host = self::str( $opts, 'redis_host', '127.0.0.1' );
111 147 $port = self::int( $opts, 'redis_port', 6379 );
148 + $user = self::str( $opts, 'redis_user', '' );
112 149 $pass = self::str( $opts, 'redis_password', '' );
113 150 $db = self::int( $opts, 'redis_database', 0 );
114 - $prefix = self::str( $opts, 'key_prefix', '' );
151 + $prefix = self::effective_salt( $opts );
115 152 $timeout = self::int( $opts, 'connection_timeout', 1 );
116 153 $persist = ! empty( $opts['persistent'] );
117 154
118 155 $lines[] = "define( 'WP_REDIS_HOST', '" . self::esc( $host ) . "' );";
119 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 + }
120 162 if ( '' !== $pass ) {
121 163 $lines[] = "define( 'WP_REDIS_PASSWORD', '" . self::esc( $pass ) . "' );";
122 164 }
123 165 $lines[] = "define( 'WP_REDIS_DATABASE', " . $db . ' );';
124 - if ( '' !== $prefix ) {
125 - $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
126 - }
166 + $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
127 167 $lines[] = "define( 'WP_REDIS_TIMEOUT', " . $timeout . ' );';
128 168 $lines[] = "define( 'WP_REDIS_PERSISTENT', " . ( $persist ? 'true' : 'false' ) . ' );';
129 169 } elseif ( 'memcached' === $backend ) {
130 170 $host = self::str( $opts, 'memcached_host', '127.0.0.1' );
131 171 $port = self::int( $opts, 'memcached_port', 11211 );
132 - $prefix = self::str( $opts, 'key_prefix', '' );
172 + $prefix = self::effective_salt( $opts );
133 173 $lines[] = "global \$memcached_servers;";
134 174 $lines[] = "\$memcached_servers = array( array( '" . self::esc( $host ) . "', " . $port . ' ) );';
135 - if ( '' !== $prefix ) {
136 - $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
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 + );
137 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 + );
138 1067 } else {
139 - $lines[] = '// No snippet for backend: ' . $backend;
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 + );
140 1076 }
141 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();
142 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;
143 1305 }
144 1306
145 1307 private static function sniff_drop_in_label( string $path ): string {
146 1308 $head = @file_get_contents( $path, false, null, 0, 2048 );