| @@ -135,9 +135,9 @@ | ||
| 135 | 135 | |
| 136 | 136 | if($gateway_checkout_id && $browser_token && self::browser_token_verify($gateway_checkout_id, $browser_token)) |
| 137 | 137 | { |
| 138 | 138 | $browser_expires_at = self::browser_token_expires_at($browser_token); |
| 139 | - $state = self::get($gateway_checkout_id); | |
| 139 | + $state = self::load_state($gateway_checkout_id); | |
| 140 | 140 | |
| 141 | 141 | if(!$state && $browser_expires_at > time()) |
| 142 | 142 | { |
| 143 | 143 | //260830.0059 Form renders use a signed provisional identity without writing to the database; persist it only when checkout processing actually begins. |
| @@ -142,9 +142,9 @@ | ||
| 142 | 142 | { |
| 143 | 143 | //260830.0059 Form renders use a signed provisional identity without writing to the database; persist it only when checkout processing actually begins. |
| 144 | 144 | $state = self::create_with_id($gateway_checkout_id, $gateway, $operation, $purchase_fingerprint, $user_id, 0, $browser_expires_at); |
| 145 | 145 | if(!$state) |
| 146 | - $state = self::get($gateway_checkout_id); // Another concurrent request may have created the same signed checkout first. | |
| 146 | + $state = self::load_state($gateway_checkout_id); // Another concurrent request may have created the same signed checkout first. | |
| 147 | 147 | } |
| 148 | 148 | if($state && (string)$state['gateway'] === $gateway && (string)$state['operation'] === $operation |
| 149 | 149 | && (empty($state['user_id']) || ($user_id && (int)$state['user_id'] === $user_id))) |
| 150 | 150 | { |
| @@ -160,9 +160,9 @@ | ||
| 160 | 160 | $state['updated_at'] = time(); |
| 161 | 161 | |
| 162 | 162 | if(!update_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id, $state, FALSE)) |
| 163 | 163 | { |
| 164 | - $persisted_state = self::get($gateway_checkout_id); | |
| 164 | + $persisted_state = self::load_state($gateway_checkout_id); | |
| 165 | 165 | if($persisted_state !== $state) |
| 166 | 166 | return FALSE; |
| 167 | 167 | } |
| 168 | 168 | } |
| @@ -206,9 +206,9 @@ | ||
| 206 | 206 | * @param bool $allow_expired Optional. Return expired state instead of removing it. |
| 207 | 207 | * |
| 208 | 208 | * @return array|bool Gateway Checkout state, else FALSE. |
| 209 | 209 | */ |
| 210 | - public static function get($gateway_checkout_id = '', $allow_expired = FALSE) | |
| 210 | + public static function load_state($gateway_checkout_id = '', $allow_expired = FALSE) | |
| 211 | 211 | { |
| 212 | 212 | if(!self::valid_id($gateway_checkout_id)) |
| 213 | 213 | return FALSE; |
| 214 | 214 | |
| @@ -226,8 +226,48 @@ | ||
| 226 | 226 | return $state; |
| 227 | 227 | } |
| 228 | 228 | |
| 229 | 229 | /** |
| 230 | + * Gets durable Gateway Checkout state. | |
| 231 | + * | |
| 232 | + * Backward-compatible alias for load_state(). | |
| 233 | + * | |
| 234 | + * @package s2Member\Gateway_Checkouts | |
| 235 | + * @since 260829.2325 | |
| 236 | + * | |
| 237 | + * @param string $gateway_checkout_id Gateway Checkout ID. | |
| 238 | + * @param bool $allow_expired Optional. Return expired state instead of removing it. | |
| 239 | + * | |
| 240 | + * @return array|bool Gateway Checkout state, else FALSE. | |
| 241 | + */ | |
| 242 | + public static function get($gateway_checkout_id = '', $allow_expired = FALSE) | |
| 243 | + { | |
| 244 | + return self::load_state($gateway_checkout_id, $allow_expired); //260927.0432 Preserve the original public method for extensions written against the first Gateway Checkout implementation. | |
| 245 | + } | |
| 246 | + | |
| 247 | + /** | |
| 248 | + * Loads the latest durable Gateway Checkout state, bypassing this request's option cache. | |
| 249 | + * | |
| 250 | + * @package s2Member\Gateway_Checkouts | |
| 251 | + * @since 260925.0411 | |
| 252 | + * | |
| 253 | + * @param string $gateway_checkout_id Gateway Checkout ID. | |
| 254 | + * @param bool $allow_expired Optional. Return expired state instead of removing it. | |
| 255 | + * | |
| 256 | + * @return array|bool Gateway Checkout state, else FALSE. | |
| 257 | + */ | |
| 258 | + public static function load_state_uncached($gateway_checkout_id = '', $allow_expired = FALSE) | |
| 259 | + { | |
| 260 | + if(!self::valid_id($gateway_checkout_id)) | |
| 261 | + return FALSE; | |
| 262 | + | |
| 263 | + //260925.0411 Concurrent webhook/browser requests can leave this PHP request's option cache stale after another worker commits a checkout patch. | |
| 264 | + wp_cache_delete('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id, 'options'); | |
| 265 | + | |
| 266 | + return self::load_state($gateway_checkout_id, $allow_expired); | |
| 267 | + } | |
| 268 | + | |
| 269 | + /** | |
| 230 | 270 | * Updates operational Gateway Checkout state. |
| 231 | 271 | * |
| 232 | 272 | * @package s2Member\Gateway_Checkouts |
| 233 | 273 | * @since 260829.2325 |
| @@ -238,9 +278,9 @@ | ||
| 238 | 278 | * @return array|bool Updated state, else FALSE. |
| 239 | 279 | */ |
| 240 | 280 | public static function update($gateway_checkout_id = '', $updates = array()) |
| 241 | 281 | { |
| 242 | - $state = self::get($gateway_checkout_id); | |
| 282 | + $state = self::load_state($gateway_checkout_id); | |
| 243 | 283 | if(!$state || !is_array($updates)) |
| 244 | 284 | return FALSE; |
| 245 | 285 | |
| 246 | 286 | //260830.0408 Only operational fields are mutable here; gateway, purchase, and user identity are established when the checkout is created/resumed. |
| @@ -250,9 +290,9 @@ | ||
| 250 | 290 | |
| 251 | 291 | if(!update_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id, $state, FALSE)) |
| 252 | 292 | { |
| 253 | 293 | //260829.2325 WordPress returns FALSE when an update makes no database change; return the persisted state if it already matches. |
| 254 | - $persisted_state = self::get($gateway_checkout_id); | |
| 294 | + $persisted_state = self::load_state($gateway_checkout_id); | |
| 255 | 295 | if($persisted_state !== $state) |
| 256 | 296 | return FALSE; |
| 257 | 297 | } |
| 258 | 298 | return $state; |
| @@ -258,8 +298,74 @@ | ||
| 258 | 298 | return $state; |
| 259 | 299 | } |
| 260 | 300 | |
| 261 | 301 | /** |
| 302 | + * Atomically patches operational Gateway Checkout state without replacing unrelated nested keys. | |
| 303 | + * | |
| 304 | + * This is intentionally separate from update(), whose full-field replacement semantics are relied on by | |
| 305 | + * callers that deliberately remove stale context. Nested `gateway_ids` and `context` values supplied here | |
| 306 | + * are merged into the latest persisted state using compare-and-swap retries, preventing concurrent browser | |
| 307 | + * and webhook requests from overwriting each other's independently-owned keys. | |
| 308 | + * | |
| 309 | + * @package s2Member\Gateway_Checkouts | |
| 310 | + * @since 260925.0232 | |
| 311 | + * | |
| 312 | + * @param string $gateway_checkout_id Gateway Checkout ID. | |
| 313 | + * @param array $updates Operational values to patch. Nested gateway_ids/context keys are merged. | |
| 314 | + * @param int $max_attempts Maximum compare-and-swap attempts under contention. | |
| 315 | + * | |
| 316 | + * @return array|bool Updated state, else FALSE. | |
| 317 | + */ | |
| 318 | + public static function patch($gateway_checkout_id = '', $updates = array(), $max_attempts = 8) | |
| 319 | + { | |
| 320 | + global $wpdb; | |
| 321 | + | |
| 322 | + if(!self::valid_id($gateway_checkout_id) || !is_array($updates)) | |
| 323 | + return FALSE; | |
| 324 | + | |
| 325 | + $updates = array_intersect_key($updates, array('gateway_ids' => TRUE, 'gateway_status' => TRUE, 'fulfillment_status' => TRUE, 'context' => TRUE)); | |
| 326 | + $max_attempts = max(1, min(20, abs((int)$max_attempts))); | |
| 327 | + $option_name = 'ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id; | |
| 328 | + | |
| 329 | + for($attempt = 0; $attempt < $max_attempts; $attempt++) | |
| 330 | + { | |
| 331 | + //260925.0232 Read directly from the options table so every retry starts from the latest committed version, not a possibly stale object-cache copy. | |
| 332 | + $raw_state = $wpdb->get_var($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", $option_name)); | |
| 333 | + $state = maybe_unserialize($raw_state); | |
| 334 | + if(!is_array($state) || empty($state['id']) || !hash_equals((string)$gateway_checkout_id, (string)$state['id']) || empty($state['expires_at']) || (int)$state['expires_at'] <= time()) | |
| 335 | + return FALSE; | |
| 336 | + | |
| 337 | + $patched_state = $state; | |
| 338 | + if(isset($updates['gateway_ids']) && is_array($updates['gateway_ids'])) | |
| 339 | + $patched_state['gateway_ids'] = array_merge((array)$patched_state['gateway_ids'], $updates['gateway_ids']); | |
| 340 | + if(array_key_exists('gateway_status', $updates)) | |
| 341 | + $patched_state['gateway_status'] = (string)$updates['gateway_status']; | |
| 342 | + if(array_key_exists('fulfillment_status', $updates)) | |
| 343 | + { | |
| 344 | + //260925.0232 Fulfillment is terminal; a slower pending browser request must never downgrade a checkout already fulfilled by a webhook. | |
| 345 | + if((string)@$patched_state['fulfillment_status'] !== 'fulfilled' || (string)$updates['fulfillment_status'] === 'fulfilled') | |
| 346 | + $patched_state['fulfillment_status'] = (string)$updates['fulfillment_status']; | |
| 347 | + } | |
| 348 | + if(isset($updates['context']) && is_array($updates['context'])) | |
| 349 | + $patched_state['context'] = array_merge((array)$patched_state['context'], $updates['context']); | |
| 350 | + $patched_state['updated_at'] = time(); | |
| 351 | + | |
| 352 | + $serialized_state = maybe_serialize($patched_state); | |
| 353 | + if($serialized_state === (string)$raw_state) | |
| 354 | + return $patched_state; | |
| 355 | + | |
| 356 | + //260925.0232 Update only the exact state version read above. A concurrent winner makes this affect zero rows, then we reload and reapply the patch without losing its keys. | |
| 357 | + $updated = $wpdb->query($wpdb->prepare("UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND BINARY option_value = BINARY %s", $serialized_state, $option_name, (string)$raw_state)); | |
| 358 | + if($updated) | |
| 359 | + { | |
| 360 | + wp_cache_delete($option_name, 'options'); | |
| 361 | + return $patched_state; | |
| 362 | + } | |
| 363 | + } | |
| 364 | + return FALSE; | |
| 365 | + } | |
| 366 | + | |
| 367 | + /** | |
| 262 | 368 | * Stores private encrypted recovery context for a Gateway Checkout. |
| 263 | 369 | * |
| 264 | 370 | * @package s2Member\Gateway_Checkouts |
| 265 | 371 | * @since 260831.0723 |
| @@ -270,9 +376,9 @@ | ||
| 270 | 376 | * @return bool TRUE if stored or cleared; else FALSE. |
| 271 | 377 | */ |
| 272 | 378 | public static function private_context_set($gateway_checkout_id = '', $context = array()) |
| 273 | 379 | { |
| 274 | - $state = self::get($gateway_checkout_id); | |
| 380 | + $state = self::load_state($gateway_checkout_id); | |
| 275 | 381 | if(!$state || !is_array($context) || !self::private_context_is_safe($context)) |
| 276 | 382 | return FALSE; |
| 277 | 383 | |
| 278 | 384 | $encrypted = ''; |
| @@ -289,9 +395,9 @@ | ||
| 289 | 395 | $state['updated_at'] = time(); |
| 290 | 396 | |
| 291 | 397 | if(!update_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id, $state, FALSE)) |
| 292 | 398 | { |
| 293 | - $persisted_state = self::get($gateway_checkout_id); | |
| 399 | + $persisted_state = self::load_state($gateway_checkout_id); | |
| 294 | 400 | if($persisted_state !== $state) |
| 295 | 401 | return FALSE; |
| 296 | 402 | } |
| 297 | 403 | return TRUE; |
| @@ -308,9 +414,9 @@ | ||
| 308 | 414 | * @return array|bool Private recovery context, an empty array when none exists, else FALSE on invalid/corrupt state. |
| 309 | 415 | */ |
| 310 | 416 | public static function private_context_get($gateway_checkout_id = '') |
| 311 | 417 | { |
| 312 | - $state = self::get($gateway_checkout_id); | |
| 418 | + $state = self::load_state($gateway_checkout_id); | |
| 313 | 419 | if(!$state) |
| 314 | 420 | return FALSE; |
| 315 | 421 | if(empty($state['private_context'])) |
| 316 | 422 | return array(); |
| @@ -355,9 +461,9 @@ | ||
| 355 | 461 | public static function processing_lock($gateway_checkout_id = '', $timeout = 300) |
| 356 | 462 | { |
| 357 | 463 | global $wpdb; |
| 358 | 464 | |
| 359 | - if(!self::valid_id($gateway_checkout_id) || !self::get($gateway_checkout_id)) | |
| 465 | + if(!self::valid_id($gateway_checkout_id) || !self::load_state($gateway_checkout_id)) | |
| 360 | 466 | return FALSE; |
| 361 | 467 | |
| 362 | 468 | $option_name = 's2m_gateway_checkout_lock_'.$gateway_checkout_id; |
| 363 | 469 | $timeout = max(30, abs((int)$timeout)); |
| @@ -450,9 +556,9 @@ | ||
| 450 | 556 | return ''; |
| 451 | 557 | |
| 452 | 558 | if(!$expires_at) |
| 453 | 559 | { |
| 454 | - $state = self::get($gateway_checkout_id); | |
| 560 | + $state = self::load_state($gateway_checkout_id); | |
| 455 | 561 | if(!$state) |
| 456 | 562 | return ''; |
| 457 | 563 | |
| 458 | 564 | $expires_at = (int)$state['expires_at']; |
| @@ -492,9 +598,9 @@ | ||
| 492 | 598 | $expected = hash_hmac('sha256', $payload, $key); |
| 493 | 599 | if(!hash_equals($expected, strtolower($matches[2]))) |
| 494 | 600 | return FALSE; |
| 495 | 601 | |
| 496 | - $state = self::get($gateway_checkout_id); | |
| 602 | + $state = self::load_state($gateway_checkout_id); | |
| 497 | 603 | //260830.0059 A provisional browser identity has no state yet; once state exists, its expiration must remain bound to the signed token. |
| 498 | 604 | return !$state || $expires_at === (int)$state['expires_at']; |
| 499 | 605 | } |
| 500 | 606 | |