| 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 |
|