Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.52% covered (success)
96.52%
111 / 115
85.71% covered (warning)
85.71%
12 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Api_Proxy_Controller
96.52% covered (success)
96.52%
111 / 115
85.71% covered (warning)
85.71%
12 / 14
35
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%
29 / 29
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%
9 / 9
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_requested_fields
100.00% covered (success)
100.00%
4 / 4
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%
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     * Param naming the summary fields a caller reads. Not `fields`, which WordPress.com reads on its own routes and is forwarded.
65     */
66    private const FIELDS_PARAM = 'report_fields';
67
68    /**
69     * Response headers passed back to the caller.
70     */
71    private const FORWARDED_HEADERS = array( 'x-wp-total', 'x-wp-totalpages' );
72
73    /**
74     * Register the proxy route. Called on `rest_api_init`.
75     *
76     * @return void
77     */
78    public static function init() {
79        ( new self() )->register_routes();
80    }
81
82    /**
83     * Register the cache prefix with the Stats package's transient cleanup, which runs from cron.
84     *
85     * @param mixed $prefixes Transient prefixes the cleanup sweeps.
86     * @return mixed
87     */
88    public static function register_transient_cleanup_prefix( $prefixes ) {
89        if ( is_array( $prefixes ) ) {
90            $prefixes[] = self::CACHE_PREFIX;
91        }
92
93        return $prefixes;
94    }
95
96    /**
97     * Drop every cached report, such as the empty ones WordPress.com answers with during the
98     * store's initial sync. Bumps the generation instead of deleting, since an object cache can't
99     * list transients by prefix.
100     *
101     * @return void
102     */
103    public static function clear_cache() {
104        update_option( self::CACHE_GENERATION_OPTION, (int) get_option( self::CACHE_GENERATION_OPTION, 0 ) + 1, false );
105    }
106
107    /**
108     * Register the route. It only reads, and only below `analytics/reports/`.
109     *
110     * @return void
111     */
112    public function register_routes() {
113        register_rest_route(
114            self::REST_NAMESPACE,
115            '/proxy/v2/(?P<endpoint>analytics/reports/.+)',
116            array(
117                'methods'             => WP_REST_Server::READABLE,
118                'callback'            => array( $this, 'handle_request' ),
119                'permission_callback' => array( $this, 'check_permission' ),
120                'args'                => array(
121                    'endpoint'         => array(
122                        'type'              => 'string',
123                        'required'          => true,
124                        'validate_callback' => array( $this, 'validate_endpoint' ),
125                    ),
126                    'provider'         => array(
127                        'description'       => __( 'Provider to serve the report from. Defaults to WordPress.com.', 'jetpack-woocommerce-stats-pkg' ),
128                        'type'              => 'string',
129                        'enum'              => Report_Executor::PROVIDERS,
130                        'validate_callback' => 'rest_validate_request_arg',
131                    ),
132                    self::FIELDS_PARAM => array(
133                        'description'       => __( 'Summary fields the caller reads, comma-separated. Defaults to every field of the report.', 'jetpack-woocommerce-stats-pkg' ),
134                        'type'              => 'array',
135                        'items'             => array( 'type' => 'string' ),
136                        'validate_callback' => 'rest_validate_request_arg',
137                        'sanitize_callback' => 'rest_sanitize_request_arg',
138                    ),
139                ),
140            )
141        );
142    }
143
144    /**
145     * Whether the current user may read store reports, by the rule the WooCommerce section uses.
146     *
147     * @return bool
148     */
149    public function check_permission() {
150        return Analytics_Dashboard::current_user_can_view_store_reports();
151    }
152
153    /**
154     * Whether the endpoint is a report path.
155     *
156     * Checked again here: a query-string `endpoint` shadows the route capture in `get_param()`.
157     *
158     * @param mixed $value Raw endpoint param.
159     * @return bool
160     */
161    public function validate_endpoint( $value ) {
162        $value = (string) $value;
163
164        return ! str_contains( $value, '..' ) && (bool) preg_match( '#^analytics/reports/[\w.,/-]+$#', $value );
165    }
166
167    /**
168     * Answer a report from the handler the executor picks.
169     *
170     * @param WP_REST_Request $request Request object.
171     * @return WP_REST_Response|WP_Error The handler's response with its status, or the executor's or handler's error.
172     */
173    public function handle_request( WP_REST_Request $request ) {
174        $route   = substr( (string) $request->get_param( 'endpoint' ), strlen( self::REPORTS_PREFIX ) );
175        $payload = ( new Report_Executor( Report_Registry::create() ) )->execute(
176            $route,
177            $this->get_forwarded_params( $request ),
178            $request->get_param( 'provider' ),
179            null !== $request->get_param( 'force_refresh' ),
180            $this->get_requested_fields( $request )
181        );
182
183        return is_wp_error( $payload ) ? $payload : $this->build_response( $payload );
184    }
185
186    /**
187     * Read a report from the cache, or from WordPress.com. The transport of `Wpcom_Forward_Handler`.
188     *
189     * @param string $route         Route below `analytics/reports/`.
190     * @param array  $params        The report's query params.
191     * @param bool   $force_refresh Whether to skip the cache.
192     * @return array|WP_Error `data`, `status` and `headers`, or an error:
193     *                        `no_connection` (403), the client's own with a 500, `api_error` (502).
194     */
195    public function fetch_remote( $route, array $params, $force_refresh = false ) {
196        // WordPress.com would fall back to the synced option, but only once Jetpack Sync delivers a change.
197        if ( ! isset( $params['date_type'] ) ) {
198            $params['date_type'] = $this->get_store_date_type();
199        }
200
201        $path      = sprintf( '/sites/%d/%s%s', (int) Jetpack_Options::get_option( 'id' ), self::REPORTS_PREFIX, $route );
202        $cache_key = $force_refresh ? null : $this->get_cache_key( $path, $params );
203
204        $cached = null === $cache_key ? false : get_transient( $cache_key );
205        if ( false !== $cached ) {
206            return $cached;
207        }
208
209        if ( ! ( new Manager() )->is_connected() ) {
210            return new WP_Error(
211                'no_connection',
212                __( 'This site is not connected to WordPress.com.', 'jetpack-woocommerce-stats-pkg' ),
213                array( 'status' => 403 )
214            );
215        }
216
217        $response = Client::wpcom_json_api_request_as_blog(
218            $path . '?' . http_build_query( $params ),
219            '2',
220            array(
221                'method'  => 'GET',
222                'timeout' => self::API_TIMEOUT,
223            ),
224            null,
225            'wpcom'
226        );
227        if ( is_wp_error( $response ) ) {
228            $response->add_data( array( 'status' => 500 ) );
229
230            return $response;
231        }
232
233        $status = (int) wp_remote_retrieve_response_code( $response );
234        $data   = json_decode( wp_remote_retrieve_body( $response ), false );
235        if ( 200 === $status && null === $data && JSON_ERROR_NONE !== json_last_error() ) {
236            return new WP_Error(
237                'api_error',
238                __( 'WordPress.com returned an unreadable response.', 'jetpack-woocommerce-stats-pkg' ),
239                array( 'status' => 502 )
240            );
241        }
242
243        $payload = array(
244            'data'    => $data,
245            'status'  => $status,
246            'headers' => $this->get_forwarded_headers( wp_remote_retrieve_headers( $response ) ),
247        );
248        if ( null !== $cache_key && 200 === $status ) {
249            set_transient( $cache_key, $payload, self::CACHE_TTL );
250        }
251
252        return $payload;
253    }
254
255    /**
256     * Build the REST response from a fetched or cached payload.
257     *
258     * @param array $payload `data`, `status` and `headers`.
259     * @return WP_REST_Response
260     */
261    private function build_response( array $payload ) {
262        $response = new WP_REST_Response( $payload['data'], (int) $payload['status'] );
263        $response->set_headers( (array) $payload['headers'] );
264
265        return $response;
266    }
267
268    /**
269     * The report's query params: all but WordPress routing and the proxy's own.
270     *
271     * @param WP_REST_Request $request Request object.
272     * @return array
273     */
274    private function get_forwarded_params( WP_REST_Request $request ) {
275        $params = $request->get_query_params();
276        unset( $params['rest_route'], $params['_locale'], $params['endpoint'], $params['force_refresh'], $params['provider'], $params[ self::FIELDS_PARAM ] );
277
278        return $params;
279    }
280
281    /**
282     * The summary fields the request names, or null when it names none.
283     *
284     * @param WP_REST_Request $request Request object.
285     * @return string[]|null
286     */
287    private function get_requested_fields( WP_REST_Request $request ) {
288        $fields = $request->get_param( self::FIELDS_PARAM );
289        if ( null === $fields ) {
290            return null;
291        }
292
293        return array_values( array_unique( array_filter( array_map( 'trim', (array) $fields ), 'strlen' ) ) );
294    }
295
296    /**
297     * The report API's name for the `woocommerce_date_type` option, defaulting to `paid` as WooCommerce does.
298     *
299     * @return string One of `created`, `paid`, or `completed`.
300     */
301    private function get_store_date_type() {
302        $date_types = array(
303            'date_created'   => 'created',
304            'date_paid'      => 'paid',
305            'date_completed' => 'completed',
306        );
307        $option     = get_option( 'woocommerce_date_type' );
308
309        return is_string( $option ) && isset( $date_types[ $option ] ) ? $date_types[ $option ] : 'paid';
310    }
311
312    /**
313     * The response headers worth keeping.
314     *
315     * @param mixed $headers Response headers as returned by the HTTP API.
316     * @return array<string, string>
317     */
318    private function get_forwarded_headers( $headers ) {
319        $forwarded = array();
320        if ( ! is_array( $headers ) && ! $headers instanceof \ArrayAccess ) {
321            return $forwarded;
322        }
323
324        foreach ( self::FORWARDED_HEADERS as $name ) {
325            if ( isset( $headers[ $name ] ) ) {
326                $forwarded[ $name ] = (string) $headers[ $name ];
327            }
328        }
329
330        return $forwarded;
331    }
332
333    /**
334     * Transient key of a path and its params, whatever their order, in the current cache generation.
335     *
336     * @param string $path   WordPress.com path, without the query string.
337     * @param array  $params Forwarded query params.
338     * @return string
339     */
340    private function get_cache_key( $path, array $params ) {
341        ksort( $params );
342
343        $generation = (int) get_option( self::CACHE_GENERATION_OPTION, 0 );
344
345        return self::CACHE_PREFIX . md5( $generation . '|' . $path . '|' . wp_json_encode( $params, JSON_UNESCAPED_SLASHES ) );
346    }
347}