clock = is_callable($clock) ? $clock : 'time'; $this->request_id_factory = is_callable($request_id_factory) ? $request_id_factory : 'wp_generate_uuid4'; } public function build_order_body($order) { $payload = $this->order_payload($order); return is_array($payload) ? $this->encode($payload, self::ORDER_PATH) : false; } public function build_status_body($order, $old_status, $new_status) { $payload = $this->status_payload($order, $old_status, $new_status); return is_array($payload) ? $this->encode($payload, self::STATUS_PATH) : false; } public function send_order($body, $idempotency_key) { return $this->send(self::ORDER_PATH, $body, $idempotency_key); } public function send_status($body, $idempotency_key) { return $this->send(self::STATUS_PATH, $body, $idempotency_key); } private function encode(array $payload, $path) { $body = wp_json_encode($payload, JSON_UNESCAPED_SLASHES); unset($payload); return is_string($body) && Akkedli_V2_Frozen_Body::is_valid($path, $body) ? $body : false; } private function send($path, $body, $idempotency_key) { if (!is_string($idempotency_key) || 1 !== preg_match('/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/D', $idempotency_key) || !Akkedli_V2_Frozen_Body::is_valid($path, $body)) { return self::quarantined('invalid_request'); } try { $timestamp = (string) call_user_func($this->clock); $request_id = strtolower((string) call_user_func($this->request_id_factory)); } catch (Throwable $error) { unset($error, $body, $timestamp, $request_id); return self::retryable(); } $url = self::endpoint($path); if (false === $url || 1 !== preg_match('/^[1-9]\d{9,10}$/D', $timestamp) || 1 !== preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/D', $request_id)) { unset($body, $timestamp, $request_id, $url); return self::retryable(); } $state = Akkedli_Connector_State::load(); if (!is_array($state) || 'v2' !== $state['mode'] || 1 !== $state['protocol_version'] || 1 !== preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/D', $state['credential_id'])) { unset($body, $timestamp, $request_id, $url, $state); return self::paused('local_not_ready'); } try { $secret = Akkedli_V2_Crypto::base64url_decode($state['secret']); if (32 !== strlen($secret)) { throw new RuntimeException('Invalid signing state.'); } $canonical = implode("\n", array('AKKEDLI-WC-V1', 'POST', $path, $timestamp, $request_id, $idempotency_key, hash('sha256', $body))); $signature = 'v1=' . Akkedli_V2_Crypto::base64url_encode( hash_hmac('sha256', $canonical, $secret, true) ); } catch (Throwable $error) { unset($error, $body, $timestamp, $request_id, $url, $state, $secret, $canonical, $signature); return self::paused('local_not_ready'); } $credential_id = $state['credential_id']; unset($secret, $canonical, $state); $args = array( 'method' => 'POST', 'body' => $body, 'timeout' => self::TIMEOUT, 'redirection' => 0, 'sslverify' => true, 'reject_unsafe_urls' => true, 'limit_response_size' => self::RESPONSE_LIMIT, 'data_format' => 'body', 'cookies' => array(), 'headers' => array( 'Content-Type' => 'application/json', 'X-Akkedli-Connector-Id' => $credential_id, 'X-Akkedli-Timestamp' => $timestamp, 'X-Akkedli-Request-Id' => $request_id, 'X-Akkedli-Idempotency-Key' => $idempotency_key, 'X-Akkedli-Signature' => $signature, ), ); unset($body, $timestamp, $request_id, $signature, $idempotency_key); try { $response = wp_safe_remote_post($url, $args); } catch (Throwable $error) { unset($error, $url, $args); return self::retryable(); } unset($url, $args); return $this->response($path, $response, $credential_id); } private function response($path, $response, $credential_id) { if (is_wp_error($response)) { unset($response); return self::retryable(); } try { $status = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); } catch (Throwable $error) { unset($error, $response, $status, $body); return self::retryable(); } if (!is_int($status) || $status < 100 || $status > 599 || !is_string($body) || strlen($body) >= self::RESPONSE_LIMIT) { unset($response, $status, $body); return self::quarantined('invalid_response'); } if ($status >= 200 && $status <= 299) { $code = self::success($path, $body); unset($response, $status, $body); return is_string($code) ? array('outcome' => 'confirmed', 'code' => $code) : self::quarantined('invalid_response'); } if (429 === $status) { $retry = self::retry_after($response, 60); unset($response, $status, $body); return self::retryable($retry); } $code = self::failure_code($body); if (503 === $status && 'CONNECTOR_TEMPORARILY_SUSPENDED' === $code) { unset($status, $body, $code); return self::paused('connector_disabled'); } if (408 === $status || $status >= 500) { unset($response, $status, $body); return self::retryable(); } $retry = self::retry_after($response, 300); unset($response); if ((401 === $status && 'CONNECTOR_AUTHENTICATION_FAILED' === $code) || (409 === $status && 'CONNECTOR_REQUEST_REJECTED' === $code)) { unset($status, $body, $code, $retry); return array('outcome' => 'authority_lost', 'credential_id' => $credential_id); } if (404 === $status && 'WOOCOMMERCE_CONNECTOR_DISABLED' === $code) { unset($status, $body, $code, $retry); return self::paused('connector_disabled'); } if (409 === $status && 'CONNECTOR_REPLAY_REJECTED' === $code) { unset($status, $body, $code, $retry); return self::retryable(); } if (409 === $status && 'CONNECTOR_REQUEST_IN_PROGRESS' === $code) { unset($status, $body, $code); return self::retryable($retry); } if (404 === $status && self::STATUS_PATH === $path && 'ORDER_NOT_FOUND' === $code) { unset($status, $body, $code, $retry); return self::retryable(); } $terminal = array( 'INVALID_CONNECTOR_REQUEST' => 'invalid_request', 'CONNECTOR_IDEMPOTENCY_CONFLICT' => 'idempotency_conflict', 'ORDER_CONFLICT' => 'order_conflict', 'INVALID_STATUS_TRANSITION' => 'invalid_status_transition', ); $diagnostic = isset($terminal[$code]) ? $terminal[$code] : (in_array($status, array(410, 422), true) ? 'unknown_terminal' : ''); unset($status, $body, $code, $retry); return '' !== $diagnostic ? self::quarantined($diagnostic) : self::rejected(); } private static function success($path, $body) { $data = json_decode($body, true); if (!is_array($data) || JSON_ERROR_NONE !== json_last_error() || !array_key_exists('success', $data) || true !== $data['success'] || !isset($data['code']) || !is_string($data['code'])) { return false; } if (self::ORDER_PATH === $path && 'ORDER_ACCEPTED' === $data['code']) { return self::exact($data, array('success', 'code', 'duplicate'), $body) && is_bool($data['duplicate']) ? 'ORDER_ACCEPTED' : false; } if (self::ORDER_PATH === $path && 'ORDER_SKIPPED' === $data['code']) { return self::exact($data, array('success', 'code', 'skipped', 'reason'), $body) && true === $data['skipped'] && 'merchant_on_vacation' === $data['reason'] ? 'ORDER_SKIPPED' : false; } return self::STATUS_PATH === $path && 'STATUS_ACCEPTED' === $data['code'] && self::exact($data, array('success', 'code', 'unchanged'), $body) && is_bool($data['unchanged']) ? 'STATUS_ACCEPTED' : false; } private static function failure_code($body) { $data = json_decode($body, true); return is_array($data) && JSON_ERROR_NONE === json_last_error() && self::exact($data, array('success', 'error', 'code', 'message'), $body) && false === $data['success'] && is_string($data['code']) && $data['code'] === $data['error'] && is_string($data['message']) ? $data['code'] : ''; } private static function exact(array $data, array $keys, $body) { $actual = array_keys($data); sort($actual); sort($keys); preg_match_all('/"((?:[^"\\\\]|\\\\.)*)"\s*:/', $body, $matches); return $actual === $keys && count($matches[1]) === count($keys); } private static function endpoint($path) { $enrollment = 'https://api.akkedli.com/api/connectors/woocommerce/enroll'; if (Akkedli_V2_Enrollment::URL !== $enrollment || !in_array($path, array(self::ORDER_PATH, self::STATUS_PATH), true)) { return false; } return substr($enrollment, 0, -strlen('/api/connectors/woocommerce/enroll')) . $path; } private static function retryable($retry_after = null) { return null === $retry_after ? array('outcome' => 'retryable') : array('outcome' => 'retryable', 'retry_after' => $retry_after); } private static function rejected() { return array('outcome' => 'rejected'); } private static function paused($code) { return array('outcome' => 'paused', 'code' => $code); } private static function quarantined($code) { return array('outcome' => 'quarantined', 'code' => $code); } private static function retry_after($response, $fallback) { $header = wp_remote_retrieve_header($response, 'retry-after'); return is_string($header) && preg_match('/^[1-9]\d{0,4}$/D', $header) ? min(43200, (int) $header) : $fallback; } }