| 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_is_ours: bool, // installed AND carries our tag |
| 41 |
* drop_in_path: string, |
| 42 |
* drop_in_label: string, |
| 43 |
* backend: string, // redis|memcached|apcu|wp_default|unknown |
| 44 |
* wp_cache_active: bool, // wp_using_ext_object_cache |
| 45 |
* degraded: bool, // ours is installed but NOT persisting |
| 46 |
* persistent: bool, // ours is installed AND persisting |
| 47 |
* class_available: array<string,bool> |
| 48 |
* } |
| 49 |
*/ |
| 50 |
public static function detect(): array { |
| 51 |
$dropin = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : ''; |
| 52 |
$has_drop_in = '' !== $dropin && file_exists( $dropin ); |
| 53 |
$label = $has_drop_in ? self::sniff_drop_in_label( $dropin ) : ''; |
| 54 |
$ext_in_use = function_exists( 'wp_using_ext_object_cache' ) ? (bool) wp_using_ext_object_cache() : false; |
| 55 |
|
| 56 |
// When OUR drop-in is the live one it exposes whether it actually |
| 57 |
// connected a persistent backend. A drop-in that's installed but |
| 58 |
// degraded reports wp_using_ext_object_cache()=true yet persists |
| 59 |
// nothing — the silent failure that makes a site slow. Read the honest |
| 60 |
// state straight off the running instance. (FBS-82210) |
| 61 |
$degraded = false; |
| 62 |
$persistent = false; |
| 63 |
if ( $has_drop_in && isset( $GLOBALS['wp_object_cache'] ) && is_object( $GLOBALS['wp_object_cache'] ) ) { |
| 64 |
$oc = $GLOBALS['wp_object_cache']; |
| 65 |
if ( method_exists( $oc, 'is_persistent' ) ) { |
| 66 |
$persistent = (bool) $oc->is_persistent(); |
| 67 |
$degraded = ! $persistent; |
| 68 |
} |
| 69 |
} |
| 70 |
|
| 71 |
// Class sniffer — independent of any plugin. Tells us what's |
| 72 |
// available to actually use, separate from what's wired up. |
| 73 |
$class_available = array( |
| 74 |
'Redis' => class_exists( '\\Redis' ), |
| 75 |
'Memcached' => class_exists( '\\Memcached' ), |
| 76 |
'Memcache' => class_exists( '\\Memcache' ), |
| 77 |
'APCu' => function_exists( 'apcu_enabled' ) && @apcu_enabled(), |
| 78 |
); |
| 79 |
|
| 80 |
$backend = 'unknown'; |
| 81 |
if ( ! $ext_in_use ) { |
| 82 |
$backend = 'wp_default'; |
| 83 |
} elseif ( $has_drop_in ) { |
| 84 |
// Authoritative source first: our own drop-in records the chosen |
| 85 |
// backend in the XSPEED_OC_BACKEND constant (written to wp-config |
| 86 |
// on enable). The drop-in label is the generic |
| 87 |
// "XSPEED_OBJECT_CACHE_DROPIN" and does NOT contain the backend |
| 88 |
// name, so the label sniff below would always yield "unknown" for |
| 89 |
// our drop-in — read the constant instead. (FBS-82111) |
| 90 |
if ( defined( 'XSPEED_OC_BACKEND' ) && '' !== (string) constant( 'XSPEED_OC_BACKEND' ) ) { |
| 91 |
$backend = strtolower( (string) constant( 'XSPEED_OC_BACKEND' ) ); |
| 92 |
} else { |
| 93 |
// Foreign drop-in (W3TC / Redis Object Cache / …): best-effort |
| 94 |
// guess from the label, which usually names the backend. |
| 95 |
$lc = strtolower( $label ); |
| 96 |
if ( false !== strpos( $lc, 'redis' ) ) { |
| 97 |
$backend = 'redis'; |
| 98 |
} elseif ( false !== strpos( $lc, 'memcached' ) || false !== strpos( $lc, 'memcache' ) ) { |
| 99 |
$backend = 'memcached'; |
| 100 |
} elseif ( false !== strpos( $lc, 'apcu' ) ) { |
| 101 |
$backend = 'apcu'; |
| 102 |
} |
| 103 |
} |
| 104 |
} |
| 105 |
|
| 106 |
return array( |
| 107 |
'drop_in_installed' => $has_drop_in, |
| 108 |
// Whether the installed drop-in is OURS. A foreign one (W3TC, |
| 109 |
// Redis Object Cache, LiteSpeed) means the object cache belongs to |
| 110 |
// another plugin: we must not offer to configure or disable it, |
| 111 |
// and "installed" must not be read as "xSpeed is running". |
| 112 |
'drop_in_is_ours' => $has_drop_in && self::is_our_dropin_present(), |
| 113 |
'drop_in_path' => $dropin, |
| 114 |
'drop_in_label' => $label, |
| 115 |
'backend' => $backend, |
| 116 |
'wp_cache_active' => $ext_in_use, |
| 117 |
'degraded' => $degraded, |
| 118 |
'persistent' => $persistent, |
| 119 |
'class_available' => $class_available, |
| 120 |
); |
| 121 |
} |
| 122 |
|
| 123 |
/** |
| 124 |
* Flush whatever cache backend is wired up. Works against any |
| 125 |
* compliant drop-in OR the WP default in-memory cache. |
| 126 |
*/ |
| 127 |
public static function flush(): bool { |
| 128 |
if ( ! function_exists( 'wp_cache_flush' ) ) { |
| 129 |
return false; |
| 130 |
} |
| 131 |
return (bool) wp_cache_flush(); |
| 132 |
} |
| 133 |
|
| 134 |
/** |
| 135 |
* Render a paste-into-wp-config.php snippet for the chosen backend |
| 136 |
* using the supplied settings. The constant names match the |
| 137 |
* conventions of the widely-used Redis Object Cache + W3TC drop-ins |
| 138 |
* so users with those installed get a working configuration |
| 139 |
* without any further translation. |
| 140 |
*/ |
| 141 |
public static function render_config_snippet( array $opts ): string { |
| 142 |
$backend = (string) ( $opts['backend'] ?? 'redis' ); |
| 143 |
$lines = array( "/* xSpeed object cache config — paste above the \"That's all, stop editing!\" comment in wp-config.php. */" ); |
| 144 |
|
| 145 |
if ( 'redis' === $backend ) { |
| 146 |
$host = self::str( $opts, 'redis_host', '127.0.0.1' ); |
| 147 |
$port = self::int( $opts, 'redis_port', 6379 ); |
| 148 |
$user = self::str( $opts, 'redis_user', '' ); |
| 149 |
$pass = self::str( $opts, 'redis_password', '' ); |
| 150 |
$db = self::int( $opts, 'redis_database', 0 ); |
| 151 |
$prefix = self::effective_salt( $opts ); |
| 152 |
$timeout = self::int( $opts, 'connection_timeout', 1 ); |
| 153 |
$persist = ! empty( $opts['persistent'] ); |
| 154 |
|
| 155 |
$lines[] = "define( 'WP_REDIS_HOST', '" . self::esc( $host ) . "' );"; |
| 156 |
$lines[] = "define( 'WP_REDIS_PORT', " . $port . ' );'; |
| 157 |
// Emit the ACL username only when set (Redis 6+). The drop-in |
| 158 |
// reads it; an empty user keeps the legacy default-user behavior. |
| 159 |
if ( '' !== $user ) { |
| 160 |
$lines[] = "define( 'WP_REDIS_USER', '" . self::esc( $user ) . "' );"; |
| 161 |
} |
| 162 |
if ( '' !== $pass ) { |
| 163 |
$lines[] = "define( 'WP_REDIS_PASSWORD', '" . self::esc( $pass ) . "' );"; |
| 164 |
} |
| 165 |
$lines[] = "define( 'WP_REDIS_DATABASE', " . $db . ' );'; |
| 166 |
$lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );"; |
| 167 |
$lines[] = "define( 'WP_REDIS_TIMEOUT', " . $timeout . ' );'; |
| 168 |
$lines[] = "define( 'WP_REDIS_PERSISTENT', " . ( $persist ? 'true' : 'false' ) . ' );'; |
| 169 |
} elseif ( 'memcached' === $backend ) { |
| 170 |
$host = self::str( $opts, 'memcached_host', '127.0.0.1' ); |
| 171 |
$port = self::int( $opts, 'memcached_port', 11211 ); |
| 172 |
$prefix = self::effective_salt( $opts ); |
| 173 |
$lines[] = "global \$memcached_servers;"; |
| 174 |
$lines[] = "\$memcached_servers = array( array( '" . self::esc( $host ) . "', " . $port . ' ) );'; |
| 175 |
$lines[] = "define( 'WP_CACHE_KEY_SALT', '" . self::esc( $prefix ) . "' );"; |
| 176 |
} else { |
| 177 |
$lines[] = '// No snippet for backend: ' . $backend; |
| 178 |
} |
| 179 |
|
| 180 |
return implode( "\n", $lines ) . "\n"; |
| 181 |
} |
| 182 |
|
| 183 |
/** |
| 184 |
* Identifier embedded in our drop-in so we can recognise (and safely |
| 185 |
* overwrite / remove) only files we installed. |
| 186 |
*/ |
| 187 |
private const DROPIN_TAG = 'XSPEED_OBJECT_CACHE_DROPIN'; |
| 188 |
|
| 189 |
/** Markers wrapping the constants we write into wp-config.php. */ |
| 190 |
private const CONFIG_BEGIN = '/* BEGIN xSpeed Object Cache */'; |
| 191 |
private const CONFIG_END = '/* END xSpeed Object Cache */'; |
| 192 |
|
| 193 |
/** |
| 194 |
* True when wp-content/object-cache.php exists AND is ours (carries the |
| 195 |
* drop-in tag). Lets callers decide whether a re-sync applies without |
| 196 |
* exposing the tag itself. |
| 197 |
*/ |
| 198 |
public static function is_our_dropin_present(): bool { |
| 199 |
$target = WP_CONTENT_DIR . '/object-cache.php'; |
| 200 |
if ( ! file_exists( $target ) ) { |
| 201 |
return false; |
| 202 |
} |
| 203 |
$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. |
| 204 |
return is_string( $contents ) && false !== strpos( $contents, self::DROPIN_TAG ); |
| 205 |
} |
| 206 |
|
| 207 |
/** |
| 208 |
* Live connection test against the configured backend. Never throws; |
| 209 |
* returns a structured pass/fail the UI can show before we write anything. |
| 210 |
* |
| 211 |
* @param array $opts Settings array (backend, redis_host, ...). |
| 212 |
* @return array{ok:bool,backend:string,message:string,latency_ms:?float} |
| 213 |
*/ |
| 214 |
public static function test_connection( array $opts ): array { |
| 215 |
$backend = (string) ( $opts['backend'] ?? 'redis' ); |
| 216 |
$start = microtime( true ); |
| 217 |
|
| 218 |
try { |
| 219 |
if ( 'memcached' === $backend ) { |
| 220 |
$host = self::str( $opts, 'memcached_host', '127.0.0.1' ); |
| 221 |
$port = self::int( $opts, 'memcached_port', 11211 ); |
| 222 |
$timeout = self::int( $opts, 'connection_timeout', 1 ); |
| 223 |
|
| 224 |
// Prefer the ext/memcached extension (libmemcached). |
| 225 |
if ( class_exists( '\\Memcached' ) ) { |
| 226 |
$mc = new \Memcached(); |
| 227 |
$mc->addServer( $host, $port ); |
| 228 |
$stats = @$mc->getStats(); |
| 229 |
$ok = is_array( $stats ) && ! empty( array_filter( $stats ) ); |
| 230 |
return self::test_result( |
| 231 |
$ok, |
| 232 |
$backend, |
| 233 |
$ok ? "Connected to Memcached at {$host}:{$port} (ext/memcached)." : "Could not reach Memcached at {$host}:{$port}.", |
| 234 |
$start |
| 235 |
); |
| 236 |
} |
| 237 |
|
| 238 |
// Pure-PHP fallback — our own client, zero dependencies. |
| 239 |
$mc = new Memcached_Client( $host, $port, (float) $timeout ); |
| 240 |
if ( ! $mc->connect() ) { |
| 241 |
return self::test_result( false, $backend, "Could not connect to Memcached at {$host}:{$port}." ); |
| 242 |
} |
| 243 |
$ver = $mc->version(); |
| 244 |
$mc->close(); |
| 245 |
$ok = ( false !== $ver ); |
| 246 |
return self::test_result( |
| 247 |
$ok, |
| 248 |
$backend, |
| 249 |
$ok ? "Connected to Memcached at {$host}:{$port} (built-in client)." : "Memcached at {$host}:{$port} did not respond.", |
| 250 |
$start |
| 251 |
); |
| 252 |
} |
| 253 |
|
| 254 |
// Redis. Prefer the phpredis extension (faster C client); fall back |
| 255 |
// to xSpeed's own dependency-free Redis_Client (pure-PHP RESP over a |
| 256 |
// socket) so Redis works even without the extension — true |
| 257 |
// plug-and-play, no bundled library. |
| 258 |
$host = self::str( $opts, 'redis_host', '127.0.0.1' ); |
| 259 |
$port = self::int( $opts, 'redis_port', 6379 ); |
| 260 |
$timeout = self::int( $opts, 'connection_timeout', 1 ); |
| 261 |
$user = self::str( $opts, 'redis_user', '' ); |
| 262 |
$pass = self::str( $opts, 'redis_password', '' ); |
| 263 |
$db = self::int( $opts, 'redis_database', 0 ); |
| 264 |
|
| 265 |
if ( class_exists( '\\Redis' ) ) { |
| 266 |
$redis = new \Redis(); |
| 267 |
if ( ! @$redis->connect( $host, $port, $timeout ) ) { |
| 268 |
return self::test_result( false, $backend, "Could not connect to Redis at {$host}:{$port}." ); |
| 269 |
} |
| 270 |
// Redis 6+ ACL: when a username is set, authenticate as that user |
| 271 |
// (phpredis ≥ 5.3 accepts ['user'=>..,'pass'=>..]); otherwise keep |
| 272 |
// the legacy password-only form that authenticates as `default`. |
| 273 |
$auth_ok = self::phpredis_auth( $redis, $user, $pass ); |
| 274 |
if ( null !== $auth_ok && ! $auth_ok ) { |
| 275 |
return self::test_result( false, $backend, '' !== $user ? 'Redis authentication failed — check the Redis user + password (ACL).' : 'Redis authentication failed — check the password.' ); |
| 276 |
} |
| 277 |
if ( $db > 0 && ! @$redis->select( $db ) ) { |
| 278 |
return self::test_result( false, $backend, "Could not select Redis database {$db}." ); |
| 279 |
} |
| 280 |
$pong = @$redis->ping(); |
| 281 |
$ok = ( '+PONG' === $pong || true === $pong || 'PONG' === $pong ); |
| 282 |
if ( ! $ok ) { |
| 283 |
return self::test_result( false, $backend, "Redis at {$host}:{$port} did not respond to PING.", $start ); |
| 284 |
} |
| 285 |
// Write-verification: PING only proves auth, not that the user can |
| 286 |
// STORE data. ACL-namespaced hosts (xCloud) restrict a user to a |
| 287 |
// key pattern (~redis:<id>:*); a SET outside it is NOPERM-denied and |
| 288 |
// the drop-in's @$redis->set() swallows it — enable() would then |
| 289 |
// green-light a cache that silently persists nothing. Do a real |
| 290 |
// SET/GET/DEL round-trip on a probe key built with the user's key |
| 291 |
// prefix so a namespace restriction is caught here. (FBS-83118 OC-2) |
| 292 |
$probe = self::probe_key( $opts ); |
| 293 |
$set = @$redis->set( $probe, '1', 5 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- NOPERM/denied is the negative answer we report, not a fatal. |
| 294 |
$got = @$redis->get( $probe ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- same. |
| 295 |
@$redis->del( $probe ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup. |
| 296 |
if ( ! $set || '1' !== (string) $got ) { |
| 297 |
return self::test_result( false, $backend, self::write_denied_message( $opts, $host, $port ), $start ); |
| 298 |
} |
| 299 |
return self::test_result( true, $backend, self::with_prefix_advisory( "Connected to Redis at {$host}:{$port} (phpredis).", $opts ), $start ); |
| 300 |
} |
| 301 |
|
| 302 |
// Pure-PHP fallback — our own client, zero dependencies. |
| 303 |
$rc = new Redis_Client( $host, $port, (float) $timeout, false ); |
| 304 |
if ( ! $rc->connect() ) { |
| 305 |
return self::test_result( false, $backend, "Could not connect to Redis at {$host}:{$port}." ); |
| 306 |
} |
| 307 |
// Authenticate when a user OR a password is set. Gating on password |
| 308 |
// alone skipped auth for the "ACL user + empty password" case, which |
| 309 |
// then failed later at PING with a misleading message. (FBS-83118 OC-1) |
| 310 |
if ( ( '' !== $pass || '' !== $user ) && false === $rc->auth( $pass, $user ) ) { |
| 311 |
$rc->close(); |
| 312 |
return self::test_result( false, $backend, '' !== $user ? 'Redis authentication failed — check the Redis user + password (ACL).' : 'Redis authentication failed — check the password.' ); |
| 313 |
} |
| 314 |
if ( $db > 0 ) { |
| 315 |
$rc->select( $db ); |
| 316 |
} |
| 317 |
$pong = $rc->ping(); |
| 318 |
$ok = ( is_string( $pong ) && false !== stripos( $pong, 'PONG' ) ); |
| 319 |
if ( ! $ok ) { |
| 320 |
$rc->close(); |
| 321 |
return self::test_result( false, $backend, "Redis at {$host}:{$port} did not respond to PING.", $start ); |
| 322 |
} |
| 323 |
// Write-verification round-trip — same rationale as the phpredis path |
| 324 |
// above. (FBS-83118 OC-2) |
| 325 |
$probe = self::probe_key( $opts ); |
| 326 |
$set = $rc->set( $probe, '1' ); |
| 327 |
$got = $rc->get( $probe ); |
| 328 |
$rc->del( $probe ); |
| 329 |
$rc->close(); |
| 330 |
if ( ! $set || '1' !== (string) $got ) { |
| 331 |
return self::test_result( false, $backend, self::write_denied_message( $opts, $host, $port ), $start ); |
| 332 |
} |
| 333 |
return self::test_result( true, $backend, self::with_prefix_advisory( "Connected to Redis at {$host}:{$port} (built-in client).", $opts ), $start ); |
| 334 |
} catch ( \Throwable $e ) { |
| 335 |
return self::test_result( false, $backend, 'Connection error: ' . $e->getMessage() ); |
| 336 |
} |
| 337 |
} |
| 338 |
|
| 339 |
/** |
| 340 |
* Redis glob metacharacters that must never appear unescaped in a SCAN |
| 341 |
* MATCH pattern. `\` is the escape character itself. |
| 342 |
*/ |
| 343 |
private const GLOB_METACHARS = '*?[]\\'; |
| 344 |
|
| 345 |
/** |
| 346 |
* Whether a salt contains Redis glob metacharacters. |
| 347 |
* |
| 348 |
* The salt is interpolated into the drop-in's scoped-flush patterns. The |
| 349 |
* drop-in escapes it, so caching and purging are correct either way — but |
| 350 |
* an explicit Cache Key Prefix exists to match a host's ACL namespace |
| 351 |
* byte-for-byte, and a wildcard in it is almost always a typo rather than |
| 352 |
* a real namespace. Reporting it on Test connection is the one place the |
| 353 |
* user is already looking at their prefix. |
| 354 |
* |
| 355 |
* @param string $salt Effective salt. |
| 356 |
* @return bool |
| 357 |
*/ |
| 358 |
public static function salt_has_glob_metachars( string $salt ): bool { |
| 359 |
return strcspn( $salt, self::GLOB_METACHARS ) !== strlen( $salt ); |
| 360 |
} |
| 361 |
|
| 362 |
/** |
| 363 |
* Append a prefix advisory to an otherwise-successful connection message. |
| 364 |
* |
| 365 |
* @param string $message Success message. |
| 366 |
* @param array $opts Settings array. |
| 367 |
* @return string |
| 368 |
*/ |
| 369 |
private static function with_prefix_advisory( string $message, array $opts ): string { |
| 370 |
// Deliberately the TYPED prefix, not effective_salt(): this advisory |
| 371 |
// says "the field you are looking at probably has a typo in it". A |
| 372 |
// host-pinned WP_CACHE_KEY_SALT is not editable from this screen and |
| 373 |
// purges are correctly scoped regardless (the drop-in escapes it), so |
| 374 |
// warning about a host's own namespace would be noise on every ACL |
| 375 |
// host. A derived salt is glob-free by construction. |
| 376 |
$prefix = self::str( $opts, 'key_prefix', '' ); |
| 377 |
if ( '' === $prefix || ! self::salt_has_glob_metachars( $prefix ) ) { |
| 378 |
return $message; |
| 379 |
} |
| 380 |
return $message . ' Note: the Cache Key Prefix contains one of * ? [ ] \\.' |
| 381 |
. ' Purges stay scoped to this site, but these are wildcard characters' |
| 382 |
. ' in Redis — check the prefix matches your host\'s key exactly.'; |
| 383 |
} |
| 384 |
|
| 385 |
private static function test_result( bool $ok, string $backend, string $message, ?float $start = null ): array { |
| 386 |
return array( |
| 387 |
'ok' => $ok, |
| 388 |
'backend' => $backend, |
| 389 |
'message' => $message, |
| 390 |
'latency_ms' => $start ? round( ( microtime( true ) - $start ) * 1000, 2 ) : null, |
| 391 |
); |
| 392 |
} |
| 393 |
|
| 394 |
/** |
| 395 |
* Authenticate a phpredis connection, honoring Redis 6+ ACL usernames. |
| 396 |
* |
| 397 |
* Returns null when no auth is needed (empty username AND password) so |
| 398 |
* callers can distinguish "didn't try" from "tried and failed". When a |
| 399 |
* username is present we pass ['user'=>..,'pass'=>..] which phpredis |
| 400 |
* ≥ 5.3 maps to the two-argument AUTH; otherwise the legacy |
| 401 |
* password-only form authenticates as the built-in `default` user. |
| 402 |
* |
| 403 |
* @param \Redis $redis Connected phpredis instance. |
| 404 |
* @param string $user ACL username; '' = default user. |
| 405 |
* @param string $pass Password. |
| 406 |
* @return bool|null true/false on auth attempt, null if none needed. |
| 407 |
*/ |
| 408 |
private static function phpredis_auth( $redis, string $user, string $pass ) { |
| 409 |
if ( '' === $user && '' === $pass ) { |
| 410 |
return null; |
| 411 |
} |
| 412 |
try { |
| 413 |
if ( '' !== $user ) { |
| 414 |
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. |
| 415 |
} |
| 416 |
return (bool) @$redis->auth( $pass ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- same. |
| 417 |
} catch ( \Throwable $e ) { |
| 418 |
return false; |
| 419 |
} |
| 420 |
} |
| 421 |
|
| 422 |
/** |
| 423 |
* Resolve the salt that namespaces this site's cache keys. |
| 424 |
* |
| 425 |
* An explicit Cache Key Prefix always wins — on ACL/namespaced hosts |
| 426 |
* (xCloud) it MUST match the host's "Redis Object Cache Key" or writes are |
| 427 |
* denied (NOPERM), so we never override what the user typed. |
| 428 |
* |
| 429 |
* When the field is blank we derive a stable, per-site salt instead of |
| 430 |
* falling back to an empty one. An empty salt makes every key look like |
| 431 |
* `:{prefix}:{group}:{key}` — identical on every install — so two sites |
| 432 |
* sharing one Redis/Memcached server collide. That is not a theoretical |
| 433 |
* clash: `blog-details` / `blog-lookup` are how WordPress resolves which |
| 434 |
* site a request belongs to, so the second site reads the first site's |
| 435 |
* entries and redirects to it. |
| 436 |
* |
| 437 |
* The derived value is a hash of the site URL plus the DB name/prefix, so |
| 438 |
* it is unique per install, stable across requests (no cache churn), and |
| 439 |
* safe to embed in wp-config.php. |
| 440 |
* |
| 441 |
* @param array $opts Settings array. |
| 442 |
* @return string Non-empty salt. |
| 443 |
*/ |
| 444 |
public static function effective_salt( array $opts ): string { |
| 445 |
$prefix = self::str( $opts, 'key_prefix', '' ); |
| 446 |
if ( '' !== $prefix ) { |
| 447 |
return $prefix; |
| 448 |
} |
| 449 |
|
| 450 |
// A salt WE already wrote is authoritative over a fresh derivation. |
| 451 |
// The keys in the backend are named after it, so re-deriving a |
| 452 |
// different value would orphan every one of them — a needless |
| 453 |
// cache-cooling on an install that is already correctly namespaced. |
| 454 |
// This matters because derive_salt()'s rule was corrected (see there): |
| 455 |
// without this branch, the next wp-config sync would rewrite the block |
| 456 |
// with a new salt and throw away a warm cache on every existing site. |
| 457 |
if ( defined( 'XSPEED_OC_SALT' ) && '' !== (string) constant( 'XSPEED_OC_SALT' ) ) { |
| 458 |
return (string) constant( 'XSPEED_OC_SALT' ); |
| 459 |
} |
| 460 |
|
| 461 |
// A salt the HOST pinned in its own wp-config (outside our block) is |
| 462 |
// the next authority. On ACL/namespaced Redis the host grants write |
| 463 |
// access to that namespace and no other, so replacing it with a |
| 464 |
// derived value gets every write denied (NOPERM) and the site silently |
| 465 |
// stops caching. |
| 466 |
// WP_REDIS_PREFIX is checked alongside WP_CACHE_KEY_SALT and before it, |
| 467 |
// matching the order the schema and the drop-in resolve (#398). It is |
| 468 |
// the name Redis Object Cache uses and the one managed hosts actually |
| 469 |
// write, so honouring only the older alias left the commonest |
| 470 |
// ACL-namespaced case deriving a salt the host denies writes to. |
| 471 |
foreach ( array( 'WP_REDIS_PREFIX', 'WP_CACHE_KEY_SALT' ) as $name ) { |
| 472 |
if ( defined( $name ) && '' !== (string) constant( $name ) ) { |
| 473 |
return (string) constant( $name ); |
| 474 |
} |
| 475 |
} |
| 476 |
|
| 477 |
return self::derive_salt(); |
| 478 |
} |
| 479 |
|
| 480 |
/** |
| 481 |
* Build a stable per-site salt for installs that left Cache Key Prefix |
| 482 |
* blank. Distinct per install: the site URL separates sites sharing a |
| 483 |
* database, and DB name + table prefix separate installs sharing a domain |
| 484 |
* (e.g. subdirectory installs). |
| 485 |
* |
| 486 |
* This MUST stay byte-identical to the drop-in's xspeed_oc_salt(), which |
| 487 |
* is the harder constraint of the two: the drop-in loads from |
| 488 |
* wp-settings.php before `$wpdb` exists, so it can only read constants and |
| 489 |
* the `$table_prefix` global that wp-config.php itself assigns. Normally |
| 490 |
* the two never both run — enable() writes XSPEED_OC_SALT and both sides |
| 491 |
* read that constant — but where wp-config is NOT writable no constant is |
| 492 |
* ever written, and then both fallbacks are live at once in different |
| 493 |
* processes. Seeding them differently made "Test connection" verify a |
| 494 |
* different key space than the cache actually writes to: on ACL/namespaced |
| 495 |
* Redis (xCloud) that reports success while writes are refused, or reports |
| 496 |
* a failure while caching is fine. (PR #390 QA round 2, issue 2) |
| 497 |
* |
| 498 |
* Two specific traps this alignment closes: |
| 499 |
* |
| 500 |
* - WP_HOME / WP_SITEURL are OPTIONAL and absent from a stock |
| 501 |
* wp-config.php, so the drop-in's URL part is usually EMPTY while |
| 502 |
* get_site_url() always returns a real URL. Using get_site_url() here |
| 503 |
* therefore diverged on virtually every default install, not just an |
| 504 |
* exotic one — so this reads the same constants, and appends ABSPATH |
| 505 |
* on the same condition, rather than reaching for the richer value. |
| 506 |
* - `$wpdb->prefix` is PER-BLOG on multisite (`wp_2_` on a sub-site) |
| 507 |
* while `$table_prefix` is always the base prefix. The drop-in reads |
| 508 |
* the salt once and separates sub-sites with blog_prefix instead, so |
| 509 |
* `$table_prefix` is the value that matches; `$wpdb->prefix` would |
| 510 |
* hand every sub-site a different salt. |
| 511 |
* |
| 512 |
* @return string |
| 513 |
*/ |
| 514 |
private static function derive_salt(): string { |
| 515 |
global $table_prefix; |
| 516 |
|
| 517 |
$url = ''; |
| 518 |
if ( defined( 'WP_HOME' ) ) { |
| 519 |
$url = (string) WP_HOME; |
| 520 |
} elseif ( defined( 'WP_SITEURL' ) ) { |
| 521 |
$url = (string) WP_SITEURL; |
| 522 |
} |
| 523 |
|
| 524 |
$parts = array( |
| 525 |
$url, |
| 526 |
defined( 'DB_NAME' ) ? (string) DB_NAME : '', |
| 527 |
isset( $table_prefix ) ? (string) $table_prefix : '', |
| 528 |
); |
| 529 |
if ( '' === $url ) { |
| 530 |
$parts[] = defined( 'ABSPATH' ) ? (string) ABSPATH : ''; |
| 531 |
} |
| 532 |
|
| 533 |
$seed = implode( '|', $parts ); |
| 534 |
if ( '' === trim( $seed, '|' ) ) { |
| 535 |
// Nothing identifying available. Mirrors the drop-in's own |
| 536 |
// last-resort seed so the two still agree. |
| 537 |
$seed = 'xspeed'; |
| 538 |
} |
| 539 |
|
| 540 |
return 'xs' . substr( md5( $seed ), 0, 12 ); |
| 541 |
} |
| 542 |
|
| 543 |
/** |
| 544 |
* Build a probe key for the write-verification round-trip. It must land in |
| 545 |
* the same key space the drop-in writes to, so an ACL namespace restriction |
| 546 |
* (~<prefix>:*) is exercised. The drop-in salts keys as |
| 547 |
* `{salt}:{prefix}:{group}:{key}`, so prefixing the probe with the same |
| 548 |
* salt makes it match the allowed pattern on namespaced hosts (xCloud) |
| 549 |
* while staying harmless everywhere else. |
| 550 |
* |
| 551 |
* @param array $opts Settings array. |
| 552 |
* @return string |
| 553 |
*/ |
| 554 |
private static function probe_key( array $opts ): string { |
| 555 |
return self::effective_salt( $opts ) . ':xspeed-oc-probe'; |
| 556 |
} |
| 557 |
|
| 558 |
/** |
| 559 |
* Message for a connect-OK-but-write-denied result. Points ACL/namespaced |
| 560 |
* hosts at the fix (match the key prefix to the host's Redis Object Cache |
| 561 |
* Key), which is exactly the xCloud failure mode. (FBS-83118 OC-2) |
| 562 |
* |
| 563 |
* @param array $opts Settings array. |
| 564 |
* @param string $host Redis host. |
| 565 |
* @param int $port Redis port. |
| 566 |
* @return string |
| 567 |
*/ |
| 568 |
private static function write_denied_message( array $opts, string $host, int $port ): string { |
| 569 |
$has_prefix = '' !== self::str( $opts, 'key_prefix', '' ); |
| 570 |
$hint = $has_prefix |
| 571 |
? 'The Redis user may lack write permission for this key prefix (NOPERM).' |
| 572 |
: '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.'; |
| 573 |
return "Connected to Redis at {$host}:{$port}, but the cache could not store data. {$hint}"; |
| 574 |
} |
| 575 |
|
| 576 |
/** |
| 577 |
* Full plug-and-play enable: test → write wp-config constants → install |
| 578 |
* drop-in → verify. Reversible via disable(). Returns a structured result |
| 579 |
* the REST/UI layer surfaces directly. |
| 580 |
* |
| 581 |
* @param array $opts Settings array. |
| 582 |
* @return array{ok:bool,message:string,steps:array<string,bool>,test:array,detect:array} |
| 583 |
*/ |
| 584 |
public static function enable( array $opts ): array { |
| 585 |
$steps = array( |
| 586 |
'connection' => false, |
| 587 |
'wp_config' => false, |
| 588 |
'drop_in' => false, |
| 589 |
'verified' => false, |
| 590 |
); |
| 591 |
|
| 592 |
// 1. Don't write anything until the backend actually answers. |
| 593 |
$test = self::test_connection( $opts ); |
| 594 |
if ( ! $test['ok'] ) { |
| 595 |
return array( |
| 596 |
'ok' => false, |
| 597 |
'message' => 'Could not enable: ' . $test['message'], |
| 598 |
'steps' => $steps, |
| 599 |
'test' => $test, |
| 600 |
'detect' => self::detect(), |
| 601 |
); |
| 602 |
} |
| 603 |
$steps['connection'] = true; |
| 604 |
|
| 605 |
// 2. Write the XSPEED_OC_* constants into wp-config.php. |
| 606 |
$steps['wp_config'] = self::write_wp_config( $opts ); |
| 607 |
|
| 608 |
// 3. Install our drop-in. |
| 609 |
$steps['drop_in'] = self::install_dropin(); |
| 610 |
|
| 611 |
// 4. Verify the drop-in is live (best-effort — wp_using_ext_object_cache |
| 612 |
// reflects state only after the drop-in loads on the NEXT request, so |
| 613 |
// we verify the file landed + constants are present this request). |
| 614 |
$detect = self::detect(); |
| 615 |
$steps['verified'] = $detect['drop_in_installed'] && self::wp_config_has_block(); |
| 616 |
|
| 617 |
$all_ok = $steps['drop_in'] && ( $steps['wp_config'] || self::backend_uses_no_constants( $opts ) ); |
| 618 |
|
| 619 |
return array( |
| 620 |
'ok' => $all_ok, |
| 621 |
'message' => $all_ok |
| 622 |
? 'Object cache enabled. Drop-in installed and configured automatically.' |
| 623 |
: ( $steps['drop_in'] |
| 624 |
? 'Drop-in installed, but wp-config.php is not writable — add the snippet manually (shown below).' |
| 625 |
: 'Could not install the object-cache drop-in (wp-content not writable).' ), |
| 626 |
'steps' => $steps, |
| 627 |
'test' => $test, |
| 628 |
'detect' => $detect, |
| 629 |
); |
| 630 |
} |
| 631 |
|
| 632 |
/** |
| 633 |
* Full reverse of enable(): remove drop-in + strip our wp-config block. |
| 634 |
* |
| 635 |
* @return array{ok:bool,message:string,steps:array<string,bool>,detect:array} |
| 636 |
*/ |
| 637 |
public static function disable(): array { |
| 638 |
// A drop-in owned by another plugin is left in place by |
| 639 |
// remove_dropin(), which then reports success because nothing of ours |
| 640 |
// is there to remove. Reporting "disabled" for that is a lie: the site |
| 641 |
// still has someone else's object cache running. Say so instead. |
| 642 |
$dropin = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : ''; |
| 643 |
if ( '' !== $dropin && file_exists( $dropin ) && ! self::is_our_dropin_present() ) { |
| 644 |
return array( |
| 645 |
'ok' => false, |
| 646 |
'message' => 'The object-cache drop-in belongs to another plugin, so xSpeed left it alone. Turn its object cache off in that plugin instead.', |
| 647 |
'steps' => array( |
| 648 |
'drop_in' => false, |
| 649 |
'wp_config' => false, |
| 650 |
), |
| 651 |
'detect' => self::detect(), |
| 652 |
); |
| 653 |
} |
| 654 |
|
| 655 |
$dropin_removed = self::remove_dropin(); |
| 656 |
$config_removed = self::remove_wp_config(); |
| 657 |
|
| 658 |
/* |
| 659 |
* The sidecar is the config on a host where wp-config.php is read-only, |
| 660 |
* and it carries the Redis password. Leaving it behind would keep a |
| 661 |
* plaintext credential on disk for a feature the admin just switched |
| 662 |
* off, and a later re-enable would silently pick up stale credentials |
| 663 |
* from a file nothing in this path had touched. |
| 664 |
*/ |
| 665 |
self::delete_sidecar(); |
| 666 |
|
| 667 |
return array( |
| 668 |
'ok' => $dropin_removed, |
| 669 |
'message' => $dropin_removed |
| 670 |
? 'Object cache disabled. Drop-in removed and wp-config.php cleaned.' |
| 671 |
: 'Could not remove the drop-in — wp-content may not be writable.', |
| 672 |
'steps' => array( |
| 673 |
'drop_in' => $dropin_removed, |
| 674 |
'wp_config' => $config_removed, |
| 675 |
), |
| 676 |
'detect' => self::detect(), |
| 677 |
); |
| 678 |
} |
| 679 |
|
| 680 |
/** |
| 681 |
* Copy our object-cache.php template into wp-content/. Mirrors |
| 682 |
* Cache::install_dropin(): only overwrites our own file, backs up a |
| 683 |
* foreign drop-in before replacing it. |
| 684 |
*/ |
| 685 |
public static function install_dropin(): bool { |
| 686 |
$source = ( defined( 'XSPEED_DIR' ) ? XSPEED_DIR : plugin_dir_path( __DIR__ ) . '../' ) . 'includes/object-cache.php'; |
| 687 |
$target = WP_CONTENT_DIR . '/object-cache.php'; |
| 688 |
if ( ! file_exists( $source ) ) { |
| 689 |
return false; |
| 690 |
} |
| 691 |
|
| 692 |
$fs = self::fs(); |
| 693 |
if ( ! $fs ) { |
| 694 |
return false; |
| 695 |
} |
| 696 |
|
| 697 |
$source_contents = $fs->get_contents( $source ); |
| 698 |
if ( ! is_string( $source_contents ) ) { |
| 699 |
return false; |
| 700 |
} |
| 701 |
|
| 702 |
if ( file_exists( $target ) ) { |
| 703 |
$existing = $fs->get_contents( $target ); |
| 704 |
$is_xspeed = is_string( $existing ) && false !== strpos( $existing, self::DROPIN_TAG ); |
| 705 |
|
| 706 |
if ( $is_xspeed ) { |
| 707 |
if ( $existing === $source_contents ) { |
| 708 |
return true; |
| 709 |
} |
| 710 |
return (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE ); |
| 711 |
} |
| 712 |
|
| 713 |
// Foreign drop-in — back it up before overwriting. |
| 714 |
$upload = wp_upload_dir( null, false ); |
| 715 |
$basedir = isset( $upload['basedir'] ) ? trailingslashit( $upload['basedir'] ) . 'xspeed-backups' : false; |
| 716 |
if ( $basedir ) { |
| 717 |
if ( ! file_exists( $basedir ) ) { |
| 718 |
wp_mkdir_p( $basedir ); |
| 719 |
} |
| 720 |
$backup = $basedir . '/object-cache.foreign-' . gmdate( 'Ymd-His' ) . '.php.bak'; |
| 721 |
$fs->move( $target, $backup, true ); |
| 722 |
} else { |
| 723 |
$fs->delete( $target ); |
| 724 |
} |
| 725 |
} |
| 726 |
|
| 727 |
return (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE ); |
| 728 |
} |
| 729 |
|
| 730 |
/** |
| 731 |
* Remove our drop-in (only if it's ours). Returns true when no xSpeed |
| 732 |
* drop-in remains. |
| 733 |
*/ |
| 734 |
public static function remove_dropin(): bool { |
| 735 |
$target = WP_CONTENT_DIR . '/object-cache.php'; |
| 736 |
if ( ! file_exists( $target ) ) { |
| 737 |
return true; |
| 738 |
} |
| 739 |
$fs = self::fs(); |
| 740 |
if ( ! $fs ) { |
| 741 |
return false; |
| 742 |
} |
| 743 |
$contents = $fs->get_contents( $target ); |
| 744 |
if ( is_string( $contents ) && false !== strpos( $contents, self::DROPIN_TAG ) ) { |
| 745 |
wp_delete_file( $target ); |
| 746 |
return ! file_exists( $target ); |
| 747 |
} |
| 748 |
// Not ours — leave it, but report success (nothing of ours to remove). |
| 749 |
return true; |
| 750 |
} |
| 751 |
|
| 752 |
/** |
| 753 |
* Write the XSPEED_OC_* constants between our markers in wp-config.php. |
| 754 |
* Idempotent: replaces an existing block. Reversible via remove_wp_config(). |
| 755 |
*/ |
| 756 |
/** Sidecar holding the config when wp-config.php cannot be written. */ |
| 757 |
private const SIDECAR_FILE = 'xspeed-object-cache.php'; |
| 758 |
|
| 759 |
/** |
| 760 |
* Absolute path of the config sidecar. |
| 761 |
* |
| 762 |
* Lives beside the drop-in in wp-content/ rather than under |
| 763 |
* wp-content/cache/, which a purge empties -- losing the settings on the |
| 764 |
* next purge would be a far stranger bug than the one this solves. |
| 765 |
*/ |
| 766 |
public static function sidecar_path(): string { |
| 767 |
return WP_CONTENT_DIR . '/' . self::SIDECAR_FILE; |
| 768 |
} |
| 769 |
|
| 770 |
/** |
| 771 |
* Write the config sidecar. Used when wp-config.php is not writable, which |
| 772 |
* is the norm on several managed hosts -- there the panel could otherwise |
| 773 |
* only ever tell the user to paste a snippet by hand. |
| 774 |
* |
| 775 |
* Written as PHP, not JSON: wp-content/ is web-reachable, and a .json here |
| 776 |
* would serve the Redis password to anyone who guessed the filename. A PHP |
| 777 |
* file with an ABSPATH guard returns nothing when requested directly. |
| 778 |
* |
| 779 |
* @param array<string,mixed> $opts Effective settings to persist. |
| 780 |
*/ |
| 781 |
public static function write_sidecar( array $opts ): bool { |
| 782 |
$fs = self::fs(); |
| 783 |
if ( ! $fs ) { |
| 784 |
return false; |
| 785 |
} |
| 786 |
|
| 787 |
$payload = array(); |
| 788 |
foreach ( self::SIDECAR_KEYS as $key ) { |
| 789 |
if ( array_key_exists( $key, $opts ) ) { |
| 790 |
$payload[ $key ] = $opts[ $key ]; |
| 791 |
} |
| 792 |
} |
| 793 |
|
| 794 |
$body = "<?php\n" |
| 795 |
. "/**\n" |
| 796 |
. " * xSpeed object-cache configuration.\n" |
| 797 |
. " *\n" |
| 798 |
. " * Written by xSpeed because wp-config.php is not writable on this host.\n" |
| 799 |
. " * The drop-in reads this before WordPress loads. Edit the Object Cache\n" |
| 800 |
. " * panel rather than this file -- it is rewritten on every save.\n" |
| 801 |
. " */\n" |
| 802 |
. "defined( 'ABSPATH' ) || exit;\n\n" |
| 803 |
. 'return ' . var_export( $payload, true ) . ";\n"; |
| 804 |
|
| 805 |
/* |
| 806 |
* Write to a temp file and rename() into place. The drop-in `include`s |
| 807 |
* this file BEFORE WordPress loads, so a reader that catches a |
| 808 |
* half-written copy gets a PHP parse error -- a white screen on every |
| 809 |
* request, not a degraded cache. rename() within the same directory is |
| 810 |
* atomic on every filesystem WordPress supports, so a reader sees |
| 811 |
* either the whole old file or the whole new one. |
| 812 |
*/ |
| 813 |
$path = self::sidecar_path(); |
| 814 |
$tmp = $path . '.' . wp_generate_password( 8, false ) . '.tmp'; |
| 815 |
|
| 816 |
if ( ! $fs->put_contents( $tmp, $body, FS_CHMOD_FILE ) ) { |
| 817 |
return false; |
| 818 |
} |
| 819 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- WP_Filesystem has no atomic move; rename() is the whole point here. |
| 820 |
if ( ! @rename( $tmp, $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- failure is reported by the return value. |
| 821 |
$fs->delete( $tmp ); |
| 822 |
return false; |
| 823 |
} |
| 824 |
|
| 825 |
/* |
| 826 |
* Managed hosts -- the ones this sidecar exists for -- often run |
| 827 |
* opcache with validate_timestamps off, where `include` would keep |
| 828 |
* returning the previously compiled array however many times we |
| 829 |
* rewrite the file. That is the exact panel-says-one-thing, |
| 830 |
* runtime-does-another failure this change exists to remove. |
| 831 |
*/ |
| 832 |
if ( function_exists( 'opcache_invalidate' ) ) { |
| 833 |
@opcache_invalidate( $path, true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- opcache may be disabled or restricted; nothing to do either way. |
| 834 |
} |
| 835 |
|
| 836 |
self::forget_sidecar(); |
| 837 |
return true; |
| 838 |
} |
| 839 |
|
| 840 |
/** |
| 841 |
* Remove the sidecar. Called when wp-config.php becomes writable again, so |
| 842 |
* two sources can never disagree about the same setting. |
| 843 |
*/ |
| 844 |
public static function delete_sidecar(): bool { |
| 845 |
$path = self::sidecar_path(); |
| 846 |
if ( ! file_exists( $path ) ) { |
| 847 |
return true; |
| 848 |
} |
| 849 |
$fs = self::fs(); |
| 850 |
$ok = $fs ? (bool) $fs->delete( $path ) : false; |
| 851 |
if ( $ok ) { |
| 852 |
self::forget_sidecar(); |
| 853 |
} |
| 854 |
return $ok; |
| 855 |
} |
| 856 |
|
| 857 |
/** |
| 858 |
* Settings the sidecar carries. Mirrors the fields wp_config_block() |
| 859 |
* emits, so the two storage paths describe the same configuration. |
| 860 |
*/ |
| 861 |
private const SIDECAR_KEYS = array( |
| 862 |
'backend', |
| 863 |
'redis_host', |
| 864 |
'redis_port', |
| 865 |
'redis_user', |
| 866 |
'redis_password', |
| 867 |
'redis_database', |
| 868 |
'memcached_host', |
| 869 |
'memcached_port', |
| 870 |
'key_prefix', |
| 871 |
'connection_timeout', |
| 872 |
'persistent', |
| 873 |
); |
| 874 |
|
| 875 |
/** Memoized sidecar contents; null until first read. */ |
| 876 |
private static $sidecar_cache = null; |
| 877 |
|
| 878 |
/** Forget the memoized sidecar. */ |
| 879 |
public static function forget_sidecar(): void { |
| 880 |
self::$sidecar_cache = null; |
| 881 |
} |
| 882 |
|
| 883 |
/** |
| 884 |
* Read the sidecar, or an empty array when there is none. |
| 885 |
* |
| 886 |
* @return array<string,mixed> |
| 887 |
*/ |
| 888 |
public static function read_sidecar(): array { |
| 889 |
if ( null !== self::$sidecar_cache ) { |
| 890 |
return self::$sidecar_cache; |
| 891 |
} |
| 892 |
$path = self::sidecar_path(); |
| 893 |
if ( ! file_exists( $path ) || ! is_readable( $path ) ) { |
| 894 |
self::$sidecar_cache = array(); |
| 895 |
return self::$sidecar_cache; |
| 896 |
} |
| 897 |
$data = include $path; |
| 898 |
self::$sidecar_cache = is_array( $data ) ? $data : array(); |
| 899 |
return self::$sidecar_cache; |
| 900 |
} |
| 901 |
|
| 902 |
/** |
| 903 |
* Host and port of the first server in a `$memcached_servers` global. |
| 904 |
* |
| 905 |
* Memcached has no constant convention the way Redis has WP_REDIS_*; this |
| 906 |
* global IS the convention, and hosts write it in two shapes: |
| 907 |
* |
| 908 |
* array( array( 'host', 11211 ) ) // W3TC pair form |
| 909 |
* array( 'default' => array( 'host:11211' ) ) // Memcached Object Cache |
| 910 |
* |
| 911 |
* Reading only the first left the second taking the whole "host:port" |
| 912 |
* string as the hostname, or missing it entirely because its bucket is |
| 913 |
* keyed `default` rather than 0. |
| 914 |
* |
| 915 |
* The drop-in carries `xspeed_oc_first_memcached_server()`, which must |
| 916 |
* behave identically -- it loads before WordPress and cannot call this |
| 917 |
* class. ObjectCacheConstantParityTest holds the two together. (#398) |
| 918 |
* |
| 919 |
* @param mixed $servers The global's value, unvalidated. |
| 920 |
* @return array{0:?string,1:?int}|null Host and port, either possibly null. |
| 921 |
*/ |
| 922 |
public static function first_memcached_server( $servers ): ?array { |
| 923 |
if ( ! is_array( $servers ) || array() === $servers ) { |
| 924 |
return null; |
| 925 |
} |
| 926 |
|
| 927 |
$bucket = array_key_exists( 0, $servers ) ? $servers[0] : reset( $servers ); |
| 928 |
|
| 929 |
/* |
| 930 |
* A bucket is EITHER a [host, port] pair or a list of server entries. |
| 931 |
* Telling them apart by shape, not by nesting depth: descending into |
| 932 |
* `array( 'mc.example', 11211 )` yields the host string and drops the |
| 933 |
* port on the floor, which is the commonest form there is. |
| 934 |
*/ |
| 935 |
$entry = $bucket; |
| 936 |
if ( is_array( $bucket ) && isset( $bucket[0] ) && is_array( $bucket[0] ) ) { |
| 937 |
$entry = $bucket[0]; |
| 938 |
} |
| 939 |
|
| 940 |
if ( is_array( $entry ) ) { |
| 941 |
$host = isset( $entry[0] ) && ! is_array( $entry[0] ) ? (string) $entry[0] : null; |
| 942 |
$port = isset( $entry[1] ) && ! is_array( $entry[1] ) ? (int) $entry[1] : null; |
| 943 |
// A single-element list, array( 'host:port' ), is the keyed form's |
| 944 |
// bucket rather than a pair -- fall through to the string parser. |
| 945 |
if ( null !== $host && null === $port && is_string( $entry[0] ) && false !== strpos( $entry[0], ':' ) ) { |
| 946 |
$entry = $entry[0]; |
| 947 |
} else { |
| 948 |
return ( null === $host && null === $port ) ? null : array( $host, $port ); |
| 949 |
} |
| 950 |
} |
| 951 |
|
| 952 |
if ( ! is_string( $entry ) || '' === $entry ) { |
| 953 |
return null; |
| 954 |
} |
| 955 |
|
| 956 |
// "host:port", or a bare host. Split only the LAST colon, and only when |
| 957 |
// what follows is numeric -- a unix socket path is a host with no port. |
| 958 |
$at = strrpos( $entry, ':' ); |
| 959 |
if ( false !== $at && ctype_digit( substr( $entry, $at + 1 ) ) ) { |
| 960 |
return array( substr( $entry, 0, $at ), (int) substr( $entry, $at + 1 ) ); |
| 961 |
} |
| 962 |
return array( $entry, null ); |
| 963 |
} |
| 964 |
|
| 965 |
/** |
| 966 |
* Names of the constants xSpeed itself wrote into wp-config.php. |
| 967 |
* |
| 968 |
* Ownership is decided by LOCATION, not by name. Our block is fenced by |
| 969 |
* CONFIG_BEGIN / CONFIG_END, so a define inside it is one we wrote and a |
| 970 |
* define anywhere else belongs to the host -- even when both are called |
| 971 |
* `XSPEED_OC_HOST`, which is exactly what a user pasting our own snippet |
| 972 |
* by hand produces. |
| 973 |
* |
| 974 |
* Judging by prefix instead is what made the panel treat xSpeed's own |
| 975 |
* values as host-pinned: the field locked, the "manage this here" control |
| 976 |
* could not unlock it, and Revert handed the field back to our snapshot |
| 977 |
* rather than to the host. (#398) |
| 978 |
* |
| 979 |
* @return string[] Constant names, empty when the block is absent. |
| 980 |
*/ |
| 981 |
|
| 982 |
public static function our_constants(): array { |
| 983 |
if ( null !== self::$our_constants_cache ) { |
| 984 |
return self::$our_constants_cache; |
| 985 |
} |
| 986 |
$cache = array(); |
| 987 |
|
| 988 |
$wp_config = ABSPATH . 'wp-config.php'; |
| 989 |
if ( ! file_exists( $wp_config ) || ! is_readable( $wp_config ) ) { |
| 990 |
self::$our_constants_cache = $cache; |
| 991 |
return $cache; |
| 992 |
} |
| 993 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading our own block; WP_Filesystem is not always initialised on the read path. |
| 994 |
$config = (string) @file_get_contents( $wp_config ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable wp-config just means "we own nothing". |
| 995 |
if ( '' === $config ) { |
| 996 |
self::$our_constants_cache = $cache; |
| 997 |
return $cache; |
| 998 |
} |
| 999 |
|
| 1000 |
$pattern = '/' . preg_quote( self::CONFIG_BEGIN, '/' ) . '(.*?)' . preg_quote( self::CONFIG_END, '/' ) . '/s'; |
| 1001 |
if ( ! preg_match( $pattern, $config, $m ) ) { |
| 1002 |
self::$our_constants_cache = $cache; |
| 1003 |
return $cache; |
| 1004 |
} |
| 1005 |
if ( preg_match_all( "/define\\(\\s*'([A-Z0-9_]+)'/", $m[1], $names ) ) { |
| 1006 |
$cache = $names[1]; |
| 1007 |
} |
| 1008 |
self::$our_constants_cache = $cache; |
| 1009 |
return $cache; |
| 1010 |
} |
| 1011 |
|
| 1012 |
/** |
| 1013 |
* Memoized result of our_constants(); null until the block is first read. |
| 1014 |
* |
| 1015 |
* @var string[]|null |
| 1016 |
*/ |
| 1017 |
private static $our_constants_cache = null; |
| 1018 |
|
| 1019 |
/** |
| 1020 |
* Forget the memoized block scan. Every write that changes the block must |
| 1021 |
* call this, or the same request keeps answering from the pre-write copy. |
| 1022 |
*/ |
| 1023 |
public static function forget_our_constants(): void { |
| 1024 |
self::$our_constants_cache = null; |
| 1025 |
} |
| 1026 |
|
| 1027 |
public static function write_wp_config( array $opts, array $force = array() ): bool { |
| 1028 |
$fs = self::fs(); |
| 1029 |
$wp_config = ABSPATH . 'wp-config.php'; |
| 1030 |
if ( ! $fs || ! file_exists( $wp_config ) || ! $fs->is_writable( $wp_config ) ) { |
| 1031 |
/* |
| 1032 |
* wp-config.php is read-only on several managed hosts. Fall back to |
| 1033 |
* a sidecar in wp-content/ -- writable wherever the drop-in itself |
| 1034 |
* could be installed, so the panel keeps working instead of telling |
| 1035 |
* the user to paste a snippet by hand. (#398) |
| 1036 |
*/ |
| 1037 |
return self::write_sidecar( $opts ); |
| 1038 |
} |
| 1039 |
|
| 1040 |
|
| 1041 |
$config = $fs->get_contents( $wp_config ); |
| 1042 |
if ( ! is_string( $config ) ) { |
| 1043 |
return false; |
| 1044 |
} |
| 1045 |
|
| 1046 |
$block = self::wp_config_block( $opts, $force ); |
| 1047 |
|
| 1048 |
// Replace an existing xSpeed block if present, else insert after <?php. |
| 1049 |
// IMPORTANT: $block is inserted via preg_replace_callback returning it |
| 1050 |
// VERBATIM — never as a preg_replace replacement string. In a |
| 1051 |
// replacement string, `\` and `$` are special (backref escapes), so a |
| 1052 |
// constant value ending in a backslash (e.g. a Redis password or key |
| 1053 |
// prefix like "secret\") or containing "$1" would corrupt the output: |
| 1054 |
// esc()'s "secret\\" collapses back to "secret\", producing |
| 1055 |
// 'secret\' ) — a PHP parse error that white-screens the whole site. |
| 1056 |
// The callback form treats $block as literal text. (FBS-82111 Bug 1) |
| 1057 |
$pattern = '/' . preg_quote( self::CONFIG_BEGIN, '/' ) . '.*?' . preg_quote( self::CONFIG_END, '/' ) . "\s*/s"; |
| 1058 |
if ( preg_match( $pattern, $config ) ) { |
| 1059 |
$config = preg_replace_callback( |
| 1060 |
$pattern, |
| 1061 |
static function () use ( $block ) { |
| 1062 |
return $block; |
| 1063 |
}, |
| 1064 |
$config, |
| 1065 |
1 |
| 1066 |
); |
| 1067 |
} else { |
| 1068 |
$config = preg_replace_callback( |
| 1069 |
'/(<\?php)/', |
| 1070 |
static function ( $m ) use ( $block ) { |
| 1071 |
return $m[1] . "\n" . $block; |
| 1072 |
}, |
| 1073 |
$config, |
| 1074 |
1 |
| 1075 |
); |
| 1076 |
} |
| 1077 |
|
| 1078 |
$written = (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE ); |
| 1079 |
if ( $written ) { |
| 1080 |
// The block just changed; a memoized scan from earlier in this |
| 1081 |
// request would still name the previous set. (#398) |
| 1082 |
self::forget_our_constants(); |
| 1083 |
|
| 1084 |
// Only NOW is the block durable, so only now is a sidecar left |
| 1085 |
// from an earlier read-only spell safely redundant. Deleting it |
| 1086 |
// before the write -- is_writable() is not a promise the write |
| 1087 |
// lands; get_contents() can fail, and put_contents() can fail on a |
| 1088 |
// full disk or an SELinux denial -- would drop the live config and |
| 1089 |
// leave the site on built-in defaults. |
| 1090 |
self::delete_sidecar(); |
| 1091 |
} |
| 1092 |
return $written; |
| 1093 |
} |
| 1094 |
|
| 1095 |
/** |
| 1096 |
* Strip our wp-config block. Returns true if the block is gone afterward. |
| 1097 |
*/ |
| 1098 |
public static function remove_wp_config(): bool { |
| 1099 |
$fs = self::fs(); |
| 1100 |
$wp_config = ABSPATH . 'wp-config.php'; |
| 1101 |
if ( ! $fs || ! file_exists( $wp_config ) ) { |
| 1102 |
return true; |
| 1103 |
} |
| 1104 |
if ( ! $fs->is_writable( $wp_config ) ) { |
| 1105 |
return false; |
| 1106 |
} |
| 1107 |
$config = $fs->get_contents( $wp_config ); |
| 1108 |
if ( ! is_string( $config ) ) { |
| 1109 |
return false; |
| 1110 |
} |
| 1111 |
$pattern = '/' . preg_quote( self::CONFIG_BEGIN, '/' ) . '.*?' . preg_quote( self::CONFIG_END, '/' ) . "\s*/s"; |
| 1112 |
$config = preg_replace( $pattern, '', $config ); |
| 1113 |
$removed = (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE ); |
| 1114 |
if ( $removed ) { |
| 1115 |
// A scan from earlier in this request would still name the |
| 1116 |
// constants we just deleted, so origins() would report a field as |
| 1117 |
// ours -- editable -- when a host define is now the only source |
| 1118 |
// and the field should read as pinned. |
| 1119 |
self::forget_our_constants(); |
| 1120 |
} |
| 1121 |
return $removed; |
| 1122 |
} |
| 1123 |
|
| 1124 |
/** |
| 1125 |
* The marker-wrapped constants block written into wp-config.php. Uses |
| 1126 |
* XSPEED_OC_* names (our drop-in reads these first, then falls back to |
| 1127 |
* WP_REDIS_* for interop). |
| 1128 |
*/ |
| 1129 |
private static function wp_config_block( array $opts, array $force = array() ): string { |
| 1130 |
// Fields the caller has decided we own, whatever pinned_elsewhere() |
| 1131 |
// would otherwise say. Used when an admin saved an override: they were |
| 1132 |
// told the host's define would stop applying, and this is the write |
| 1133 |
// that makes that true. (#398) |
| 1134 |
self::$force_fields = $force; |
| 1135 |
$backend = (string) ( $opts['backend'] ?? 'redis' ); |
| 1136 |
$lines = array( self::CONFIG_BEGIN ); |
| 1137 |
$lines[] = "define( 'XSPEED_OC_BACKEND', '" . self::esc( $backend ) . "' );"; |
| 1138 |
|
| 1139 |
if ( 'memcached' === $backend ) { |
| 1140 |
// XSPEED_OC_MC_*, not the Redis pair: one shared name meant enabling |
| 1141 |
// Redis overwrote the Memcached host/port. (#398) |
| 1142 |
if ( ! self::pinned_elsewhere( 'memcached_host' ) ) { |
| 1143 |
$lines[] = "define( 'XSPEED_OC_MC_HOST', '" . self::esc( self::str( $opts, 'memcached_host', '127.0.0.1' ) ) . "' );"; |
| 1144 |
} |
| 1145 |
if ( ! self::pinned_elsewhere( 'memcached_port' ) ) { |
| 1146 |
$lines[] = "define( 'XSPEED_OC_MC_PORT', " . self::int( $opts, 'memcached_port', 11211 ) . ' );'; |
| 1147 |
} |
| 1148 |
} else { |
| 1149 |
if ( ! self::pinned_elsewhere( 'redis_host' ) ) { |
| 1150 |
$lines[] = "define( 'XSPEED_OC_HOST', '" . self::esc( self::str( $opts, 'redis_host', '127.0.0.1' ) ) . "' );"; |
| 1151 |
} |
| 1152 |
if ( ! self::pinned_elsewhere( 'redis_port' ) ) { |
| 1153 |
$lines[] = "define( 'XSPEED_OC_PORT', " . self::int( $opts, 'redis_port', 6379 ) . ' );'; |
| 1154 |
} |
| 1155 |
$user = self::str( $opts, 'redis_user', '' ); |
| 1156 |
if ( '' !== $user && ! self::pinned_elsewhere( 'redis_user' ) ) { |
| 1157 |
$lines[] = "define( 'XSPEED_OC_USER', '" . self::esc( $user ) . "' );"; |
| 1158 |
} |
| 1159 |
$pass = self::str( $opts, 'redis_password', '' ); |
| 1160 |
if ( '' !== $pass && ! self::pinned_elsewhere( 'redis_password' ) ) { |
| 1161 |
$lines[] = "define( 'XSPEED_OC_PASSWORD', '" . self::esc( $pass ) . "' );"; |
| 1162 |
} |
| 1163 |
if ( ! self::pinned_elsewhere( 'redis_database' ) ) { |
| 1164 |
$lines[] = "define( 'XSPEED_OC_DATABASE', " . self::int( $opts, 'redis_database', 0 ) . ' );'; |
| 1165 |
} |
| 1166 |
if ( ! self::pinned_elsewhere( 'connection_timeout' ) ) { |
| 1167 |
$lines[] = "define( 'XSPEED_OC_TIMEOUT', " . self::int( $opts, 'connection_timeout', 1 ) . ' );'; |
| 1168 |
} |
| 1169 |
if ( ! self::pinned_elsewhere( 'persistent' ) ) { |
| 1170 |
$lines[] = "define( 'XSPEED_OC_PERSISTENT', " . ( ! empty( $opts['persistent'] ) ? 'true' : 'false' ) . ' );'; |
| 1171 |
} |
| 1172 |
} |
| 1173 |
// Always emit a salt (#390): a blank Cache Key Prefix derives a per-site |
| 1174 |
// value rather than leaving keys unnamespaced, which collides when |
| 1175 |
// several sites share one Redis/Memcached server. |
| 1176 |
// |
| 1177 |
// Unless a foreign define already owns it (#398). Emitting ours would |
| 1178 |
// outrank the host's WP_REDIS_PREFIX, and on an ACL/namespaced Redis a |
| 1179 |
// prefix that does not match the host's exactly means every write is |
| 1180 |
// denied with NOPERM -- so a derived salt there is worse than none. |
| 1181 |
// The host's define IS the namespace in that case, and it is already |
| 1182 |
// non-empty, so the collision #390 closes cannot reopen. |
| 1183 |
if ( ! self::pinned_elsewhere( 'key_prefix' ) ) { |
| 1184 |
$lines[] = "define( 'XSPEED_OC_SALT', '" . self::esc( self::effective_salt( $opts ) ) . "' );"; |
| 1185 |
} |
| 1186 |
$lines[] = self::CONFIG_END; |
| 1187 |
self::$force_fields = array(); |
| 1188 |
return implode( "\n", $lines ) . "\n"; |
| 1189 |
} |
| 1190 |
|
| 1191 |
/** |
| 1192 |
* Fields the current block write owns outright. Set for the duration of one |
| 1193 |
* wp_config_block() call; see the $force parameter there. |
| 1194 |
* |
| 1195 |
* @var string[] |
| 1196 |
*/ |
| 1197 |
private static array $force_fields = array(); |
| 1198 |
|
| 1199 |
/** |
| 1200 |
* Is this field already pinned by a constant we are not about to write? |
| 1201 |
* |
| 1202 |
* Enable() resolves settings through Settings_Manager, so on a |
| 1203 |
* host-provisioned site those values came FROM wp-config in the first |
| 1204 |
* place -- typically WP_REDIS_*. Writing them back out under our own |
| 1205 |
* XSPEED_OC_* names, which outrank every alias, would freeze a snapshot: |
| 1206 |
* when the host later rotated the password, the site would keep |
| 1207 |
* authenticating with our stale copy and silently drop to a |
| 1208 |
* non-persistent cache. It would also re-emit a credential as a second |
| 1209 |
* plaintext literal, which is the thing sourcing it from a constant |
| 1210 |
* avoids. So leave the host's define alone and emit nothing for it. (#398) |
| 1211 |
*/ |
| 1212 |
private static function pinned_elsewhere( string $field ): bool { |
| 1213 |
if ( in_array( $field, self::$force_fields, true ) ) { |
| 1214 |
return false; |
| 1215 |
} |
| 1216 |
if ( ! class_exists( '\\XSpeed\\Settings_Manager' ) ) { |
| 1217 |
return false; |
| 1218 |
} |
| 1219 |
$module = \XSpeed\Module_Registry::get( 'object-cache' ); |
| 1220 |
if ( ! $module ) { |
| 1221 |
return false; |
| 1222 |
} |
| 1223 |
|
| 1224 |
// An admin who deliberately overrode this field asked us to shadow the |
| 1225 |
// host's define -- they were told so in as many words before the field |
| 1226 |
// unlocked. Protecting it here would silently drop their value on the |
| 1227 |
// next enable, which is the same silent-no-op failure the whole |
| 1228 |
// pinned-field contract exists to prevent. (#398) |
| 1229 |
if ( \XSpeed\Settings_Manager::is_overridden( 'object-cache', $field ) ) { |
| 1230 |
return false; |
| 1231 |
} |
| 1232 |
|
| 1233 |
// Somebody else's define, anywhere in this field's list, is protected -- |
| 1234 |
// even when our own XSPEED_OC_* copy currently outranks it. Testing only |
| 1235 |
// the WINNING constant made an override permanent in a subtler way: on |
| 1236 |
// revert we rewrote our copy with the host's value, our copy still |
| 1237 |
// outranked theirs, and a later rotation on their side was shadowed |
| 1238 |
// forever. Emitting nothing for the field lets the host's define surface |
| 1239 |
// again and keep surfacing. (#398) |
| 1240 |
return null !== \XSpeed\Settings_Manager::foreign_constant( 'object-cache', $field ); |
| 1241 |
} |
| 1242 |
|
| 1243 |
/** |
| 1244 |
* Is our marker block present in wp-config.php? |
| 1245 |
* |
| 1246 |
* Public so a caller can tell "we already manage constants here" from |
| 1247 |
* "this site never enabled the object cache" -- rewriting the block is |
| 1248 |
* right in the first case and would be an unasked-for file edit in the |
| 1249 |
* second. (#398) |
| 1250 |
*/ |
| 1251 |
public static function wp_config_has_our_block(): bool { |
| 1252 |
return self::wp_config_has_block(); |
| 1253 |
} |
| 1254 |
|
| 1255 |
private static function wp_config_has_block(): bool { |
| 1256 |
$wp_config = ABSPATH . 'wp-config.php'; |
| 1257 |
if ( ! file_exists( $wp_config ) ) { |
| 1258 |
return false; |
| 1259 |
} |
| 1260 |
$fs = self::fs(); |
| 1261 |
if ( ! $fs ) { |
| 1262 |
return false; |
| 1263 |
} |
| 1264 |
$config = $fs->get_contents( $wp_config ); |
| 1265 |
return is_string( $config ) && false !== strpos( $config, self::CONFIG_BEGIN ); |
| 1266 |
} |
| 1267 |
|
| 1268 |
/** |
| 1269 |
* Memcached config goes through $memcached_servers (handled by our drop-in's |
| 1270 |
* defaults), so a non-writable wp-config isn't necessarily fatal for it. |
| 1271 |
*/ |
| 1272 |
private static function backend_uses_no_constants( array $opts ): bool { |
| 1273 |
return false; // both backends currently rely on the constants block |
| 1274 |
} |
| 1275 |
|
| 1276 |
/** |
| 1277 |
* Initialised WP_Filesystem handle, or null. Plugin Check-compliant access. |
| 1278 |
* |
| 1279 |
* Forces the 'direct' transport when PHP can write the WordPress tree |
| 1280 |
* itself. Without this, WP_Filesystem() can fall back to the FTP transport |
| 1281 |
* (no credentials in a non-interactive context) and fatal in |
| 1282 |
* ftp_fget(). We only need 'direct' — these writes target wp-config.php / |
| 1283 |
* wp-content, both owned by the PHP user on a normal install. |
| 1284 |
*/ |
| 1285 |
private static function fs() { |
| 1286 |
global $wp_filesystem; |
| 1287 |
if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 1288 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 1289 |
} |
| 1290 |
|
| 1291 |
// Pin the method to 'direct' for this call so a missing FTP/SSH config |
| 1292 |
// can never trigger the credential-prompt / ftp_*() fatal path. Use a |
| 1293 |
// closure on the filter so we don't permanently alter global behaviour. |
| 1294 |
$force_direct = static function () { |
| 1295 |
return 'direct'; |
| 1296 |
}; |
| 1297 |
add_filter( 'filesystem_method', $force_direct, 99 ); |
| 1298 |
$ok = WP_Filesystem(); |
| 1299 |
remove_filter( 'filesystem_method', $force_direct, 99 ); |
| 1300 |
|
| 1301 |
if ( ! $ok || ! $wp_filesystem || 'direct' !== $wp_filesystem->method ) { |
| 1302 |
return null; |
| 1303 |
} |
| 1304 |
return $wp_filesystem; |
| 1305 |
} |
| 1306 |
|
| 1307 |
private static function sniff_drop_in_label( string $path ): string { |
| 1308 |
$head = @file_get_contents( $path, false, null, 0, 2048 ); |
| 1309 |
if ( ! is_string( $head ) || '' === $head ) { |
| 1310 |
return ''; |
| 1311 |
} |
| 1312 |
// PluginName / Plugin Name in standard WP file header form. |
| 1313 |
if ( preg_match( '#Plugin Name:\s*([^\r\n]+)#i', $head, $m ) ) { |
| 1314 |
return trim( $m[1] ); |
| 1315 |
} |
| 1316 |
// Many drop-ins just put their identity in a comment. |
| 1317 |
if ( preg_match( '#\*\s*([A-Za-z][A-Za-z0-9 _\-]{2,40}(?:Cache|Redis|Memcached)[^\r\n]*)#i', $head, $m ) ) { |
| 1318 |
return trim( $m[1] ); |
| 1319 |
} |
| 1320 |
return basename( $path ); |
| 1321 |
} |
| 1322 |
|
| 1323 |
private static function str( array $opts, string $key, string $default ): string { |
| 1324 |
return isset( $opts[ $key ] ) && '' !== $opts[ $key ] ? (string) $opts[ $key ] : $default; |
| 1325 |
} |
| 1326 |
|
| 1327 |
private static function int( array $opts, string $key, int $default ): int { |
| 1328 |
return isset( $opts[ $key ] ) && '' !== $opts[ $key ] ? (int) $opts[ $key ] : $default; |
| 1329 |
} |
| 1330 |
|
| 1331 |
private static function esc( string $s ): string { |
| 1332 |
return str_replace( array( '\\', "'" ), array( '\\\\', "\\'" ), $s ); |
| 1333 |
} |
| 1334 |
} |
| 1335 |
|