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

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

632 lines 22.9 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 $pass = self::str( $opts, 'redis_password', '' );
143 $db = self::int( $opts, 'redis_database', 0 );
144 $prefix = self::str( $opts, 'key_prefix', '' );
145 $timeout = self::int( $opts, 'connection_timeout', 1 );
146 $persist = ! empty( $opts['persistent'] );
147
148 $lines[] = "define( 'WP_REDIS_HOST', '" . self::esc( $host ) . "' );";
149 $lines[] = "define( 'WP_REDIS_PORT', " . $port . ' );';
150 if ( '' !== $pass ) {
151 $lines[] = "define( 'WP_REDIS_PASSWORD', '" . self::esc( $pass ) . "' );";
152 }
153 $lines[] = "define( 'WP_REDIS_DATABASE', " . $db . ' );';
154 if ( '' !== $prefix ) {
155 $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
156 }
157 $lines[] = "define( 'WP_REDIS_TIMEOUT', " . $timeout . ' );';
158 $lines[] = "define( 'WP_REDIS_PERSISTENT', " . ( $persist ? 'true' : 'false' ) . ' );';
159 } elseif ( 'memcached' === $backend ) {
160 $host = self::str( $opts, 'memcached_host', '127.0.0.1' );
161 $port = self::int( $opts, 'memcached_port', 11211 );
162 $prefix = self::str( $opts, 'key_prefix', '' );
163 $lines[] = "global \$memcached_servers;";
164 $lines[] = "\$memcached_servers = array( array( '" . self::esc( $host ) . "', " . $port . ' ) );';
165 if ( '' !== $prefix ) {
166 $lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );";
167 }
168 } else {
169 $lines[] = '// No snippet for backend: ' . $backend;
170 }
171
172 return implode( "\n", $lines ) . "\n";
173 }
174
175 /**
176 * Identifier embedded in our drop-in so we can recognise (and safely
177 * overwrite / remove) only files we installed.
178 */
179 private const DROPIN_TAG = 'XSPEED_OBJECT_CACHE_DROPIN';
180
181 /** Markers wrapping the constants we write into wp-config.php. */
182 private const CONFIG_BEGIN = '/* BEGIN xSpeed Object Cache */';
183 private const CONFIG_END = '/* END xSpeed Object Cache */';
184
185 /**
186 * Live connection test against the configured backend. Never throws;
187 * returns a structured pass/fail the UI can show before we write anything.
188 *
189 * @param array $opts Settings array (backend, redis_host, ...).
190 * @return array{ok:bool,backend:string,message:string,latency_ms:?float}
191 */
192 public static function test_connection( array $opts ): array {
193 $backend = (string) ( $opts['backend'] ?? 'redis' );
194 $start = microtime( true );
195
196 try {
197 if ( 'memcached' === $backend ) {
198 $host = self::str( $opts, 'memcached_host', '127.0.0.1' );
199 $port = self::int( $opts, 'memcached_port', 11211 );
200 $timeout = self::int( $opts, 'connection_timeout', 1 );
201
202 // Prefer the ext/memcached extension (libmemcached).
203 if ( class_exists( '\\Memcached' ) ) {
204 $mc = new \Memcached();
205 $mc->addServer( $host, $port );
206 $stats = @$mc->getStats();
207 $ok = is_array( $stats ) && ! empty( array_filter( $stats ) );
208 return self::test_result(
209 $ok,
210 $backend,
211 $ok ? "Connected to Memcached at {$host}:{$port} (ext/memcached)." : "Could not reach Memcached at {$host}:{$port}.",
212 $start
213 );
214 }
215
216 // Pure-PHP fallback — our own client, zero dependencies.
217 $mc = new Memcached_Client( $host, $port, (float) $timeout );
218 if ( ! $mc->connect() ) {
219 return self::test_result( false, $backend, "Could not connect to Memcached at {$host}:{$port}." );
220 }
221 $ver = $mc->version();
222 $mc->close();
223 $ok = ( false !== $ver );
224 return self::test_result(
225 $ok,
226 $backend,
227 $ok ? "Connected to Memcached at {$host}:{$port} (built-in client)." : "Memcached at {$host}:{$port} did not respond.",
228 $start
229 );
230 }
231
232 // Redis. Prefer the phpredis extension (faster C client); fall back
233 // to xSpeed's own dependency-free Redis_Client (pure-PHP RESP over a
234 // socket) so Redis works even without the extension — true
235 // plug-and-play, no bundled library.
236 $host = self::str( $opts, 'redis_host', '127.0.0.1' );
237 $port = self::int( $opts, 'redis_port', 6379 );
238 $timeout = self::int( $opts, 'connection_timeout', 1 );
239 $pass = self::str( $opts, 'redis_password', '' );
240 $db = self::int( $opts, 'redis_database', 0 );
241
242 if ( class_exists( '\\Redis' ) ) {
243 $redis = new \Redis();
244 if ( ! @$redis->connect( $host, $port, $timeout ) ) {
245 return self::test_result( false, $backend, "Could not connect to Redis at {$host}:{$port}." );
246 }
247 if ( '' !== $pass && ! @$redis->auth( $pass ) ) {
248 return self::test_result( false, $backend, 'Redis authentication failed — check the password.' );
249 }
250 if ( $db > 0 && ! @$redis->select( $db ) ) {
251 return self::test_result( false, $backend, "Could not select Redis database {$db}." );
252 }
253 $pong = @$redis->ping();
254 $ok = ( '+PONG' === $pong || true === $pong || 'PONG' === $pong );
255 return self::test_result(
256 $ok,
257 $backend,
258 $ok ? "Connected to Redis at {$host}:{$port} (phpredis)." : "Redis at {$host}:{$port} did not respond to PING.",
259 $start
260 );
261 }
262
263 // Pure-PHP fallback — our own client, zero dependencies.
264 $rc = new Redis_Client( $host, $port, (float) $timeout, false );
265 if ( ! $rc->connect() ) {
266 return self::test_result( false, $backend, "Could not connect to Redis at {$host}:{$port}." );
267 }
268 if ( '' !== $pass && false === $rc->auth( $pass ) ) {
269 $rc->close();
270 return self::test_result( false, $backend, 'Redis authentication failed — check the password.' );
271 }
272 if ( $db > 0 ) {
273 $rc->select( $db );
274 }
275 $pong = $rc->ping();
276 $rc->close();
277 $ok = ( is_string( $pong ) && false !== stripos( $pong, 'PONG' ) );
278 return self::test_result(
279 $ok,
280 $backend,
281 $ok ? "Connected to Redis at {$host}:{$port} (built-in client)." : "Redis at {$host}:{$port} did not respond to PING.",
282 $start
283 );
284 } catch ( \Throwable $e ) {
285 return self::test_result( false, $backend, 'Connection error: ' . $e->getMessage() );
286 }
287 }
288
289 private static function test_result( bool $ok, string $backend, string $message, ?float $start = null ): array {
290 return array(
291 'ok' => $ok,
292 'backend' => $backend,
293 'message' => $message,
294 'latency_ms' => $start ? round( ( microtime( true ) - $start ) * 1000, 2 ) : null,
295 );
296 }
297
298 /**
299 * Full plug-and-play enable: test → write wp-config constants → install
300 * drop-in → verify. Reversible via disable(). Returns a structured result
301 * the REST/UI layer surfaces directly.
302 *
303 * @param array $opts Settings array.
304 * @return array{ok:bool,message:string,steps:array<string,bool>,test:array,detect:array}
305 */
306 public static function enable( array $opts ): array {
307 $steps = array(
308 'connection' => false,
309 'wp_config' => false,
310 'drop_in' => false,
311 'verified' => false,
312 );
313
314 // 1. Don't write anything until the backend actually answers.
315 $test = self::test_connection( $opts );
316 if ( ! $test['ok'] ) {
317 return array(
318 'ok' => false,
319 'message' => 'Could not enable: ' . $test['message'],
320 'steps' => $steps,
321 'test' => $test,
322 'detect' => self::detect(),
323 );
324 }
325 $steps['connection'] = true;
326
327 // 2. Write the XSPEED_OC_* constants into wp-config.php.
328 $steps['wp_config'] = self::write_wp_config( $opts );
329
330 // 3. Install our drop-in.
331 $steps['drop_in'] = self::install_dropin();
332
333 // 4. Verify the drop-in is live (best-effort — wp_using_ext_object_cache
334 // reflects state only after the drop-in loads on the NEXT request, so
335 // we verify the file landed + constants are present this request).
336 $detect = self::detect();
337 $steps['verified'] = $detect['drop_in_installed'] && self::wp_config_has_block();
338
339 $all_ok = $steps['drop_in'] && ( $steps['wp_config'] || self::backend_uses_no_constants( $opts ) );
340
341 return array(
342 'ok' => $all_ok,
343 'message' => $all_ok
344 ? 'Object cache enabled. Drop-in installed and configured automatically.'
345 : ( $steps['drop_in']
346 ? 'Drop-in installed, but wp-config.php is not writable — add the snippet manually (shown below).'
347 : 'Could not install the object-cache drop-in (wp-content not writable).' ),
348 'steps' => $steps,
349 'test' => $test,
350 'detect' => $detect,
351 );
352 }
353
354 /**
355 * Full reverse of enable(): remove drop-in + strip our wp-config block.
356 *
357 * @return array{ok:bool,message:string,steps:array<string,bool>,detect:array}
358 */
359 public static function disable(): array {
360 $dropin_removed = self::remove_dropin();
361 $config_removed = self::remove_wp_config();
362
363 return array(
364 'ok' => $dropin_removed,
365 'message' => $dropin_removed
366 ? 'Object cache disabled. Drop-in removed and wp-config.php cleaned.'
367 : 'Could not remove the drop-in — wp-content may not be writable.',
368 'steps' => array(
369 'drop_in' => $dropin_removed,
370 'wp_config' => $config_removed,
371 ),
372 'detect' => self::detect(),
373 );
374 }
375
376 /**
377 * Copy our object-cache.php template into wp-content/. Mirrors
378 * Cache::install_dropin(): only overwrites our own file, backs up a
379 * foreign drop-in before replacing it.
380 */
381 public static function install_dropin(): bool {
382 $source = ( defined( 'XSPEED_DIR' ) ? XSPEED_DIR : plugin_dir_path( __DIR__ ) . '../' ) . 'includes/object-cache.php';
383 $target = WP_CONTENT_DIR . '/object-cache.php';
384 if ( ! file_exists( $source ) ) {
385 return false;
386 }
387
388 $fs = self::fs();
389 if ( ! $fs ) {
390 return false;
391 }
392
393 $source_contents = $fs->get_contents( $source );
394 if ( ! is_string( $source_contents ) ) {
395 return false;
396 }
397
398 if ( file_exists( $target ) ) {
399 $existing = $fs->get_contents( $target );
400 $is_xspeed = is_string( $existing ) && false !== strpos( $existing, self::DROPIN_TAG );
401
402 if ( $is_xspeed ) {
403 if ( $existing === $source_contents ) {
404 return true;
405 }
406 return (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
407 }
408
409 // Foreign drop-in — back it up before overwriting.
410 $upload = wp_upload_dir( null, false );
411 $basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false;
412 if ( $basedir ) {
413 if ( ! file_exists( $basedir ) ) {
414 wp_mkdir_p( $basedir );
415 }
416 $backup = $basedir . '/object-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak';
417 $fs->move( $target, $backup, true );
418 } else {
419 $fs->delete( $target );
420 }
421 }
422
423 return (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
424 }
425
426 /**
427 * Remove our drop-in (only if it's ours). Returns true when no xSpeed
428 * drop-in remains.
429 */
430 public static function remove_dropin(): bool {
431 $target = WP_CONTENT_DIR . '/object-cache.php';
432 if ( ! file_exists( $target ) ) {
433 return true;
434 }
435 $fs = self::fs();
436 if ( ! $fs ) {
437 return false;
438 }
439 $contents = $fs->get_contents( $target );
440 if ( is_string( $contents ) && false !== strpos( $contents, self::DROPIN_TAG ) ) {
441 wp_delete_file( $target );
442 return ! file_exists( $target );
443 }
444 // Not ours — leave it, but report success (nothing of ours to remove).
445 return true;
446 }
447
448 /**
449 * Write the XSPEED_OC_* constants between our markers in wp-config.php.
450 * Idempotent: replaces an existing block. Reversible via remove_wp_config().
451 */
452 public static function write_wp_config( array $opts ): bool {
453 $fs = self::fs();
454 $wp_config = ABSPATH . 'wp-config.php';
455 if ( ! $fs || ! file_exists( $wp_config ) || ! $fs->is_writable( $wp_config ) ) {
456 return false;
457 }
458
459 $config = $fs->get_contents( $wp_config );
460 if ( ! is_string( $config ) ) {
461 return false;
462 }
463
464 $block = self::wp_config_block( $opts );
465
466 // Replace an existing xSpeed block if present, else insert after <?php.
467 // IMPORTANT: $block is inserted via preg_replace_callback returning it
468 // VERBATIM — never as a preg_replace replacement string. In a
469 // replacement string, `\` and `$` are special (backref escapes), so a
470 // constant value ending in a backslash (e.g. a Redis password or key
471 // prefix like "secret\") or containing "$1" would corrupt the output:
472 // esc()'s "secret\\" collapses back to "secret\", producing
473 // 'secret\' ) — a PHP parse error that white-screens the whole site.
474 // The callback form treats $block as literal text. (FBS-82111 Bug 1)
475 $pattern = '/' . preg_quote( self::CONFIG_BEGIN, '/' ) . '.*?' . preg_quote( self::CONFIG_END, '/' ) . "\s*/s";
476 if ( preg_match( $pattern, $config ) ) {
477 $config = preg_replace_callback(
478 $pattern,
479 static function () use ( $block ) {
480 return $block;
481 },
482 $config,
483 1
484 );
485 } else {
486 $config = preg_replace_callback(
487 '/(<\?php)/',
488 static function ( $m ) use ( $block ) {
489 return $m[1] . "\n" . $block;
490 },
491 $config,
492 1
493 );
494 }
495
496 return (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
497 }
498
499 /**
500 * Strip our wp-config block. Returns true if the block is gone afterward.
501 */
502 public static function remove_wp_config(): bool {
503 $fs = self::fs();
504 $wp_config = ABSPATH . 'wp-config.php';
505 if ( ! $fs || ! file_exists( $wp_config ) ) {
506 return true;
507 }
508 if ( ! $fs->is_writable( $wp_config ) ) {
509 return false;
510 }
511 $config = $fs->get_contents( $wp_config );
512 if ( ! is_string( $config ) ) {
513 return false;
514 }
515 $pattern = '/' . preg_quote( self::CONFIG_BEGIN, '/' ) . '.*?' . preg_quote( self::CONFIG_END, '/' ) . "\s*/s";
516 $config = preg_replace( $pattern, '', $config );
517 return (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
518 }
519
520 /**
521 * The marker-wrapped constants block written into wp-config.php. Uses
522 * XSPEED_OC_* names (our drop-in reads these first, then falls back to
523 * WP_REDIS_* for interop).
524 */
525 private static function wp_config_block( array $opts ): string {
526 $backend = (string) ( $opts['backend'] ?? 'redis' );
527 $lines = array( self::CONFIG_BEGIN );
528 $lines[] = "define( 'XSPEED_OC_BACKEND', '" . self::esc( $backend ) . "' );";
529
530 if ( 'memcached' === $backend ) {
531 $lines[] = "define( 'XSPEED_OC_HOST', '" . self::esc( self::str( $opts, 'memcached_host', '127.0.0.1' ) ) . "' );";
532 $lines[] = "define( 'XSPEED_OC_PORT', " . self::int( $opts, 'memcached_port', 11211 ) . ' );';
533 } else {
534 $lines[] = "define( 'XSPEED_OC_HOST', '" . self::esc( self::str( $opts, 'redis_host', '127.0.0.1' ) ) . "' );";
535 $lines[] = "define( 'XSPEED_OC_PORT', " . self::int( $opts, 'redis_port', 6379 ) . ' );';
536 $pass = self::str( $opts, 'redis_password', '' );
537 if ( '' !== $pass ) {
538 $lines[] = "define( 'XSPEED_OC_PASSWORD', '" . self::esc( $pass ) . "' );";
539 }
540 $lines[] = "define( 'XSPEED_OC_DATABASE', " . self::int( $opts, 'redis_database', 0 ) . ' );';
541 $lines[] = "define( 'XSPEED_OC_TIMEOUT', " . self::int( $opts, 'connection_timeout', 1 ) . ' );';
542 $lines[] = "define( 'XSPEED_OC_PERSISTENT', " . ( ! empty( $opts['persistent'] ) ? 'true' : 'false' ) . ' );';
543 }
544 $prefix = self::str( $opts, 'key_prefix', '' );
545 if ( '' !== $prefix ) {
546 $lines[] = "define( 'XSPEED_OC_SALT', '" . self::esc( $prefix ) . "' );";
547 }
548 $lines[] = self::CONFIG_END;
549 return implode( "\n", $lines ) . "\n";
550 }
551
552 private static function wp_config_has_block(): bool {
553 $wp_config = ABSPATH . 'wp-config.php';
554 if ( ! file_exists( $wp_config ) ) {
555 return false;
556 }
557 $fs = self::fs();
558 if ( ! $fs ) {
559 return false;
560 }
561 $config = $fs->get_contents( $wp_config );
562 return is_string( $config ) && false !== strpos( $config, self::CONFIG_BEGIN );
563 }
564
565 /**
566 * Memcached config goes through $memcached_servers (handled by our drop-in's
567 * defaults), so a non-writable wp-config isn't necessarily fatal for it.
568 */
569 private static function backend_uses_no_constants( array $opts ): bool {
570 return false; // both backends currently rely on the constants block
571 }
572
573 /**
574 * Initialised WP_Filesystem handle, or null. Plugin Check-compliant access.
575 *
576 * Forces the 'direct' transport when PHP can write the WordPress tree
577 * itself. Without this, WP_Filesystem() can fall back to the FTP transport
578 * (no credentials in a non-interactive context) and fatal in
579 * ftp_fget(). We only need 'direct' — these writes target wp-config.php /
580 * wp-content, both owned by the PHP user on a normal install.
581 */
582 private static function fs() {
583 global $wp_filesystem;
584 if ( ! function_exists( 'WP_Filesystem' ) ) {
585 require_once ABSPATH . 'wp-admin/includes/file.php';
586 }
587
588 // Pin the method to 'direct' for this call so a missing FTP/SSH config
589 // can never trigger the credential-prompt / ftp_*() fatal path. Use a
590 // closure on the filter so we don't permanently alter global behaviour.
591 $force_direct = static function () {
592 return 'direct';
593 };
594 add_filter( 'filesystem_method', $force_direct, 99 );
595 $ok = WP_Filesystem();
596 remove_filter( 'filesystem_method', $force_direct, 99 );
597
598 if ( ! $ok || ! $wp_filesystem || 'direct' !== $wp_filesystem->method ) {
599 return null;
600 }
601 return $wp_filesystem;
602 }
603
604 private static function sniff_drop_in_label( string $path ): string {
605 $head = @file_get_contents( $path, false, null, 0, 2048 );
606 if ( ! is_string( $head ) || '' === $head ) {
607 return '';
608 }
609 // PluginName / Plugin Name in standard WP file header form.
610 if ( preg_match( '#Plugin Name:\s*([^\r\n]+)#i', $head, $m ) ) {
611 return trim( $m[1] );
612 }
613 // Many drop-ins just put their identity in a comment.
614 if ( preg_match( '#\*\s*([A-Za-z][A-Za-z0-9 _\-]{2,40}(?:Cache|Redis|Memcached)[^\r\n]*)#i', $head, $m ) ) {
615 return trim( $m[1] );
616 }
617 return basename( $path );
618 }
619
620 private static function str( array $opts, string $key, string $default ): string {
621 return isset( $opts[ $key ] ) && '' !== $opts[ $key ] ? (string) $opts[ $key ] : $default;
622 }
623
624 private static function int( array $opts, string $key, int $default ): int {
625 return isset( $opts[ $key ] ) && '' !== $opts[ $key ] ? (int) $opts[ $key ] : $default;
626 }
627
628 private static function esc( string $s ): string {
629 return str_replace( array( '\\', "'" ), array( '\\\\', "\\'" ), $s );
630 }
631 }
632