Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.94% covered (success)
94.94%
75 / 79
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Api_Proxy_Controller
94.94% covered (success)
94.94%
75 / 79
80.00% covered (warning)
80.00%
8 / 10
28.10
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_transient_cleanup_prefix
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 register_routes
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 check_permission
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 validate_endpoint
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 handle_request
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
12
 build_response
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get_forwarded_params
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get_forwarded_headers
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 get_cache_key
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Proxy for the WooCommerce analytics reports of WordPress.com.
4 *
5 * @package automattic/jetpack-woocommerce-stats
6 */
7
8namespace Automattic\Jetpack\WooCommerceStats;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager;
12use Jetpack_Options;
13use WP_Error;
14use WP_REST_Request;
15use WP_REST_Response;
16use WP_REST_Server;
17
18/**
19 * Serves `/jetpack/v4/woocommerce-stats/proxy/v2/analytics/reports/<report>`: a read of the same
20 * path under the connected site on WordPress.com, signed with the blog token and cached briefly.
21 *
22 * For now it carries its own forward. The route has the shape of the proxy controller the
23 * connection package is getting, so this class can shrink to a registration on it.
24 *
25 * @since $$next-version$$
26 */
27class Api_Proxy_Controller {
28
29    /**
30     * REST namespace of the route.
31     */
32    const REST_NAMESPACE = 'jetpack/v4/woocommerce-stats';
33
34    /**
35     * Transient key prefix of the cached responses.
36     */
37    const CACHE_PREFIX = 'jetpack_woocommerce_stats_proxy_';
38
39    /**
40     * How long a successful response stays cached.
41     */
42    private const CACHE_TTL = 5 * MINUTE_IN_SECONDS;
43
44    /**
45     * Timeout of the request to WordPress.com, in seconds.
46     */
47    private const API_TIMEOUT = 20;
48
49    /**
50     * Response headers passed back to the caller.
51     */
52    private const FORWARDED_HEADERS = array( 'x-wp-total', 'x-wp-totalpages' );
53
54    /**
55     * Register the proxy route. Called on `rest_api_init`.
56     *
57     * @return void
58     */
59    public static function init() {
60        ( new self() )->register_routes();
61    }
62
63    /**
64     * Register the cache prefix with the Stats package's transient cleanup, which runs from cron.
65     *
66     * @param mixed $prefixes Transient prefixes the cleanup sweeps.
67     * @return mixed
68     */
69    public static function register_transient_cleanup_prefix( $prefixes ) {
70        if ( is_array( $prefixes ) ) {
71            $prefixes[] = self::CACHE_PREFIX;
72        }
73
74        return $prefixes;
75    }
76
77    /**
78     * Register the route. It only reads, and only below `analytics/reports/`.
79     *
80     * @return void
81     */
82    public function register_routes() {
83        register_rest_route(
84            self::REST_NAMESPACE,
85            '/proxy/v2/(?P<endpoint>analytics/reports/.+)',
86            array(
87                'methods'             => WP_REST_Server::READABLE,
88                'callback'            => array( $this, 'handle_request' ),
89                'permission_callback' => array( $this, 'check_permission' ),
90                'args'                => array(
91                    'endpoint' => array(
92                        'type'              => 'string',
93                        'required'          => true,
94                        'validate_callback' => array( $this, 'validate_endpoint' ),
95                    ),
96                ),
97            )
98        );
99    }
100
101    /**
102     * Whether the current user may read store reports, by the rule the WooCommerce section uses.
103     *
104     * @return bool
105     */
106    public function check_permission() {
107        // phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce registers this capability.
108        return current_user_can( 'manage_options' ) || current_user_can( 'view_woocommerce_reports' );
109    }
110
111    /**
112     * Whether the endpoint is a report path.
113     *
114     * Checked again here: a query-string `endpoint` shadows the route capture in `get_param()`.
115     *
116     * @param mixed $value Raw endpoint param.
117     * @return bool
118     */
119    public function validate_endpoint( $value ) {
120        $value = (string) $value;
121
122        return ! str_contains( $value, '..' ) && (bool) preg_match( '#^analytics/reports/[\w.,/-]+$#', $value );
123    }
124
125    /**
126     * Answer a report from the cache, or from WordPress.com.
127     *
128     * @param WP_REST_Request $request Request object.
129     * @return WP_REST_Response|WP_Error The response of WordPress.com with its status, or an error:
130     *                                   `no_connection` (403), the client's own with a 500, `api_error` (502).
131     */
132    public function handle_request( WP_REST_Request $request ) {
133        $path      = sprintf( '/sites/%d/%s', (int) Jetpack_Options::get_option( 'id' ), $request->get_param( 'endpoint' ) );
134        $params    = $this->get_forwarded_params( $request );
135        $cache_key = null === $request->get_param( 'force_refresh' ) ? $this->get_cache_key( $path, $params ) : null;
136
137        $cached = null === $cache_key ? false : get_transient( $cache_key );
138        if ( false !== $cached ) {
139            return $this->build_response( $cached );
140        }
141
142        if ( ! ( new Manager() )->is_connected() ) {
143            return new WP_Error(
144                'no_connection',
145                __( 'This site is not connected to WordPress.com.', 'jetpack-woocommerce-stats-pkg' ),
146                array( 'status' => 403 )
147            );
148        }
149
150        $response = Client::wpcom_json_api_request_as_blog(
151            $params ? $path . '?' . http_build_query( $params ) : $path,
152            '2',
153            array(
154                'method'  => 'GET',
155                'timeout' => self::API_TIMEOUT,
156            ),
157            null,
158            'wpcom'
159        );
160        if ( is_wp_error( $response ) ) {
161            $response->add_data( array( 'status' => 500 ) );
162
163            return $response;
164        }
165
166        $status = (int) wp_remote_retrieve_response_code( $response );
167        $data   = json_decode( wp_remote_retrieve_body( $response ), false );
168        if ( 200 === $status && null === $data && JSON_ERROR_NONE !== json_last_error() ) {
169            return new WP_Error(
170                'api_error',
171                __( 'WordPress.com returned an unreadable response.', 'jetpack-woocommerce-stats-pkg' ),
172                array( 'status' => 502 )
173            );
174        }
175
176        $payload = array(
177            'data'    => $data,
178            'status'  => $status,
179            'headers' => $this->get_forwarded_headers( wp_remote_retrieve_headers( $response ) ),
180        );
181        if ( null !== $cache_key && 200 === $status ) {
182            set_transient( $cache_key, $payload, self::CACHE_TTL );
183        }
184
185        return $this->build_response( $payload );
186    }
187
188    /**
189     * Build the REST response from a fetched or cached payload.
190     *
191     * @param array $payload `data`, `status` and `headers`.
192     * @return WP_REST_Response
193     */
194    private function build_response( array $payload ) {
195        $response = new WP_REST_Response( $payload['data'], (int) $payload['status'] );
196        $response->set_headers( (array) $payload['headers'] );
197
198        return $response;
199    }
200
201    /**
202     * The query params to forward: all but WordPress routing and the proxy's own.
203     *
204     * @param WP_REST_Request $request Request object.
205     * @return array
206     */
207    private function get_forwarded_params( WP_REST_Request $request ) {
208        $params = $request->get_query_params();
209        unset( $params['rest_route'], $params['_locale'], $params['endpoint'], $params['force_refresh'] );
210
211        return $params;
212    }
213
214    /**
215     * The response headers worth keeping.
216     *
217     * @param mixed $headers Response headers as returned by the HTTP API.
218     * @return array<string, string>
219     */
220    private function get_forwarded_headers( $headers ) {
221        $forwarded = array();
222        if ( ! is_array( $headers ) && ! $headers instanceof \ArrayAccess ) {
223            return $forwarded;
224        }
225
226        foreach ( self::FORWARDED_HEADERS as $name ) {
227            if ( isset( $headers[ $name ] ) ) {
228                $forwarded[ $name ] = (string) $headers[ $name ];
229            }
230        }
231
232        return $forwarded;
233    }
234
235    /**
236     * Transient key of a path and its params, whatever their order.
237     *
238     * @param string $path   WordPress.com path, without the query string.
239     * @param array  $params Forwarded query params.
240     * @return string
241     */
242    private function get_cache_key( $path, array $params ) {
243        ksort( $params );
244
245        return self::CACHE_PREFIX . md5( $path . '|' . wp_json_encode( $params, JSON_UNESCAPED_SLASHES ) );
246    }
247}