ss, $callback ) )->addMethodCall( 'setHttpClient', [ ClientInterface::class ] ); } /** * Register the various Google classes we use. */ protected function register_google_classes() { $this->add( Client::class )->addMethodCall( 'setHttpClient', [ ClientInterface::class ] ); $this->add( ShoppingContent::class, Client::class, $this->get_connect_server_url_root( 'google/google-mc' ) ); $this->add( SiteVerificationService::class, Client::class, $this->get_connect_server_url_root( 'google/google-sv' ) ); $this->share( GoogleProductService::class, ShoppingContent::class ); $this->share( MerchantApiClient::class, ClientInterface::class, $this->get_connect_server_url_root( 'google/google-merchant' ) ); $this->share( MapiProductsService::class, MerchantApiClient::class ); $this->share( MapiDataSourcesService::class, MerchantApiClient::class ); $this->share( MapiProductInputsService::class, MerchantApiClient::class, MapiDataSourcesService::class ); $this->share( MapiPromotionsService::class, MerchantApiClient::class ); $this->share( MapiAccountIssuesService::class, MerchantApiClient::class ); $this->share( MapiIssueResolutionService::class, MerchantApiClient::class ); $this->share( MapiAccountHomepageService::class, MerchantApiClient::class ); $this->share( MapiAccountBusinessInfoService::class, MerchantApiClient::class ); $this->share( MapiAccountUsersService::class, MerchantApiClient::class ); $this->share( MapiAccountShippingSettingsService::class, MerchantApiClient::class ); $this->share( MapiAccountRegionsService::class, MerchantApiClient::class ); $this->share( MapiAccountServicesService::class, MerchantApiClient::class ); } /** * Middleware that turns the response status into connection state and exceptions: * a successful (2xx) response marks Jetpack as connected and ends a sync pause, a 401 marks * the Jetpack or Google account as disconnected, and any other error status is thrown * as a RequestException. The response status is the only evidence the plugin has about * the Jetpack token, which is why both connection state transitions live here. * * Registered under Guzzle's `http_errors` name, replacing the built-in middleware. * * @return callable */ protected function handle_response_status(): callable { return function ( callable $handler ) { return function ( RequestInterface $request, array $options ) use ( $handler ) { return $handler( $request, $options )->then( function ( ResponseInterface $response ) use ( $request ) { $code = $response->getStatusCode(); $path = $request->getUri()->getPath(); // Partial Failures come back with a status code of 200, so it's necessary to call GoogleAdsFailures:init every time. if ( strpos( $path, 'google-ads' ) !== false ) { GoogleAdsFailures::init(); } if ( $code < 400 ) { // Only a 2xx proves the token is valid. A 3xx is not treated as proof, but // the proxy does not redirect and the client follows any redirect before // this runs, so the final status seen here is 2xx or an error. if ( $code < 300 ) { $this->set_jetpack_connected( true ); $this->get_circuit_breaker()->reset(); } return $response; } if ( 401 === $code ) { $this->handle_unauthorized_error( $request, $response ); } throw RequestException::create( $request, $response ); } ); }; }; } /** * Handle a 401 unauthorized error. * Marks either the Jetpack or the Google account as disconnected. A Jetpack * authentication failure also pauses syncing (see JetpackAuthCircuitBreaker); * a Google one already stops it through the GOOGLE_CONNECTED option. * * @since 1.12.5 * * @param RequestInterface $request * @param ResponseInterface $response * * @throws AccountReconnect When an account must be reconnected. */ protected function handle_unauthorized_error( RequestInterface $request, ResponseInterface $response ) { $auth_header = $response->getHeader( 'www-authenticate' )[0] ?? ''; if ( 0 === strpos( $auth_header, 'X_JP_Auth' ) ) { // Log original exception before throwing reconnect exception. do_action( 'woocommerce_gla_exception', RequestException::create( $request, $response ), __METHOD__ ); $this->set_jetpack_connected( false ); $this->get_circuit_breaker()->trip(); throw AccountReconnect::jetpack_disconnected(); } // Exclude listing customers as it will handle it's own unauthorized errors. $path = $request->getUri()->getPath(); if ( false === strpos( $path, 'customers:listAccessibleCustomers' ) ) { // Log original exception before throwing reconnect exception. do_action( 'woocommerce_gla_exception', RequestException::create( $request, $response ), __METHOD__ ); $this->set_google_disconnected(); throw AccountReconnect::google_disconnected(); } } /** * @return callable */ protected function add_auth_header(): callable { return function ( callable $handler ) { return function ( RequestInterface $request, array $options ) use ( $handler ) { try { $request = $request->withHeader( 'Authorization', $this->generate_auth_header() ); } catch ( WPError $error ) { do_action( 'woocommerce_gla_guzzle_client_exception', $error, __METHOD__ . ' in add_auth_header()' ); $this->set_jetpack_connected( false ); throw AccountReconnect::jetpack_disconnected(); } return $handler( $request, $options ); }; }; } /** * Add client name and version headers to request * * @since 2.4.11 * * @return callable */ protected function add_plugin_version_header(): callable { return function ( callable $handler ) { return function ( RequestInterface $request, array $options ) use ( $handler ) { $request = $request->withHeader( 'x-client-name', $this->get_client_name() ) ->withHeader( 'x-client-version', $this->get_version() ); return $handler( $request, $options ); }; }; } /** * Strip path-bound fields from the ApplyIncentive request body. * * The ApplyIncentive REST config places customer_id and selected_incentive_id * in both the URL path and the JSON body (body: *). WCS cannot handle proto3 * optional fields that appear in both locations, so we remove them from the * body before the request is sent. * * @since 3.3.0 * * @return callable */ protected function strip_apply_incentive_duplicates(): callable { return function ( callable $handler ) { return function ( RequestInterface $request, array $options ) use ( $handler ) { $path = $request->getUri()->getPath(); if ( false !== strpos( $path, ':applyIncentive' ) ) { $body = json_decode( (string) $request->getBody(), true ); if ( is_array( $body ) ) { unset( $body['selectedIncentiveId'], $body['customerId'] ); $request = $request->withBody( Utils::streamFor( wp_json_encode( $body ) ) ); } } return $handler( $request, $options ); }; }; } /** * @return callable */ protected function override_http_url(): callable { return function ( callable $handler ) { return function ( RequestInterface $request, array $options ) use ( $handler ) { $request = $request->withUri( $request->getUri()->withScheme( 'http' ) ); return $handler( $request, $options ); }; }; } /** * Generate the authorization header for the GuzzleClient and GoogleAdsClient. * * @return string Empty if no access token is available. * * @throws WPError If the authorization token isn't found. */ protected function generate_auth_header(): string { /** @var Manager $manager */ $manager = $this->getContainer()->get( Manager::class ); $token = $manager->get_tokens()->get_access_token( false, false, false ); $this->check_for_wp_error( $token ); [ $key, $secret ] = explode( '.', $token->secret ); $key = sprintf( '%s:%d:%d', $key, defined( 'JETPACK__API_VERSION' ) ? JETPACK__API_VERSION : 1, $token->external_user_id ); $timestamp = time() + (int) Jetpack_Options::get_option( 'time_diff' ); $nonce = wp_generate_password( 10, false ); $request = join( "\n", [ $key, $timestamp, $nonce, '' ] ); $signature = base64_encode( hash_hmac( 'sha1', $request, $secret, true ) ); $auth = [ 'token' => $key, 'timestamp' => $timestamp, 'nonce' => $nonce, 'signature' => $signature, ]; $pieces = [ 'X_JP_Auth' ]; foreach ( $auth as $key => $value ) { $pieces[] = sprintf( '%s="%s"', $key, $value ); } return join( ' ', $pieces ); } /** * Get the root Url for the connect server. * * @param string $path (Optional) A path relative to the root to include. * * @return string */ protected function get_connect_server_url_root( string $path = '' ): string { $url = trailingslashit( $this->get_connect_server_url() ); $path = trim( $path, '/' ); return "{$url}{$path}"; } /** * Get the connect server endpoint in the format `domain:port/path` * * @return string */ protected function get_connect_server_endpoint(): string { $parts = wp_parse_url( $this->get_connect_server_url_root( 'google/google-ads' ) ); $port = empty( $parts['port'] ) ? 443 : $parts['port']; return sprintf( '%s:%d%s', $parts['host'], $port, $parts['path'] ); } /** * @return JetpackAuthCircuitBreaker */ protected function get_circuit_breaker(): JetpackAuthCircuitBreaker { return $this->getContainer()->get( JetpackAuthCircuitBreaker::class ); } /** * Set the Google account connection as disconnected. */ protected function set_google_disconnected() { /** @var Options $options */ $options = $this->getContainer()->get( OptionsInterface::class ); $options->update( OptionsInterface::GOOGLE_CONNECTED, false ); } /** * Set the Jetpack account connection. * * @since 1.12.5 * * @param bool $connected Is connected or disconnected? */ protected function set_jetpack_connected( bool $connected ) { /** @var Options $options */ $options = $this->getContainer()->get( OptionsInterface::class ); $previous_connected = boolval( $options->get( OptionsInterface::JETPACK_CONNECTED ) ); // Comparing here avoids a wp_options write on every accepted response: WordPress stores // the value as '1' and compares it strictly against the boolean, so update_option() // would issue an UPDATE each time even though nothing changes. if ( $previous_connected === $connected ) { return; } $options->update( OptionsInterface::JETPACK_CONNECTED, $connected ); $this->jetpack_connected_change( $connected ); } /** * Handle the reconnect notification when the Jetpack connection status changes. * * @since 1.12.5 * * @param boolean $connected */ protected function jetpack_connected_change( bool $connected ) { /** @var ReconnectWordPress $note */ $note = $this->getContainer()->get( ReconnectWordPress::class ); if ( $connected ) { $note->delete(); } else { $note->get_entry()->save(); } } }