| @@ -35,30 +35,429 @@ | ||
| 35 | 35 | /** |
| 36 | 36 | * Resolve a single config value from constants with sane defaults. |
| 37 | 37 | */ |
| 38 | 38 | function xspeed_oc_config( $key, $default ) { |
| 39 | + // Keep this map in lockstep with the `constants` declared in | |
| 40 | + // ObjectCacheModule::settings_schema(). The drop-in loads before | |
| 41 | + // WordPress, so it cannot call Settings_Manager and the list genuinely | |
| 42 | + // exists twice; ObjectCacheConstantParityTest asserts the two agree | |
| 43 | + // field-for-field, because one rule in two files is how the last | |
| 44 | + // ownership bug survived three review rounds. (#398) | |
| 45 | + // The backend decides which host/port field to read, so it has to be | |
| 46 | + // resolved the same way everything else is: constant first, then the | |
| 47 | + // sidecar. Reading only the constant made a sidecar-configured | |
| 48 | + // Memcached site read Redis's host and port. | |
| 49 | + if ( defined( 'XSPEED_OC_BACKEND' ) ) { | |
| 50 | + $backend = (string) constant( 'XSPEED_OC_BACKEND' ); | |
| 51 | + } else { | |
| 52 | + $sc = xspeed_oc_sidecar(); | |
| 53 | + $backend = isset( $sc['backend'] ) ? (string) $sc['backend'] : ''; | |
| 54 | + } | |
| 55 | + $is_memcached = ( 'memcached' === $backend ); | |
| 56 | + | |
| 39 | 57 | $map = array( |
| 40 | 58 | 'backend' => array( 'XSPEED_OC_BACKEND' ), |
| 41 | - 'host' => array( 'XSPEED_OC_HOST', 'WP_REDIS_HOST' ), | |
| 42 | - 'port' => array( 'XSPEED_OC_PORT', 'WP_REDIS_PORT' ), | |
| 43 | - 'user' => array( 'XSPEED_OC_USER', 'WP_REDIS_USER' ), | |
| 59 | + // WP_REDIS_* are Redis's own conventions, so they only answer when | |
| 60 | + // Redis is the backend. Consulting them on Memcached made a | |
| 61 | + // Memcached site connect to the Redis host and port -- the same | |
| 62 | + // cross-backend bleed that one shared XSPEED_OC_HOST/PORT pair | |
| 63 | + // caused in the panel. (#398) | |
| 64 | + // XSPEED_OC_HOST/PORT trail the Memcached names for BACKWARD | |
| 65 | + // COMPATIBILITY: installs configured before the split have the | |
| 66 | + // generic pair in their block, and dropping it would move them to | |
| 67 | + // 127.0.0.1 on upgrade -- breaking a working cache. New writes use | |
| 68 | + // XSPEED_OC_MC_*, so the fallback fades out on the next save. | |
| 69 | + 'host' => $is_memcached | |
| 70 | + ? array( 'XSPEED_OC_MC_HOST', 'XSPEED_OC_HOST' ) | |
| 71 | + : array( 'XSPEED_OC_HOST', 'WP_REDIS_HOST' ), | |
| 72 | + 'port' => $is_memcached | |
| 73 | + ? array( 'XSPEED_OC_MC_PORT', 'XSPEED_OC_PORT' ) | |
| 74 | + : array( 'XSPEED_OC_PORT', 'WP_REDIS_PORT' ), | |
| 75 | + // WP_REDIS_PASSWORD trails the user names on purpose: in its array | |
| 76 | + // form it carries the ACL username too, so a site that defines only | |
| 77 | + // the password pair still authenticates as the right user. | |
| 78 | + 'user' => array( 'XSPEED_OC_USER', 'WP_REDIS_USER', 'WP_REDIS_PASSWORD' ), | |
| 44 | 79 | 'password' => array( 'XSPEED_OC_PASSWORD', 'WP_REDIS_PASSWORD' ), |
| 45 | 80 | 'database' => array( 'XSPEED_OC_DATABASE', 'WP_REDIS_DATABASE' ), |
| 46 | 81 | 'timeout' => array( 'XSPEED_OC_TIMEOUT', 'WP_REDIS_TIMEOUT' ), |
| 47 | - 'salt' => array( 'XSPEED_OC_SALT', 'WP_CACHE_KEY_SALT' ), | |
| 82 | + 'salt' => array( 'XSPEED_OC_SALT', 'WP_REDIS_PREFIX', 'WP_CACHE_KEY_SALT' ), | |
| 48 | 83 | 'persist' => array( 'XSPEED_OC_PERSISTENT', 'WP_REDIS_PERSISTENT' ), |
| 49 | 84 | ); |
| 85 | + /* | |
| 86 | + * This drop-in's short keys mapped to the schema field names the | |
| 87 | + * sidecar stores. Redis and Memcached each name their own host/port | |
| 88 | + * field, so switching backend cannot make one read the other's value. | |
| 89 | + */ | |
| 90 | + $sidecar_map = array( | |
| 91 | + 'backend' => 'backend', | |
| 92 | + 'host' => $is_memcached ? 'memcached_host' : 'redis_host', | |
| 93 | + 'port' => $is_memcached ? 'memcached_port' : 'redis_port', | |
| 94 | + 'user' => 'redis_user', | |
| 95 | + 'password' => 'redis_password', | |
| 96 | + 'database' => 'redis_database', | |
| 97 | + 'timeout' => 'connection_timeout', | |
| 98 | + 'salt' => 'key_prefix', | |
| 99 | + 'persist' => 'persistent', | |
| 100 | + ); | |
| 101 | + | |
| 102 | + /* | |
| 103 | + * $memcached_servers is Memcached's convention the way WP_REDIS_* is | |
| 104 | + * Redis's -- W3TC and the Memcached Object Cache drop-in both read it, | |
| 105 | + * and hosts write it. Settings_Manager resolves it for the panel, so | |
| 106 | + * without it here the panel and Test connection reported the host's | |
| 107 | + * server while the drop-in quietly used 127.0.0.1 and cached nothing: | |
| 108 | + * the two-truths split this whole change exists to close, on the one | |
| 109 | + * backend where the convention IS a global. Ranked below our own | |
| 110 | + * constants, matching the schema's constants-then-global order. (#398) | |
| 111 | + */ | |
| 112 | + if ( $is_memcached && ( 'host' === $key || 'port' === $key ) ) { | |
| 113 | + $ours = 'host' === $key | |
| 114 | + ? array( 'XSPEED_OC_MC_HOST', 'XSPEED_OC_HOST' ) | |
| 115 | + : array( 'XSPEED_OC_MC_PORT', 'XSPEED_OC_PORT' ); | |
| 116 | + $pinned = false; | |
| 117 | + foreach ( $ours as $const ) { | |
| 118 | + if ( defined( $const ) ) { | |
| 119 | + $pinned = true; | |
| 120 | + break; | |
| 121 | + } | |
| 122 | + } | |
| 123 | + if ( ! $pinned && isset( $GLOBALS['memcached_servers'] ) ) { | |
| 124 | + $pair = xspeed_oc_first_memcached_server( $GLOBALS['memcached_servers'] ); | |
| 125 | + $slot = 'host' === $key ? 0 : 1; | |
| 126 | + if ( null !== $pair && null !== $pair[ $slot ] ) { | |
| 127 | + return $pair[ $slot ]; | |
| 128 | + } | |
| 129 | + } | |
| 130 | + } | |
| 131 | + | |
| 50 | 132 | if ( isset( $map[ $key ] ) ) { |
| 133 | + /* | |
| 134 | + * A host's define outranks one we wrote, whatever order this map | |
| 135 | + * lists them in -- the same rule Settings_Manager applies for the | |
| 136 | + * panel. Ours only mirrors the option row, so preferring it meant a | |
| 137 | + * rotated host credential was ignored until someone saved. Two | |
| 138 | + * passes rather than a reorder, because the map's order is still | |
| 139 | + * right for every other case (ours before the convention). (#398) | |
| 140 | + */ | |
| 141 | + /* | |
| 142 | + * Only a name that is not ours to begin with can be promoted. Our | |
| 143 | + * OWN legacy aliases must never be: XSPEED_OC_HOST/PORT trail the | |
| 144 | + * Memcached names for backward compatibility and, on a site that | |
| 145 | + * ran Redis first, hold the REDIS host. Promoting one because it | |
| 146 | + * happened to sit outside the current fence pointed a Memcached | |
| 147 | + * site at the Redis server -- while the panel, which gates those | |
| 148 | + * aliases on the backend (`constants_when`), still showed the right | |
| 149 | + * value. That is the panel/runtime split this change exists to | |
| 150 | + * close, reopened from the other side. | |
| 151 | + * | |
| 152 | + * WP_CACHE_KEY_SALT is NEVER promoted (#430). It is not a host's | |
| 153 | + * namespace declaration the way WP_REDIS_* is -- it is WordPress's | |
| 154 | + * OWN cache-uniqueness salt, present on almost every install and | |
| 155 | + * usually a random value. On xCloud the provisioner writes the | |
| 156 | + * correct namespace as XSPEED_OC_SALT AND WordPress carries its | |
| 157 | + * own random WP_CACHE_KEY_SALT beside it; promoting the latter over | |
| 158 | + * ours pointed every write outside the ACL namespace (NOPERM) while | |
| 159 | + * released code -- which had no promotion pass -- worked. So for the | |
| 160 | + * salt, our own define always wins over WP_CACHE_KEY_SALT; a genuine | |
| 161 | + * WP_REDIS_PREFIX still ranks by position like any other convention. | |
| 162 | + */ | |
| 163 | + $ours = xspeed_oc_our_constants(); | |
| 164 | + $sorted = array(); | |
| 51 | 165 | foreach ( $map[ $key ] as $const ) { |
| 52 | - if ( defined( $const ) ) { | |
| 53 | - return constant( $const ); | |
| 166 | + if ( 0 === strpos( $const, 'XSPEED_OC_' ) ) { | |
| 167 | + continue; | |
| 54 | 168 | } |
| 169 | + if ( 'WP_CACHE_KEY_SALT' === $const ) { | |
| 170 | + continue; | |
| 171 | + } | |
| 172 | + if ( defined( $const ) && ! in_array( $const, $ours, true ) ) { | |
| 173 | + $sorted[] = $const; | |
| 174 | + } | |
| 55 | 175 | } |
| 176 | + foreach ( $map[ $key ] as $const ) { | |
| 177 | + if ( ! in_array( $const, $sorted, true ) ) { | |
| 178 | + $sorted[] = $const; | |
| 179 | + } | |
| 180 | + } | |
| 181 | + | |
| 182 | + foreach ( $sorted as $const ) { | |
| 183 | + if ( ! defined( $const ) ) { | |
| 184 | + continue; | |
| 185 | + } | |
| 186 | + $value = constant( $const ); | |
| 187 | + // WP_REDIS_PASSWORD only answers for `user` in its array form, | |
| 188 | + // which carries the ACL username. As a plain string it is just | |
| 189 | + // a password: skip it, or we would authenticate with the | |
| 190 | + // password as the username. | |
| 191 | + if ( 'user' === $key && 'WP_REDIS_PASSWORD' === $const && ! is_array( $value ) ) { | |
| 192 | + continue; | |
| 193 | + } | |
| 194 | + return xspeed_oc_credential_part( $key, $value ); | |
| 195 | + } | |
| 56 | 196 | } |
| 197 | + | |
| 198 | + /* | |
| 199 | + * No constant answered. On a host where wp-config.php is not writable | |
| 200 | + * the panel stores the configuration in a sidecar beside this drop-in | |
| 201 | + * instead, so consult it before falling back to the built-in default -- | |
| 202 | + * otherwise every setting the user saved there would be ignored at | |
| 203 | + * runtime while the panel showed it as active. (#398) | |
| 204 | + */ | |
| 205 | + $sidecar = xspeed_oc_sidecar(); | |
| 206 | + $field = isset( $sidecar_map[ $key ] ) ? $sidecar_map[ $key ] : null; | |
| 207 | + if ( null !== $field && array_key_exists( $field, $sidecar ) ) { | |
| 208 | + return xspeed_oc_credential_part( $key, $sidecar[ $field ] ); | |
| 209 | + } | |
| 210 | + | |
| 57 | 211 | return $default; |
| 58 | 212 | } |
| 59 | 213 | } |
| 60 | 214 | |
| 215 | +if ( ! function_exists( 'xspeed_oc_first_memcached_server' ) ) { | |
| 216 | + /** | |
| 217 | + * Host and port of the first server in a `$memcached_servers` global. | |
| 218 | + * | |
| 219 | + * Two shapes are in circulation and hosts write both: | |
| 220 | + * | |
| 221 | + * array( array( 'host', 11211 ) ) // W3TC pair form | |
| 222 | + * array( 'default' => array( 'host:11211' ) ) // Memcached Object Cache | |
| 223 | + * | |
| 224 | + * Supporting only the first left the second reading the whole "host:port" | |
| 225 | + * string as the hostname -- or missing entirely, since its bucket is keyed | |
| 226 | + * `default` rather than 0. Kept in one function because Settings_Manager | |
| 227 | + * has to answer identically or the panel and the runtime disagree, which is | |
| 228 | + * the bug this whole change closes. (#398) | |
| 229 | + * | |
| 230 | + * @param mixed $servers The global's value, unvalidated. | |
| 231 | + * @return array{0:?string,1:?int}|null Host and port, either possibly null. | |
| 232 | + */ | |
| 233 | + function xspeed_oc_first_memcached_server( $servers ) { | |
| 234 | + if ( ! is_array( $servers ) || array() === $servers ) { | |
| 235 | + return null; | |
| 236 | + } | |
| 237 | + | |
| 238 | + // Either the 0th bucket or, for the keyed form, whichever comes first. | |
| 239 | + $bucket = array_key_exists( 0, $servers ) ? $servers[0] : reset( $servers ); | |
| 240 | + | |
| 241 | + /* | |
| 242 | + * A bucket is EITHER a [host, port] pair or a list of server entries. | |
| 243 | + * Telling them apart by shape, not by nesting depth: descending into | |
| 244 | + * array( 'mc.example', 11211 ) yields the host string and drops the | |
| 245 | + * port on the floor, which is the commonest form there is. | |
| 246 | + */ | |
| 247 | + $entry = $bucket; | |
| 248 | + if ( is_array( $bucket ) && isset( $bucket[0] ) && is_array( $bucket[0] ) ) { | |
| 249 | + $entry = $bucket[0]; | |
| 250 | + } | |
| 251 | + | |
| 252 | + // Pair form: [ host, port ]. | |
| 253 | + if ( is_array( $entry ) ) { | |
| 254 | + $host = isset( $entry[0] ) && ! is_array( $entry[0] ) ? (string) $entry[0] : null; | |
| 255 | + $port = isset( $entry[1] ) && ! is_array( $entry[1] ) ? (int) $entry[1] : null; | |
| 256 | + // A single-element list, array( 'host:port' ), is the keyed form's | |
| 257 | + // bucket rather than a pair -- fall through to the string parser. | |
| 258 | + if ( null !== $host && null === $port && is_string( $entry[0] ) && false !== strpos( $entry[0], ':' ) ) { | |
| 259 | + $entry = $entry[0]; | |
| 260 | + } else { | |
| 261 | + return ( null === $host && null === $port ) ? null : array( $host, $port ); | |
| 262 | + } | |
| 263 | + } | |
| 264 | + | |
| 265 | + if ( ! is_string( $entry ) || '' === $entry ) { | |
| 266 | + return null; | |
| 267 | + } | |
| 268 | + | |
| 269 | + // "host:port", or a bare host. A unix socket path has no port and can | |
| 270 | + // contain no colon we should split on, so only split the LAST one and | |
| 271 | + // only when what follows is numeric. | |
| 272 | + $at = strrpos( $entry, ':' ); | |
| 273 | + if ( false !== $at && ctype_digit( substr( $entry, $at + 1 ) ) ) { | |
| 274 | + return array( substr( $entry, 0, $at ), (int) substr( $entry, $at + 1 ) ); | |
| 275 | + } | |
| 276 | + return array( $entry, null ); | |
| 277 | + } | |
| 278 | +} | |
| 279 | + | |
| 280 | +if ( ! function_exists( 'xspeed_oc_our_constants' ) ) { | |
| 281 | + /** | |
| 282 | + * Constant names defined inside OUR fenced block in wp-config.php. | |
| 283 | + * | |
| 284 | + * Ownership is decided by WHERE a define sits, exactly as | |
| 285 | + * Object_Cache::our_constants() decides it for the admin half. The drop-in | |
| 286 | + * needs the same answer for the same reason the panel does: a define we | |
| 287 | + * wrote is only a mirror of the option row, so a HOST define has to outrank | |
| 288 | + * it. Without this the drop-in kept connecting to our stale snapshot after | |
| 289 | + * a host rotated its credentials, while the panel -- which does apply the | |
| 290 | + * rule -- showed the new one. Panel and runtime disagreeing is the whole | |
| 291 | + * bug this change exists to remove. (#398) | |
| 292 | + * | |
| 293 | + * @return string[] | |
| 294 | + */ | |
| 295 | + function xspeed_oc_our_constants() { | |
| 296 | + static $names = null; | |
| 297 | + if ( null !== $names ) { | |
| 298 | + return $names; | |
| 299 | + } | |
| 300 | + $names = array(); | |
| 301 | + | |
| 302 | + // ABSPATH is defined by wp-load.php before the drop-in is included. | |
| 303 | + $path = defined( 'ABSPATH' ) ? ABSPATH . 'wp-config.php' : ''; | |
| 304 | + if ( '' === $path || ! is_readable( $path ) ) { | |
| 305 | + // One level up is the standard "wp-config outside the root" layout. | |
| 306 | + $alt = defined( 'ABSPATH' ) ? dirname( ABSPATH ) . '/wp-config.php' : ''; | |
| 307 | + $path = ( '' !== $alt && is_readable( $alt ) ) ? $alt : ''; | |
| 308 | + } | |
| 309 | + if ( '' === $path ) { | |
| 310 | + return $names; | |
| 311 | + } | |
| 312 | + | |
| 313 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- runs before WordPress; WP_Filesystem does not exist yet. | |
| 314 | + $config = (string) @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable wp-config just means "we own nothing". | |
| 315 | + if ( '' === $config ) { | |
| 316 | + return $names; | |
| 317 | + } | |
| 318 | + if ( ! preg_match( '/\/\* BEGIN xSpeed Object Cache \*\/(.*?)\/\* END xSpeed Object Cache \*\//s', $config, $m ) ) { | |
| 319 | + return $names; | |
| 320 | + } | |
| 321 | + if ( preg_match_all( "/define\(\s*'([A-Z0-9_]+)'/", $m[1], $found ) ) { | |
| 322 | + $names = $found[1]; | |
| 323 | + } | |
| 324 | + return $names; | |
| 325 | + } | |
| 326 | +} | |
| 327 | + | |
| 328 | +if ( ! function_exists( 'xspeed_oc_sidecar' ) ) { | |
| 329 | + /** | |
| 330 | + * Configuration written beside this drop-in when wp-config.php is | |
| 331 | + * read-only. Returns an empty array when there is none. | |
| 332 | + * | |
| 333 | + * This file IS wp-content/object-cache.php, so the sidecar sits in the | |
| 334 | + * same directory -- no constant needed to locate it, which matters because | |
| 335 | + * WP_CONTENT_DIR is not guaranteed to be defined this early. | |
| 336 | + * | |
| 337 | + * @return array<string,mixed> | |
| 338 | + */ | |
| 339 | + function xspeed_oc_sidecar() { | |
| 340 | + static $data = null; | |
| 341 | + if ( null !== $data ) { | |
| 342 | + return $data; | |
| 343 | + } | |
| 344 | + $data = array(); | |
| 345 | + $path = __DIR__ . '/xspeed-object-cache.php'; | |
| 346 | + if ( ! is_readable( $path ) ) { | |
| 347 | + return $data; | |
| 348 | + } | |
| 349 | + | |
| 350 | + /* | |
| 351 | + * This runs before WordPress, so a parse error here is a white screen | |
| 352 | + * on every request rather than a degraded cache. The writer renames | |
| 353 | + * into place atomically, but a file truncated by something else -- a | |
| 354 | + * failed deploy, a partial restore -- must not take the site down, so | |
| 355 | + * the include is guarded and any failure degrades to "no sidecar". | |
| 356 | + */ | |
| 357 | + try { | |
| 358 | + $loaded = @include $path; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a broken sidecar must not white-screen the site. | |
| 359 | + if ( is_array( $loaded ) ) { | |
| 360 | + $data = $loaded; | |
| 361 | + } | |
| 362 | + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- degrade to no sidecar. | |
| 363 | + $data = array(); | |
| 364 | + } | |
| 365 | + return $data; | |
| 366 | + } | |
| 367 | +} | |
| 368 | + | |
| 369 | +if ( ! function_exists( 'xspeed_oc_salt' ) ) { | |
| 370 | + /** | |
| 371 | + * Resolve the salt that namespaces this site's keys. | |
| 372 | + * | |
| 373 | + * Normally the salt constant is written into wp-config.php on enable. When | |
| 374 | + * wp-config is NOT writable (some managed hosts) the drop-in still gets | |
| 375 | + * installed, so without a fallback every key would come out as | |
| 376 | + * `:{blog}:{group}:{key}` — identical on every install. Two sites sharing a | |
| 377 | + * Redis/Memcached server would then read each other's blog-details / | |
| 378 | + * blog-lookup entries and the second site would redirect to the first. | |
| 379 | + * | |
| 380 | + * Derived the same way Object_Cache::derive_salt() does, so a site keeps | |
| 381 | + * the same namespace whether the constant is present or not. | |
| 382 | + * | |
| 383 | + * @return string Non-empty salt. | |
| 384 | + */ | |
| 385 | + function xspeed_oc_salt() { | |
| 386 | + $salt = (string) xspeed_oc_config( 'salt', '' ); | |
| 387 | + if ( '' !== $salt ) { | |
| 388 | + return $salt; | |
| 389 | + } | |
| 390 | + | |
| 391 | + global $table_prefix; | |
| 392 | + | |
| 393 | + $url = ''; | |
| 394 | + if ( defined( 'WP_HOME' ) ) { | |
| 395 | + $url = (string) WP_HOME; | |
| 396 | + } elseif ( defined( 'WP_SITEURL' ) ) { | |
| 397 | + $url = (string) WP_SITEURL; | |
| 398 | + } | |
| 399 | + | |
| 400 | + // WP_HOME / WP_SITEURL are OPTIONAL and absent from a stock | |
| 401 | + // wp-config.php, so the URL is usually empty here — DB_NAME alone | |
| 402 | + // would then be identical for two sites sharing one database and the | |
| 403 | + // collision this salt exists to prevent would come straight back. | |
| 404 | + // $table_prefix separates them: it is assigned in wp-config.php itself, | |
| 405 | + // so it is already a global by the time the drop-in loads (well before | |
| 406 | + // $wpdb exists). ABSPATH is added whenever no URL was available, since | |
| 407 | + // two installs on one database necessarily live in different | |
| 408 | + // directories. | |
| 409 | + $parts = array( | |
| 410 | + $url, | |
| 411 | + defined( 'DB_NAME' ) ? (string) DB_NAME : '', | |
| 412 | + isset( $table_prefix ) ? (string) $table_prefix : '', | |
| 413 | + ); | |
| 414 | + if ( '' === $url ) { | |
| 415 | + $parts[] = defined( 'ABSPATH' ) ? (string) ABSPATH : ''; | |
| 416 | + } | |
| 417 | + | |
| 418 | + $seed = implode( '|', $parts ); | |
| 419 | + if ( '' === trim( $seed, '|' ) ) { | |
| 420 | + $seed = 'xspeed'; | |
| 421 | + } | |
| 422 | + | |
| 423 | + return 'xs' . substr( md5( $seed ), 0, 12 ); | |
| 424 | + } | |
| 425 | +} | |
| 426 | + | |
| 427 | +if ( ! function_exists( 'xspeed_oc_credential_part' ) ) { | |
| 428 | + /** | |
| 429 | + * Unpack the array form of a Redis credential constant. | |
| 430 | + * | |
| 431 | + * Managed hosts that provision Redis ACL users (xCloud, Cloudways) ship | |
| 432 | + * the pair in one define: | |
| 433 | + * | |
| 434 | + * define( 'WP_REDIS_PASSWORD', array( 'acl_user', 's3cret' ) ); | |
| 435 | + * | |
| 436 | + * Casting that to string yields "Array" and a notice, so the site would | |
| 437 | + * authenticate with garbage. Split it here, at the single point both | |
| 438 | + * `user` and `password` resolve through, rather than in each caller. | |
| 439 | + * | |
| 440 | + * @param string $key Config key being resolved. | |
| 441 | + * @param mixed $value Raw constant value. | |
| 442 | + * @return mixed | |
| 443 | + */ | |
| 444 | + function xspeed_oc_credential_part( $key, $value ) { | |
| 445 | + if ( ! is_array( $value ) || ( 'user' !== $key && 'password' !== $key ) ) { | |
| 446 | + return $value; | |
| 447 | + } | |
| 448 | + // Scalars only. A nested value would stringify to "Array" and emit a | |
| 449 | + // warning on EVERY request from the drop-in -- before headers are sent, | |
| 450 | + // on the code path whose whole design rule is never to break the site. | |
| 451 | + $parts = array_values( array_filter( $value, 'is_scalar' ) ); | |
| 452 | + if ( 'user' === $key ) { | |
| 453 | + // A one-element array is a password with no ACL user. | |
| 454 | + return count( $parts ) > 1 ? (string) $parts[0] : ''; | |
| 455 | + } | |
| 456 | + return (string) ( count( $parts ) > 1 ? $parts[1] : ( $parts[0] ?? '' ) ); | |
| 457 | + } | |
| 458 | +} | |
| 459 | + | |
| 61 | 460 | // ----------------------------------------------------------------------------- |
| 62 | 461 | // WordPress object-cache API surface. Thin wrappers over the global instance. |
| 63 | 462 | // ----------------------------------------------------------------------------- |
| 64 | 463 | if ( ! function_exists( 'wp_cache_init' ) ) { |
| @@ -193,8 +592,26 @@ | ||
| 193 | 592 | |
| 194 | 593 | /** @var string Key salt / prefix. */ |
| 195 | 594 | private $salt = ''; |
| 196 | 595 | |
| 596 | + /** Option holding the Memcached generation floor (see generation_floor()). */ | |
| 597 | + const GENERATION_OPTION = 'xspeed_oc_generation'; | |
| 598 | + | |
| 599 | + /** | |
| 600 | + * @var int|null This site's namespace generation, resolved lazily once | |
| 601 | + * per request. Advancing it is how Memcached flushes only this site's | |
| 602 | + * keys — the daemon offers no way to enumerate or scope a real flush. | |
| 603 | + * Null until first read; 1 means the original, unsuffixed key shape. | |
| 604 | + */ | |
| 605 | + private $generation = null; | |
| 606 | + | |
| 607 | + /** | |
| 608 | + * @var int|null Lowest generation this site may use, mirrored in the | |
| 609 | + * database so an LRU eviction of the cached counter cannot rewind the | |
| 610 | + * namespace. Null until first read. | |
| 611 | + */ | |
| 612 | + private $generation_floor = null; | |
| 613 | + | |
| 197 | 614 | /** @var int Current blog id (multisite prefixing). */ |
| 198 | 615 | private $blog_prefix = 0; |
| 199 | 616 | |
| 200 | 617 | /** @var bool */ |
| @@ -214,23 +631,33 @@ | ||
| 214 | 631 | |
| 215 | 632 | public function __construct() { |
| 216 | 633 | $this->multisite = function_exists( 'is_multisite' ) && is_multisite(); |
| 217 | 634 | $this->blog_prefix = $this->multisite ? (int) get_current_blog_id() : 0; |
| 218 | - $this->salt = (string) xspeed_oc_config( 'salt', '' ); | |
| 635 | + $this->salt = xspeed_oc_salt(); | |
| 219 | 636 | $this->backend = strtolower( (string) xspeed_oc_config( 'backend', 'redis' ) ); |
| 220 | 637 | |
| 221 | - // Core groups that must never be served from the shared | |
| 222 | - // persistent backend, or a stale copy can resurrect data WP | |
| 223 | - // just wrote. The critical one is `options`: WordPress keeps | |
| 224 | - // `active_plugins` inside the autoloaded `alloptions` blob, so | |
| 225 | - // a stale persistent `alloptions` makes a just-deactivated | |
| 226 | - // plugin read as still active (the write succeeds, the | |
| 227 | - // verify-read sees the old blob) — plugins then "can't be | |
| 228 | - // deactivated". These groups load once per request from the DB | |
| 229 | - // (still fast), kept per-request only. Same default set Redis | |
| 230 | - // Object Cache and friends ship. | |
| 638 | + // Non-persistent groups. We deliberately DO persist `options` | |
| 639 | + // (incl. the autoloaded `alloptions` blob), `comment`, and | |
| 640 | + // `counts` — these are the highest-volume, highest-hit groups, | |
| 641 | + // and excluding them was why the persistent cache stored only a | |
| 642 | + // fraction of the keys a mature object cache (e.g. Redis Object | |
| 643 | + // Cache) does. Redis Object Cache persists all of them by | |
| 644 | + // default; matching that is the whole point of the feature. | |
| 645 | + // | |
| 646 | + // The historical "can't deactivate a plugin" bug (FBS-82210) was | |
| 647 | + // a stale `alloptions` being read back after a plugin write. That | |
| 648 | + // is NOT solved by refusing to persist options — a correct cache | |
| 649 | + // solves it by invalidating on write, which WordPress core already | |
| 650 | + // does: update_option()/add_option()/delete_option() each call | |
| 651 | + // wp_cache_delete( 'alloptions', 'options' ). Our delete() | |
| 652 | + // propagates to the backend for every persistent group (see | |
| 653 | + // delete()), so the stale blob is removed the moment WP writes an | |
| 654 | + // option — deactivation stays correct WITH options persisted. | |
| 655 | + // | |
| 656 | + // `plugins` and `themes` remain non-persistent: they're tiny, | |
| 657 | + // rebuilt cheaply per request, and never worth a round trip. | |
| 231 | 658 | $this->add_non_persistent_groups( |
| 232 | - array( 'options', 'site-options', 'comment', 'counts', 'plugins', 'themes' ) | |
| 659 | + array( 'plugins', 'themes' ) | |
| 233 | 660 | ); |
| 234 | 661 | |
| 235 | 662 | $this->connect(); |
| 236 | 663 | } |
| @@ -461,9 +888,14 @@ | ||
| 461 | 888 | |
| 462 | 889 | private function full_key( $key, $group ) { |
| 463 | 890 | $group = $this->group( $group ); |
| 464 | 891 | $prefix = isset( $this->global_groups[ $group ] ) ? 0 : $this->blog_prefix; |
| 465 | - return $this->salt . ':' . $prefix . ':' . $group . ':' . $key; | |
| 892 | + $gen = $this->generation(); | |
| 893 | + // Generation 1 keeps the historical key shape, so Redis (which | |
| 894 | + // flushes by pattern and never advances the generation) is | |
| 895 | + // byte-identical to before and existing entries stay readable. | |
| 896 | + $ns = 1 === $gen ? $this->salt : $this->salt . '.g' . $gen; | |
| 897 | + return $ns . ':' . $prefix . ':' . $group . ':' . $key; | |
| 466 | 898 | } |
| 467 | 899 | |
| 468 | 900 | private function is_persistent_group( $group ) { |
| 469 | 901 | return $this->persistent && ! isset( $this->non_persistent_groups[ $this->group( $group ) ] ); |
| @@ -524,19 +956,52 @@ | ||
| 524 | 956 | } |
| 525 | 957 | $this->cache[ $id ] = $data; |
| 526 | 958 | |
| 527 | 959 | if ( $this->is_persistent_group( $group ) ) { |
| 960 | + $stored = false; | |
| 961 | + $threw = false; | |
| 528 | 962 | try { |
| 529 | 963 | $payload = maybe_serialize( $data ); |
| 530 | 964 | if ( 'redis' === $this->backend ) { |
| 531 | - return $expire > 0 | |
| 965 | + $stored = $expire > 0 | |
| 532 | 966 | ? (bool) $this->conn->setex( $id, (int) $expire, $payload ) |
| 533 | 967 | : (bool) $this->conn->set( $id, $payload ); |
| 968 | + } else { | |
| 969 | + $stored = (bool) $this->conn->set( $id, $payload, (int) $expire ); | |
| 534 | 970 | } |
| 535 | - return (bool) $this->conn->set( $id, $payload, (int) $expire ); | |
| 536 | 971 | } catch ( \Throwable $e ) { |
| 537 | - return true; // runtime cache still set | |
| 972 | + $threw = true; | |
| 538 | 973 | } |
| 974 | + if ( ! $stored ) { | |
| 975 | + // The backend may still hold the PREVIOUS value for this | |
| 976 | + // key (classic: memcached rejecting an alloptions blob | |
| 977 | + // over its item-size limit). The DB now has the new value; | |
| 978 | + // leaving the old one here would serve stale data to every | |
| 979 | + // later request — e.g. a settings change confirmed over | |
| 980 | + // REST/MCP that the dashboard never shows. Evict so | |
| 981 | + // readers fall back to the database. | |
| 982 | + try { | |
| 983 | + $this->backend_delete( $id ); | |
| 984 | + } catch ( \Throwable $e ) { | |
| 985 | + // Backend fully down → reads fail too, so no staleness. | |
| 986 | + } | |
| 987 | + } | |
| 988 | + // Return value: the runtime cache always accepted the value, and | |
| 989 | + // WP core's contract for wp_cache_set() is "was it cached", | |
| 990 | + // which a persistent-backend refusal doesn't falsify — the | |
| 991 | + // value is live for this request and the DB holds the truth | |
| 992 | + // for later ones (we evicted the stale copy above). Some | |
| 993 | + // callers treat false as "the write was lost" and retry or | |
| 994 | + // bail, so report success and surface backend trouble through | |
| 995 | + // the degraded flag instead. | |
| 996 | + // | |
| 997 | + // Exception: a THROWN backend is a hard failure we still | |
| 998 | + // report as cached for the same reason — the runtime cache | |
| 999 | + // holds it. | |
| 1000 | + if ( ! $stored ) { | |
| 1001 | + $this->degraded = true; | |
| 1002 | + } | |
| 1003 | + return true; | |
| 539 | 1004 | } |
| 540 | 1005 | return true; |
| 541 | 1006 | } |
| 542 | 1007 | |
| @@ -676,27 +1141,293 @@ | ||
| 676 | 1141 | |
| 677 | 1142 | private function backend_flush() { |
| 678 | 1143 | if ( 'redis' === $this->backend ) { |
| 679 | 1144 | // Scope the flush to THIS site's namespace (salt:*) instead of |
| 680 | - // FLUSHDB, which would wipe the entire Redis database — including | |
| 681 | - // other sites / apps sharing the same DB index. Falls back to | |
| 682 | - // FLUSHDB only when no salt is configured (single-tenant) so | |
| 683 | - // behavior is unchanged on a dedicated Redis. (FBS-83119) | |
| 1145 | + // FLUSHDB, which would wipe the entire Redis database — | |
| 1146 | + // including other sites / apps sharing the same DB index. | |
| 1147 | + // (FBS-83119) | |
| 1148 | + // | |
| 1149 | + // There is deliberately NO empty-salt fallback to FLUSHDB. | |
| 1150 | + // xspeed_oc_salt() always returns a non-empty value, but a | |
| 1151 | + // drop-in left over from an older version can still be the | |
| 1152 | + // object loaded for the request that runs the upgrade | |
| 1153 | + // migration — and that is exactly when a global flush would | |
| 1154 | + // destroy a neighbouring site's cache. An unsalted pattern is | |
| 1155 | + // scoped to nothing, so we bail rather than widen the blast | |
| 1156 | + // radius. | |
| 684 | 1157 | if ( '' === $this->salt ) { |
| 685 | - return $this->conn->flushDB(); | |
| 1158 | + return false; | |
| 686 | 1159 | } |
| 687 | - return $this->delete_redis_pattern( $this->salt . ':*' ) >= 0; | |
| 1160 | + return $this->delete_redis_pattern( $this->escape_glob( $this->salt ) . ':*' ) >= 0; | |
| 688 | 1161 | } |
| 689 | - return 'builtin-memcached' === $this->client | |
| 690 | - ? $this->conn->flush_all() | |
| 691 | - : $this->conn->flush(); | |
| 1162 | + | |
| 1163 | + // Memcached has no key enumeration, so flush_all() / flush() are | |
| 1164 | + // unavoidably SERVER-WIDE — they wipe every other site and app on | |
| 1165 | + // the same daemon. Bump this site's namespace generation instead: | |
| 1166 | + // every key is built through it (see full_key()), so incrementing | |
| 1167 | + // it orphans this site's entries and leaves everyone else's alone. | |
| 1168 | + // The orphans expire on their own under Memcached's LRU. | |
| 1169 | + return $this->bump_generation(); | |
| 692 | 1170 | } |
| 693 | 1171 | |
| 694 | 1172 | /** |
| 1173 | + * Advance this site's namespace generation, invalidating every key | |
| 1174 | + * built from it. Used as the Memcached flush primitive. | |
| 1175 | + * | |
| 1176 | + * @return bool | |
| 1177 | + */ | |
| 1178 | + private function bump_generation() { | |
| 1179 | + $key = $this->generation_key(); | |
| 1180 | + $new = null; | |
| 1181 | + | |
| 1182 | + try { | |
| 1183 | + $new = 'builtin-memcached' === $this->client | |
| 1184 | + ? $this->conn->incr( $key, 1 ) | |
| 1185 | + : $this->conn->increment( $key, 1 ); | |
| 1186 | + } catch ( \Throwable $e ) { | |
| 1187 | + $new = false; | |
| 1188 | + } | |
| 1189 | + | |
| 1190 | + // increment() fails when the counter does not exist — either it was | |
| 1191 | + // never seeded, or the daemon evicted it under LRU. Either way, | |
| 1192 | + // resuming from the CURRENT generation is what matters: seeding | |
| 1193 | + // back to a low number could land on a generation this site used | |
| 1194 | + // before and resurrect the keys this flush is meant to clear. | |
| 1195 | + // Jumping forward from the generation we resolved for this request | |
| 1196 | + // keeps the namespace monotonic across an eviction. | |
| 1197 | + if ( false === $new || null === $new ) { | |
| 1198 | + $next = $this->generation() + 1; | |
| 1199 | + try { | |
| 1200 | + // Store as a string: the bundled Memcached_Client types | |
| 1201 | + // this parameter `string`, and Memcached stores scalars as | |
| 1202 | + // strings regardless. (This file has no strict_types — it | |
| 1203 | + // must load standalone — so an int would be coerced rather | |
| 1204 | + // than rejected, but passing the right type keeps the two | |
| 1205 | + // clients behaving identically.) | |
| 1206 | + $this->conn->set( $key, (string) $next, 0 ); | |
| 1207 | + $new = $next; | |
| 1208 | + } catch ( \Throwable $e ) { | |
| 1209 | + return false; | |
| 1210 | + } | |
| 1211 | + } | |
| 1212 | + | |
| 1213 | + $this->generation = (int) $new; | |
| 1214 | + $this->persist_generation_floor( $this->generation ); | |
| 1215 | + return true; | |
| 1216 | + } | |
| 1217 | + | |
| 1218 | + /** | |
| 1219 | + * The counter key holding this site's namespace generation. Salted, so | |
| 1220 | + * each site owns its own counter on a shared daemon. | |
| 1221 | + * | |
| 1222 | + * @return string | |
| 1223 | + */ | |
| 1224 | + private function generation_key() { | |
| 1225 | + // Deliberately OUTSIDE the `{salt}:` namespace: Redis flushes by | |
| 1226 | + // deleting everything matching `{salt}:*`, which would otherwise | |
| 1227 | + // sweep away this invalidation marker if a site were reconfigured | |
| 1228 | + // from memcached to redis and back. | |
| 1229 | + return $this->salt . '.xspeed-oc-gen'; | |
| 1230 | + } | |
| 1231 | + | |
| 1232 | + /** | |
| 1233 | + * The lowest generation this site may use, read from the database. | |
| 1234 | + * | |
| 1235 | + * Memcached can evict the counter at any time; the database cannot, so | |
| 1236 | + * this is what stops an eviction from silently rewinding the namespace | |
| 1237 | + * and resurrecting keys a Purge already cleared. | |
| 1238 | + * | |
| 1239 | + * Read straight through $wpdb rather than get_option(), because the | |
| 1240 | + * options API routes through this very cache and would recurse. Returns | |
| 1241 | + * 1 whenever the database is not available yet — the drop-in loads | |
| 1242 | + * before $wpdb exists, and on those early requests nothing has been | |
| 1243 | + * flushed anyway. | |
| 1244 | + * | |
| 1245 | + * @return int | |
| 1246 | + */ | |
| 1247 | + /** | |
| 1248 | + * The options table holding the generation floor, or '' when the | |
| 1249 | + * database cannot be queried yet. | |
| 1250 | + * | |
| 1251 | + * Always the NETWORK-wide table. On multisite $wpdb->options points at | |
| 1252 | + * the current blog's table, but the salt and the generation have no | |
| 1253 | + * blog component — they namespace the whole install, global groups | |
| 1254 | + * included. Storing the floor per blog would let a purge on blog 2 go | |
| 1255 | + * unseen by blog 1, so an eviction there would rewind the namespace and | |
| 1256 | + * resurrect the shared blog-details / blog-lookup entries that are the | |
| 1257 | + * original bug. | |
| 1258 | + * | |
| 1259 | + * @param object $wpdb The database handle. | |
| 1260 | + * @return string Table name, or '' when unusable. | |
| 1261 | + */ | |
| 1262 | + private function generation_table( $wpdb ) { | |
| 1263 | + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'get_var' ) || ! method_exists( $wpdb, 'prepare' ) ) { | |
| 1264 | + return ''; | |
| 1265 | + } | |
| 1266 | + | |
| 1267 | + // During wp-admin/install.php and `wp core install` the drop-in is | |
| 1268 | + // already live while the options table does not exist yet. A query | |
| 1269 | + // then emits a database error that our try/catch cannot suppress, | |
| 1270 | + // because get_var() reports rather than throws. | |
| 1271 | + if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) { | |
| 1272 | + return ''; | |
| 1273 | + } | |
| 1274 | + | |
| 1275 | + $base = isset( $wpdb->base_prefix ) ? (string) $wpdb->base_prefix : ''; | |
| 1276 | + if ( '' !== $base ) { | |
| 1277 | + return $base . 'options'; | |
| 1278 | + } | |
| 1279 | + | |
| 1280 | + return isset( $wpdb->options ) ? (string) $wpdb->options : ''; | |
| 1281 | + } | |
| 1282 | + | |
| 1283 | + private function generation_floor() { | |
| 1284 | + if ( null !== $this->generation_floor ) { | |
| 1285 | + return $this->generation_floor; | |
| 1286 | + } | |
| 1287 | + | |
| 1288 | + $this->generation_floor = 1; | |
| 1289 | + | |
| 1290 | + if ( 'memcached' !== $this->backend || ! isset( $GLOBALS['wpdb'] ) ) { | |
| 1291 | + return $this->generation_floor; | |
| 1292 | + } | |
| 1293 | + | |
| 1294 | + $wpdb = $GLOBALS['wpdb']; | |
| 1295 | + $table = $this->generation_table( $wpdb ); | |
| 1296 | + if ( '' === $table ) { | |
| 1297 | + return $this->generation_floor; | |
| 1298 | + } | |
| 1299 | + | |
| 1300 | + try { | |
| 1301 | + $val = $wpdb->get_var( | |
| 1302 | + $wpdb->prepare( | |
| 1303 | + "SELECT option_value FROM {$table} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is derived from $wpdb, never user input. | |
| 1304 | + self::GENERATION_OPTION | |
| 1305 | + ) | |
| 1306 | + ); | |
| 1307 | + if ( is_numeric( $val ) && (int) $val > 1 ) { | |
| 1308 | + $this->generation_floor = (int) $val; | |
| 1309 | + } | |
| 1310 | + } catch ( \Throwable $e ) { | |
| 1311 | + $this->generation_floor = 1; | |
| 1312 | + } | |
| 1313 | + | |
| 1314 | + return $this->generation_floor; | |
| 1315 | + } | |
| 1316 | + | |
| 1317 | + /** | |
| 1318 | + * Persist the generation as the new floor, so an eviction of the cached | |
| 1319 | + * counter cannot rewind past it. | |
| 1320 | + * | |
| 1321 | + * @param int $generation The generation just written. | |
| 1322 | + * @return void | |
| 1323 | + */ | |
| 1324 | + private function persist_generation_floor( $generation ) { | |
| 1325 | + if ( 'memcached' !== $this->backend || ! isset( $GLOBALS['wpdb'] ) ) { | |
| 1326 | + return; | |
| 1327 | + } | |
| 1328 | + | |
| 1329 | + $wpdb = $GLOBALS['wpdb']; | |
| 1330 | + $table = $this->generation_table( $wpdb ); | |
| 1331 | + if ( '' === $table || ! method_exists( $wpdb, 'query' ) ) { | |
| 1332 | + return; | |
| 1333 | + } | |
| 1334 | + | |
| 1335 | + try { | |
| 1336 | + // Upsert without the options API, which would recurse through | |
| 1337 | + // this cache. autoload='no' keeps it out of alloptions. | |
| 1338 | + $wpdb->query( | |
| 1339 | + $wpdb->prepare( | |
| 1340 | + "INSERT INTO {$table} (option_name, option_value, autoload) | |
| 1341 | + VALUES (%s, %s, 'no') | |
| 1342 | + ON DUPLICATE KEY UPDATE option_value = VALUES(option_value)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is derived from $wpdb, never user input. | |
| 1343 | + self::GENERATION_OPTION, | |
| 1344 | + (string) (int) $generation | |
| 1345 | + ) | |
| 1346 | + ); | |
| 1347 | + $this->generation_floor = (int) $generation; | |
| 1348 | + } catch ( \Throwable $e ) { | |
| 1349 | + // Best effort: the cached counter still carries the flush for | |
| 1350 | + // as long as it survives. | |
| 1351 | + return; | |
| 1352 | + } | |
| 1353 | + } | |
| 1354 | + | |
| 1355 | + /** | |
| 1356 | + * Read this site's namespace generation, once per request. | |
| 1357 | + * | |
| 1358 | + * Only meaningful for Memcached, where flushing works by advancing the | |
| 1359 | + * generation rather than deleting keys. Redis deletes by pattern, so it | |
| 1360 | + * stays on generation 1 and its key shape is unchanged. | |
| 1361 | + * | |
| 1362 | + * @return int | |
| 1363 | + */ | |
| 1364 | + private function generation() { | |
| 1365 | + if ( null !== $this->generation ) { | |
| 1366 | + return $this->generation; | |
| 1367 | + } | |
| 1368 | + | |
| 1369 | + // The floor comes from the database, because the counter lives in | |
| 1370 | + // the cache it namespaces and Memcached can evict it under LRU. | |
| 1371 | + // Falling back to generation 1 after an eviction would re-expose | |
| 1372 | + // the keys an earlier Purge cleared, so the DB copy — which cannot | |
| 1373 | + // be evicted — is what the generation may never drop below. | |
| 1374 | + $this->generation = $this->generation_floor(); | |
| 1375 | + | |
| 1376 | + if ( 'memcached' === $this->backend && $this->persistent ) { | |
| 1377 | + try { | |
| 1378 | + $val = $this->conn->get( $this->generation_key() ); | |
| 1379 | + if ( is_numeric( $val ) && (int) $val > $this->generation ) { | |
| 1380 | + $this->generation = (int) $val; | |
| 1381 | + } | |
| 1382 | + } catch ( \Throwable $e ) { | |
| 1383 | + // Unreadable counter — the floor still applies, so a | |
| 1384 | + // previous flush is never undone. | |
| 1385 | + $this->generation = max( 1, $this->generation ); | |
| 1386 | + } | |
| 1387 | + } | |
| 1388 | + | |
| 1389 | + return $this->generation; | |
| 1390 | + } | |
| 1391 | + | |
| 1392 | + /** | |
| 1393 | + * Escape Redis glob metacharacters so a literal string matches only | |
| 1394 | + * itself inside a SCAN MATCH pattern. | |
| 1395 | + * | |
| 1396 | + * The salt and group name are interpolated into the flush patterns | |
| 1397 | + * below, and neither is guaranteed to be glob-safe: an explicit Cache | |
| 1398 | + * Key Prefix is whatever the user typed, and a host-pinned | |
| 1399 | + * WP_CACHE_KEY_SALT is whatever the host wrote (WordPress builds these | |
| 1400 | + * from the site URL, so punctuation is normal). Left unescaped, `*`, | |
| 1401 | + * `?` and `[...]` are wildcards — `wp_[dev]site_:*` matches a | |
| 1402 | + * NEIGHBOURING site's `wp_dsite_:*` keys, so purging one site deletes | |
| 1403 | + * another site's cache. A bare `*` prefix matches everything and | |
| 1404 | + * empties the whole Redis DB, which is the exact damage the scoped | |
| 1405 | + * flush exists to prevent. | |
| 1406 | + * | |
| 1407 | + * Redis's stringmatchlen() treats `\` as the escape character, so | |
| 1408 | + * backslash-prefixing each metacharacter makes it literal. `\` itself | |
| 1409 | + * is escaped first, or escaping the others would be undone. | |
| 1410 | + * | |
| 1411 | + * @param string $literal Text to match literally. | |
| 1412 | + * @return string Glob-safe form of $literal. | |
| 1413 | + */ | |
| 1414 | + private function escape_glob( $literal ) { | |
| 1415 | + return str_replace( | |
| 1416 | + array( '\\', '*', '?', '[', ']' ), | |
| 1417 | + array( '\\\\', '\\*', '\\?', '\\[', '\\]' ), | |
| 1418 | + (string) $literal | |
| 1419 | + ); | |
| 1420 | + } | |
| 1421 | + | |
| 1422 | + /** | |
| 695 | 1423 | * Delete every Redis key matching $pattern across both client kinds |
| 696 | 1424 | * (phpredis native scan + our pure-PHP Redis_Client). Returns the |
| 697 | 1425 | * count deleted, or -1 if the backend isn't redis. SCAN-based so it |
| 698 | 1426 | * never blocks the server the way KEYS would. (FBS-83119) |
| 1427 | + * | |
| 1428 | + * Callers MUST pass any literal segment through escape_glob() — this | |
| 1429 | + * receives a finished pattern and cannot tell wildcard from data. | |
| 699 | 1430 | */ |
| 700 | 1431 | private function delete_redis_pattern( $pattern ) { |
| 701 | 1432 | if ( 'redis' !== $this->backend || ! $this->conn ) { |
| 702 | 1433 | return -1; |
| @@ -756,9 +1487,11 @@ | ||
| 756 | 1487 | // both blog-prefixed and global groups for this site's namespace. |
| 757 | 1488 | // (FBS-83119) |
| 758 | 1489 | if ( $this->is_persistent_group( $group ) && 'redis' === $this->backend && '' !== $this->salt ) { |
| 759 | 1490 | try { |
| 760 | - $this->delete_redis_pattern( $this->salt . ':*:' . $group . ':*' ); | |
| 1491 | + $this->delete_redis_pattern( | |
| 1492 | + $this->escape_glob( $this->salt ) . ':*:' . $this->escape_glob( $group ) . ':*' | |
| 1493 | + ); | |
| 761 | 1494 | } catch ( \Throwable $e ) { |
| 762 | 1495 | return false; |
| 763 | 1496 | } |
| 764 | 1497 | } |