PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.7
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.0.7, at includes/class-object-cache.php

733 lines 28.4 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_path: string,
41 * drop_in_label: string,
42 * backend: string, // redis|memcached|apcu|wp_default|unknown
43 * wp_cache_active: bool, // wp_using_ext_object_cache
44 * degraded: bool, // ours is installed but NOT persisting
45 * persistent: bool, // ours is installed AND persisting
46 * class_available: array<string,bool>
47 * }
48 */
49 public static function detect(): array {
50 $dropin = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : '';
51 $has_drop_in = '' !== $dropin && file_exists( $dropin );
52 $label = $has_drop_in ? self::sniff_drop_in_label( $dropin ) : '';
53 $ext_in_use = function_exists( 'wp_using_ext_object_cache' ) ? (bool) wp_using_ext_object_cache() : false;
54
55 // When OUR drop-in is the live one it exposes whether it actually
56 // connected a persistent backend. A drop-in that's installed but
57 // degraded reports wp_using_ext_object_cache()=true yet persists
58 // nothing — the silent failure that makes a site slow. Read the honest
59 // state straight off the running instance. (FBS-82210)
60 $degraded = false;
61 $persistent = false;
62 if ( $has_drop_in && isset( $GLOBALS['wp_object_cache'] ) && is_object( $GLOBALS['wp_object_cache'] ) ) {
63 $oc = $GLOBALS['wp_object_cache'];
64 if ( method_exists( $oc, 'is_persistent' ) ) {
65 $persistent = (bool) $oc->is_persistent();
66 $degraded = ! $persistent;
67 }
68 }
69
70 // Class sniffer — independent of any plugin. Tells us what's
71 // available to actually use, separate from what's wired up.
72 $class_available = array(
73 'Redis' => class_exists( '\\Redis' ),
74 'Memcached' => class_exists( '\\Memcached' ),
75 'Memcache' => class_exists( '\\Memcache' ),
76 'APCu' => function_exists( 'apcu_enabled' ) && @apcu_enabled(),
77 );
78
79 $backend = 'unknown';
80 if ( ! $ext_in_use ) {
81 $backend = 'wp_default';
82 } elseif ( $has_drop_in ) {
83 // Authoritative source first: our own drop-in records the chosen
84 // backend in the XSPEED_OC_BACKEND constant (written to wp-config
85 // on enable). The drop-in label is the generic
86 // "XSPEED_OBJECT_CACHE_DROPIN" and does NOT contain the backend
87 // name, so the label sniff below would always yield "unknown" for
88 // our drop-in — read the constant instead. (FBS-82111)
89 if ( defined( 'XSPEED_OC_BACKEND' ) && '' !== (string) constant( 'XSPEED_OC_BACKEND' ) ) {
90 $backend = strtolower( (string) constant( 'XSPEED_OC_BACKEND' ) );
91 } else {
92 // Foreign drop-in (W3TC / Redis Object Cache / …): best-effort
93 // guess from the label, which usually names the backend.
94 $lc = strtolower( $label );
95 if ( false !== strpos( $lc, 'redis' ) ) {
96 $backend = 'redis';
97 } elseif ( false !== strpos( $lc, 'memcached' ) || false !== strpos( $lc, 'memcache' ) ) {
98 $backend = 'memcached';
99 } elseif ( false !== strpos( $lc, 'apcu' ) ) {
100 $backend = 'apcu';
101 }
102 }
103 }
104
105 return array(
106 'drop_in_installed' => $has_drop_in,
107 'drop_in_path' => $dropin,
108 'drop_in_label' => $label,
109 'backend' => $backend,
110 'wp_cache_active' => $ext_in_use,
111 'degraded' => $degraded,
112 'persistent' => $persistent,
113 'class_available' => $class_available,
114 );
115 }
116
117 /**
118 * Flush whatever cache backend is wired up. Works against any
119 * compliant drop-in OR the WP default in-memory cache.
120 */
121 public static function flush(): bool {
122 if ( ! function_exists( 'wp_cache_flush' ) ) {
123 return false;
124 }
125 return (bool) wp_cache_flush();
126 }
127
128 /**
129 * Render a paste-into-wp-config.php snippet for the chosen backend
130 * using the supplied settings. The constant names match the
131 * conventions of the widely-used Redis Object Cache + W3TC drop-ins
132 * so users with those installed get a working configuration
133 * without any further translation.
134 */
135 public static function render_config_snippet( array $opts ): string {
136 $backend = (string) ( $opts['backend'] ?? 'redis' );
137 $lines = array( "/* xSpeed object cache config — paste above the \"That's all, stop editing!\" comment in wp-config.php. */" );
138
139 if ( 'redis' === $backend ) {
140 $host = self::str( $opts, 'redis_host', '127.0.0.1' );
141 $port = self::int( $opts, 'redis_port', 6379 );
142 $user = self::str( $opts, 'redis_user', '' );
143 $pass = self::str( $opts, 'redis_password', '' );
144 $db = self::int( $opts, 'redis_database', 0 );
145 $prefix = self::str( $opts, 'key_prefix', '' );
146 $timeout = self::int( $opts, 'connection_timeout', 1 );
147 $persist = ! empty( $opts['persistent'] );
148
149 $lines[] = "define( 'WP_REDIS_HOST', '" . self::esc( $host ) . "' );";
150 $lines[] = "define( 'WP_REDIS_PORT', " . $port . ' );';
151 // Emit the ACL username only when set (Redis 6+). The drop-in
152 // reads it; an empty user keeps the legacy default-user behavior.
153 if ( '' !== $user ) {
154 $lines[] = "define( 'WP_REDIS_USER', '" . self::esc( $user ) . "' );";
155 }
156 if ( '' !== $pass ) {
157 $lines[] = "define( 'WP_REDIS_PASSWORD', '" . self::esc( $pass ) . "' );";
158 }
159 $lines[] = "define( 'WP_REDIS_DATABASE', " . $db . ' );';
160 if ( '' !== $prefix ) {
161 $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
162 }
163 $lines[] = "define( 'WP_REDIS_TIMEOUT', " . $timeout . ' );';
164 $lines[] = "define( 'WP_REDIS_PERSISTENT', " . ( $persist ? 'true' : 'false' ) . ' );';
165 } elseif ( 'memcached' === $backend ) {
166 $host = self::str( $opts, 'memcached_host', '127.0.0.1' );
167 $port = self::int( $opts, 'memcached_port', 11211 );
168 $prefix = self::str( $opts, 'key_prefix', '' );
169 $lines[] = "global \$memcached_servers;";
170 $lines[] = "\$memcached_servers = array( array( '" . self::esc( $host ) . "', " . $port . ' ) );';
171 if ( '' !== $prefix ) {
172 $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
173 }
174 } else {
175 $lines[] = '// No snippet for backend: ' . $backend;
176 }
177
178 return implode( "\n", $lines ) . "\n";
179 }
180
181 /**
182 * Identifier embedded in our drop-in so we can recognise (and safely
183 * overwrite / remove) only files we installed.
184 */
185 private const DROPIN_TAG = 'XSPEED_OBJECT_CACHE_DROPIN';
186
187 /** Markers wrapping the constants we write into wp-config.php. */
188 private const CONFIG_BEGIN = '/* BEGIN xSpeed Object Cache */';
189 private const CONFIG_END = '/* END xSpeed Object Cache */';
190
191 /**
192 * Live connection test against the configured backend. Never throws;
193 * returns a structured pass/fail the UI can show before we write anything.
194 *
195 * @param array $opts Settings array (backend, redis_host, ...).
196 * @return array{ok:bool,backend:string,message:string,latency_ms:?float}
197 */
198 public static function test_connection( array $opts ): array {
199 $backend = (string) ( $opts['backend'] ?? 'redis' );
200 $start = microtime( true );
201
202 try {
203 if ( 'memcached' === $backend ) {
204 $host = self::str( $opts, 'memcached_host', '127.0.0.1' );
205 $port = self::int( $opts, 'memcached_port', 11211 );
206 $timeout = self::int( $opts, 'connection_timeout', 1 );
207
208 // Prefer the ext/memcached extension (libmemcached).
209 if ( class_exists( '\\Memcached' ) ) {
210 $mc = new \Memcached();
211 $mc->addServer( $host, $port );
212 $stats = @$mc->getStats();
213 $ok = is_array( $stats ) && ! empty( array_filter( $stats ) );
214 return self::test_result(
215 $ok,
216 $backend,
217 $ok ? "Connected to Memcached at {$host}:{$port} (ext/memcached)." : "Could not reach Memcached at {$host}:{$port}.",
218 $start
219 );
220 }
221
222 // Pure-PHP fallback — our own client, zero dependencies.
223 $mc = new Memcached_Client( $host, $port, (float) $timeout );
224 if ( ! $mc->connect() ) {
225 return self::test_result( false, $backend, "Could not connect to Memcached at {$host}:{$port}." );
226 }
227 $ver = $mc->version();
228 $mc->close();
229 $ok = ( false !== $ver );
230 return self::test_result(
231 $ok,
232 $backend,
233 $ok ? "Connected to Memcached at {$host}:{$port} (built-in client)." : "Memcached at {$host}:{$port} did not respond.",
234 $start
235 );
236 }
237
238 // Redis. Prefer the phpredis extension (faster C client); fall back
239 // to xSpeed's own dependency-free Redis_Client (pure-PHP RESP over a
240 // socket) so Redis works even without the extension — true
241 // plug-and-play, no bundled library.
242 $host = self::str( $opts, 'redis_host', '127.0.0.1' );
243 $port = self::int( $opts, 'redis_port', 6379 );
244 $timeout = self::int( $opts, 'connection_timeout', 1 );
245 $user = self::str( $opts, 'redis_user', '' );
246 $pass = self::str( $opts, 'redis_password', '' );
247 $db = self::int( $opts, 'redis_database', 0 );
248
249 if ( class_exists( '\\Redis' ) ) {
250 $redis = new \Redis();
251 if ( ! @$redis->connect( $host, $port, $timeout ) ) {
252 return self::test_result( false, $backend, "Could not connect to Redis at {$host}:{$port}." );
253 }
254 // Redis 6+ ACL: when a username is set, authenticate as that user
255 // (phpredis ≥ 5.3 accepts ['user'=>..,'pass'=>..]); otherwise keep
256 // the legacy password-only form that authenticates as `default`.
257 $auth_ok = self::phpredis_auth( $redis, $user, $pass );
258 if ( null !== $auth_ok && ! $auth_ok ) {
259 return self::test_result( false, $backend, '' !== $user ? 'Redis authentication failed — check the Redis user + password (ACL).' : 'Redis authentication failed — check the password.' );
260 }
261 if ( $db > 0 && ! @$redis->select( $db ) ) {
262 return self::test_result( false, $backend, "Could not select Redis database {$db}." );
263 }
264 $pong = @$redis->ping();
265 $ok = ( '+PONG' === $pong || true === $pong || 'PONG' === $pong );
266 if ( ! $ok ) {
267 return self::test_result( false, $backend, "Redis at {$host}:{$port} did not respond to PING.", $start );
268 }
269 // Write-verification: PING only proves auth, not that the user can
270 // STORE data. ACL-namespaced hosts (xCloud) restrict a user to a
271 // key pattern (~redis:<id>:*); a SET outside it is NOPERM-denied and
272 // the drop-in's @$redis->set() swallows it — enable() would then
273 // green-light a cache that silently persists nothing. Do a real
274 // SET/GET/DEL round-trip on a probe key built with the user's key
275 // prefix so a namespace restriction is caught here. (FBS-83118 OC-2)
276 $probe = self::probe_key( $opts );
277 $set = @$redis->set( $probe, '1', 5 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- NOPERM/denied is the negative answer we report, not a fatal.
278 $got = @$redis->get( $probe ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- same.
279 @$redis->del( $probe ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup.
280 if ( ! $set || '1' !== (string) $got ) {
281 return self::test_result( false, $backend, self::write_denied_message( $opts, $host, $port ), $start );
282 }
283 return self::test_result( true, $backend, "Connected to Redis at {$host}:{$port} (phpredis).", $start );
284 }
285
286 // Pure-PHP fallback — our own client, zero dependencies.
287 $rc = new Redis_Client( $host, $port, (float) $timeout, false );
288 if ( ! $rc->connect() ) {
289 return self::test_result( false, $backend, "Could not connect to Redis at {$host}:{$port}." );
290 }
291 // Authenticate when a user OR a password is set. Gating on password
292 // alone skipped auth for the "ACL user + empty password" case, which
293 // then failed later at PING with a misleading message. (FBS-83118 OC-1)
294 if ( ( '' !== $pass || '' !== $user ) && false === $rc->auth( $pass, $user ) ) {
295 $rc->close();
296 return self::test_result( false, $backend, '' !== $user ? 'Redis authentication failed — check the Redis user + password (ACL).' : 'Redis authentication failed — check the password.' );
297 }
298 if ( $db > 0 ) {
299 $rc->select( $db );
300 }
301 $pong = $rc->ping();
302 $ok = ( is_string( $pong ) && false !== stripos( $pong, 'PONG' ) );
303 if ( ! $ok ) {
304 $rc->close();
305 return self::test_result( false, $backend, "Redis at {$host}:{$port} did not respond to PING.", $start );
306 }
307 // Write-verification round-trip — same rationale as the phpredis path
308 // above. (FBS-83118 OC-2)
309 $probe = self::probe_key( $opts );
310 $set = $rc->set( $probe, '1' );
311 $got = $rc->get( $probe );
312 $rc->del( $probe );
313 $rc->close();
314 if ( ! $set || '1' !== (string) $got ) {
315 return self::test_result( false, $backend, self::write_denied_message( $opts, $host, $port ), $start );
316 }
317 return self::test_result( true, $backend, "Connected to Redis at {$host}:{$port} (built-in client).", $start );
318 } catch ( \Throwable $e ) {
319 return self::test_result( false, $backend, 'Connection error: ' . $e->getMessage() );
320 }
321 }
322
323 private static function test_result( bool $ok, string $backend, string $message, ?float $start = null ): array {
324 return array(
325 'ok' => $ok,
326 'backend' => $backend,
327 'message' => $message,
328 'latency_ms' => $start ? round( ( microtime( true ) - $start ) * 1000, 2 ) : null,
329 );
330 }
331
332 /**
333 * Authenticate a phpredis connection, honoring Redis 6+ ACL usernames.
334 *
335 * Returns null when no auth is needed (empty username AND password) so
336 * callers can distinguish "didn't try" from "tried and failed". When a
337 * username is present we pass ['user'=>..,'pass'=>..] which phpredis
338 * ≥ 5.3 maps to the two-argument AUTH; otherwise the legacy
339 * password-only form authenticates as the built-in `default` user.
340 *
341 * @param \Redis $redis Connected phpredis instance.
342 * @param string $user ACL username; '' = default user.
343 * @param string $pass Password.
344 * @return bool|null true/false on auth attempt, null if none needed.
345 */
346 private static function phpredis_auth( $redis, string $user, string $pass ) {
347 if ( '' === $user && '' === $pass ) {
348 return null;
349 }
350 try {
351 if ( '' !== $user ) {
352 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.
353 }
354 return (bool) @$redis->auth( $pass ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- same.
355 } catch ( \Throwable $e ) {
356 return false;
357 }
358 }
359
360 /**
361 * Build a probe key for the write-verification round-trip. It must land in
362 * the same key space the drop-in writes to, so an ACL namespace restriction
363 * (~<prefix>:*) is exercised. The drop-in salts keys as
364 * `{salt}:{prefix}:{group}:{key}` where the salt is the user's key prefix,
365 * so prefixing the probe with that value makes it match the allowed pattern
366 * on namespaced hosts (xCloud) while staying harmless everywhere else.
367 *
368 * @param array $opts Settings array.
369 * @return string
370 */
371 private static function probe_key( array $opts ): string {
372 $prefix = self::str( $opts, 'key_prefix', '' );
373 $suffix = 'xspeed-oc-probe';
374 return '' !== $prefix ? $prefix . ':' . $suffix : $suffix;
375 }
376
377 /**
378 * Message for a connect-OK-but-write-denied result. Points ACL/namespaced
379 * hosts at the fix (match the key prefix to the host's Redis Object Cache
380 * Key), which is exactly the xCloud failure mode. (FBS-83118 OC-2)
381 *
382 * @param array $opts Settings array.
383 * @param string $host Redis host.
384 * @param int $port Redis port.
385 * @return string
386 */
387 private static function write_denied_message( array $opts, string $host, int $port ): string {
388 $has_prefix = '' !== self::str( $opts, 'key_prefix', '' );
389 $hint = $has_prefix
390 ? 'The Redis user may lack write permission for this key prefix (NOPERM).'
391 : '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.';
392 return "Connected to Redis at {$host}:{$port}, but the cache could not store data. {$hint}";
393 }
394
395 /**
396 * Full plug-and-play enable: test → write wp-config constants → install
397 * drop-in → verify. Reversible via disable(). Returns a structured result
398 * the REST/UI layer surfaces directly.
399 *
400 * @param array $opts Settings array.
401 * @return array{ok:bool,message:string,steps:array<string,bool>,test:array,detect:array}
402 */
403 public static function enable( array $opts ): array {
404 $steps = array(
405 'connection' => false,
406 'wp_config' => false,
407 'drop_in' => false,
408 'verified' => false,
409 );
410
411 // 1. Don't write anything until the backend actually answers.
412 $test = self::test_connection( $opts );
413 if ( ! $test['ok'] ) {
414 return array(
415 'ok' => false,
416 'message' => 'Could not enable: ' . $test['message'],
417 'steps' => $steps,
418 'test' => $test,
419 'detect' => self::detect(),
420 );
421 }
422 $steps['connection'] = true;
423
424 // 2. Write the XSPEED_OC_* constants into wp-config.php.
425 $steps['wp_config'] = self::write_wp_config( $opts );
426
427 // 3. Install our drop-in.
428 $steps['drop_in'] = self::install_dropin();
429
430 // 4. Verify the drop-in is live (best-effort — wp_using_ext_object_cache
431 // reflects state only after the drop-in loads on the NEXT request, so
432 // we verify the file landed + constants are present this request).
433 $detect = self::detect();
434 $steps['verified'] = $detect['drop_in_installed'] && self::wp_config_has_block();
435
436 $all_ok = $steps['drop_in'] && ( $steps['wp_config'] || self::backend_uses_no_constants( $opts ) );
437
438 return array(
439 'ok' => $all_ok,
440 'message' => $all_ok
441 ? 'Object cache enabled. Drop-in installed and configured automatically.'
442 : ( $steps['drop_in']
443 ? 'Drop-in installed, but wp-config.php is not writable — add the snippet manually (shown below).'
444 : 'Could not install the object-cache drop-in (wp-content not writable).' ),
445 'steps' => $steps,
446 'test' => $test,
447 'detect' => $detect,
448 );
449 }
450
451 /**
452 * Full reverse of enable(): remove drop-in + strip our wp-config block.
453 *
454 * @return array{ok:bool,message:string,steps:array<string,bool>,detect:array}
455 */
456 public static function disable(): array {
457 $dropin_removed = self::remove_dropin();
458 $config_removed = self::remove_wp_config();
459
460 return array(
461 'ok' => $dropin_removed,
462 'message' => $dropin_removed
463 ? 'Object cache disabled. Drop-in removed and wp-config.php cleaned.'
464 : 'Could not remove the drop-in — wp-content may not be writable.',
465 'steps' => array(
466 'drop_in' => $dropin_removed,
467 'wp_config' => $config_removed,
468 ),
469 'detect' => self::detect(),
470 );
471 }
472
473 /**
474 * Copy our object-cache.php template into wp-content/. Mirrors
475 * Cache::install_dropin(): only overwrites our own file, backs up a
476 * foreign drop-in before replacing it.
477 */
478 public static function install_dropin(): bool {
479 $source = ( defined( 'XSPEED_DIR' ) ? XSPEED_DIR : plugin_dir_path( __DIR__ ) . '../' ) . 'includes/object-cache.php';
480 $target = WP_CONTENT_DIR . '/object-cache.php';
481 if ( ! file_exists( $source ) ) {
482 return false;
483 }
484
485 $fs = self::fs();
486 if ( ! $fs ) {
487 return false;
488 }
489
490 $source_contents = $fs->get_contents( $source );
491 if ( ! is_string( $source_contents ) ) {
492 return false;
493 }
494
495 if ( file_exists( $target ) ) {
496 $existing = $fs->get_contents( $target );
497 $is_xspeed = is_string( $existing ) && false !== strpos( $existing, self::DROPIN_TAG );
498
499 if ( $is_xspeed ) {
500 if ( $existing === $source_contents ) {
501 return true;
502 }
503 return (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
504 }
505
506 // Foreign drop-in — back it up before overwriting.
507 $upload = wp_upload_dir( null, false );
508 $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
509 if ( $basedir ) {
510 if ( ! file_exists( $basedir ) ) {
511 wp_mkdir_p( $basedir );
512 }
513 $backup = $basedir . '/object-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
514 $fs->move( $target, $backup, true );
515 } else {
516 $fs->delete( $target );
517 }
518 }
519
520 return (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
521 }
522
523 /**
524 * Remove our drop-in (only if it's ours). Returns true when no xSpeed
525 * drop-in remains.
526 */
527 public static function remove_dropin(): bool {
528 $target = WP_CONTENT_DIR . '/object-cache.php';
529 if ( ! file_exists( $target ) ) {
530 return true;
531 }
532 $fs = self::fs();
533 if ( ! $fs ) {
534 return false;
535 }
536 $contents = $fs->get_contents( $target );
537 if ( is_string( $contents ) && false !== strpos( $contents, self::DROPIN_TAG ) ) {
538 wp_delete_file( $target );
539 return ! file_exists( $target );
540 }
541 // Not ours — leave it, but report success (nothing of ours to remove).
542 return true;
543 }
544
545 /**
546 * Write the XSPEED_OC_* constants between our markers in wp-config.php.
547 * Idempotent: replaces an existing block. Reversible via remove_wp_config().
548 */
549 public static function write_wp_config( array $opts ): bool {
550 $fs = self::fs();
551 $wp_config = ABSPATH . 'wp-config.php';
552 if ( ! $fs || ! file_exists( $wp_config ) || ! $fs->is_writable( $wp_config ) ) {
553 return false;
554 }
555
556 $config = $fs->get_contents( $wp_config );
557 if ( ! is_string( $config ) ) {
558 return false;
559 }
560
561 $block = self::wp_config_block( $opts );
562
563 // Replace an existing xSpeed block if present, else insert after <?php.
564 // IMPORTANT: $block is inserted via preg_replace_callback returning it
565 // VERBATIM — never as a preg_replace replacement string. In a
566 // replacement string, `\` and `$` are special (backref escapes), so a
567 // constant value ending in a backslash (e.g. a Redis password or key
568 // prefix like "secret\") or containing "$1" would corrupt the output:
569 // esc()'s "secret\\" collapses back to "secret\", producing
570 // 'secret\' ) — a PHP parse error that white-screens the whole site.
571 // The callback form treats $block as literal text. (FBS-82111 Bug 1)
572 $pattern = '/' . preg_quote( self::CONFIG_BEGIN, '/' ) . '.*?' . preg_quote( self::CONFIG_END, '/' ) . "\s*/s";
573 if ( preg_match( $pattern, $config ) ) {
574 $config = preg_replace_callback(
575 $pattern,
576 static function () use ( $block ) {
577 return $block;
578 },
579 $config,
580 1
581 );
582 } else {
583 $config = preg_replace_callback(
584 '/(<\?php)/',
585 static function ( $m ) use ( $block ) {
586 return $m[1] . "\n" . $block;
587 },
588 $config,
589 1
590 );
591 }
592
593 return (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
594 }
595
596 /**
597 * Strip our wp-config block. Returns true if the block is gone afterward.
598 */
599 public static function remove_wp_config(): bool {
600 $fs = self::fs();
601 $wp_config = ABSPATH . 'wp-config.php';
602 if ( ! $fs || ! file_exists( $wp_config ) ) {
603 return true;
604 }
605 if ( ! $fs->is_writable( $wp_config ) ) {
606 return false;
607 }
608 $config = $fs->get_contents( $wp_config );
609 if ( ! is_string( $config ) ) {
610 return false;
611 }
612 $pattern = '/' . preg_quote( self::CONFIG_BEGIN, '/' ) . '.*?' . preg_quote( self::CONFIG_END, '/' ) . "\s*/s";
613 $config = preg_replace( $pattern, '', $config );
614 return (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
615 }
616
617 /**
618 * The marker-wrapped constants block written into wp-config.php. Uses
619 * XSPEED_OC_* names (our drop-in reads these first, then falls back to
620 * WP_REDIS_* for interop).
621 */
622 private static function wp_config_block( array $opts ): string {
623 $backend = (string) ( $opts['backend'] ?? 'redis' );
624 $lines = array( self::CONFIG_BEGIN );
625 $lines[] = "define( 'XSPEED_OC_BACKEND', '" . self::esc( $backend ) . "' );";
626
627 if ( 'memcached' === $backend ) {
628 $lines[] = "define( 'XSPEED_OC_HOST', '" . self::esc( self::str( $opts, 'memcached_host', '127.0.0.1' ) ) . "' );";
629 $lines[] = "define( 'XSPEED_OC_PORT', " . self::int( $opts, 'memcached_port', 11211 ) . ' );';
630 } else {
631 $lines[] = "define( 'XSPEED_OC_HOST', '" . self::esc( self::str( $opts, 'redis_host', '127.0.0.1' ) ) . "' );";
632 $lines[] = "define( 'XSPEED_OC_PORT', " . self::int( $opts, 'redis_port', 6379 ) . ' );';
633 $user = self::str( $opts, 'redis_user', '' );
634 if ( '' !== $user ) {
635 $lines[] = "define( 'XSPEED_OC_USER', '" . self::esc( $user ) . "' );";
636 }
637 $pass = self::str( $opts, 'redis_password', '' );
638 if ( '' !== $pass ) {
639 $lines[] = "define( 'XSPEED_OC_PASSWORD', '" . self::esc( $pass ) . "' );";
640 }
641 $lines[] = "define( 'XSPEED_OC_DATABASE', " . self::int( $opts, 'redis_database', 0 ) . ' );';
642 $lines[] = "define( 'XSPEED_OC_TIMEOUT', " . self::int( $opts, 'connection_timeout', 1 ) . ' );';
643 $lines[] = "define( 'XSPEED_OC_PERSISTENT', " . ( ! empty( $opts['persistent'] ) ? 'true' : 'false' ) . ' );';
644 }
645 $prefix = self::str( $opts, 'key_prefix', '' );
646 if ( '' !== $prefix ) {
647 $lines[] = "define( 'XSPEED_OC_SALT', '" . self::esc( $prefix ) . "' );";
648 }
649 $lines[] = self::CONFIG_END;
650 return implode( "\n", $lines ) . "\n";
651 }
652
653 private static function wp_config_has_block(): bool {
654 $wp_config = ABSPATH . 'wp-config.php';
655 if ( ! file_exists( $wp_config ) ) {
656 return false;
657 }
658 $fs = self::fs();
659 if ( ! $fs ) {
660 return false;
661 }
662 $config = $fs->get_contents( $wp_config );
663 return is_string( $config ) && false !== strpos( $config, self::CONFIG_BEGIN );
664 }
665
666 /**
667 * Memcached config goes through $memcached_servers (handled by our drop-in's
668 * defaults), so a non-writable wp-config isn't necessarily fatal for it.
669 */
670 private static function backend_uses_no_constants( array $opts ): bool {
671 return false; // both backends currently rely on the constants block
672 }
673
674 /**
675 * Initialised WP_Filesystem handle, or null. Plugin Check-compliant access.
676 *
677 * Forces the 'direct' transport when PHP can write the WordPress tree
678 * itself. Without this, WP_Filesystem() can fall back to the FTP transport
679 * (no credentials in a non-interactive context) and fatal in
680 * ftp_fget(). We only need 'direct' — these writes target wp-config.php /
681 * wp-content, both owned by the PHP user on a normal install.
682 */
683 private static function fs() {
684 global $wp_filesystem;
685 if ( ! function_exists( 'WP_Filesystem' ) ) {
686 require_once ABSPATH . 'wp-admin/includes/file.php';
687 }
688
689 // Pin the method to 'direct' for this call so a missing FTP/SSH config
690 // can never trigger the credential-prompt / ftp_*() fatal path. Use a
691 // closure on the filter so we don't permanently alter global behaviour.
692 $force_direct = static function () {
693 return 'direct';
694 };
695 add_filter( 'filesystem_method', $force_direct, 99 );
696 $ok = WP_Filesystem();
697 remove_filter( 'filesystem_method', $force_direct, 99 );
698
699 if ( ! $ok || ! $wp_filesystem || 'direct' !== $wp_filesystem->method ) {
700 return null;
701 }
702 return $wp_filesystem;
703 }
704
705 private static function sniff_drop_in_label( string $path ): string {
706 $head = @file_get_contents( $path, false, null, 0, 2048 );
707 if ( ! is_string( $head ) || '' === $head ) {
708 return '';
709 }
710 // PluginName / Plugin Name in standard WP file header form.
711 if ( preg_match( '#Plugin Name:\s*([^\r\n]+)#i', $head, $m ) ) {
712 return trim( $m[1] );
713 }
714 // Many drop-ins just put their identity in a comment.
715 if ( preg_match( '#\*\s*([A-Za-z][A-Za-z0-9 _\-]{2,40}(?:Cache|Redis|Memcached)[^\r\n]*)#i', $head, $m ) ) {
716 return trim( $m[1] );
717 }
718 return basename( $path );
719 }
720
721 private static function str( array $opts, string $key, string $default ): string {
722 return isset( $opts[ $key ] ) && '' !== $opts[ $key ] ? (string) $opts[ $key ] : $default;
723 }
724
725 private static function int( array $opts, string $key, int $default ): int {
726 return isset( $opts[ $key ] ) && '' !== $opts[ $key ] ? (int) $opts[ $key ] : $default;
727 }
728
729 private static function esc( string $s ): string {
730 return str_replace( array( '\\', "'" ), array( '\\\\', "\\'" ), $s );
731 }
732 }
733