PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / trunk
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions vtrunk
260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 111220 120213 120219 All 188 releases
s2member / src / includes / classes / gateway-checkouts.inc.php

gateway-checkouts.inc.php in s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions trunk, at src/includes/classes/gateway-checkouts.inc.php

660 lines 24.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // @codingStandardsIgnoreFile
3 /**
4 * Gateway Checkout state utilities.
5 *
6 * A Gateway Checkout represents one logical customer checkout that can create
7 * either a one-time payment or a recurring subscription. Its server-side state preserves
8 * the checkout identity and recovery data across reloads, retries, redirects, and lost
9 * gateway responses, so the same checkout can be reconciled safely instead of duplicated.
10 *
11 * @package s2Member\Gateway_Checkouts
12 * @since 260829.2325
13 */
14 if(!defined('WPINC')) //260829.2325 MUST have WordPress.
15 exit ('Do not access this file directly.');
16
17 if(!class_exists('c_ws_plugin__s2member_gateway_checkouts'))
18 {
19 /**
20 * Gateway Checkout state utilities.
21 *
22 * @package s2Member\Gateway_Checkouts
23 * @since 260829.2325
24 */
25 class c_ws_plugin__s2member_gateway_checkouts
26 {
27 /**
28 * Generates a Gateway Checkout ID.
29 *
30 * @package s2Member\Gateway_Checkouts
31 * @since 260829.2325
32 *
33 * @return string Gateway Checkout ID.
34 */
35 public static function generate_id()
36 {
37 //260829.2325 Prefer WordPress UUIDs, while retaining a sufficiently unique fallback for older WordPress versions supported by s2Member.
38 return function_exists('wp_generate_uuid4') ? wp_generate_uuid4() : md5(uniqid('s2member-gateway-checkout-', TRUE).wp_rand());
39 }
40
41 /**
42 * Validates a Gateway Checkout ID.
43 *
44 * @package s2Member\Gateway_Checkouts
45 * @since 260829.2325
46 *
47 * @param string $gateway_checkout_id Gateway Checkout ID.
48 *
49 * @return bool TRUE if valid; else FALSE.
50 */
51 public static function valid_id($gateway_checkout_id = '')
52 {
53 $gateway_checkout_id = (string)$gateway_checkout_id;
54
55 return (bool)preg_match('/^(?:[a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})$/i', $gateway_checkout_id);
56 }
57
58 /**
59 * Builds a deterministic fingerprint from Gateway Checkout purchase terms.
60 *
61 * @package s2Member\Gateway_Checkouts
62 * @since 260829.2325
63 *
64 * @param array $purchase_terms Purchase terms.
65 *
66 * @return string SHA-256 fingerprint.
67 */
68 public static function purchase_fingerprint($purchase_terms = array())
69 {
70 //260829.2325 Sort associative purchase data recursively so equivalent server-side terms fingerprint identically regardless of insertion order.
71 $purchase_terms = c_ws_plugin__s2member_utils_arrays::ksort_deep((array)$purchase_terms, SORT_STRING);
72
73 return hash('sha256', serialize($purchase_terms));
74 }
75
76 /**
77 * Creates durable state for a new Gateway Checkout.
78 *
79 * @package s2Member\Gateway_Checkouts
80 * @since 260829.2325
81 *
82 * @param string $gateway Gateway identifier.
83 * @param string $operation Gateway operation identifier.
84 * @param string $purchase_fingerprint Purchase fingerprint, if already known.
85 * @param integer $user_id WordPress user ID, if already known.
86 * @param integer $ttl Optional state TTL in seconds.
87 *
88 * @return array|bool Gateway Checkout state, else FALSE.
89 */
90 public static function create($gateway = '', $operation = '', $purchase_fingerprint = '', $user_id = 0, $ttl = 0)
91 {
92 $gateway = sanitize_key((string)$gateway);
93 $operation = sanitize_key((string)$operation);
94 $user_id = abs((int)$user_id);
95 $ttl = self::ttl($ttl);
96
97 if(!$gateway || !$operation || !$ttl)
98 return FALSE;
99
100 //260829.2325 Use add_option() so an extremely unlikely ID collision cannot overwrite another in-progress checkout.
101 for($attempt = 0; $attempt < 3; $attempt++)
102 {
103 $state = self::create_with_id(self::generate_id(), $gateway, $operation, $purchase_fingerprint, $user_id, $ttl);
104 if($state)
105 return $state;
106 }
107 return FALSE;
108 }
109
110 /**
111 * Resumes a signed browser Gateway Checkout, or creates its durable state on first use.
112 *
113 * @package s2Member\Gateway_Checkouts
114 * @since 260830.0059
115 *
116 * @param string $gateway Gateway identifier.
117 * @param string $operation Gateway operation identifier.
118 * @param string $gateway_checkout_id Browser Gateway Checkout ID, if any.
119 * @param string $browser_token Signed browser token, if any.
120 * @param string $purchase_fingerprint Finalized purchase fingerprint, if known.
121 * @param integer $user_id Current WordPress user ID, if any.
122 * @param integer $ttl Optional state TTL in seconds when a replacement identity is needed.
123 *
124 * @return array|bool Gateway Checkout state, else FALSE.
125 */
126 public static function create_or_resume($gateway = '', $operation = '', $gateway_checkout_id = '', $browser_token = '', $purchase_fingerprint = '', $user_id = 0, $ttl = 0)
127 {
128 $gateway = sanitize_key((string)$gateway);
129 $operation = sanitize_key((string)$operation);
130 $purchase_fingerprint = (string)$purchase_fingerprint;
131 $user_id = abs((int)$user_id);
132
133 if(!$gateway || !$operation)
134 return FALSE;
135
136 if($gateway_checkout_id && $browser_token && self::browser_token_verify($gateway_checkout_id, $browser_token))
137 {
138 $browser_expires_at = self::browser_token_expires_at($browser_token);
139 $state = self::get($gateway_checkout_id);
140
141 if(!$state && $browser_expires_at > time())
142 {
143 //260830.0059 Form renders use a signed provisional identity without writing to the database; persist it only when checkout processing actually begins.
144 $state = self::create_with_id($gateway_checkout_id, $gateway, $operation, $purchase_fingerprint, $user_id, 0, $browser_expires_at);
145 if(!$state)
146 $state = self::get($gateway_checkout_id); // Another concurrent request may have created the same signed checkout first.
147 }
148 if($state && (string)$state['gateway'] === $gateway && (string)$state['operation'] === $operation
149 && (empty($state['user_id']) || ($user_id && (int)$state['user_id'] === $user_id)))
150 {
151 if($purchase_fingerprint)
152 {
153 //260830.0308 Once bound, a checkout cannot be reassigned to different purchase terms or a different known WordPress user.
154 if(!empty($state['purchase_fingerprint']) && !hash_equals((string)$state['purchase_fingerprint'], $purchase_fingerprint))
155 return FALSE;
156
157 $state['purchase_fingerprint'] = $purchase_fingerprint;
158 if(!$state['user_id'] && $user_id)
159 $state['user_id'] = $user_id;
160 $state['updated_at'] = time();
161
162 if(!update_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id, $state, FALSE))
163 {
164 $persisted_state = self::get($gateway_checkout_id);
165 if($persisted_state !== $state)
166 return FALSE;
167 }
168 }
169 return $state;
170 }
171 }
172 return self::create($gateway, $operation, $purchase_fingerprint, $user_id, $ttl);
173 }
174
175 /**
176 * Creates or preserves a signed provisional browser identity without durable state.
177 *
178 * @package s2Member\Gateway_Checkouts
179 * @since 260830.0059
180 *
181 * @param string $gateway_checkout_id Existing browser Gateway Checkout ID, if any.
182 * @param string $browser_token Existing signed browser token, if any.
183 * @param integer $ttl Optional token TTL in seconds.
184 *
185 * @return array Browser identity containing `id`, `token`, and `expires_at`.
186 */
187 public static function browser_identity($gateway_checkout_id = '', $browser_token = '', $ttl = 0)
188 {
189 if($gateway_checkout_id && $browser_token && self::browser_token_verify($gateway_checkout_id, $browser_token))
190 return array('id' => (string)$gateway_checkout_id, 'token' => (string)$browser_token, 'expires_at' => self::browser_token_expires_at($browser_token));
191
192 $gateway_checkout_id = self::generate_id();
193 $expires_at = time() + self::ttl($ttl);
194 $browser_token = self::browser_token($gateway_checkout_id, $expires_at);
195
196 return array('id' => $gateway_checkout_id, 'token' => $browser_token, 'expires_at' => $expires_at);
197 }
198
199 /**
200 * Gets durable Gateway Checkout state.
201 *
202 * @package s2Member\Gateway_Checkouts
203 * @since 260829.2325
204 *
205 * @param string $gateway_checkout_id Gateway Checkout ID.
206 * @param bool $allow_expired Optional. Return expired state instead of removing it.
207 *
208 * @return array|bool Gateway Checkout state, else FALSE.
209 */
210 public static function get($gateway_checkout_id = '', $allow_expired = FALSE)
211 {
212 if(!self::valid_id($gateway_checkout_id))
213 return FALSE;
214
215 $option_name = 'ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id;
216 $state = get_option($option_name, FALSE);
217 if(!is_array($state) || empty($state['id']) || !hash_equals((string)$gateway_checkout_id, (string)$state['id']) || empty($state['expires_at']))
218 return FALSE;
219
220 if(!$allow_expired && (int)$state['expires_at'] <= time())
221 {
222 //260829.2325 Expired checkout state is unusable for recovery; remove it lazily when encountered.
223 self::delete($gateway_checkout_id);
224 return FALSE;
225 }
226 return $state;
227 }
228
229 /**
230 * Updates operational Gateway Checkout state.
231 *
232 * @package s2Member\Gateway_Checkouts
233 * @since 260829.2325
234 *
235 * @param string $gateway_checkout_id Gateway Checkout ID.
236 * @param array $updates Operational state values to update.
237 *
238 * @return array|bool Updated state, else FALSE.
239 */
240 public static function update($gateway_checkout_id = '', $updates = array())
241 {
242 $state = self::get($gateway_checkout_id);
243 if(!$state || !is_array($updates))
244 return FALSE;
245
246 //260830.0408 Only operational fields are mutable here; gateway, purchase, and user identity are established when the checkout is created/resumed.
247 $updates = array_intersect_key($updates, array('gateway_ids' => TRUE, 'gateway_status' => TRUE, 'fulfillment_status' => TRUE, 'context' => TRUE));
248 $state = array_merge($state, $updates);
249 $state['updated_at'] = time();
250
251 if(!update_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id, $state, FALSE))
252 {
253 //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);
255 if($persisted_state !== $state)
256 return FALSE;
257 }
258 return $state;
259 }
260
261 /**
262 * Stores private encrypted recovery context for a Gateway Checkout.
263 *
264 * @package s2Member\Gateway_Checkouts
265 * @since 260831.0723
266 *
267 * @param string $gateway_checkout_id Gateway Checkout ID.
268 * @param array $context Private recovery context; passwords/credentials are not allowed.
269 *
270 * @return bool TRUE if stored or cleared; else FALSE.
271 */
272 public static function private_context_set($gateway_checkout_id = '', $context = array())
273 {
274 $state = self::get($gateway_checkout_id);
275 if(!$state || !is_array($context) || !self::private_context_is_safe($context))
276 return FALSE;
277
278 $encrypted = '';
279 if($context)
280 {
281 //260831.0723 Bind ciphertext to this checkout ID so copied/tampered private state cannot be accepted by another checkout.
282 $payload = array('version' => 1, 'gateway_checkout_id' => (string)$gateway_checkout_id, 'context' => $context);
283 $encrypted = c_ws_plugin__s2member_utils_encryption::encrypt(serialize($payload));
284 if(!$encrypted)
285 return FALSE;
286 }
287
288 $state['private_context'] = $encrypted;
289 $state['updated_at'] = time();
290
291 if(!update_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id, $state, FALSE))
292 {
293 $persisted_state = self::get($gateway_checkout_id);
294 if($persisted_state !== $state)
295 return FALSE;
296 }
297 return TRUE;
298 }
299
300 /**
301 * Gets private encrypted recovery context for a Gateway Checkout.
302 *
303 * @package s2Member\Gateway_Checkouts
304 * @since 260831.0723
305 *
306 * @param string $gateway_checkout_id Gateway Checkout ID.
307 *
308 * @return array|bool Private recovery context, an empty array when none exists, else FALSE on invalid/corrupt state.
309 */
310 public static function private_context_get($gateway_checkout_id = '')
311 {
312 $state = self::get($gateway_checkout_id);
313 if(!$state)
314 return FALSE;
315 if(empty($state['private_context']))
316 return array();
317 if(!is_string($state['private_context']))
318 return FALSE;
319
320 $payload = c_ws_plugin__s2member_utils_arrays::maybe_unserialize(c_ws_plugin__s2member_utils_encryption::decrypt($state['private_context']));
321 if(!is_array($payload) || empty($payload['version']) || (int)$payload['version'] !== 1
322 || empty($payload['gateway_checkout_id']) || !hash_equals((string)$gateway_checkout_id, (string)$payload['gateway_checkout_id'])
323 || !isset($payload['context']) || !is_array($payload['context']) || !self::private_context_is_safe($payload['context']))
324 return FALSE;
325
326 return $payload['context'];
327 }
328
329 /**
330 * Clears private encrypted recovery context for a Gateway Checkout.
331 *
332 * @package s2Member\Gateway_Checkouts
333 * @since 260831.0723
334 *
335 * @param string $gateway_checkout_id Gateway Checkout ID.
336 *
337 * @return bool TRUE if cleared; else FALSE.
338 */
339 public static function private_context_delete($gateway_checkout_id = '')
340 {
341 return self::private_context_set($gateway_checkout_id, array());
342 }
343
344 /**
345 * Acquires an atomic processing lock for a Gateway Checkout.
346 *
347 * @package s2Member\Gateway_Checkouts
348 * @since 260830.0408
349 *
350 * @param string $gateway_checkout_id Gateway Checkout ID.
351 * @param integer $timeout Optional stale-lock timeout in seconds.
352 *
353 * @return string|bool Lock token if acquired; else FALSE.
354 */
355 public static function processing_lock($gateway_checkout_id = '', $timeout = 300)
356 {
357 global $wpdb;
358
359 if(!self::valid_id($gateway_checkout_id) || !self::get($gateway_checkout_id))
360 return FALSE;
361
362 $option_name = 's2m_gateway_checkout_lock_'.$gateway_checkout_id;
363 $timeout = max(30, abs((int)$timeout));
364 $token = self::generate_id();
365 $lock = array('token' => $token, 'time' => time());
366
367 if(add_option($option_name, $lock, '', 'no'))
368 return $token;
369
370 $existing = get_option($option_name, FALSE);
371 if(!is_array($existing) || empty($existing['time']) || time() - (int)$existing['time'] >= $timeout)
372 {
373 //260830.1504 Delete only the stale lock version we inspected; another request may replace it between get_option() and this delete.
374 $deleted = $wpdb->delete($wpdb->options, array('option_name' => $option_name, 'option_value' => maybe_serialize($existing)), array('%s', '%s'));
375 if($deleted)
376 {
377 wp_cache_delete($option_name, 'options');
378 if(add_option($option_name, $lock, '', 'no'))
379 return $token;
380 }
381 }
382 return FALSE;
383 }
384
385 /**
386 * Releases a Gateway Checkout processing lock owned by the supplied token.
387 *
388 * @package s2Member\Gateway_Checkouts
389 * @since 260830.0408
390 *
391 * @param string $gateway_checkout_id Gateway Checkout ID.
392 * @param string $token Lock token returned by processing_lock().
393 *
394 * @return bool TRUE if released; else FALSE.
395 */
396 public static function processing_unlock($gateway_checkout_id = '', $token = '')
397 {
398 global $wpdb;
399
400 if(!self::valid_id($gateway_checkout_id) || !self::valid_id($token))
401 return FALSE;
402
403 $option_name = 's2m_gateway_checkout_lock_'.$gateway_checkout_id;
404 $existing = get_option($option_name, FALSE);
405 if(!is_array($existing) || empty($existing['token']) || !hash_equals((string)$existing['token'], (string)$token))
406 return FALSE;
407
408 //260830.1504 Release only the lock version owned by this token; a stale owner must never delete a newer replacement lock.
409 $deleted = $wpdb->delete($wpdb->options, array('option_name' => $option_name, 'option_value' => maybe_serialize($existing)), array('%s', '%s'));
410 if($deleted)
411 wp_cache_delete($option_name, 'options');
412
413 return (bool)$deleted;
414 }
415
416 /**
417 * Deletes durable Gateway Checkout state.
418 *
419 * @package s2Member\Gateway_Checkouts
420 * @since 260829.2325
421 *
422 * @param string $gateway_checkout_id Gateway Checkout ID.
423 *
424 * @return bool TRUE if state was deleted; else FALSE.
425 */
426 public static function delete($gateway_checkout_id = '')
427 {
428 if(!self::valid_id($gateway_checkout_id))
429 return FALSE;
430
431 delete_option('s2m_gateway_checkout_lock_'.$gateway_checkout_id);
432
433 return delete_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id);
434 }
435
436 /**
437 * Creates a signed browser token for a Gateway Checkout identity.
438 *
439 * @package s2Member\Gateway_Checkouts
440 * @since 260829.2325
441 *
442 * @param string $gateway_checkout_id Gateway Checkout ID.
443 * @param integer $expires_at Optional absolute expiration time for a provisional identity.
444 *
445 * @return string Signed browser token, else an empty string.
446 */
447 public static function browser_token($gateway_checkout_id = '', $expires_at = 0)
448 {
449 if(!self::valid_id($gateway_checkout_id))
450 return '';
451
452 if(!$expires_at)
453 {
454 $state = self::get($gateway_checkout_id);
455 if(!$state)
456 return '';
457
458 $expires_at = (int)$state['expires_at'];
459 }
460 if((int)$expires_at <= time())
461 return '';
462
463 $payload = (string)$gateway_checkout_id.'|'.(int)$expires_at;
464 $key = hash_hmac('sha256', 's2member:gateway-checkout:browser-token', c_ws_plugin__s2member_utils_encryption::key());
465 $signature = hash_hmac('sha256', $payload, $key);
466
467 return (int)$expires_at.'.'.$signature;
468 }
469
470 /**
471 * Verifies a signed Gateway Checkout browser token.
472 *
473 * @package s2Member\Gateway_Checkouts
474 * @since 260829.2325
475 *
476 * @param string $gateway_checkout_id Gateway Checkout ID.
477 * @param string $browser_token Signed browser token.
478 *
479 * @return bool TRUE if valid; else FALSE.
480 */
481 public static function browser_token_verify($gateway_checkout_id = '', $browser_token = '')
482 {
483 if(!self::valid_id($gateway_checkout_id) || !is_string($browser_token) || !preg_match('/^([0-9]+)\.([a-f0-9]{64})$/i', $browser_token, $matches))
484 return FALSE;
485
486 $expires_at = (int)$matches[1];
487 if($expires_at <= time())
488 return FALSE;
489
490 $payload = (string)$gateway_checkout_id.'|'.$expires_at;
491 $key = hash_hmac('sha256', 's2member:gateway-checkout:browser-token', c_ws_plugin__s2member_utils_encryption::key());
492 $expected = hash_hmac('sha256', $payload, $key);
493 if(!hash_equals($expected, strtolower($matches[2])))
494 return FALSE;
495
496 $state = self::get($gateway_checkout_id);
497 //260830.0059 A provisional browser identity has no state yet; once state exists, its expiration must remain bound to the signed token.
498 return !$state || $expires_at === (int)$state['expires_at'];
499 }
500
501 /**
502 * Gets the expiration time encoded in a syntactically valid browser token.
503 *
504 * @package s2Member\Gateway_Checkouts
505 * @since 260830.0059
506 *
507 * @param string $browser_token Signed browser token.
508 *
509 * @return integer Absolute expiration time, else 0.
510 */
511 protected static function browser_token_expires_at($browser_token = '')
512 {
513 return is_string($browser_token) && preg_match('/^([0-9]+)\.[a-f0-9]{64}$/i', $browser_token, $matches) ? (int)$matches[1] : 0;
514 }
515
516 /**
517 * Removes a bounded batch of expired Gateway Checkout states.
518 *
519 * @package s2Member\Gateway_Checkouts
520 * @since 260829.2325
521 *
522 * @param integer $limit Maximum candidate options to inspect.
523 *
524 * @return integer Number of expired/malformed state options removed.
525 */
526 public static function cleanup_expired($limit = 50)
527 {
528 global $wpdb;
529
530 $limit = min(500, max(1, abs((int)$limit)));
531 $option_prefix = 'ws_plugin__s2member_gateway_checkout_';
532 $option_names = $wpdb->get_col($wpdb->prepare("SELECT `option_name` FROM `{$wpdb->options}` WHERE `option_name` LIKE %s ORDER BY `option_id` ASC LIMIT %d", $wpdb->esc_like($option_prefix).'%', $limit));
533 $removed = 0;
534
535 foreach((array)$option_names as $option_name)
536 {
537 $gateway_checkout_id = substr((string)$option_name, strlen($option_prefix));
538 if(!self::valid_id($gateway_checkout_id))
539 continue;
540
541 $state = get_option($option_name, FALSE);
542 if(!is_array($state) || empty($state['expires_at']) || (int)$state['expires_at'] <= time())
543 {
544 if(self::delete($gateway_checkout_id))
545 $removed++;
546 }
547 }
548 return $removed;
549 }
550
551 /**
552 * Gets the configured Gateway Checkout state TTL.
553 *
554 * @package s2Member\Gateway_Checkouts
555 * @since 260829.2325
556 *
557 * @param integer $ttl Optional explicit TTL in seconds.
558 *
559 * @return integer TTL in seconds.
560 */
561 public static function ttl($ttl = 0)
562 {
563 if((int)$ttl > 0)
564 return abs((int)$ttl);
565
566 //260831.0626 Keep checkout recovery state beyond supported gateway idempotency windows and long enough for delayed browser/webhook recovery; sites can tune this without changing the storage contract.
567 return max(HOUR_IN_SECONDS, abs((int)apply_filters('ws_plugin__s2member_gateway_checkout_ttl', 7 * DAY_IN_SECONDS)));
568 }
569
570 /**
571 * Validates private recovery context before encryption/persistence.
572 *
573 * @package s2Member\Gateway_Checkouts
574 * @since 260831.0723
575 *
576 * @param array $context Private recovery context.
577 *
578 * @return bool TRUE if safe to persist; else FALSE.
579 */
580 protected static function private_context_is_safe($context = array())
581 {
582 foreach((array)$context as $key => $value)
583 {
584 $normalized_key = strtolower(trim(preg_replace('/[^a-z0-9]+/i', '_', (string)$key), '_'));
585 //260901.0722 Never persist actual login credentials, while allowing unrelated site-defined profile fields such as `password_hint` in otherwise valid private recovery context.
586 $credential_keys = array('pass', 'pass1', 'pass2', 'passwd', 'password', 'password1', 'password2', 'pwd', 'user_pass', 'user_password', 'current_password', 'new_password', 'old_password', 'confirm_password', 'password_confirmation');
587 if(in_array($normalized_key, $credential_keys, TRUE))
588 return FALSE;
589 if(is_array($value))
590 {
591 if(!self::private_context_is_safe($value))
592 return FALSE;
593 }
594 else if(is_object($value) || is_resource($value))
595 return FALSE;
596 }
597 return TRUE;
598 }
599
600 /**
601 * Creates durable Gateway Checkout state with a specific signed identity.
602 *
603 * @package s2Member\Gateway_Checkouts
604 * @since 260830.0059
605 *
606 * @param string $gateway_checkout_id Gateway Checkout ID.
607 * @param string $gateway Gateway identifier.
608 * @param string $operation Gateway operation identifier.
609 * @param string $purchase_fingerprint Purchase fingerprint, if already known.
610 * @param integer $user_id WordPress user ID, if already known.
611 * @param integer $ttl State TTL in seconds.
612 * @param integer $expires_at Optional absolute expiration time; used by signed provisional browser identities.
613 *
614 * @return array|bool Gateway Checkout state, else FALSE.
615 */
616 protected static function create_with_id($gateway_checkout_id = '', $gateway = '', $operation = '', $purchase_fingerprint = '', $user_id = 0, $ttl = 0, $expires_at = 0)
617 {
618 $gateway = sanitize_key((string)$gateway);
619 $operation = sanitize_key((string)$operation);
620 $user_id = abs((int)$user_id);
621 $expires_at = abs((int)$expires_at);
622 $ttl = $expires_at ? 0 : self::ttl($ttl);
623
624 if(!self::valid_id($gateway_checkout_id) || !$gateway || !$operation || (!$ttl && $expires_at <= time()))
625 return FALSE;
626
627 $option_name = 'ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id;
628
629 $now = time();
630 $expires_at = $expires_at ?: $now + $ttl;
631 $state = array(
632 'version' => 1,
633 'id' => (string)$gateway_checkout_id,
634 'gateway' => $gateway,
635 'operation' => $operation,
636 'purchase_fingerprint' => (string)$purchase_fingerprint,
637 'user_id' => $user_id,
638 'gateway_ids' => array(),
639 'gateway_status' => '',
640 'context' => array(),
641 //260831.0723 Keep private recovery data encrypted inside the same non-autoloaded checkout option so it shares the checkout lifecycle without exposing plaintext in normal state reads.
642 'private_context' => '',
643 'fulfillment_status' => 'pending',
644 'created_at' => $now,
645 'updated_at' => $now,
646 'expires_at' => $expires_at,
647 );
648
649 if(!add_option($option_name, $state, '', 'no'))
650 return FALSE;
651
652 //260830.1504 Keep opportunistic cleanup ahead of steady-state creation; a 1-in-50 run can remove up to 100 expired states, providing cleanup headroom without another scheduled task.
653 if(wp_rand(1, 50) === 1)
654 self::cleanup_expired(100);
655
656 return $state;
657 }
658 }
659 }
660