| @@ -35,30 +35,458 @@ | ||
| 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_PATH', '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_USERNAME', '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 | + // WP_REDIS_PATH names a socket only when WP_REDIS_SCHEME is | |
| 187 | + // `unix` in any case, the same gate the schema declares in | |
| 188 | + // `constants_when`. Redis Object Cache uses strcasecmp(). (#566) | |
| 189 | + if ( 'WP_REDIS_PATH' === $const && ! ( defined( 'WP_REDIS_SCHEME' ) && is_string( constant( 'WP_REDIS_SCHEME' ) ) && 0 === strcasecmp( constant( 'WP_REDIS_SCHEME' ), 'unix' ) ) ) { | |
| 190 | + continue; | |
| 191 | + } | |
| 192 | + $value = constant( $const ); | |
| 193 | + // WP_REDIS_PASSWORD only answers for `user` in its array form, | |
| 194 | + // which carries the ACL username. As a plain string it is just | |
| 195 | + // a password: skip it, or we would authenticate with the | |
| 196 | + // password as the username. | |
| 197 | + if ( 'user' === $key && 'WP_REDIS_PASSWORD' === $const && ! is_array( $value ) ) { | |
| 198 | + continue; | |
| 199 | + } | |
| 200 | + return xspeed_oc_credential_part( $key, $value ); | |
| 201 | + } | |
| 56 | 202 | } |
| 203 | + | |
| 204 | + /* | |
| 205 | + * No constant answered. On a host where wp-config.php is not writable | |
| 206 | + * the panel stores the configuration in a sidecar beside this drop-in | |
| 207 | + * instead, so consult it before falling back to the built-in default -- | |
| 208 | + * otherwise every setting the user saved there would be ignored at | |
| 209 | + * runtime while the panel showed it as active. (#398) | |
| 210 | + */ | |
| 211 | + $sidecar = xspeed_oc_sidecar(); | |
| 212 | + $field = isset( $sidecar_map[ $key ] ) ? $sidecar_map[ $key ] : null; | |
| 213 | + if ( null !== $field && array_key_exists( $field, $sidecar ) ) { | |
| 214 | + return xspeed_oc_credential_part( $key, $sidecar[ $field ] ); | |
| 215 | + } | |
| 216 | + | |
| 57 | 217 | return $default; |
| 58 | 218 | } |
| 59 | 219 | } |
| 60 | 220 | |
| 221 | +if ( ! function_exists( 'xspeed_oc_first_memcached_server' ) ) { | |
| 222 | + /** | |
| 223 | + * Host and port of the first server in a `$memcached_servers` global. | |
| 224 | + * | |
| 225 | + * Two shapes are in circulation and hosts write both: | |
| 226 | + * | |
| 227 | + * array( array( 'host', 11211 ) ) // W3TC pair form | |
| 228 | + * array( 'default' => array( 'host:11211' ) ) // Memcached Object Cache | |
| 229 | + * | |
| 230 | + * Supporting only the first left the second reading the whole "host:port" | |
| 231 | + * string as the hostname -- or missing entirely, since its bucket is keyed | |
| 232 | + * `default` rather than 0. Kept in one function because Settings_Manager | |
| 233 | + * has to answer identically or the panel and the runtime disagree, which is | |
| 234 | + * the bug this whole change closes. (#398) | |
| 235 | + * | |
| 236 | + * @param mixed $servers The global's value, unvalidated. | |
| 237 | + * @return array{0:?string,1:?int}|null Host and port, either possibly null. | |
| 238 | + */ | |
| 239 | + function xspeed_oc_first_memcached_server( $servers ) { | |
| 240 | + if ( ! is_array( $servers ) || array() === $servers ) { | |
| 241 | + return null; | |
| 242 | + } | |
| 243 | + | |
| 244 | + // Either the 0th bucket or, for the keyed form, whichever comes first. | |
| 245 | + $bucket = array_key_exists( 0, $servers ) ? $servers[0] : reset( $servers ); | |
| 246 | + | |
| 247 | + /* | |
| 248 | + * A bucket is EITHER a [host, port] pair or a list of server entries. | |
| 249 | + * Telling them apart by shape, not by nesting depth: descending into | |
| 250 | + * array( 'mc.example', 11211 ) yields the host string and drops the | |
| 251 | + * port on the floor, which is the commonest form there is. | |
| 252 | + */ | |
| 253 | + $entry = $bucket; | |
| 254 | + if ( is_array( $bucket ) && isset( $bucket[0] ) && is_array( $bucket[0] ) ) { | |
| 255 | + $entry = $bucket[0]; | |
| 256 | + } | |
| 257 | + | |
| 258 | + // Pair form: [ host, port ]. | |
| 259 | + if ( is_array( $entry ) ) { | |
| 260 | + $host = isset( $entry[0] ) && ! is_array( $entry[0] ) ? (string) $entry[0] : null; | |
| 261 | + $port = isset( $entry[1] ) && ! is_array( $entry[1] ) ? (int) $entry[1] : null; | |
| 262 | + // A single-element list, array( 'host:port' ), is the keyed form's | |
| 263 | + // bucket rather than a pair -- fall through to the string parser. | |
| 264 | + if ( null !== $host && null === $port && is_string( $entry[0] ) && false !== strpos( $entry[0], ':' ) ) { | |
| 265 | + $entry = $entry[0]; | |
| 266 | + } else { | |
| 267 | + return ( null === $host && null === $port ) ? null : array( $host, $port ); | |
| 268 | + } | |
| 269 | + } | |
| 270 | + | |
| 271 | + if ( ! is_string( $entry ) || '' === $entry ) { | |
| 272 | + return null; | |
| 273 | + } | |
| 274 | + | |
| 275 | + // "host:port", or a bare host. A unix socket path has no port and can | |
| 276 | + // contain no colon we should split on, so only split the LAST one and | |
| 277 | + // only when what follows is numeric. | |
| 278 | + $at = strrpos( $entry, ':' ); | |
| 279 | + if ( false !== $at && ctype_digit( substr( $entry, $at + 1 ) ) ) { | |
| 280 | + return array( substr( $entry, 0, $at ), (int) substr( $entry, $at + 1 ) ); | |
| 281 | + } | |
| 282 | + return array( $entry, null ); | |
| 283 | + } | |
| 284 | +} | |
| 285 | + | |
| 286 | +if ( ! function_exists( 'xspeed_oc_redis_socket' ) ) { | |
| 287 | + /** | |
| 288 | + * The unix socket path a Redis host names, or '' for a network host. | |
| 289 | + * | |
| 290 | + * A copy of \XSpeed\Redis_Client::socket_path(), which this drop-in cannot | |
| 291 | + * call when phpredis is the client: the class is only loaded for the | |
| 292 | + * built-in one. ObjectCacheSocketTest runs both over the same inputs, so | |
| 293 | + * the panel's Test connection and the runtime agree on what a socket is. | |
| 294 | + * (#566) | |
| 295 | + * | |
| 296 | + * @param string $host Configured Redis host. | |
| 297 | + * @return string Socket path, or ''. | |
| 298 | + */ | |
| 299 | + function xspeed_oc_redis_socket( $host ) { | |
| 300 | + $host = trim( (string) $host ); | |
| 301 | + if ( 0 === stripos( $host, 'unix:' ) ) { | |
| 302 | + // unix:///path and unix:/path both name /path. | |
| 303 | + $host = '/' . ltrim( substr( $host, 5 ), '/' ); | |
| 304 | + } | |
| 305 | + return ( '' !== $host && '/' === $host[0] && '/' !== $host ) ? $host : ''; | |
| 306 | + } | |
| 307 | +} | |
| 308 | + | |
| 309 | +if ( ! function_exists( 'xspeed_oc_our_constants' ) ) { | |
| 310 | + /** | |
| 311 | + * Constant names defined inside OUR fenced block in wp-config.php. | |
| 312 | + * | |
| 313 | + * Ownership is decided by WHERE a define sits, exactly as | |
| 314 | + * Object_Cache::our_constants() decides it for the admin half. The drop-in | |
| 315 | + * needs the same answer for the same reason the panel does: a define we | |
| 316 | + * wrote is only a mirror of the option row, so a HOST define has to outrank | |
| 317 | + * it. Without this the drop-in kept connecting to our stale snapshot after | |
| 318 | + * a host rotated its credentials, while the panel -- which does apply the | |
| 319 | + * rule -- showed the new one. Panel and runtime disagreeing is the whole | |
| 320 | + * bug this change exists to remove. (#398) | |
| 321 | + * | |
| 322 | + * @return string[] | |
| 323 | + */ | |
| 324 | + function xspeed_oc_our_constants() { | |
| 325 | + static $names = null; | |
| 326 | + if ( null !== $names ) { | |
| 327 | + return $names; | |
| 328 | + } | |
| 329 | + $names = array(); | |
| 330 | + | |
| 331 | + // ABSPATH is defined by wp-load.php before the drop-in is included. | |
| 332 | + $path = defined( 'ABSPATH' ) ? ABSPATH . 'wp-config.php' : ''; | |
| 333 | + if ( '' === $path || ! is_readable( $path ) ) { | |
| 334 | + // One level up is the standard "wp-config outside the root" layout. | |
| 335 | + $alt = defined( 'ABSPATH' ) ? dirname( ABSPATH ) . '/wp-config.php' : ''; | |
| 336 | + $path = ( '' !== $alt && is_readable( $alt ) ) ? $alt : ''; | |
| 337 | + } | |
| 338 | + if ( '' === $path ) { | |
| 339 | + return $names; | |
| 340 | + } | |
| 341 | + | |
| 342 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- runs before WordPress; WP_Filesystem does not exist yet. | |
| 343 | + $config = (string) @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable wp-config just means "we own nothing". | |
| 344 | + if ( '' === $config ) { | |
| 345 | + return $names; | |
| 346 | + } | |
| 347 | + if ( ! preg_match( '/\/\* BEGIN xSpeed Object Cache \*\/(.*?)\/\* END xSpeed Object Cache \*\//s', $config, $m ) ) { | |
| 348 | + return $names; | |
| 349 | + } | |
| 350 | + if ( preg_match_all( "/define\(\s*'([A-Z0-9_]+)'/", $m[1], $found ) ) { | |
| 351 | + $names = $found[1]; | |
| 352 | + } | |
| 353 | + return $names; | |
| 354 | + } | |
| 355 | +} | |
| 356 | + | |
| 357 | +if ( ! function_exists( 'xspeed_oc_sidecar' ) ) { | |
| 358 | + /** | |
| 359 | + * Configuration written beside this drop-in when wp-config.php is | |
| 360 | + * read-only. Returns an empty array when there is none. | |
| 361 | + * | |
| 362 | + * This file IS wp-content/object-cache.php, so the sidecar sits in the | |
| 363 | + * same directory -- no constant needed to locate it, which matters because | |
| 364 | + * WP_CONTENT_DIR is not guaranteed to be defined this early. | |
| 365 | + * | |
| 366 | + * @return array<string,mixed> | |
| 367 | + */ | |
| 368 | + function xspeed_oc_sidecar() { | |
| 369 | + static $data = null; | |
| 370 | + if ( null !== $data ) { | |
| 371 | + return $data; | |
| 372 | + } | |
| 373 | + $data = array(); | |
| 374 | + $path = __DIR__ . '/xspeed-object-cache.php'; | |
| 375 | + if ( ! is_readable( $path ) ) { | |
| 376 | + return $data; | |
| 377 | + } | |
| 378 | + | |
| 379 | + /* | |
| 380 | + * This runs before WordPress, so a parse error here is a white screen | |
| 381 | + * on every request rather than a degraded cache. The writer renames | |
| 382 | + * into place atomically, but a file truncated by something else -- a | |
| 383 | + * failed deploy, a partial restore -- must not take the site down, so | |
| 384 | + * the include is guarded and any failure degrades to "no sidecar". | |
| 385 | + */ | |
| 386 | + try { | |
| 387 | + $loaded = @include $path; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a broken sidecar must not white-screen the site. | |
| 388 | + if ( is_array( $loaded ) ) { | |
| 389 | + $data = $loaded; | |
| 390 | + } | |
| 391 | + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- degrade to no sidecar. | |
| 392 | + $data = array(); | |
| 393 | + } | |
| 394 | + return $data; | |
| 395 | + } | |
| 396 | +} | |
| 397 | + | |
| 398 | +if ( ! function_exists( 'xspeed_oc_salt' ) ) { | |
| 399 | + /** | |
| 400 | + * Resolve the salt that namespaces this site's keys. | |
| 401 | + * | |
| 402 | + * Normally the salt constant is written into wp-config.php on enable. When | |
| 403 | + * wp-config is NOT writable (some managed hosts) the drop-in still gets | |
| 404 | + * installed, so without a fallback every key would come out as | |
| 405 | + * `:{blog}:{group}:{key}` — identical on every install. Two sites sharing a | |
| 406 | + * Redis/Memcached server would then read each other's blog-details / | |
| 407 | + * blog-lookup entries and the second site would redirect to the first. | |
| 408 | + * | |
| 409 | + * Derived the same way Object_Cache::derive_salt() does, so a site keeps | |
| 410 | + * the same namespace whether the constant is present or not. | |
| 411 | + * | |
| 412 | + * @return string Non-empty salt. | |
| 413 | + */ | |
| 414 | + function xspeed_oc_salt() { | |
| 415 | + $salt = (string) xspeed_oc_config( 'salt', '' ); | |
| 416 | + if ( '' !== $salt ) { | |
| 417 | + return $salt; | |
| 418 | + } | |
| 419 | + | |
| 420 | + global $table_prefix; | |
| 421 | + | |
| 422 | + $url = ''; | |
| 423 | + if ( defined( 'WP_HOME' ) ) { | |
| 424 | + $url = (string) WP_HOME; | |
| 425 | + } elseif ( defined( 'WP_SITEURL' ) ) { | |
| 426 | + $url = (string) WP_SITEURL; | |
| 427 | + } | |
| 428 | + | |
| 429 | + // WP_HOME / WP_SITEURL are OPTIONAL and absent from a stock | |
| 430 | + // wp-config.php, so the URL is usually empty here — DB_NAME alone | |
| 431 | + // would then be identical for two sites sharing one database and the | |
| 432 | + // collision this salt exists to prevent would come straight back. | |
| 433 | + // $table_prefix separates them: it is assigned in wp-config.php itself, | |
| 434 | + // so it is already a global by the time the drop-in loads (well before | |
| 435 | + // $wpdb exists). ABSPATH is added whenever no URL was available, since | |
| 436 | + // two installs on one database necessarily live in different | |
| 437 | + // directories. | |
| 438 | + $parts = array( | |
| 439 | + $url, | |
| 440 | + defined( 'DB_NAME' ) ? (string) DB_NAME : '', | |
| 441 | + isset( $table_prefix ) ? (string) $table_prefix : '', | |
| 442 | + ); | |
| 443 | + if ( '' === $url ) { | |
| 444 | + $parts[] = defined( 'ABSPATH' ) ? (string) ABSPATH : ''; | |
| 445 | + } | |
| 446 | + | |
| 447 | + $seed = implode( '|', $parts ); | |
| 448 | + if ( '' === trim( $seed, '|' ) ) { | |
| 449 | + $seed = 'xspeed'; | |
| 450 | + } | |
| 451 | + | |
| 452 | + return 'xs' . substr( md5( $seed ), 0, 12 ); | |
| 453 | + } | |
| 454 | +} | |
| 455 | + | |
| 456 | +if ( ! function_exists( 'xspeed_oc_credential_part' ) ) { | |
| 457 | + /** | |
| 458 | + * Unpack the array form of a Redis credential constant. | |
| 459 | + * | |
| 460 | + * Managed hosts that provision Redis ACL users (xCloud, Cloudways) ship | |
| 461 | + * the pair in one define: | |
| 462 | + * | |
| 463 | + * define( 'WP_REDIS_PASSWORD', array( 'acl_user', 's3cret' ) ); | |
| 464 | + * | |
| 465 | + * Casting that to string yields "Array" and a notice, so the site would | |
| 466 | + * authenticate with garbage. Split it here, at the single point both | |
| 467 | + * `user` and `password` resolve through, rather than in each caller. | |
| 468 | + * | |
| 469 | + * @param string $key Config key being resolved. | |
| 470 | + * @param mixed $value Raw constant value. | |
| 471 | + * @return mixed | |
| 472 | + */ | |
| 473 | + function xspeed_oc_credential_part( $key, $value ) { | |
| 474 | + if ( ! is_array( $value ) || ( 'user' !== $key && 'password' !== $key ) ) { | |
| 475 | + return $value; | |
| 476 | + } | |
| 477 | + // Scalars only. A nested value would stringify to "Array" and emit a | |
| 478 | + // warning on EVERY request from the drop-in -- before headers are sent, | |
| 479 | + // on the code path whose whole design rule is never to break the site. | |
| 480 | + $parts = array_values( array_filter( $value, 'is_scalar' ) ); | |
| 481 | + if ( 'user' === $key ) { | |
| 482 | + // A one-element array is a password with no ACL user. | |
| 483 | + return count( $parts ) > 1 ? (string) $parts[0] : ''; | |
| 484 | + } | |
| 485 | + return (string) ( count( $parts ) > 1 ? $parts[1] : ( $parts[0] ?? '' ) ); | |
| 486 | + } | |
| 487 | +} | |
| 488 | + | |
| 61 | 489 | // ----------------------------------------------------------------------------- |
| 62 | 490 | // WordPress object-cache API surface. Thin wrappers over the global instance. |
| 63 | 491 | // ----------------------------------------------------------------------------- |
| 64 | 492 | if ( ! function_exists( 'wp_cache_init' ) ) { |
| @@ -193,8 +621,26 @@ | ||
| 193 | 621 | |
| 194 | 622 | /** @var string Key salt / prefix. */ |
| 195 | 623 | private $salt = ''; |
| 196 | 624 | |
| 625 | + /** Option holding the Memcached generation floor (see generation_floor()). */ | |
| 626 | + const GENERATION_OPTION = 'xspeed_oc_generation'; | |
| 627 | + | |
| 628 | + /** | |
| 629 | + * @var int|null This site's namespace generation, resolved lazily once | |
| 630 | + * per request. Advancing it is how Memcached flushes only this site's | |
| 631 | + * keys — the daemon offers no way to enumerate or scope a real flush. | |
| 632 | + * Null until first read; 1 means the original, unsuffixed key shape. | |
| 633 | + */ | |
| 634 | + private $generation = null; | |
| 635 | + | |
| 636 | + /** | |
| 637 | + * @var int|null Lowest generation this site may use, mirrored in the | |
| 638 | + * database so an LRU eviction of the cached counter cannot rewind the | |
| 639 | + * namespace. Null until first read. | |
| 640 | + */ | |
| 641 | + private $generation_floor = null; | |
| 642 | + | |
| 197 | 643 | /** @var int Current blog id (multisite prefixing). */ |
| 198 | 644 | private $blog_prefix = 0; |
| 199 | 645 | |
| 200 | 646 | /** @var bool */ |
| @@ -214,9 +660,9 @@ | ||
| 214 | 660 | |
| 215 | 661 | public function __construct() { |
| 216 | 662 | $this->multisite = function_exists( 'is_multisite' ) && is_multisite(); |
| 217 | 663 | $this->blog_prefix = $this->multisite ? (int) get_current_blog_id() : 0; |
| 218 | - $this->salt = (string) xspeed_oc_config( 'salt', '' ); | |
| 664 | + $this->salt = xspeed_oc_salt(); | |
| 219 | 665 | $this->backend = strtolower( (string) xspeed_oc_config( 'backend', 'redis' ) ); |
| 220 | 666 | |
| 221 | 667 | // Non-persistent groups. We deliberately DO persist `options` |
| 222 | 668 | // (incl. the autoloaded `alloptions` blob), `comment`, and |
| @@ -283,12 +729,19 @@ | ||
| 283 | 729 | $db = (int) xspeed_oc_config( 'database', 0 ); |
| 284 | 730 | $persist = (bool) xspeed_oc_config( 'persist', false ); |
| 285 | 731 | |
| 286 | 732 | if ( class_exists( 'Redis' ) ) { |
| 287 | - // phpredis extension (preferred). | |
| 733 | + // phpredis extension (preferred). It opens a unix | |
| 734 | + // socket only for a bare path with a port below 1; | |
| 735 | + // with any real port it tries TCP to the path. (#566) | |
| 288 | 736 | $this->client = 'phpredis'; |
| 289 | 737 | $redis = new Redis(); |
| 290 | - $ok = $persist | |
| 738 | + $socket = xspeed_oc_redis_socket( $host ); | |
| 739 | + if ( '' !== $socket ) { | |
| 740 | + $host = $socket; | |
| 741 | + $port = 0; | |
| 742 | + } | |
| 743 | + $ok = $persist | |
| 291 | 744 | ? @$redis->pconnect( $host, $port, $timeout ) |
| 292 | 745 | : @$redis->connect( $host, $port, $timeout ); |
| 293 | 746 | if ( $ok ) { |
| 294 | 747 | // Redis 6+ ACL: ['user'=>..,'pass'=>..] when a username |
| @@ -471,9 +924,14 @@ | ||
| 471 | 924 | |
| 472 | 925 | private function full_key( $key, $group ) { |
| 473 | 926 | $group = $this->group( $group ); |
| 474 | 927 | $prefix = isset( $this->global_groups[ $group ] ) ? 0 : $this->blog_prefix; |
| 475 | - return $this->salt . ':' . $prefix . ':' . $group . ':' . $key; | |
| 928 | + $gen = $this->generation(); | |
| 929 | + // Generation 1 keeps the historical key shape, so Redis (which | |
| 930 | + // flushes by pattern and never advances the generation) is | |
| 931 | + // byte-identical to before and existing entries stay readable. | |
| 932 | + $ns = 1 === $gen ? $this->salt : $this->salt . '.g' . $gen; | |
| 933 | + return $ns . ':' . $prefix . ':' . $group . ':' . $key; | |
| 476 | 934 | } |
| 477 | 935 | |
| 478 | 936 | private function is_persistent_group( $group ) { |
| 479 | 937 | return $this->persistent && ! isset( $this->non_persistent_groups[ $this->group( $group ) ] ); |
| @@ -719,27 +1177,293 @@ | ||
| 719 | 1177 | |
| 720 | 1178 | private function backend_flush() { |
| 721 | 1179 | if ( 'redis' === $this->backend ) { |
| 722 | 1180 | // Scope the flush to THIS site's namespace (salt:*) instead of |
| 723 | - // FLUSHDB, which would wipe the entire Redis database — including | |
| 724 | - // other sites / apps sharing the same DB index. Falls back to | |
| 725 | - // FLUSHDB only when no salt is configured (single-tenant) so | |
| 726 | - // behavior is unchanged on a dedicated Redis. (FBS-83119) | |
| 1181 | + // FLUSHDB, which would wipe the entire Redis database — | |
| 1182 | + // including other sites / apps sharing the same DB index. | |
| 1183 | + // (FBS-83119) | |
| 1184 | + // | |
| 1185 | + // There is deliberately NO empty-salt fallback to FLUSHDB. | |
| 1186 | + // xspeed_oc_salt() always returns a non-empty value, but a | |
| 1187 | + // drop-in left over from an older version can still be the | |
| 1188 | + // object loaded for the request that runs the upgrade | |
| 1189 | + // migration — and that is exactly when a global flush would | |
| 1190 | + // destroy a neighbouring site's cache. An unsalted pattern is | |
| 1191 | + // scoped to nothing, so we bail rather than widen the blast | |
| 1192 | + // radius. | |
| 727 | 1193 | if ( '' === $this->salt ) { |
| 728 | - return $this->conn->flushDB(); | |
| 1194 | + return false; | |
| 729 | 1195 | } |
| 730 | - return $this->delete_redis_pattern( $this->salt . ':*' ) >= 0; | |
| 1196 | + return $this->delete_redis_pattern( $this->escape_glob( $this->salt ) . ':*' ) >= 0; | |
| 731 | 1197 | } |
| 732 | - return 'builtin-memcached' === $this->client | |
| 733 | - ? $this->conn->flush_all() | |
| 734 | - : $this->conn->flush(); | |
| 1198 | + | |
| 1199 | + // Memcached has no key enumeration, so flush_all() / flush() are | |
| 1200 | + // unavoidably SERVER-WIDE — they wipe every other site and app on | |
| 1201 | + // the same daemon. Bump this site's namespace generation instead: | |
| 1202 | + // every key is built through it (see full_key()), so incrementing | |
| 1203 | + // it orphans this site's entries and leaves everyone else's alone. | |
| 1204 | + // The orphans expire on their own under Memcached's LRU. | |
| 1205 | + return $this->bump_generation(); | |
| 735 | 1206 | } |
| 736 | 1207 | |
| 737 | 1208 | /** |
| 1209 | + * Advance this site's namespace generation, invalidating every key | |
| 1210 | + * built from it. Used as the Memcached flush primitive. | |
| 1211 | + * | |
| 1212 | + * @return bool | |
| 1213 | + */ | |
| 1214 | + private function bump_generation() { | |
| 1215 | + $key = $this->generation_key(); | |
| 1216 | + $new = null; | |
| 1217 | + | |
| 1218 | + try { | |
| 1219 | + $new = 'builtin-memcached' === $this->client | |
| 1220 | + ? $this->conn->incr( $key, 1 ) | |
| 1221 | + : $this->conn->increment( $key, 1 ); | |
| 1222 | + } catch ( \Throwable $e ) { | |
| 1223 | + $new = false; | |
| 1224 | + } | |
| 1225 | + | |
| 1226 | + // increment() fails when the counter does not exist — either it was | |
| 1227 | + // never seeded, or the daemon evicted it under LRU. Either way, | |
| 1228 | + // resuming from the CURRENT generation is what matters: seeding | |
| 1229 | + // back to a low number could land on a generation this site used | |
| 1230 | + // before and resurrect the keys this flush is meant to clear. | |
| 1231 | + // Jumping forward from the generation we resolved for this request | |
| 1232 | + // keeps the namespace monotonic across an eviction. | |
| 1233 | + if ( false === $new || null === $new ) { | |
| 1234 | + $next = $this->generation() + 1; | |
| 1235 | + try { | |
| 1236 | + // Store as a string: the bundled Memcached_Client types | |
| 1237 | + // this parameter `string`, and Memcached stores scalars as | |
| 1238 | + // strings regardless. (This file has no strict_types — it | |
| 1239 | + // must load standalone — so an int would be coerced rather | |
| 1240 | + // than rejected, but passing the right type keeps the two | |
| 1241 | + // clients behaving identically.) | |
| 1242 | + $this->conn->set( $key, (string) $next, 0 ); | |
| 1243 | + $new = $next; | |
| 1244 | + } catch ( \Throwable $e ) { | |
| 1245 | + return false; | |
| 1246 | + } | |
| 1247 | + } | |
| 1248 | + | |
| 1249 | + $this->generation = (int) $new; | |
| 1250 | + $this->persist_generation_floor( $this->generation ); | |
| 1251 | + return true; | |
| 1252 | + } | |
| 1253 | + | |
| 1254 | + /** | |
| 1255 | + * The counter key holding this site's namespace generation. Salted, so | |
| 1256 | + * each site owns its own counter on a shared daemon. | |
| 1257 | + * | |
| 1258 | + * @return string | |
| 1259 | + */ | |
| 1260 | + private function generation_key() { | |
| 1261 | + // Deliberately OUTSIDE the `{salt}:` namespace: Redis flushes by | |
| 1262 | + // deleting everything matching `{salt}:*`, which would otherwise | |
| 1263 | + // sweep away this invalidation marker if a site were reconfigured | |
| 1264 | + // from memcached to redis and back. | |
| 1265 | + return $this->salt . '.xspeed-oc-gen'; | |
| 1266 | + } | |
| 1267 | + | |
| 1268 | + /** | |
| 1269 | + * The lowest generation this site may use, read from the database. | |
| 1270 | + * | |
| 1271 | + * Memcached can evict the counter at any time; the database cannot, so | |
| 1272 | + * this is what stops an eviction from silently rewinding the namespace | |
| 1273 | + * and resurrecting keys a Purge already cleared. | |
| 1274 | + * | |
| 1275 | + * Read straight through $wpdb rather than get_option(), because the | |
| 1276 | + * options API routes through this very cache and would recurse. Returns | |
| 1277 | + * 1 whenever the database is not available yet — the drop-in loads | |
| 1278 | + * before $wpdb exists, and on those early requests nothing has been | |
| 1279 | + * flushed anyway. | |
| 1280 | + * | |
| 1281 | + * @return int | |
| 1282 | + */ | |
| 1283 | + /** | |
| 1284 | + * The options table holding the generation floor, or '' when the | |
| 1285 | + * database cannot be queried yet. | |
| 1286 | + * | |
| 1287 | + * Always the NETWORK-wide table. On multisite $wpdb->options points at | |
| 1288 | + * the current blog's table, but the salt and the generation have no | |
| 1289 | + * blog component — they namespace the whole install, global groups | |
| 1290 | + * included. Storing the floor per blog would let a purge on blog 2 go | |
| 1291 | + * unseen by blog 1, so an eviction there would rewind the namespace and | |
| 1292 | + * resurrect the shared blog-details / blog-lookup entries that are the | |
| 1293 | + * original bug. | |
| 1294 | + * | |
| 1295 | + * @param object $wpdb The database handle. | |
| 1296 | + * @return string Table name, or '' when unusable. | |
| 1297 | + */ | |
| 1298 | + private function generation_table( $wpdb ) { | |
| 1299 | + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'get_var' ) || ! method_exists( $wpdb, 'prepare' ) ) { | |
| 1300 | + return ''; | |
| 1301 | + } | |
| 1302 | + | |
| 1303 | + // During wp-admin/install.php and `wp core install` the drop-in is | |
| 1304 | + // already live while the options table does not exist yet. A query | |
| 1305 | + // then emits a database error that our try/catch cannot suppress, | |
| 1306 | + // because get_var() reports rather than throws. | |
| 1307 | + if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) { | |
| 1308 | + return ''; | |
| 1309 | + } | |
| 1310 | + | |
| 1311 | + $base = isset( $wpdb->base_prefix ) ? (string) $wpdb->base_prefix : ''; | |
| 1312 | + if ( '' !== $base ) { | |
| 1313 | + return $base . 'options'; | |
| 1314 | + } | |
| 1315 | + | |
| 1316 | + return isset( $wpdb->options ) ? (string) $wpdb->options : ''; | |
| 1317 | + } | |
| 1318 | + | |
| 1319 | + private function generation_floor() { | |
| 1320 | + if ( null !== $this->generation_floor ) { | |
| 1321 | + return $this->generation_floor; | |
| 1322 | + } | |
| 1323 | + | |
| 1324 | + $this->generation_floor = 1; | |
| 1325 | + | |
| 1326 | + if ( 'memcached' !== $this->backend || ! isset( $GLOBALS['wpdb'] ) ) { | |
| 1327 | + return $this->generation_floor; | |
| 1328 | + } | |
| 1329 | + | |
| 1330 | + $wpdb = $GLOBALS['wpdb']; | |
| 1331 | + $table = $this->generation_table( $wpdb ); | |
| 1332 | + if ( '' === $table ) { | |
| 1333 | + return $this->generation_floor; | |
| 1334 | + } | |
| 1335 | + | |
| 1336 | + try { | |
| 1337 | + $val = $wpdb->get_var( | |
| 1338 | + $wpdb->prepare( | |
| 1339 | + "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. | |
| 1340 | + self::GENERATION_OPTION | |
| 1341 | + ) | |
| 1342 | + ); | |
| 1343 | + if ( is_numeric( $val ) && (int) $val > 1 ) { | |
| 1344 | + $this->generation_floor = (int) $val; | |
| 1345 | + } | |
| 1346 | + } catch ( \Throwable $e ) { | |
| 1347 | + $this->generation_floor = 1; | |
| 1348 | + } | |
| 1349 | + | |
| 1350 | + return $this->generation_floor; | |
| 1351 | + } | |
| 1352 | + | |
| 1353 | + /** | |
| 1354 | + * Persist the generation as the new floor, so an eviction of the cached | |
| 1355 | + * counter cannot rewind past it. | |
| 1356 | + * | |
| 1357 | + * @param int $generation The generation just written. | |
| 1358 | + * @return void | |
| 1359 | + */ | |
| 1360 | + private function persist_generation_floor( $generation ) { | |
| 1361 | + if ( 'memcached' !== $this->backend || ! isset( $GLOBALS['wpdb'] ) ) { | |
| 1362 | + return; | |
| 1363 | + } | |
| 1364 | + | |
| 1365 | + $wpdb = $GLOBALS['wpdb']; | |
| 1366 | + $table = $this->generation_table( $wpdb ); | |
| 1367 | + if ( '' === $table || ! method_exists( $wpdb, 'query' ) ) { | |
| 1368 | + return; | |
| 1369 | + } | |
| 1370 | + | |
| 1371 | + try { | |
| 1372 | + // Upsert without the options API, which would recurse through | |
| 1373 | + // this cache. autoload='no' keeps it out of alloptions. | |
| 1374 | + $wpdb->query( | |
| 1375 | + $wpdb->prepare( | |
| 1376 | + "INSERT INTO {$table} (option_name, option_value, autoload) | |
| 1377 | + VALUES (%s, %s, 'no') | |
| 1378 | + ON DUPLICATE KEY UPDATE option_value = VALUES(option_value)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is derived from $wpdb, never user input. | |
| 1379 | + self::GENERATION_OPTION, | |
| 1380 | + (string) (int) $generation | |
| 1381 | + ) | |
| 1382 | + ); | |
| 1383 | + $this->generation_floor = (int) $generation; | |
| 1384 | + } catch ( \Throwable $e ) { | |
| 1385 | + // Best effort: the cached counter still carries the flush for | |
| 1386 | + // as long as it survives. | |
| 1387 | + return; | |
| 1388 | + } | |
| 1389 | + } | |
| 1390 | + | |
| 1391 | + /** | |
| 1392 | + * Read this site's namespace generation, once per request. | |
| 1393 | + * | |
| 1394 | + * Only meaningful for Memcached, where flushing works by advancing the | |
| 1395 | + * generation rather than deleting keys. Redis deletes by pattern, so it | |
| 1396 | + * stays on generation 1 and its key shape is unchanged. | |
| 1397 | + * | |
| 1398 | + * @return int | |
| 1399 | + */ | |
| 1400 | + private function generation() { | |
| 1401 | + if ( null !== $this->generation ) { | |
| 1402 | + return $this->generation; | |
| 1403 | + } | |
| 1404 | + | |
| 1405 | + // The floor comes from the database, because the counter lives in | |
| 1406 | + // the cache it namespaces and Memcached can evict it under LRU. | |
| 1407 | + // Falling back to generation 1 after an eviction would re-expose | |
| 1408 | + // the keys an earlier Purge cleared, so the DB copy — which cannot | |
| 1409 | + // be evicted — is what the generation may never drop below. | |
| 1410 | + $this->generation = $this->generation_floor(); | |
| 1411 | + | |
| 1412 | + if ( 'memcached' === $this->backend && $this->persistent ) { | |
| 1413 | + try { | |
| 1414 | + $val = $this->conn->get( $this->generation_key() ); | |
| 1415 | + if ( is_numeric( $val ) && (int) $val > $this->generation ) { | |
| 1416 | + $this->generation = (int) $val; | |
| 1417 | + } | |
| 1418 | + } catch ( \Throwable $e ) { | |
| 1419 | + // Unreadable counter — the floor still applies, so a | |
| 1420 | + // previous flush is never undone. | |
| 1421 | + $this->generation = max( 1, $this->generation ); | |
| 1422 | + } | |
| 1423 | + } | |
| 1424 | + | |
| 1425 | + return $this->generation; | |
| 1426 | + } | |
| 1427 | + | |
| 1428 | + /** | |
| 1429 | + * Escape Redis glob metacharacters so a literal string matches only | |
| 1430 | + * itself inside a SCAN MATCH pattern. | |
| 1431 | + * | |
| 1432 | + * The salt and group name are interpolated into the flush patterns | |
| 1433 | + * below, and neither is guaranteed to be glob-safe: an explicit Cache | |
| 1434 | + * Key Prefix is whatever the user typed, and a host-pinned | |
| 1435 | + * WP_CACHE_KEY_SALT is whatever the host wrote (WordPress builds these | |
| 1436 | + * from the site URL, so punctuation is normal). Left unescaped, `*`, | |
| 1437 | + * `?` and `[...]` are wildcards — `wp_[dev]site_:*` matches a | |
| 1438 | + * NEIGHBOURING site's `wp_dsite_:*` keys, so purging one site deletes | |
| 1439 | + * another site's cache. A bare `*` prefix matches everything and | |
| 1440 | + * empties the whole Redis DB, which is the exact damage the scoped | |
| 1441 | + * flush exists to prevent. | |
| 1442 | + * | |
| 1443 | + * Redis's stringmatchlen() treats `\` as the escape character, so | |
| 1444 | + * backslash-prefixing each metacharacter makes it literal. `\` itself | |
| 1445 | + * is escaped first, or escaping the others would be undone. | |
| 1446 | + * | |
| 1447 | + * @param string $literal Text to match literally. | |
| 1448 | + * @return string Glob-safe form of $literal. | |
| 1449 | + */ | |
| 1450 | + private function escape_glob( $literal ) { | |
| 1451 | + return str_replace( | |
| 1452 | + array( '\\', '*', '?', '[', ']' ), | |
| 1453 | + array( '\\\\', '\\*', '\\?', '\\[', '\\]' ), | |
| 1454 | + (string) $literal | |
| 1455 | + ); | |
| 1456 | + } | |
| 1457 | + | |
| 1458 | + /** | |
| 738 | 1459 | * Delete every Redis key matching $pattern across both client kinds |
| 739 | 1460 | * (phpredis native scan + our pure-PHP Redis_Client). Returns the |
| 740 | 1461 | * count deleted, or -1 if the backend isn't redis. SCAN-based so it |
| 741 | 1462 | * never blocks the server the way KEYS would. (FBS-83119) |
| 1463 | + * | |
| 1464 | + * Callers MUST pass any literal segment through escape_glob() — this | |
| 1465 | + * receives a finished pattern and cannot tell wildcard from data. | |
| 742 | 1466 | */ |
| 743 | 1467 | private function delete_redis_pattern( $pattern ) { |
| 744 | 1468 | if ( 'redis' !== $this->backend || ! $this->conn ) { |
| 745 | 1469 | return -1; |
| @@ -799,9 +1523,11 @@ | ||
| 799 | 1523 | // both blog-prefixed and global groups for this site's namespace. |
| 800 | 1524 | // (FBS-83119) |
| 801 | 1525 | if ( $this->is_persistent_group( $group ) && 'redis' === $this->backend && '' !== $this->salt ) { |
| 802 | 1526 | try { |
| 803 | - $this->delete_redis_pattern( $this->salt . ':*:' . $group . ':*' ); | |
| 1527 | + $this->delete_redis_pattern( | |
| 1528 | + $this->escape_glob( $this->salt ) . ':*:' . $this->escape_glob( $group ) . ':*' | |
| 1529 | + ); | |
| 804 | 1530 | } catch ( \Throwable $e ) { |
| 805 | 1531 | return false; |
| 806 | 1532 | } |
| 807 | 1533 | } |