'unavailable'); } $request_name = self::name('r', $context['request_id']); $idempotency_name = self::name('i', $context['idempotency_key']); $request = self::claim_record( $request_name, $context['request_fingerprint'], $owner, $now ); if ('acquired' !== $request['outcome']) { return array('outcome' => 'unavailable' === $request['outcome'] ? 'unavailable' : 'conflict'); } $idempotency = self::claim_record( $idempotency_name, $context['idempotency_fingerprint'], $owner, $now ); if (!in_array($idempotency['outcome'], array('acquired', 'completed'), true)) { self::release_record($request_name, $request['record']); return array('outcome' => 'unavailable' === $idempotency['outcome'] ? 'unavailable' : 'conflict'); } return array( 'outcome' => 'completed' === $idempotency['outcome'] ? 'completed' : 'execute', 'request_name' => $request_name, 'request_record' => $request['record'], 'idempotency_name' => $idempotency_name, 'idempotency_record' => $idempotency['record'], 'idempotency_completed' => 'completed' === $idempotency['outcome'], ); } public static function complete(array $claim, $now) { $idempotency_complete = $claim['idempotency_completed'] || self::complete_record( $claim['idempotency_name'], $claim['idempotency_record'], $now ); $request_complete = self::complete_record( $claim['request_name'], $claim['request_record'], $now ); return $idempotency_complete && $request_complete; } public static function release(array $claim) { self::release_record($claim['request_name'], $claim['request_record']); if (!$claim['idempotency_completed']) { self::release_record($claim['idempotency_name'], $claim['idempotency_record']); } } public static function acquire_order($order_id, $now) { $owner = self::owner(); if (!is_string($owner)) { return false; } $name = self::name('o', $order_id); $record = array('v' => 1, 'o' => $owner, 'e' => $now + self::PROCESSING_TTL); if (add_option($name, $record, '', 'no')) { return array('name' => $name, 'record' => $record); } $current = get_option($name, null); if (!self::valid_lock($current) || $current['e'] > $now || !self::compare_and_replace($name, $current, $record)) { return false; } return array('name' => $name, 'record' => $record); } public static function release_order(array $lock) { return self::release_record($lock['name'], $lock['record']); } private static function claim_record($name, $fingerprint, $owner, $now) { $record = array( 'v' => 1, 's' => 'processing', 'f' => $fingerprint, 'o' => $owner, 'e' => $now + self::PROCESSING_TTL, ); if (add_option($name, $record, '', 'no')) { return array('outcome' => 'acquired', 'record' => $record); } $current = get_option($name, null); if (!self::valid_claim($current)) { return array('outcome' => 'unavailable'); } if ($current['e'] <= $now) { return self::compare_and_replace($name, $current, $record) ? array('outcome' => 'acquired', 'record' => $record) : array('outcome' => 'conflict'); } if (!hash_equals($current['f'], $fingerprint)) { return array('outcome' => 'conflict'); } if ('completed' === $current['s']) { return array('outcome' => 'completed', 'record' => $current); } return array('outcome' => 'conflict'); } private static function complete_record($name, array $expected, $now) { $current = get_option($name, null); if (!self::valid_claim($current) || !hash_equals($current['f'], $expected['f'])) { return false; } if (!hash_equals($current['o'], $expected['o'])) { return false; } if ('completed' === $current['s']) { return true; } $completed = array( 'v' => 1, 's' => 'completed', 'f' => $current['f'], 'o' => $current['o'], 'e' => $now + self::COMPLETED_TTL, ); return self::compare_and_replace($name, $current, $completed); } private static function release_record($name, array $expected) { $current = get_option($name, null); if (!self::valid_claim($current) && !self::valid_lock($current)) { return false; } if (!isset($current['o'], $expected['o']) || !is_string($current['o']) || !is_string($expected['o']) || !hash_equals($current['o'], $expected['o'])) { return false; } if (isset($current['s']) && 'processing' !== $current['s']) { return false; } return self::compare_and_delete($name, $current); } private static function name($kind, $value) { $context = $kind . "\nblog:" . (string) get_current_blog_id() . "\n" . $value; $opaque = Akkedli_V2_Crypto::base64url_encode(hash_hmac( 'sha256', $context, wp_salt('auth'), true )); return self::PREFIX . $kind . '_' . $opaque; } private static function owner() { try { return Akkedli_V2_Crypto::base64url_encode(random_bytes(24)); } catch (Throwable $error) { unset($error); return false; } } private static function valid_claim($value) { return is_array($value) && isset($value['v'], $value['s'], $value['f'], $value['o'], $value['e']) && 5 === count($value) && 1 === $value['v'] && in_array($value['s'], array('processing', 'completed'), true) && is_string($value['f']) && preg_match('/^[A-Za-z0-9_-]{43}$/D', $value['f']) && is_string($value['o']) && preg_match('/^[A-Za-z0-9_-]{32}$/D', $value['o']) && is_int($value['e']); } private static function valid_lock($value) { return is_array($value) && isset($value['v'], $value['o'], $value['e']) && 1 === $value['v'] && 3 === count($value) && is_string($value['o']) && preg_match('/^[A-Za-z0-9_-]{32}$/D', $value['o']) && is_int($value['e']); } private static function cleanup($now) { global $wpdb; if (!method_exists($wpdb, 'get_results')) { return; } $like = method_exists($wpdb, 'esc_like') ? $wpdb->esc_like(self::PREFIX) . '%' : self::PREFIX . '%'; $rows = $wpdb->get_results($wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_id ASC LIMIT %d", $like, self::CLEANUP_LIMIT ), ARRAY_A); if (!is_array($rows)) { return; } foreach ($rows as $row) { if (!isset($row['option_name'], $row['option_value']) || 0 !== strpos($row['option_name'], self::PREFIX)) { continue; } $record = maybe_unserialize($row['option_value']); if (is_array($record) && isset($record['e']) && is_int($record['e']) && $record['e'] <= $now) { self::compare_and_delete_raw($row['option_name'], $row['option_value']); } } } private static function compare_and_replace($name, $expected, $replacement) { global $wpdb; $updated = $wpdb->query($wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s, autoload = %s WHERE option_name = %s AND option_value = %s", maybe_serialize($replacement), 'no', $name, maybe_serialize($expected) )); self::clear_cache($name); return 1 === $updated; } private static function compare_and_delete($name, $expected) { return self::compare_and_delete_raw($name, maybe_serialize($expected)); } private static function compare_and_delete_raw($name, $expected) { global $wpdb; $deleted = $wpdb->query($wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $name, $expected )); self::clear_cache($name); return 1 === $deleted; } private static function clear_cache($name) { wp_cache_delete($name, 'options'); wp_cache_delete('alloptions', 'options'); wp_cache_delete('notoptions', 'options'); } }