Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.21% covered (success)
98.21%
55 / 56
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_Client
98.21% covered (success)
98.21%
55 / 56
75.00% covered (warning)
75.00%
3 / 4
23
0.00% covered (danger)
0.00%
0 / 1
 request_as_blog_cached
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 request_as_blog
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
5
 get_wp_error
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 should_bypass_cache
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2/**
3 * A class that wraps `Automattic\Jetpack\Connection\Client` and handles cache and errors.
4 *
5 * @package automattic/jetpack-stats-admin
6 */
7
8namespace Automattic\Jetpack\Stats_Admin;
9
10use Automattic\Jetpack\Connection\Client;
11use WP_Error;
12
13/**
14 * A class that wraps `Automattic\Jetpack\Connection\Client` and handles cache and errors.
15 *
16 * @package Automattic\Jetpack\Stats_Admin
17 */
18class WPCOM_Client {
19    /**
20     * Transient prefix for caching REST API responses.
21     *
22     * @var string
23     */
24    const CACHE_TRANSIENT_PREFIX = 'STATS_REST_RESP_';
25
26    /**
27     * Query the WordPress.com REST API using the blog token cached.
28     *
29     * @param String $path The API endpoint relative path.
30     * @param String $version The API version.
31     * @param array  $args Request arguments.
32     * @param String $body Request body.
33     * @param String $base_api_path (optional) the API base path override, defaults to 'rest'.
34     * @param bool   $use_cache (optional) default to true.
35     * @param string $cache_key (optional) default to null meaning the function auto generates cache key.
36     * @return array|WP_Error $response Data.
37     */
38    public static function request_as_blog_cached( $path, $version = '1.1', $args = array(), $body = null, $base_api_path = 'rest', $use_cache = true, $cache_key = null ) {
39        // Only allow caching GET requests.
40        $use_cache = $use_cache && ! ( isset( $args['method'] ) && strtoupper( $args['method'] ) !== 'GET' ) && ! static::should_bypass_cache();
41
42        // Arrays are serialized without considering the order of objects, but it's okay atm.
43        $cache_key ??= self::CACHE_TRANSIENT_PREFIX . md5( implode( '|', array( $path, $version, wp_json_encode( $args, JSON_UNESCAPED_SLASHES ), wp_json_encode( $body, JSON_UNESCAPED_SLASHES ), $base_api_path ) ) );
44
45        if ( $use_cache ) {
46            $response_body_content = get_transient( $cache_key );
47            if ( false !== $response_body_content ) {
48                return json_decode( $response_body_content, true );
49            }
50        }
51
52        $response_body = static::request_as_blog( $path, $version, $args, $body, $base_api_path );
53
54        if ( is_wp_error( $response_body ) ) {
55            return $response_body;
56        }
57
58        // Cache the response for 5 minutes.
59        set_transient( $cache_key, wp_json_encode( $response_body, JSON_UNESCAPED_SLASHES ), 5 * MINUTE_IN_SECONDS );
60
61        return $response_body;
62    }
63
64    /**
65     * Query the WordPress.com REST API using the blog token
66     *
67     * @param String $path The API endpoint relative path.
68     * @param String $version The API version.
69     * @param array  $args Request arguments.
70     * @param String $body Request body.
71     * @param String $base_api_path (optional) the API base path override, defaults to 'rest'.
72     * @return array|WP_Error $response Data.
73     */
74    public static function request_as_blog( $path, $version = '1.1', $args = array(), $body = null, $base_api_path = 'rest' ) {
75        $response = Client::wpcom_json_api_request_as_blog(
76            $path,
77            $version,
78            $args,
79            $body,
80            $base_api_path
81        );
82
83        if ( is_wp_error( $response ) ) {
84            // `Client` fails before sending anything when the site holds no blog token, and that
85            // error carries no status, which the REST API renders as a 500. Say what actually
86            // happened so callers can tell an unconnected site from a broken one. Newer connection
87            // packages name the reason (`no_possible_tokens`); older ones return `missing_token`.
88            // `malformed_token` means the stored token has no secret half and cannot sign anything.
89            if ( in_array( $response->get_error_code(), array( 'missing_token', 'no_possible_tokens', 'malformed_token' ), true ) ) {
90                return new WP_Error(
91                    'site_not_connected',
92                    __( 'This site is not connected to WordPress.com.', 'jetpack-stats-admin' ),
93                    array( 'status' => 400 )
94                );
95            }
96
97            return $response;
98        }
99
100        $response_code         = wp_remote_retrieve_response_code( $response );
101        $response_body_content = wp_remote_retrieve_body( $response );
102        $response_body         = json_decode( $response_body_content, true );
103
104        $error = static::get_wp_error( $response_body, (int) $response_code );
105        if ( is_wp_error( $error ) ) {
106            // Unknown token keys and incorrect secrets also mean the site cannot authenticate.
107            // Expose these rejections like a missing token so callers can identify the broken connection.
108            if ( in_array( $error->get_error_code(), array( 'invalid_token', 'unknown_token', 'signature_mismatch' ), true ) ) {
109                return new WP_Error(
110                    'site_not_connected',
111                    __( 'This site is not connected to WordPress.com.', 'jetpack-stats-admin' ),
112                    array( 'status' => 400 )
113                );
114            }
115            return $error;
116        }
117
118        return $response_body;
119    }
120
121    /**
122     * Build error object from remote response body and status code.
123     *
124     * @param array $response_body Remote response body.
125     * @param int   $response_code Http response code.
126     * @return WP_Error
127     */
128    protected static function get_wp_error( $response_body, $response_code = 200 ) {
129        $error_code = null;
130        foreach ( array( 'code', 'error' ) as $error_code_key ) {
131            if ( isset( $response_body[ $error_code_key ] ) ) {
132                $error_code = $response_body[ $error_code_key ];
133                break;
134            }
135        }
136
137        // Sometimes the response code could be 200 but the response body still contains an error.
138        if ( $error_code !== null || $response_code !== 200 ) {
139            return new WP_Error(
140                $error_code,
141                $response_body['message'] ?? 'unknown remote error',
142                array( 'status' => $response_code )
143            );
144        }
145
146        // No error.
147        return null;
148    }
149
150    /**
151     * Check if the cache should be bypassed.
152     *
153     * @return bool
154     */
155    protected static function should_bypass_cache() {
156        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
157        return isset( $_GET['force_refresh'] ) || isset( $_GET['statsPurchaseSuccess'] ) ||
158            // phpcs:ignore WordPress.Arrays.ArrayKeySpacingRestrictions.SpacesAroundArrayKeys, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
159            ( isset( $_SERVER[ 'HTTP_REFERER' ] ) && false !== strpos( $_SERVER[ 'HTTP_REFERER' ], 'force_refresh' ) ) ||
160            // phpcs:ignore WordPress.Arrays.ArrayKeySpacingRestrictions.SpacesAroundArrayKeys, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
161            ( isset( $_SERVER[ 'HTTP_REFERER' ] ) && false !== strpos( $_SERVER[ 'HTTP_REFERER' ], 'statsPurchaseSuccess' ) );
162    }
163}