$filtered ) ); return $resolved; } return $filtered; } /** * Whether a base URL is one signed traffic may be sent to. * * Delegates to the single allowlist in AdvisorPPC_Connect so there is one * definition of "an AdvisorPPC host". Without that class the allowlist * cannot be consulted, so nothing is trusted and the caller falls back. * * @param string $url Candidate base URL. * @return bool */ public static function is_allowed_base( $url ) { if ( ! class_exists( 'AdvisorPPC_Connect' ) || ! method_exists( 'AdvisorPPC_Connect', 'is_allowed_service_url' ) ) { return false; } return (bool) AdvisorPPC_Connect::is_allowed_service_url( $url ); } /** * Absolute URL for an API path. * * @param string $path Path such as /wp/v1/health. * @return string */ public static function url( $path ) { $path = '/' . ltrim( (string) $path, '/' ); return self::api_base() . $path; } /** * The User-Agent every request carries. * * @return string */ public static function user_agent() { return 'AdvisorPPC-WP/' . ADVISORPPC_VERSION . '; ' . home_url( '/' ); } /** * GET an API path. * * @param string $path Path. * @param array $query Query arguments. * @param array $args Options: timeout, retries, allow_unconnected, signed, headers. * @return array|WP_Error Normalised response, or WP_Error on transport failure. */ public static function get( $path, $query = array(), $args = array() ) { $url = self::url( $path ); if ( ! empty( $query ) && is_array( $query ) ) { // add_query_arg() url-encodes the values; do not pre-encode them. $url = add_query_arg( array_map( 'strval', $query ), $url ); } $request = array( 'method' => 'GET', 'headers' => self::headers( '', $args ), ); return self::request( $url, $request, $args ); } /** * POST a JSON body to an API path. Unsigned. * * @param string $path Path. * @param array $body Body, JSON encoded. * @param array $args Options. * @return array|WP_Error */ public static function post( $path, $body = array(), $args = array() ) { $raw = wp_json_encode( $body ); if ( ! is_string( $raw ) ) { return new WP_Error( 'advisorppc_encode_failed', __( 'Could not encode the request body.', 'advisorppc' ) ); } $request = array( 'method' => 'POST', 'headers' => self::headers( $raw, $args ), 'body' => $raw, ); return self::request( self::url( $path ), $request, $args ); } /** * POST a JSON body signed with the site secret. * * Sends, per the build contract: * X-AdvisorPPC-Site-Key * X-AdvisorPPC-Timestamp * X-AdvisorPPC-Signature: sha256= * * @param string $path Path. * @param array $body Body. * @param array $args Options. * @return array|WP_Error */ public static function signed_post( $path, $body = array(), $args = array() ) { $site_key = AdvisorPPC_Options::get_site_key(); $secret = AdvisorPPC_Options::get_secret(); if ( '' === $site_key || '' === $secret ) { return new WP_Error( 'advisorppc_not_connected', __( 'This site is not connected to an AdvisorPPC workspace yet.', 'advisorppc' ) ); } $raw = wp_json_encode( $body ); if ( ! is_string( $raw ) ) { return new WP_Error( 'advisorppc_encode_failed', __( 'Could not encode the request body.', 'advisorppc' ) ); } $timestamp = (string) time(); $args = is_array( $args ) ? $args : array(); $extra = isset( $args['headers'] ) && is_array( $args['headers'] ) ? $args['headers'] : array(); $args['headers'] = array_merge( $extra, array( 'X-AdvisorPPC-Site-Key' => $site_key, 'X-AdvisorPPC-Timestamp' => $timestamp, 'X-AdvisorPPC-Signature' => self::sign( $raw, $timestamp, $secret ), ) ); $request = array( 'method' => 'POST', 'headers' => self::headers( $raw, $args ), 'body' => $raw, ); return self::request( self::url( $path ), $request, $args ); } /** * Compute the signature header value for a raw body. * * AdvisorPPC_Signer is the canonical implementation of the signing rule. * This delegates to it whenever it is present so there is exactly one * definition of how a request is signed. The inline fallback exists only * so the shell still signs correctly in a build that does not ship the * server-side conversion module. * * @param string $raw_body Exact bytes that will be sent. * @param string $timestamp Unix seconds, as a string. * @param string $secret Site secret. * @return string sha256=, or an empty string when there is no secret. */ public static function sign( $raw_body, $timestamp, $secret ) { if ( class_exists( 'AdvisorPPC_Signer' ) ) { return AdvisorPPC_Signer::signature( $raw_body, $timestamp, $secret ); } return 'sha256=' . hash_hmac( 'sha256', (string) (int) $timestamp . '.' . $raw_body, $secret ); } /** * Whether a normalised response is a 2xx. * * @param mixed $response Result of get()/post()/signed_post(). * @return bool */ public static function is_success( $response ) { if ( is_wp_error( $response ) || ! is_array( $response ) || ! isset( $response['status'] ) ) { return false; } return $response['status'] >= 200 && $response['status'] < 300; } /** * A human-readable error for a failed call, safe to show an administrator. * * @param mixed $response Result of get()/post()/signed_post(). * @return string */ public static function error_message( $response ) { if ( is_wp_error( $response ) ) { return $response->get_error_message(); } if ( is_array( $response ) && isset( $response['body']['error'] ) && is_string( $response['body']['error'] ) ) { return $response['body']['error']; } if ( is_array( $response ) && isset( $response['body']['message'] ) && is_string( $response['body']['message'] ) ) { return $response['body']['message']; } $status = is_array( $response ) && isset( $response['status'] ) ? (int) $response['status'] : 0; /* translators: %d: HTTP status code. */ return sprintf( __( 'The AdvisorPPC service returned an unexpected response (HTTP %d).', 'advisorppc' ), $status ); } /** * Build the request headers. * * @param string $raw_body Raw body, empty for GET. * @param array $args Options. * @return array */ protected static function headers( $raw_body, $args ) { $headers = array( 'Accept' => 'application/json', ); if ( '' !== $raw_body ) { $headers['Content-Type'] = 'application/json'; } if ( is_array( $args ) && isset( $args['headers'] ) && is_array( $args['headers'] ) ) { $headers = array_merge( $headers, $args['headers'] ); } return $headers; } /** * Whether this build may contact an AdvisorPPC host at all. * * @return bool */ public static function workspace_enabled() { return class_exists( 'AdvisorPPC_Options' ) && method_exists( 'AdvisorPPC_Options', 'workspace_enabled' ) && AdvisorPPC_Options::workspace_enabled(); } /** * Perform the request. * * @param string $url Absolute URL. * @param array $request wp_remote_request arguments. * @param array $args Options. * @return array|WP_Error */ protected static function request( $url, $request, $args ) { $args = is_array( $args ) ? $args : array(); if ( ! self::workspace_enabled() ) { /* * Workspace features are off in this build (ADVISORPPC_WORKSPACE). * Checked before allow_unconnected, so not even the connect * exchange can reach an AdvisorPPC host. */ return new WP_Error( 'advisorppc_workspace_disabled', __( 'AdvisorPPC workspace features are not available in this version.', 'advisorppc' ) ); } $allow_unconnected = ! empty( $args['allow_unconnected'] ); if ( ! $allow_unconnected && ! AdvisorPPC_Options::is_connected() ) { /* * Guideline 7. Nothing contacts the service until the site owner * has connected an account. This is the choke point that makes * that true for every lane, not a promise in a comment. */ return new WP_Error( 'advisorppc_not_connected', __( 'This site is not connected to an AdvisorPPC workspace yet.', 'advisorppc' ) ); } $timeout = isset( $args['timeout'] ) ? (int) $args['timeout'] : self::DEFAULT_TIMEOUT; $timeout = max( 1, min( 30, $timeout ) ); $retries = isset( $args['retries'] ) ? (int) $args['retries'] : 0; $retries = max( 0, min( self::MAX_RETRIES, $retries ) ); $request = array_merge( $request, array( 'timeout' => $timeout, 'redirection' => 2, 'sslverify' => true, 'user-agent' => self::user_agent(), 'blocking' => isset( $args['blocking'] ) ? (bool) $args['blocking'] : true, ) ); $attempt = 0; $response = null; while ( $attempt <= $retries ) { if ( $attempt > 0 ) { // Short linear backoff. Deliberately small: this can run in a // page request and must never hold a customer's page hostage. usleep( 400000 * $attempt ); } $response = wp_remote_request( $url, $request ); $attempt++; if ( is_wp_error( $response ) ) { if ( $attempt > $retries ) { AdvisorPPC_Logger::error( 'HTTP transport error', array( 'url' => $url, 'error' => $response->get_error_message(), ) ); return $response; } continue; } $status = (int) wp_remote_retrieve_response_code( $response ); if ( 429 === $status || $status >= 500 ) { if ( $attempt > $retries ) { break; } continue; } break; } if ( is_wp_error( $response ) ) { return $response; } if ( null === $response ) { return new WP_Error( 'advisorppc_no_response', __( 'No response from the AdvisorPPC service.', 'advisorppc' ) ); } $status = (int) wp_remote_retrieve_response_code( $response ); $raw = (string) wp_remote_retrieve_body( $response ); $parsed = json_decode( $raw, true ); $normalised = array( 'status' => $status, 'body' => is_array( $parsed ) ? $parsed : null, 'raw' => $raw, 'headers' => wp_remote_retrieve_headers( $response ), ); AdvisorPPC_Logger::debug( 'HTTP response', array( 'url' => $url, 'status' => $status, ) ); return $normalised; } }