Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.45% covered (success)
95.45%
84 / 88
81.82% covered (warning)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Api_Proxy_Controller
95.45% covered (success)
95.45%
84 / 88
81.82% covered (warning)
81.82%
9 / 11
31
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
1
 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%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 get_store_date_type
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 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 * Reports follow the store's WooCommerce Analytics date type unless the request names one.
22 *
23 * For now it carries its own forward. The route has the shape of the proxy controller the
24 * connection package is getting, so this class can shrink to a registration on it.
25 *
26 * @since 0.1.0
27 */
28class Api_Proxy_Controller {
29
30    /**
31     * REST namespace of the route.
32     */
33    const REST_NAMESPACE = 'jetpack/v4/woocommerce-stats';
34
35    /**
36     * Transient key prefix of the cached responses.
37     */
38    const CACHE_PREFIX = 'jetpack_woocommerce_stats_proxy_';
39
40    /**
41     * How long a successful response stays cached.
42     */
43    private const CACHE_TTL = 5 * MINUTE_IN_SECONDS;
44
45    /**
46     * Timeout of the request to WordPress.com, in seconds.
47     */
48    private const API_TIMEOUT = 20;
49
50    /**
51     * Response headers passed back to the caller.
52     */
53    private const FORWARDED_HEADERS = array( 'x-wp-total', 'x-wp-totalpages' );
54
55    /**
56     * Register the proxy route. Called on `rest_api_init`.
57     *
58     * @return void
59     */
60    public static function init() {
61        ( new self() )->register_routes();
62    }
63
64    /**
65     * Register the cache prefix with the Stats package's transient cleanup, which runs from cron.
66     *
67     * @param mixed $prefixes Transient prefixes the cleanup sweeps.
68     * @return mixed
69     */
70    public static function register_transient_cleanup_prefix( $prefixes ) {
71        if ( is_array( $prefixes ) ) {
72            $prefixes[] = self::CACHE_PREFIX;
73        }
74
75        return $prefixes;
76    }
77
78    /**
79     * Register the route. It only reads, and only below `analytics/reports/`.
80     *
81     * @return void
82     */
83    public function register_routes() {
84        register_rest_route(
85            self::REST_NAMESPACE,
86            '/proxy/v2/(?P<endpoint>analytics/reports/.+)',
87            array(
88                'methods'             => WP_REST_Server::READABLE,
89                'callback'            => array( $this, 'handle_request' ),
90                'permission_callback' => array( $this, 'check_permission' ),
91                'args'                => array(
92                    'endpoint' => array(
93                        'type'              => 'string',
94                        'required'          => true,
95                        'validate_callback' => array( $this, 'validate_endpoint' ),
96                    ),
97                ),
98            )
99        );
100    }
101
102    /**
103     * Whether the current user may read store reports, by the rule the WooCommerce section uses.
104     *
105     * @return bool
106     */
107    public function check_permission() {
108        return Analytics_Dashboard::current_user_can_view_store_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, with the store's
203     * date type when the caller names none.
204     *
205     * WordPress.com would fall back to the synced option, but only once Jetpack Sync delivers a change.
206     *
207     * @param WP_REST_Request $request Request object.
208     * @return array
209     */
210    private function get_forwarded_params( WP_REST_Request $request ) {
211        $params = $request->get_query_params();
212        unset( $params['rest_route'], $params['_locale'], $params['endpoint'], $params['force_refresh'] );
213
214        if ( ! isset( $params['date_type'] ) ) {
215            $params['date_type'] = $this->get_store_date_type();
216        }
217
218        return $params;
219    }
220
221    /**
222     * The report API's name for the `woocommerce_date_type` option, defaulting to `paid` as WooCommerce does.
223     *
224     * @return string One of `created`, `paid`, or `completed`.
225     */
226    private function get_store_date_type() {
227        $date_types = array(
228            'date_created'   => 'created',
229            'date_paid'      => 'paid',
230            'date_completed' => 'completed',
231        );
232        $option     = get_option( 'woocommerce_date_type' );
233
234        return is_string( $option ) && isset( $date_types[ $option ] ) ? $date_types[ $option ] : 'paid';
235    }
236
237    /**
238     * The response headers worth keeping.
239     *
240     * @param mixed $headers Response headers as returned by the HTTP API.
241     * @return array<string, string>
242     */
243    private function get_forwarded_headers( $headers ) {
244        $forwarded = array();
245        if ( ! is_array( $headers ) && ! $headers instanceof \ArrayAccess ) {
246            return $forwarded;
247        }
248
249        foreach ( self::FORWARDED_HEADERS as $name ) {
250            if ( isset( $headers[ $name ] ) ) {
251                $forwarded[ $name ] = (string) $headers[ $name ];
252            }
253        }
254
255        return $forwarded;
256    }
257
258    /**
259     * Transient key of a path and its params, whatever their order.
260     *
261     * @param string $path   WordPress.com path, without the query string.
262     * @param array  $params Forwarded query params.
263     * @return string
264     */
265    private function get_cache_key( $path, array $params ) {
266        ksort( $params );
267
268        return self::CACHE_PREFIX . md5( $path . '|' . wp_json_encode( $params, JSON_UNESCAPED_SLASHES ) );
269    }
270}