PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 260927
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v260927
260927 260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 111220 All 190 releases
← All changes | src/includes/classes/gateway-checkouts.inc.php +118 -12 260909 → 260927 View file →
@@ -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