Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.56% covered (success)
95.56%
86 / 90
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Api_Proxy_Controller
95.56% covered (success)
95.56%
86 / 90
83.33% covered (warning)
83.33%
10 / 12
32
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%
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%
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 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     * Option holding the cache generation, part of every cache key.
47     */
48    private const CACHE_GENERATION_OPTION = 'jetpack_woocommerce_stats_proxy_cache_generation';
49
50    /**
51     * Timeout of the request to WordPress.com, in seconds.
52     */
53    private const API_TIMEOUT = 20;
54
55    /**
56     * Response headers passed back to the caller.
57     */
58    private const FORWARDED_HEADERS = array( 'x-wp-total', 'x-wp-totalpages' );
59
60    /**
61     * Register the proxy route. Called on `rest_api_init`.
62     *
63     * @return void
64     */
65    public static function init() {
66        ( new self() )->register_routes();
67    }
68
69    /**
70     * Register the cache prefix with the Stats package's transient cleanup, which runs from cron.
71     *
72     * @param mixed $prefixes Transient prefixes the cleanup sweeps.
73     * @return mixed
74     */
75    public static function register_transient_cleanup_prefix( $prefixes ) {
76        if ( is_array( $prefixes ) ) {
77            $prefixes[] = self::CACHE_PREFIX;
78        }
79
80        return $prefixes;
81    }
82
83    /**
84     * Drop every cached report, such as the empty ones WordPress.com answers with during the
85     * store's initial sync. Bumps the generation instead of deleting, since an object cache can't
86     * list transients by prefix.
87     *
88     * @return void
89     */
90    public static function clear_cache() {
91        update_option( self::CACHE_GENERATION_OPTION, (int) get_option( self::CACHE_GENERATION_OPTION, 0 ) + 1, false );
92    }
93
94    /**
95     * Register the route. It only reads, and only below `analytics/reports/`.
96     *
97     * @return void
98     */
99    public function register_routes() {
100        register_rest_route(
101            self::REST_NAMESPACE,
102            '/proxy/v2/(?P<endpoint>analytics/reports/.+)',
103            array(
104                'methods'             => WP_REST_Server::READABLE,
105                'callback'            => array( $this, 'handle_request' ),
106                'permission_callback' => array( $this, 'check_permission' ),
107                'args'                => array(
108                    'endpoint' => array(
109                        'type'              => 'string',
110                        'required'          => true,
111                        'validate_callback' => array( $this, 'validate_endpoint' ),
112                    ),
113                ),
114            )
115        );
116    }
117
118    /**
119     * Whether the current user may read store reports, by the rule the WooCommerce section uses.
120     *
121     * @return bool
122     */
123    public function check_permission() {
124        return Analytics_Dashboard::current_user_can_view_store_reports();
125    }
126
127    /**
128     * Whether the endpoint is a report path.
129     *
130     * Checked again here: a query-string `endpoint` shadows the route capture in `get_param()`.
131     *
132     * @param mixed $value Raw endpoint param.
133     * @return bool
134     */
135    public function validate_endpoint( $value ) {
136        $value = (string) $value;
137
138        return ! str_contains( $value, '..' ) && (bool) preg_match( '#^analytics/reports/[\w.,/-]+$#', $value );
139    }
140
141    /**
142     * Answer a report from the cache, or from WordPress.com.
143     *
144     * @param WP_REST_Request $request Request object.
145     * @return WP_REST_Response|WP_Error The response of WordPress.com with its status, or an error:
146     *                                   `no_connection` (403), the client's own with a 500, `api_error` (502).
147     */
148    public function handle_request( WP_REST_Request $request ) {
149        $path      = sprintf( '/sites/%d/%s', (int) Jetpack_Options::get_option( 'id' ), $request->get_param( 'endpoint' ) );
150        $params    = $this->get_forwarded_params( $request );
151        $cache_key = null === $request->get_param( 'force_refresh' ) ? $this->get_cache_key( $path, $params ) : null;
152
153        $cached = null === $cache_key ? false : get_transient( $cache_key );
154        if ( false !== $cached ) {
155            return $this->build_response( $cached );
156        }
157
158        if ( ! ( new Manager() )->is_connected() ) {
159            return new WP_Error(
160                'no_connection',
161                __( 'This site is not connected to WordPress.com.', 'jetpack-woocommerce-stats-pkg' ),
162                array( 'status' => 403 )
163            );
164        }
165
166        $response = Client::wpcom_json_api_request_as_blog(
167            $params ? $path . '?' . http_build_query( $params ) : $path,
168            '2',
169            array(
170                'method'  => 'GET',
171                'timeout' => self::API_TIMEOUT,
172            ),
173            null,
174            'wpcom'
175        );
176        if ( is_wp_error( $response ) ) {
177            $response->add_data( array( 'status' => 500 ) );
178
179            return $response;
180        }
181
182        $status = (int) wp_remote_retrieve_response_code( $response );
183        $data   = json_decode( wp_remote_retrieve_body( $response ), false );
184        if ( 200 === $status && null === $data && JSON_ERROR_NONE !== json_last_error() ) {
185            return new WP_Error(
186                'api_error',
187                __( 'WordPress.com returned an unreadable response.', 'jetpack-woocommerce-stats-pkg' ),
188                array( 'status' => 502 )
189            );
190        }
191
192        $payload = array(
193            'data'    => $data,
194            'status'  => $status,
195            'headers' => $this->get_forwarded_headers( wp_remote_retrieve_headers( $response ) ),
196        );
197        if ( null !== $cache_key && 200 === $status ) {
198            set_transient( $cache_key, $payload, self::CACHE_TTL );
199        }
200
201        return $this->build_response( $payload );
202    }
203
204    /**
205     * Build the REST response from a fetched or cached payload.
206     *
207     * @param array $payload `data`, `status` and `headers`.
208     * @return WP_REST_Response
209     */
210    private function build_response( array $payload ) {
211        $response = new WP_REST_Response( $payload['data'], (int) $payload['status'] );
212        $response->set_headers( (array) $payload['headers'] );
213
214        return $response;
215    }
216
217    /**
218     * The query params to forward: all but WordPress routing and the proxy's own, with the store's
219     * date type when the caller names none.
220     *
221     * WordPress.com would fall back to the synced option, but only once Jetpack Sync delivers a change.
222     *
223     * @param WP_REST_Request $request Request object.
224     * @return array
225     */
226    private function get_forwarded_params( WP_REST_Request $request ) {
227        $params = $request->get_query_params();
228        unset( $params['rest_route'], $params['_locale'], $params['endpoint'], $params['force_refresh'] );
229
230        if ( ! isset( $params['date_type'] ) ) {
231            $params['date_type'] = $this->get_store_date_type();
232        }
233
234        return $params;
235    }
236
237    /**
238     * The report API's name for the `woocommerce_date_type` option, defaulting to `paid` as WooCommerce does.
239     *
240     * @return string One of `created`, `paid`, or `completed`.
241     */
242    private function get_store_date_type() {
243        $date_types = array(
244            'date_created'   => 'created',
245            'date_paid'      => 'paid',
246            'date_completed' => 'completed',
247        );
248        $option     = get_option( 'woocommerce_date_type' );
249
250        return is_string( $option ) && isset( $date_types[ $option ] ) ? $date_types[ $option ] : 'paid';
251    }
252
253    /**
254     * The response headers worth keeping.
255     *
256     * @param mixed $headers Response headers as returned by the HTTP API.
257     * @return array<string, string>
258     */
259    private function get_forwarded_headers( $headers ) {
260        $forwarded = array();
261        if ( ! is_array( $headers ) && ! $headers instanceof \ArrayAccess ) {
262            return $forwarded;
263        }
264
265        foreach ( self::FORWARDED_HEADERS as $name ) {
266            if ( isset( $headers[ $name ] ) ) {
267                $forwarded[ $name ] = (string) $headers[ $name ];
268            }
269        }
270
271        return $forwarded;
272    }
273
274    /**
275     * Transient key of a path and its params, whatever their order, in the current cache generation.
276     *
277     * @param string $path   WordPress.com path, without the query string.
278     * @param array  $params Forwarded query params.
279     * @return string
280     */
281    private function get_cache_key( $path, array $params ) {
282        ksort( $params );
283
284        $generation = (int) get_option( self::CACHE_GENERATION_OPTION, 0 );
285
286        return self::CACHE_PREFIX . md5( $generation . '|' . $path . '|' . wp_json_encode( $params, JSON_UNESCAPED_SLASHES ) );
287    }
288}