'wordpress', 'external_installation_id' => $this->external_installation_id(), 'site_url' => home_url( '/' ), 'verification_path' => $this->challenge_path(), 'client' => array( 'client_version' => TWO_UA_SEO_RELEASE_GUARD_VERSION, 'platform_version' => get_bloginfo( 'version' ), ), ); $response = $this->send( new ApiRequest( 'POST', '/installations/challenges', $this->encode( $payload ), null ), null ); $data = $this->data( $response ); return new Challenge( $this->required_string( $data, 'public_id' ), $this->required_string( $data, 'verification_path' ), $this->required_string( $data, 'verification_token' ), $this->required_string( $data, 'expires_at' ) ); } public function verify_challenge( Challenge $challenge ): Credential { $path = '/installations/challenges/' . rawurlencode( $challenge->public_id ) . '/verify'; $body = $this->encode( array( 'verification_token' => $challenge->verification_token ) ); $data = $this->data( $this->send( new ApiRequest( 'POST', $path, $body, null ), null ) ); return $this->credential( $data ); } public function rotate_secret( string $installation_id, string $secret ): Credential { $path = '/installations/' . rawurlencode( $installation_id ) . '/rotate-secret'; $request = new ApiRequest( 'POST', $path, '[]', $this->idempotency_key() ); return $this->credential( $this->data( $this->send( $request, $secret ) ) ); } public function disconnect( string $installation_id, string $secret ): void { $path = '/installations/' . rawurlencode( $installation_id ); $request = new ApiRequest( 'DELETE', $path, '[]', $this->idempotency_key() ); $this->send( $request, $secret ); } public function send_signed( ApiRequest $request ): ApiResponse { return $this->send( $request, $this->required_option( Options::HMAC_SECRET ) ); } private function send( ApiRequest $request, ?string $secret ): ApiResponse { for ( $attempt = 1; $attempt <= self::MAX_ATTEMPTS; ++$attempt ) { $request_url = $this->api_base_url() . $request->path; $response = wp_safe_remote_request( $request_url, $this->request_arguments( $request, $secret, $request_url ) ); if ( is_wp_error( $response ) ) { if ( $attempt < self::MAX_ATTEMPTS && $this->can_retry( $request ) ) { continue; } $exception = ApiException::categorized( '2UA could not be reached. Please try again.', $this->request_failure_code( $request ) ); throw $exception; } $result = new ApiResponse( (int) wp_remote_retrieve_response_code( $response ), (string) wp_remote_retrieve_body( $response ) ); if ( $this->should_retry( $request, $result, $attempt ) ) { continue; } return $this->successful( $request, $result ); } $exception = ApiException::categorized( '2UA could not complete the request. Please try again.', $this->request_failure_code( $request ) ); throw $exception; } /** * @return array WordPress HTTP API request arguments. */ private function request_arguments( ApiRequest $request, ?string $secret, string $request_url ): array { return array( 'method' => $request->method, 'timeout' => self::TIMEOUT_SECONDS, 'redirection' => 0, 'limit_response_size' => 1048576, 'headers' => $this->headers( $request, $secret, $request_url ), 'body' => $request->body, 'data_format' => 'body', ); } /** * @return array HTTP headers are a WordPress API boundary. */ private function headers( ApiRequest $request, ?string $secret, string $request_url ): array { $headers = array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', ); if ( null !== $secret ) { $headers = array_merge( $headers, $this->signed_headers( $request, $secret, $request_url ) ); } if ( null !== $request->idempotency_key ) { $headers['Idempotency-Key'] = $request->idempotency_key; } return $headers; } /** * @return array HMAC authentication headers. */ private function signed_headers( ApiRequest $request, string $secret, string $request_url ): array { $timestamp = (string) time(); $nonce = str_replace( '-', '', wp_generate_uuid4() ); $path = wp_parse_url( $request_url, PHP_URL_PATH ); $canonical = implode( "\n", array( $request->method, is_string( $path ) ? $path : $request->path, hash( 'sha256', $request->body ), $timestamp, $nonce, ) ); return array( 'X-2UA-Installation' => $this->option_string( Options::INSTALLATION_ID ), 'X-2UA-Timestamp' => $timestamp, 'X-2UA-Nonce' => $nonce, 'X-2UA-Signature' => hash_hmac( 'sha256', $canonical, $secret ), ); } private function successful( ApiRequest $request, ApiResponse $response ): ApiResponse { if ( ! $response->is_successful() ) { $exception = ApiException::categorized( '2UA rejected the request. Please try again.', $this->error_code( $request, $response ) ); throw $exception; } return $response; } private function error_code( ApiRequest $request, ApiResponse $response ): string { if ( in_array( $response->status_code, array( 401, 403 ), true ) ) { return ApiException::AUTHENTICATION; } if ( in_array( $response->status_code, array( 422, 429 ), true ) ) { return ApiException::LIMIT; } if ( 'POST' === $request->method && '/runs' === $request->path ) { return ApiException::QUEUE; } return ApiException::REMOTE_FETCH; } private function request_failure_code( ApiRequest $request ): string { return 'POST' === $request->method && '/runs' === $request->path ? ApiException::QUEUE : ApiException::REMOTE_FETCH; } private function should_retry( ApiRequest $request, ApiResponse $response, int $attempt ): bool { return $attempt < self::MAX_ATTEMPTS && $this->can_retry( $request ) && in_array( $response->status_code, array( 502, 503, 504 ), true ); } private function can_retry( ApiRequest $request ): bool { return 'GET' === $request->method || null !== $request->idempotency_key; } /** * @param array $payload JSON object payload. */ private function encode( array $payload ): string { $encoded = wp_json_encode( $payload ); if ( ! is_string( $encoded ) ) { throw new ApiException( '2UA request data could not be encoded.' ); } return $encoded; } /** * @return array Decoded response data used only inside this client. */ private function data( ApiResponse $response ): array { $decoded = json_decode( $response->body, true ); $data = is_array( $decoded ) ? ( $decoded['data'] ?? null ) : null; if ( ! is_array( $data ) ) { throw new ApiException( '2UA returned an invalid response.' ); } return $data; } /** * @param array $data Decoded API data. */ private function credential( array $data ): Credential { return new Credential( $this->required_string( $data, 'installation_id' ), $this->required_string( $data, 'secret' ), $this->required_string( $data, 'issued_at' ) ); } /** * @param array $data Decoded API data. */ private function required_string( array $data, string $key ): string { $value = $data[ $key ] ?? null; if ( ! is_string( $value ) || '' === $value ) { throw new ApiException( '2UA returned an incomplete response.' ); } return $value; } private function external_installation_id(): string { $external_id = $this->option_string( Options::EXTERNAL_INSTALLATION_ID ); if ( '' !== $external_id ) { return $external_id; } $external_id = wp_generate_uuid4(); update_option( Options::EXTERNAL_INSTALLATION_ID, $external_id, false ); return $external_id; } private function challenge_path(): string { $url = rest_url( '2ua/v1/challenge' ); $query = wp_parse_url( $url, PHP_URL_QUERY ); if ( 'rest_route=/2ua/v1/challenge' === $query ) { $path = wp_parse_url( $url, PHP_URL_PATH ); return ( is_string( $path ) ? $path : '/' ) . '?' . $query; } $path = wp_parse_url( $url, PHP_URL_PATH ); return is_string( $path ) ? $path : '/wp-json/2ua/v1/challenge'; } private function option_string( string $option ): string { $value = get_option( $option, '' ); return is_string( $value ) ? $value : ''; } private function required_option( string $option ): string { $value = $this->option_string( $option ); if ( '' === $value ) { throw new ApiException( 'The local 2UA connection is incomplete. Connect the site again.' ); } return $value; } private function idempotency_key(): string { return 'two-ua-' . str_replace( '-', '', wp_generate_uuid4() ); } private function api_base_url(): string { $base_url = apply_filters( 'two_ua_seo_release_guard_api_base_url', self::API_BASE_URL ); return is_string( $base_url ) ? rtrim( $base_url, '/' ) : self::API_BASE_URL; } }