PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.1 1.2.2 1.2.3
xspeed / includes / class-object-cache.php

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

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