cooldown_remaining(self::CONNECT_COOLDOWN); if ($remaining_seconds > 0) { return [ 'ok' => false, 'message' => sprintf( /* translators: %d: remaining cooldown duration in seconds. */ __('Please wait %d seconds before trying to connect again.', 'market-sync'), $remaining_seconds ), ]; } $this->start_cooldown(self::CONNECT_COOLDOWN, self::CONNECT_COOLDOWN_SECONDS); // reauthorize: false - this is a first install, not a token refresh. $result = $this->authenticate(true, false); if (!$result['ok']) { return [ 'ok' => false, 'message' => '' !== $result['message'] ? $result['message'] : __('Unable to connect this store to Market Sync.', 'market-sync'), ]; } return [ 'ok' => true, 'message' => __('This store is now connected to Market Sync.', 'market-sync'), ]; } public function reauthorize(): array { $remaining_seconds = $this->cooldown_remaining(self::REAUTHORIZE_COOLDOWN); if ($remaining_seconds > 0) { return [ 'ok' => false, 'message' => sprintf( /* translators: %d: remaining cooldown duration in seconds. */ __('Please wait %d seconds before requesting another reauthorization.', 'market-sync'), $remaining_seconds ), ]; } $this->start_cooldown(self::REAUTHORIZE_COOLDOWN, self::REAUTHORIZE_COOLDOWN_SECONDS); $result = $this->authenticate(true, true); if (!$result['ok']) { return [ 'ok' => false, 'message' => '' !== $result['message'] ? $result['message'] : __('Unable to refresh Market Sync authorization.', 'market-sync'), ]; } return [ 'ok' => true, 'message' => __('Market Sync authorization was refreshed successfully.', 'market-sync'), ]; } /** * @return array{ok: bool, message: string} message is '' when there is nothing * more specific than the generic failure text callers already show. */ private function authenticate(bool $force, bool $reauthorize): array { if (!$force && $this->is_connected()) { return ['ok' => true, 'message' => '']; } $logger = Log_Helper::by_area(Log_Helper::LOG_AUTH); // Concurrency guard: if another request is mid-handshake, skip instead of firing a // duplicate. Always released in the finally below. if (get_transient(self::AUTH_LOCK)) { $logger->debug('Handshake skipped: another request already holds the lock.'); return ['ok' => false, 'message' => '']; } if (!$this->has_static_config()) { $logger->error('Handshake aborted: static SaaS config is missing.'); $message = __('Market Sync static SaaS config is missing.', 'market-sync'); $this->save_last_error($message); return ['ok' => false, 'message' => $message]; } set_transient(self::AUTH_LOCK, '1', 2 * MINUTE_IN_SECONDS); $logger->info( 'Starting handshake with Market Sync.', ['reauthorize' => $reauthorize, 'forced' => $force] ); try { $session = $this->handshake_init($reauthorize); if (is_wp_error($session)) { $message = sanitize_text_field($session->get_error_message()); $this->mark_failed($message); return ['ok' => false, 'message' => $message]; } if (empty($session['session_id']) || empty($session['challenge_token'])) { $message = __('Handshake init response did not include a session and challenge.', 'market-sync'); $this->mark_failed($message); return ['ok' => false, 'message' => $message]; } $authentication = $this->handshake_verify($session); if (is_wp_error($authentication)) { $message = sanitize_text_field($authentication->get_error_message()); $this->mark_failed($message); return ['ok' => false, 'message' => $message]; } $store_token = $authentication['store_token']; if ('' === $store_token) { $message = __('Handshake verify response did not include a store token.', 'market-sync'); $this->mark_failed($message); return ['ok' => false, 'message' => $message]; } update_option(self::OPTION_STORE_TOKEN, $store_token, false); update_option(self::OPTION_SHOP_ID, $authentication['shop_id'], false); update_option(self::OPTION_CONNECTED_AT, current_time('mysql', true), false); $this->mark_connected(); delete_option(self::OPTION_HANDSHAKE_SESSION); $logger->info( 'Handshake completed, store is connected.', ['shop_id' => $authentication['shop_id'], 'domain' => Store::domain()] ); return ['ok' => true, 'message' => '']; } finally { delete_transient(self::AUTH_LOCK); } } public function is_connected(): bool { return '' !== $this->store_token(); } public function connection_status(): string { if (!$this->is_connected()) { return self::STATUS_NOT_CONNECTED; } $status = (string)get_option(self::OPTION_CONNECTION_STATUS, self::STATUS_CONNECTED); return in_array($status, [self::STATUS_CONNECTED, self::STATUS_FAILED], true) ? $status : self::STATUS_CONNECTED; } /** * Gate for background jobs: only call the SaaS API when the cached connection * is healthy. The cron check-in and the manual status check keep this fresh, * so future sync jobs should call this first instead of re-probing. */ public function can_sync(): bool { return self::STATUS_CONNECTED === $this->connection_status(); } private function store_token(): string { return (string)get_option(self::OPTION_STORE_TOKEN, ''); } public function shop_id(): int { return absint(get_option(self::OPTION_SHOP_ID, 0)); } public function connected_at(): string { return (string)get_option(self::OPTION_CONNECTED_AT, ''); } public function last_error(): string { return (string)get_option(self::OPTION_LAST_ERROR, ''); } public function last_sync_at(): string { return (string)get_option(self::OPTION_LAST_SYNC_AT, ''); } public function store_currency(): string { if (function_exists('get_woocommerce_currency')) { return strtoupper((string)get_woocommerce_currency()); } return strtoupper((string)get_option('woocommerce_currency', '')); } public function store_locale(): string { return str_replace('_', '-', get_locale()); } public function saas_base_url(): string { return untrailingslashit(MARKET_SYNC_SAAS_BASE_URL); } public function check_shop_status(): array { $remaining_seconds = $this->cooldown_remaining(self::CHECK_STATUS_COOLDOWN); if ($remaining_seconds > 0) { return [ 'ok' => false, 'message' => sprintf( /* translators: %d: remaining cooldown duration in seconds. */ __('Please wait %d seconds before checking the connection again.', 'market-sync'), $remaining_seconds ), ]; } $this->start_cooldown(self::CHECK_STATUS_COOLDOWN, self::CHECK_STATUS_COOLDOWN_SECONDS); if (!$this->is_connected()) { $message = __('Connection token is missing.', 'market-sync'); $this->mark_failed($message); return [ 'ok' => false, 'message' => $message, ]; } $result = $this->request_check_in(); return [ 'ok' => $result['ok'], 'message' => $result['message'], ]; } /** * Run the scheduled connection probe without closing the sync gate before the * check-in retry policy is exhausted. * * @return array{ok: bool, message: string, body: array, retryable: bool, status_code: ?int} */ public function send_check_in(): array { $logger = Log_Helper::by_area(Log_Helper::LOG_CRON); if (!$this->is_connected()) { $logger->debug('Check-in skipped: the store is not connected.'); return [ 'ok' => false, 'message' => __('Connection token is missing.', 'market-sync'), 'body' => [], 'retryable' => false, 'status_code' => null, ]; } $result = $this->request_check_in(true); $logger->log( $result['ok'] ? 'info' : 'warning', $result['ok'] ? 'Scheduled check-in succeeded.' : 'Scheduled check-in failed.', [ 'reason' => $result['message'], 'retryable' => $result['retryable'], 'status_code' => $result['status_code'], ] ); return $result; } /** * Close the sync gate after the scheduled check-in exhausted its retries. * Until then send_check_in() deliberately keeps the last known status. */ public function mark_check_in_failed(string $message): void { $this->mark_failed($message); } public function saas_get_json(string $path, array $query = []): array|WP_Error { if (!$this->can_sync()) { return $this->not_connected_error(); } $url = $this->endpoint_url($path); if (!empty($query)) { $url = add_query_arg($query, $url); } $response = wp_remote_get($url, [ 'timeout' => 15, 'headers' => $this->authorized_headers(), ]); return $this->json_response($response, __('Market Sync API request failed.', 'market-sync')); } public function saas_post_json(string $path, array $data = []): array|WP_Error { if (!$this->can_sync()) { return $this->not_connected_error(); } $response = wp_remote_post($this->endpoint_url($path), [ 'timeout' => 20, 'headers' => array_merge($this->authorized_headers(), [ 'Content-Type' => 'application/json', ]), 'body' => (string)wp_json_encode($data), ]); return $this->json_response($response, __('Market Sync API request failed.', 'market-sync')); } public function saas_post_file(string $path, string $file_path, array $fields = []): array|WP_Error { if (!$this->can_sync()) { return $this->not_connected_error(); } if (!is_readable($file_path)) { return new WP_Error( 'market_sync_file_unreadable', __('Market Sync export file is not readable.', 'market-sync'), ['status' => 500] ); } if (!function_exists('curl_init')) { // 409 keeps this out of the retry ladder: a missing PHP extension is a // host configuration issue, not something that resolves within a backoff. return new WP_Error( 'market_sync_streaming_upload_unavailable', __('Market Sync requires the PHP cURL extension to stream catalog exports.', 'market-sync'), ['status' => 409] ); } return $this->saas_stream_file($path, $file_path, $fields); } public function saas_delete_json(string $path): array|WP_Error { if (!$this->can_sync()) { return $this->not_connected_error(); } $response = wp_remote_request($this->endpoint_url($path), [ 'method' => 'DELETE', 'timeout' => 20, 'headers' => $this->authorized_headers(), ]); return $this->json_response($response, __('Market Sync API request failed.', 'market-sync')); } private function not_connected_error(): WP_Error { return new WP_Error( 'market_sync_not_connected', __('Store is not connected to Market Sync.', 'market-sync'), ['status' => 409] ); } /** * @return array{ok: bool, message: string, body: array, retryable: bool, status_code: ?int} */ private function request_check_in(bool $defer_failure_status = false): array { $response = wp_remote_post( $this->endpoint_url('/api/woocommerce/check-in/' . rawurlencode(Store::domain())), [ 'timeout' => 15, 'headers' => $this->authorized_headers(), ] ); if (is_wp_error($response)) { $message = $response->get_error_message(); return [ 'ok' => false, 'message' => $message, 'body' => [], 'retryable' => true, 'status_code' => null, ]; } $status_code = (int)wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); $decoded = json_decode($body, true); $decoded = is_array($decoded) ? $decoded : []; if ($status_code < 200 || $status_code >= 300) { $fallback_message = __('Plugin check-in failed.', 'market-sync'); $message = isset($decoded['message']) ? (string)$decoded['message'] : sprintf( /* translators: %1$s: failure message. %2$d: HTTP status code. */ __('%1$s HTTP %2$d.', 'market-sync'), $fallback_message, $status_code ); $message = sanitize_text_field($message); $retryable = $this->is_transient_check_in_failure($status_code); // Manual checks still close the sync gate for permanent failures. // Scheduled checks defer every failure to Check_In_Job, which closes it // once the retry ladder is exhausted. if (!$retryable && !$defer_failure_status) { $this->mark_failed($message); } return [ 'ok' => false, 'message' => $message, 'body' => $decoded, 'retryable' => $retryable, 'status_code' => $status_code > 0 ? $status_code : null, ]; } $this->mark_connected(); return [ 'ok' => true, 'message' => __('Connection verified with Market Sync.', 'market-sync'), 'body' => $decoded, 'retryable' => false, 'status_code' => $status_code, ]; } private function is_transient_check_in_failure(int $status_code): bool { return $status_code <= 0 || 408 === $status_code || 429 === $status_code || $status_code >= 500; } /** * Shared headers for authenticated SaaS calls made with the store token. */ private function authorized_headers(): array { return [ 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $this->store_token(), 'X-ECommerce-Platform' => 'woocommerce', ]; } private function mark_connected(): void { $was = (string)get_option(self::OPTION_CONNECTION_STATUS, ''); update_option(self::OPTION_CONNECTION_STATUS, self::STATUS_CONNECTED, false); update_option(self::OPTION_LAST_ERROR, '', false); if (self::STATUS_CONNECTED !== $was) { Log_Helper::by_area(Log_Helper::LOG_AUTH)->info( 'Connection is healthy.', ['previous_status' => '' === $was ? 'unknown' : $was] ); } } private function mark_failed(string $message): void { $was = (string)get_option(self::OPTION_CONNECTION_STATUS, ''); update_option(self::OPTION_CONNECTION_STATUS, self::STATUS_FAILED, false); $this->save_last_error($message); Log_Helper::by_area(Log_Helper::LOG_AUTH)->error( 'Connection marked as failed.', [ 'previous_status' => '' === $was ? 'unknown' : $was, 'reason' => $message, ] ); } private function handshake_init(bool $reauthorize = false): array|WP_Error { $response = wp_remote_post($this->endpoint_url('/api/request-install/' . rawurlencode(Store::domain()) . '/woocommerce'), [ 'timeout' => 20, 'headers' => [ 'Accept' => 'application/json', 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . MARKET_SYNC_PLUGIN_CLIENT_KEY, ], 'body' => (string)wp_json_encode([ 'domain' => Store::domain(), 'store_url' => Store::url(), 'plugin_version' => MARKET_SYNC_VERSION, 'wp_version' => get_bloginfo('version'), 'woo_version' => defined('WC_VERSION') ? WC_VERSION : '', 'reauthorize' => $reauthorize, ]), ]); $decoded = $this->json_response($response, __('Handshake init failed.', 'market-sync')); if (is_wp_error($decoded)) { return $decoded; } if (empty($decoded)) { return []; } $session = [ 'session_id' => sanitize_text_field((string)($decoded['session_id'] ?? '')), 'challenge_token' => sanitize_text_field((string)($decoded['challenge_token'] ?? '')), 'expires_at' => sanitize_text_field((string)($decoded['expires_at'] ?? '')), 'created_at' => current_time('mysql', true), ]; update_option(self::OPTION_HANDSHAKE_SESSION, $session, false); return $session; } private function handshake_verify(array $session): array|WP_Error { $response = wp_remote_post($this->endpoint_url('/authenticate'), [ 'timeout' => 20, 'headers' => [ 'Accept' => 'application/json', 'Content-Type' => 'application/json', ], 'body' => (string)wp_json_encode([ 'session_id' => sanitize_text_field((string)$session['session_id']), 'platform' => 'woocommerce', 'domain' => Store::domain(), 'challenge_proof' => $this->challenge_proof($session), 'currency' => $this->store_currency(), 'locale' => $this->store_locale(), ]), ]); $decoded = $this->json_response($response, __('Handshake verify failed.', 'market-sync')); if (is_wp_error($decoded)) { return $decoded; } if (empty($decoded)) { return [ 'store_token' => '', 'shop_id' => 0, ]; } return [ 'store_token' => $this->response_value($decoded, ['store_token']), 'shop_id' => absint($decoded['shop_id'] ?? ($decoded['data']['shop_id'] ?? 0)), ]; } private function challenge_proof(array $session): string { $message = implode('|', [ (string)$session['session_id'], (string)$session['challenge_token'], Store::domain(), ]); return hash_hmac('sha256', $message, MARKET_SYNC_PLUGIN_CLIENT_KEY); } /** * Parse a wp_remote_* result and preserve failures as WP_Error so callers * can explicitly choose between propagating the error and using fallback data. */ private function json_response(mixed $response, string $fallback_message): array|WP_Error { if (is_wp_error($response)) { $message = trim($response->get_error_message()); return new WP_Error( 'market_sync_saas_unreachable', '' !== $message ? $message : $fallback_message, ['status' => 502] ); } $status_code = (int)wp_remote_retrieve_response_code($response); $body = (string)wp_remote_retrieve_body($response); return $this->json_body_response($status_code, $body, $fallback_message); } private function saas_stream_file(string $path, string $file_path, array $fields): array|WP_Error { $file_size = @filesize($file_path); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $handle = @fopen($file_path, 'rb'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged if (false === $file_size || false === $handle) { if (is_resource($handle)) { fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the native file handle opened for the streaming upload. } return new WP_Error( 'market_sync_file_unreadable', __('Market Sync export file is not readable.', 'market-sync'), ['status' => 500] ); } $streamed = false; // wp_remote_post() only takes a string body, which would hold the whole export // in memory, and WordPress.org rejects raw curl_* calls in plugin code. The // http_api_curl hook hands us the HTTP API's own cURL handle, // so the body is read straight from the file instead. $stream_body = static function (\CurlHandle $curl) use ($handle, $file_size, &$streamed): void { curl_setopt_array($curl, [ // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt_array -- Stream the request body from the file handle via http_api_curl. CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_UPLOAD => true, CURLOPT_INFILE => $handle, CURLOPT_INFILESIZE => $file_size, ]); $streamed = true; }; add_action('http_api_curl', $stream_body); try { $response = wp_remote_post($this->endpoint_url($path), [ 'timeout' => 600, // The file handle is consumed by the first attempt, so a redirect // would re-send an empty body. 'redirection' => 0, 'headers' => array_merge($this->authorized_headers(), [ 'Content-Type' => 'application/x-ndjson', 'X-Marketplace-Export-Id' => sanitize_text_field((string)($fields['export_id'] ?? '')), 'X-Marketplace-Snapshot-Started-At' => sanitize_text_field((string)($fields['snapshot_started_at'] ?? '')), ]), 'body' => '', ]); } finally { remove_action('http_api_curl', $stream_body); fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the native file handle opened for the streaming upload. } // Without the hook the request went out with an empty body, e.g. when another // plugin swapped the transport or answered through pre_http_request. if (!$streamed && !is_wp_error($response)) { return new WP_Error( 'market_sync_streaming_upload_unavailable', __('Market Sync could not stream the catalog export through the WordPress HTTP API.', 'market-sync'), ['status' => 409] ); } return $this->json_response($response, __('Market Sync catalog export failed.', 'market-sync')); } private function json_body_response(int $status_code, string $body, string $fallback_message): array|WP_Error { $decoded = json_decode($body, true); $has_json_data = is_array($decoded); $decoded = $has_json_data ? $decoded : []; if ($status_code < 200 || $status_code >= 300) { // The SaaS puts its reason under different keys depending on the endpoint. $message = ''; foreach (['message', 'exception', 'error'] as $key) { if (is_string($decoded[$key] ?? null) && '' !== trim($decoded[$key])) { $message = trim($decoded[$key]); break; } } if ('' === $message) { $message = sprintf( /* translators: %1$s: fallback message. %2$d: HTTP status code. */ __('%1$s HTTP %2$d.', 'market-sync'), $fallback_message, $status_code ); } return new WP_Error( 'market_sync_saas_error', sanitize_text_field($message), ['status' => $status_code > 0 ? $status_code : 502] ); } if ('' !== trim($body) && !$has_json_data) { return new WP_Error( 'market_sync_saas_invalid_response', __('Market Sync API returned an invalid response.', 'market-sync'), ['status' => 502] ); } return $decoded; } private function response_value(array $response, array $keys): string { $containers = [ $response, is_array($response['data'] ?? null) ? $response['data'] : [], ]; foreach ($containers as $container) { foreach ($keys as $key) { if (isset($container[$key])) { return sanitize_text_field((string)$container[$key]); } } } return ''; } private function endpoint_url(string $path): string { return $this->saas_base_url() . '/' . ltrim($path, '/'); } private function start_cooldown(string $key, int $duration): void { set_transient($key, time() + $duration, $duration); } private function cooldown_remaining(string $key): int { $expires_at = (int)get_transient($key); return max(0, $expires_at - time()); } private function has_static_config(): bool { return '' !== $this->saas_base_url() && '' !== MARKET_SYNC_PLUGIN_CLIENT_KEY; } private function save_last_error(string $message): void { update_option(self::OPTION_LAST_ERROR, $message, false); } }