HEX
Server: Apache
System: Linux host17.registrar-servers.com 4.18.0-513.18.1.lve.2.el8.x86_64 #1 SMP Sat Mar 30 15:36:11 UTC 2024 x86_64
User: shrsglobal (7178)
PHP: 8.0.30
Disabled: NONE
Upload Files
File: //home/shrsglobal/www/wp-content/plugins/woocommerce/src/Internal/FraudProtection/ApiClient.php
<?php
/**
 * ApiClient class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\FraudProtection;

use Automattic\Jetpack\Connection\Client as Jetpack_Connection_Client;

defined( 'ABSPATH' ) || exit;

/**
 * Handles communication with the WPCOM fraud protection endpoint.
 *
 * Uses Jetpack Connection for authenticated requests to the WPCOM endpoint
 * to get fraud protection decisions (allow, block, or challenge).
 *
 * This class implements a fail-open pattern: if the endpoint is unreachable,
 * times out, or returns an error, it returns an "allow" decision to ensure
 * legitimate transactions are never blocked due to service issues.
 *
 * @since 10.5.0
 * @internal This class is part of the internal API and is subject to change without notice.
 */
class ApiClient {

	/**
	 * Default timeout for API requests in seconds.
	 */
	private const DEFAULT_TIMEOUT = 30;

	/**
	 * WPCOM API version.
	 */
	private const WPCOM_API_VERSION = '2';

	/**
	 * WPCOM fraud protection events endpoint path within Transact platform.
	 */
	private const EVENTS_ENDPOINT = 'transact/fraud_protection/events';

	/**
	 * Decision type: allow session.
	 */
	public const DECISION_ALLOW = 'allow';

	/**
	 * Decision type: block session.
	 */
	public const DECISION_BLOCK = 'block';

	/**
	 * Decision type: challenge session.
	 */
	public const DECISION_CHALLENGE = 'challenge';

	/**
	 * Valid decision values that can be returned by the API.
	 *
	 * @var array<string>
	 */
	public const VALID_DECISIONS = array(
		self::DECISION_ALLOW,
		self::DECISION_BLOCK,
	);

	/**
	 * Send a fraud protection event and get a decision from WPCOM endpoint.
	 *
	 * Implements fail-open pattern: if the endpoint is unreachable or times out,
	 * returns "allow" decision and logs the error.
	 *
	 * @since 10.5.0
	 *
	 * @param string               $event_type Type of event being sent (e.g., 'cart_updated', 'checkout_started').
	 * @param array<string, mixed> $event_data Event data to send to the endpoint.
	 * @return string Decision: "allow" or "block".
	 */
	public function send_event( string $event_type, array $event_data ): string {
		$payload = array_merge(
			array( 'event_type' => $event_type ),
			array_filter( $event_data, fn( $value ) => null !== $value )
		);

		FraudProtectionController::log(
			'info',
			sprintf( 'Sending fraud protection event: %s', $event_type ),
			array( 'payload' => $payload )
		);

		$response = $this->make_request( 'POST', self::EVENTS_ENDPOINT, $payload );

		if ( is_wp_error( $response ) ) {
			$error_data = $response->get_error_data() ?? array();
			$error_data = is_array( $error_data ) ? $error_data : array( 'error' => $error_data );
			FraudProtectionController::log(
				'error',
				sprintf(
					'Event track request failed: %s. Failing open with "allow" decision.',
					$response->get_error_message()
				),
				$error_data
			);
			return self::DECISION_ALLOW;
		}

		if ( ! isset( $response['decision'] ) ) {
			FraudProtectionController::log(
				'error',
				'Response missing "decision" field. Failing open with "allow" decision.',
				array( 'response' => $response )
			);
			return self::DECISION_ALLOW;
		}

		$decision = $response['decision'];

		if ( ! in_array( $decision, self::VALID_DECISIONS, true ) ) {
			FraudProtectionController::log(
				'error',
				sprintf(
					'Invalid decision value "%s". Failing open with "allow" decision.',
					$decision
				),
				array( 'response' => $response )
			);
			return self::DECISION_ALLOW;
		}

		$session    = is_array( $event_data['session'] ?? null ) ? $event_data['session'] : array();
		$session_id = $session['session_id'] ?? 'unknown';
		FraudProtectionController::log(
			'info',
			sprintf(
				'Fraud decision received: %s | Event: %s | Session: %s',
				$decision,
				$event_type,
				$session_id
			),
			array( 'response' => $response )
		);

		return $decision;
	}

	/**
	 * Make an HTTP request to a WPCOM endpoint via Jetpack Connection.
	 *
	 * @param string               $method  HTTP method (GET, POST, etc.).
	 * @param string               $path    Endpoint path (relative to sites/{blog_id}/).
	 * @param array<string, mixed> $payload Request payload.
	 * @return array<string, mixed>|\WP_Error Parsed JSON response or WP_Error on failure.
	 */
	private function make_request( string $method, string $path, array $payload ) {
		if ( ! class_exists( Jetpack_Connection_Client::class ) ) {
			return new \WP_Error(
				'jetpack_not_available',
				'Jetpack Connection is not available'
			);
		}

		$blog_id = $this->get_blog_id();
		if ( ! $blog_id ) {
			return new \WP_Error(
				'no_blog_id',
				'Jetpack blog ID not found. Is the site connected to WordPress.com?'
			);
		}

		$full_path = sprintf( 'sites/%d/%s', $blog_id, $path );

		$body = \wp_json_encode( $payload );

		if ( false === $body ) {
			return new \WP_Error(
				'json_encode_error',
				'Failed to encode payload',
				array( 'payload' => $payload )
			);
		}

		$response = Jetpack_Connection_Client::wpcom_json_api_request_as_blog(
			$full_path,
			self::WPCOM_API_VERSION,
			array(
				'headers' => array( 'Content-Type' => 'application/json' ),
				'method'  => $method,
				'timeout' => self::DEFAULT_TIMEOUT,
			),
			$body,
			'wpcom'
		);

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		/**
		 * Type assertion for PHPStan - Jetpack returns array on success.
		 *
		 * @var array $response
		 */
		$response_code = wp_remote_retrieve_response_code( $response );
		$response_body = wp_remote_retrieve_body( $response );

		$data = json_decode( $response_body, true );

		if ( $response_code >= 300 ) {
			return new \WP_Error(
				'api_error',
				sprintf( 'Endpoint %s returned status code %d', "$method $path", $response_code ),
				array( 'response' => JSON_ERROR_NONE === json_last_error() ? $data : $response_body )
			);
		}

		if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) {
			return new \WP_Error(
				'json_decode_error',
				sprintf( 'Failed to decode JSON response: %s', json_last_error_msg() ),
				array( 'response' => $response_body )
			);
		}

		return $data;
	}

	/**
	 * Get the Jetpack blog ID.
	 *
	 * @return int|false Blog ID or false if not available.
	 */
	private function get_blog_id() {
		if ( ! class_exists( \Jetpack_Options::class ) ) {
			return false;
		}
		return \Jetpack_Options::get_option( 'id' );
	}
}