Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.12% covered (success)
96.12%
99 / 103
84.62% covered (warning)
84.62%
11 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Api_Proxy_Controller
96.12% covered (success)
96.12%
99 / 103
84.62% covered (warning)
84.62%
11 / 13
33
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
 clear_cache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
22 / 22
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%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 fetch_remote
100.00% covered (success)
100.00%
42 / 42
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_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%
3 / 3
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 Automattic\Jetpack\WooCommerceStats\Report_Execution\Report_Executor;
13use Automattic\Jetpack\WooCommerceStats\Report_Execution\Report_Registry;
14use Jetpack_Options;
15use WP_Error;
16use WP_REST_Request;
17use WP_REST_Response;
18use WP_REST_Server;
19
20/**
21 * Serves `/jetpack/v4/woocommerce-stats/proxy/v2/analytics/reports/<report>` through the report
22 * executor, whose only provider is a read of the same path under the connected site on WordPress.com,
23 * signed with the blog token and cached briefly.
24 * Reports follow the store's WooCommerce Analytics date type unless the request names one.
25 *
26 * For now it carries its own forward. The route has the shape of the proxy controller the
27 * connection package is getting, so this class can shrink to a registration on it.
28 *
29 * @since 0.1.0
30 */
31class Api_Proxy_Controller {
32
33    /**
34     * REST namespace of the route.
35     */
36    const REST_NAMESPACE = 'jetpack/v4/woocommerce-stats';
37
38    /**
39     * Path prefix of the report routes.
40     */
41    private const REPORTS_PREFIX = 'analytics/reports/';
42
43    /**
44     * Transient key prefix of the cached responses.
45     */
46    const CACHE_PREFIX = 'jetpack_woocommerce_stats_proxy_';
47
48    /**
49     * How long a successful response stays cached.
50     */
51    private const CACHE_TTL = 5 * MINUTE_IN_SECONDS;
52
53    /**
54     * Option holding the cache generation, part of every cache key.
55     */
56    private const CACHE_GENERATION_OPTION = 'jetpack_woocommerce_stats_proxy_cache_generation';
57
58    /**
59     * Timeout of the request to WordPress.com, in seconds.
60     */
61    private const API_TIMEOUT = 20;
62
63    /**
64     * Response headers passed back to the caller.
65     */
66    private const FORWARDED_HEADERS = array( 'x-wp-total', 'x-wp-totalpages' );
67
68    /**
69     * Register the proxy route. Called on `rest_api_init`.
70     *
71     * @return void
72     */
73    public static function init() {
74        ( new self() )->register_routes();
75    }
76
77    /**
78     * Register the cache prefix with the Stats package's transient cleanup, which runs from cron.
79     *
80     * @param mixed $prefixes Transient prefixes the cleanup sweeps.
81     * @return mixed
82     */
83    public static function register_transient_cleanup_prefix( $prefixes ) {
84        if ( is_array( $prefixes ) ) {
85            $prefixes[] = self::CACHE_PREFIX;
86        }
87
88        return $prefixes;
89    }
90
91    /**
92     * Drop every cached report, such as the empty ones WordPress.com answers with during the
93     * store's initial sync. Bumps the generation instead of deleting, since an object cache can't
94     * list transients by prefix.
95     *
96     * @return void
97     */
98    public static function clear_cache() {
99        update_option( self::CACHE_GENERATION_OPTION, (int) get_option( self::CACHE_GENERATION_OPTION, 0 ) + 1, false );
100    }
101
102    /**
103     * Register the route. It only reads, and only below `analytics/reports/`.
104     *
105     * @return void
106     */
107    public function register_routes() {
108        register_rest_route(
109            self::REST_NAMESPACE,
110            '/proxy/v2/(?P<endpoint>analytics/reports/.+)',
111            array(
112                'methods'             => WP_REST_Server::READABLE,
113                'callback'            => array( $this, 'handle_request' ),
114                'permission_callback' => array( $this, 'check_permission' ),
115                'args'                => array(
116                    'endpoint' => array(
117                        'type'              => 'string',
118                        'required'          => true,
119                        'validate_callback' => array( $this, 'validate_endpoint' ),
120                    ),
121                    'provider' => array(
122                        'description'       => __( 'Provider to serve the report from. Defaults to WordPress.com.', 'jetpack-woocommerce-stats-pkg' ),
123                        'type'              => 'string',
124                        'enum'              => Report_Executor::PROVIDERS,
125                        'validate_callback' => 'rest_validate_request_arg',
126                    ),
127                ),
128            )
129        );
130    }
131
132    /**
133     * Whether the current user may read store reports, by the rule the WooCommerce section uses.
134     *
135     * @return bool
136     */
137    public function check_permission() {
138        return Analytics_Dashboard::current_user_can_view_store_reports();
139    }
140
141    /**
142     * Whether the endpoint is a report path.
143     *
144     * Checked again here: a query-string `endpoint` shadows the route capture in `get_param()`.
145     *
146     * @param mixed $value Raw endpoint param.
147     * @return bool
148     */
149    public function validate_endpoint( $value ) {
150        $value = (string) $value;
151
152        return ! str_contains( $value, '..' ) && (bool) preg_match( '#^analytics/reports/[\w.,/-]+$#', $value );
153    }
154
155    /**
156     * Answer a report from the handler the executor picks.
157     *
158     * @param WP_REST_Request $request Request object.
159     * @return WP_REST_Response|WP_Error The handler's response with its status, or the executor's or handler's error.
160     */
161    public function handle_request( WP_REST_Request $request ) {
162        $route   = substr( (string) $request->get_param( 'endpoint' ), strlen( self::REPORTS_PREFIX ) );
163        $payload = ( new Report_Executor( Report_Registry::create() ) )->execute(
164            $route,
165            $this->get_forwarded_params( $request ),
166            $request->get_param( 'provider' ),
167            null !== $request->get_param( 'force_refresh' )
168        );
169
170        return is_wp_error( $payload ) ? $payload : $this->build_response( $payload );
171    }
172
173    /**
174     * Read a report from the cache, or from WordPress.com. The transport of `Wpcom_Forward_Handler`.
175     *
176     * @param string $route         Route below `analytics/reports/`.
177     * @param array  $params        The report's query params.
178     * @param bool   $force_refresh Whether to skip the cache.
179     * @return array|WP_Error `data`, `status` and `headers`, or an error:
180     *                        `no_connection` (403), the client's own with a 500, `api_error` (502).
181     */
182    public function fetch_remote( $route, array $params, $force_refresh = false ) {
183        // WordPress.com would fall back to the synced option, but only once Jetpack Sync delivers a change.
184        if ( ! isset( $params['date_type'] ) ) {
185            $params['date_type'] = $this->get_store_date_type();
186        }
187
188        $path      = sprintf( '/sites/%d/%s%s', (int) Jetpack_Options::get_option( 'id' ), self::REPORTS_PREFIX, $route );
189        $cache_key = $force_refresh ? null : $this->get_cache_key( $path, $params );
190
191        $cached = null === $cache_key ? false : get_transient( $cache_key );
192        if ( false !== $cached ) {
193            return $cached;
194        }
195
196        if ( ! ( new Manager() )->is_connected() ) {
197            return new WP_Error(
198                'no_connection',
199                __( 'This site is not connected to WordPress.com.', 'jetpack-woocommerce-stats-pkg' ),
200                array( 'status' => 403 )
201            );
202        }
203
204        $response = Client::wpcom_json_api_request_as_blog(
205            $path . '?' . http_build_query( $params ),
206            '2',
207            array(
208                'method'  => 'GET',
209                'timeout' => self::API_TIMEOUT,
210            ),
211            null,
212            'wpcom'
213        );
214        if ( is_wp_error( $response ) ) {
215            $response->add_data( array( 'status' => 500 ) );
216
217            return $response;
218        }
219
220        $status = (int) wp_remote_retrieve_response_code( $response );
221        $data   = json_decode( wp_remote_retrieve_body( $response ), false );
222        if ( 200 === $status && null === $data && JSON_ERROR_NONE !== json_last_error() ) {
223            return new WP_Error(
224                'api_error',
225                __( 'WordPress.com returned an unreadable response.', 'jetpack-woocommerce-stats-pkg' ),
226                array( 'status' => 502 )
227            );
228        }
229
230        $payload = array(
231            'data'    => $data,
232            'status'  => $status,
233            'headers' => $this->get_forwarded_headers( wp_remote_retrieve_headers( $response ) ),
234        );
235        if ( null !== $cache_key && 200 === $status ) {
236            set_transient( $cache_key, $payload, self::CACHE_TTL );
237        }
238
239        return $payload;
240    }
241
242    /**
243     * Build the REST response from a fetched or cached payload.
244     *
245     * @param array $payload `data`, `status` and `headers`.
246     * @return WP_REST_Response
247     */
248    private function build_response( array $payload ) {
249        $response = new WP_REST_Response( $payload['data'], (int) $payload['status'] );
250        $response->set_headers( (array) $payload['headers'] );
251
252        return $response;
253    }
254
255    /**
256     * The report's query params: all but WordPress routing and the proxy's own.
257     *
258     * @param WP_REST_Request $request Request object.
259     * @return array
260     */
261    private function get_forwarded_params( WP_REST_Request $request ) {
262        $params = $request->get_query_params();
263        unset( $params['rest_route'], $params['_locale'], $params['endpoint'], $params['force_refresh'], $params['provider'] );
264
265        return $params;
266    }
267
268    /**
269     * The report API's name for the `woocommerce_date_type` option, defaulting to `paid` as WooCommerce does.
270     *
271     * @return string One of `created`, `paid`, or `completed`.
272     */
273    private function get_store_date_type() {
274        $date_types = array(
275            'date_created'   => 'created',
276            'date_paid'      => 'paid',
277            'date_completed' => 'completed',
278        );
279        $option     = get_option( 'woocommerce_date_type' );
280
281        return is_string( $option ) && isset( $date_types[ $option ] ) ? $date_types[ $option ] : 'paid';
282    }
283
284    /**
285     * The response headers worth keeping.
286     *
287     * @param mixed $headers Response headers as returned by the HTTP API.
288     * @return array<string, string>
289     */
290    private function get_forwarded_headers( $headers ) {
291        $forwarded = array();
292        if ( ! is_array( $headers ) && ! $headers instanceof \ArrayAccess ) {
293            return $forwarded;
294        }
295
296        foreach ( self::FORWARDED_HEADERS as $name ) {
297            if ( isset( $headers[ $name ] ) ) {
298                $forwarded[ $name ] = (string) $headers[ $name ];
299            }
300        }
301
302        return $forwarded;
303    }
304
305    /**
306     * Transient key of a path and its params, whatever their order, in the current cache generation.
307     *
308     * @param string $path   WordPress.com path, without the query string.
309     * @param array  $params Forwarded query params.
310     * @return string
311     */
312    private function get_cache_key( $path, array $params ) {
313        ksort( $params );
314
315        $generation = (int) get_option( self::CACHE_GENERATION_OPTION, 0 );
316
317        return self::CACHE_PREFIX . md5( $generation . '|' . $path . '|' . wp_json_encode( $params, JSON_UNESCAPED_SLASHES ) );
318    }
319}