Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 129
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Speed_Score
0.00% covered (danger)
0.00%
0 / 125
0.00% covered (danger)
0.00%
0 / 13
1722
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 register_rest_routes
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
2
 process_url_arg
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 dispatch_speed_score_request
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 dispatch_speed_score_graph_history_request
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 allow_jb_disable_module
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 fetch_speed_score
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
132
 maybe_dispatch_no_boost_score_request
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 get_score_request_by_url
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 get_boost_modules_disabled_url
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 can_access_speed_scores
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 clear_speed_score_request_cache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 prepare_speed_score_response
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
156
1<?php
2/**
3 * Speed Score API endpoints.
4 *
5 * @package automattic/jetpack-boost-speed-score
6 */
7
8namespace Automattic\Jetpack\Boost_Speed_Score;
9
10use Automattic\Jetpack\Boost_Core\Lib\Utils;
11
12if ( ! defined( 'JETPACK_BOOST_REST_NAMESPACE' ) ) {
13    define( 'JETPACK_BOOST_REST_NAMESPACE', 'jetpack-boost/v1' );
14}
15
16// For use in situations where you want additional namespacing.
17if ( ! defined( 'JETPACK_BOOST_REST_PREFIX' ) ) {
18    define( 'JETPACK_BOOST_REST_PREFIX', '' );
19}
20
21/**
22 * Class Speed_Score
23 *
24 * @phan-constructor-used-for-side-effects
25 */
26class Speed_Score {
27
28    const PACKAGE_VERSION = '0.4.18';
29
30    /**
31     * Array of module slugs that are currently active and can impact speed score.
32     *
33     * @var string[]
34     */
35    protected $modules;
36
37    /**
38     * A string representing the client making the request (e.g. 'boost-plugin', 'jetpack-dashboard', etc).
39     *
40     * @var string
41     */
42    protected $client;
43
44    /**
45     * Constructor.
46     *
47     * @param string[] $modules - Array of module slugs that are currently active and can impact speed score.
48     * @param string   $client  - A string representing the client making the request.
49     */
50    public function __construct( $modules, $client ) {
51        /*
52         * Plugins using the old version of the package may pass an object instead of an array. Converting the
53         * object to an array keeps it backward compatible. We will lose the module slugs in case of an object,
54         * but it is better than a fatal error.
55         */
56        if ( ! is_array( $modules ) ) {
57            $modules = array();
58        }
59
60        $this->modules = $modules;
61        $this->client  = $client;
62
63        add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
64        add_action( 'jetpack_boost_deactivate', array( $this, 'clear_speed_score_request_cache' ) );
65
66        /**
67         * Mark the speed score history as stale when the environment changes.
68         *
69         * @since 0.3.9 - This hook replaced `handle_environment_change` action.
70         * @since $$next-version - jetpack_boost_critical_css_environment_changed has been replaced by jetpack_boost_environment_changed.
71         */
72        add_action( 'jetpack_boost_environment_changed', array( Speed_Score_History::class, 'mark_stale' ) );
73        /**
74         * The `handle_environment_change` action is replaced by `jetpack_boost_environment_changed` in Jetpack Boost.
75         * Keeping the `handle_environment_change` action for backward compatibility.
76         */
77        add_action( 'handle_environment_change', array( Speed_Score_History::class, 'mark_stale' ) );
78        add_action( 'jetpack_boost_deactivate', array( Speed_Score_History::class, 'mark_stale' ) );
79    }
80
81    /**
82     * Register Speed Score related REST routes.
83     */
84    public function register_rest_routes() {
85        register_rest_route(
86            JETPACK_BOOST_REST_NAMESPACE,
87            JETPACK_BOOST_REST_PREFIX . '/speed-scores',
88            array(
89                'methods'             => \WP_REST_Server::EDITABLE,
90                'callback'            => array( $this, 'fetch_speed_score' ),
91                'permission_callback' => array( $this, 'can_access_speed_scores' ),
92            )
93        );
94
95        register_rest_route(
96            JETPACK_BOOST_REST_NAMESPACE,
97            JETPACK_BOOST_REST_PREFIX . '/speed-scores/refresh',
98            array(
99                'methods'             => \WP_REST_Server::EDITABLE,
100                'callback'            => array( $this, 'dispatch_speed_score_request' ),
101                'permission_callback' => array( $this, 'can_access_speed_scores' ),
102            )
103        );
104
105        register_rest_route(
106            JETPACK_BOOST_REST_NAMESPACE,
107            JETPACK_BOOST_REST_PREFIX . '/speed-scores-history',
108            array(
109                'methods'             => \WP_REST_Server::EDITABLE,
110                'callback'            => array( $this, 'dispatch_speed_score_graph_history_request' ),
111                'permission_callback' => array( $this, 'can_access_speed_scores' ),
112                'args'                => array(
113                    'start' => array(
114                        'required' => true,
115                        'type'     => 'number',
116                    ),
117                    'end'   => array(
118                        'required' => true,
119                        'type'     => 'number',
120                    ),
121                ),
122            )
123        );
124    }
125
126    /**
127     * Verify and normalize the URL argument for a request.
128     *
129     * @param \WP_REST_Request $request The request object.
130     *
131     * @return string|\WP_Error An error to return or the target url.
132     */
133    private function process_url_arg( $request ) {
134        $params = $request->get_json_params();
135
136        if ( ! isset( $params['url'] ) ) {
137            return new \WP_Error(
138                'invalid_parameter',
139                __(
140                    'The url parameter is required',
141                    'jetpack-boost-speed-score'
142                ),
143                array( 'status' => 400 )
144            );
145        }
146
147        return Utils::force_url_to_absolute( $params['url'] );
148    }
149
150    /**
151     * Handler for POST /speed-scores/refresh.
152     *
153     * @param \WP_REST_Request $request The request object.
154     *
155     * @return \WP_REST_Response|\WP_Error The response.
156     */
157    public function dispatch_speed_score_request( $request ) {
158        $url = $this->process_url_arg( $request );
159        if ( is_wp_error( $url ) ) {
160            return $url;
161        }
162
163        // Create and store the Speed Score request.
164        $score_request = new Speed_Score_Request( $url, $this->modules, null, 'pending', null, $this->client );
165        $score_request->store( 1800 ); // Keep the request for 30 minutes even if no one access the results.
166
167        // Send the request.
168        $score_request->execute();
169
170        $score_request_no_boost = $this->maybe_dispatch_no_boost_score_request( $url );
171
172        return $this->prepare_speed_score_response( $url, $score_request, $score_request_no_boost );
173    }
174
175    /**
176     * Handler for POST /speed-scores-history.
177     *
178     * @param \WP_REST_Request $request The request object.
179     *
180     * @return \WP_REST_Response|\WP_Error The response.
181     */
182    public function dispatch_speed_score_graph_history_request( $request ) {
183        $score_history_request = new Speed_Score_Graph_History_Request( $request->get_param( 'start' ), $request->get_param( 'end' ), array() );
184        // Send the request.
185        return $score_history_request->execute();
186    }
187
188    /**
189     * Remove the string "jb-disable-module" from array of strings.
190     *
191     * This is intended to be used by the filter `jetpack_boost_excluded_query_parameters` to allow `jb-disable-module` url parameter during score requests.
192     *
193     * @param string[] $params List of parameters to be removed from url.
194     *
195     * @return string[] Revised list of parameters to remove from url.
196     */
197    public function allow_jb_disable_module( $params ) {
198        $index = array_search( 'jb-disable-modules', $params, true );
199        unset( $params[ $index ] );
200
201        return $params;
202    }
203
204    /**
205     * Handler for POST /speed-scores.
206     *
207     * @param \WP_REST_Request $request The request object.
208     *
209     * @return \WP_REST_Response|\WP_Error The response.
210     */
211    public function fetch_speed_score( $request ) {
212        $url = $this->process_url_arg( $request );
213        if ( is_wp_error( $url ) ) {
214            return $url;
215        }
216
217        // Poll update if there is an ongoing request for scores with boost disabled.
218        $url_no_boost           = $this->get_boost_modules_disabled_url( $url );
219        $score_request_no_boost = $this->get_score_request_by_url( $url_no_boost );
220        if ( $score_request_no_boost && $score_request_no_boost->is_pending() ) {
221            $response = $score_request_no_boost->poll_update();
222            if ( is_wp_error( $response ) ) {
223                return $response;
224            }
225        }
226
227        // Poll update if there is an ongoing request for scores with boost enabled.
228        $score_request = $this->get_score_request_by_url( $url );
229        if ( $score_request && $score_request->is_pending() ) {
230            $response = $score_request->poll_update();
231            if ( is_wp_error( $response ) ) {
232                return $response;
233            }
234        }
235
236        // If this is a fresh install, there might not be any speed score history. In which case, we want to fetch the initial scores.
237        // While updating plugin from 1.2 -> 1.3, the history will be missing along with a non-pending score request due to data structure change.
238        $history = new Speed_Score_History( $url );
239        if ( null === $history->latest_scores() && ( empty( $score_request ) || ! $score_request->is_pending() ) ) {
240            return $this->dispatch_speed_score_request( $request );
241        }
242
243        return $this->prepare_speed_score_response( $url, $score_request, $score_request_no_boost );
244    }
245
246    /**
247     * If it is time to fetch the score without boost, fetch it.
248     *
249     * @param string $url Url of the site.
250     *
251     * @return Speed_Score_Request
252     */
253    private function maybe_dispatch_no_boost_score_request( $url ) {
254
255        // Allow `jb-disable-module` URL param to fetch score without boost modules being active.
256        add_filter( 'jetpack_boost_excluded_query_parameters', array( $this, 'allow_jb_disable_module' ) );
257
258        $url_no_boost = $this->get_boost_modules_disabled_url( $url );
259
260        $history       = new Speed_Score_History( $url_no_boost );
261        $score_request = $this->get_score_request_by_url( $url_no_boost );
262        if (
263            // If there isn't already a pending request.
264            ( empty( $score_request ) || ! $score_request->is_pending() )
265            && ! empty( $this->modules )
266            && $history->is_stale()
267        ) {
268            $score_request = new Speed_Score_Request( $url_no_boost, array(), null, 'pending', null, $this->client ); // Dispatch a new speed score request to measure score without boost.
269            $score_request->store( 3600 ); // Keep the request for 1 hour even if no one access the results. The value is persisted for 1 hour in wp.com from initial request.
270
271            // Send the request.
272            $score_request->execute();
273        }
274        remove_filter( 'jetpack_boost_excluded_query_parameters', array( $this, 'allow_jb_disable_module' ) );
275
276        return $score_request;
277    }
278
279    /**
280     * Get Speed_Score_Request instance by url.
281     *
282     * @param string $url Url to get the Speed_Score_Request instance for.
283     *
284     * @return Speed_Score_Request
285     */
286    private function get_score_request_by_url( $url ) {
287        return Speed_Score_Request::get(
288            Speed_Score_Request::generate_cache_id_from_url( $url )
289        );
290    }
291
292    /**
293     * Add query parameters to the url that would disable all boost modules.
294     *
295     * @param string $url The original URL we are measuring for score.
296     *
297     * @return string
298     */
299    private function get_boost_modules_disabled_url( $url ) {
300        return add_query_arg( 'jb-disable-modules', 'all', $url );
301    }
302
303    /**
304     * Can the user access speed scores?
305     *
306     * @return bool
307     */
308    public function can_access_speed_scores() {
309        return current_user_can( 'manage_options' );
310    }
311
312    /**
313     * Clear speed score request cache on jetpack_boost_deactivate action.
314     */
315    public function clear_speed_score_request_cache() {
316        Speed_Score_Request::clear_cache();
317    }
318
319    /**
320     * Prepare the speed score response.
321     *
322     * @param string              $url                    URL of the speed is requested for.
323     * @param Speed_Score_Request $score_request          Speed score request.
324     * @param Speed_Score_Request $score_request_no_boost Speed score request without boost enabled.
325     *
326     * @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response
327     */
328    private function prepare_speed_score_response( $url, $score_request, $score_request_no_boost ) {
329        $history          = new Speed_Score_History( $url );
330        $url_no_boost     = $this->get_boost_modules_disabled_url( $url );
331        $history_no_boost = new Speed_Score_History( $url_no_boost );
332
333        $response = array();
334
335        if ( ( ! $score_request || $score_request->is_success() ) && ( ! $score_request_no_boost || $score_request_no_boost->is_success() ) ) {
336            $response['status'] = 'success';
337
338            $response['scores'] = array(
339                'current' => $history->latest_scores(),
340                'noBoost' => null,
341            );
342
343            // Only include noBoost scores if at least one module is enabled.
344            if ( $score_request && ! empty( $score_request->get_active_modules() ) ) {
345                $response['scores']['noBoost'] = $history_no_boost->latest_scores();
346            }
347
348            $response['scores']['isStale'] = $history->is_stale();
349
350        } elseif ( ( $score_request && $score_request->is_error() ) || ( $score_request_no_boost && $score_request_no_boost->is_error() ) ) {
351            // If either request ended up in error, we can just return the one with error so front-end can take action. The relevent url is available on the serialized object.
352            if ( $score_request->is_error() ) {
353                // Serialized version of score request contains the status property and error description if any.
354                $response = $score_request->jsonSerialize();
355            } else {
356                $response = $score_request_no_boost->jsonSerialize();
357            }
358        } else {
359            // If no request ended up in error/success as previous conditions dictate, it means that either of them are in pending state.
360            $response['status'] = 'pending';
361        }
362
363        return rest_ensure_response( $response );
364    }
365}