PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.6
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.1.6, 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