Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
41.13% covered (danger)
41.13%
58 / 141
21.43% covered (danger)
21.43%
3 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Speed_Score
39.42% covered (danger)
39.42%
54 / 137
21.43% covered (danger)
21.43%
3 / 14
538.21
0.00% covered (danger)
0.00%
0 / 1
 __construct
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 register_rest_routes
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
2
 process_url_arg
27.27% covered (danger)
27.27%
3 / 11
0.00% covered (danger)
0.00%
0 / 1
3.54
 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
57.14% covered (warning)
57.14%
12 / 21
0.00% covered (danger)
0.00%
0 / 1
20.52
 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
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get_boost_modules_disabled_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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
 get_progress
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 prepare_speed_score_response
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
15
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.6.1';
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        $polled = array();
218
219        // Poll update if there is an ongoing request for scores with boost disabled.
220        $url_no_boost           = $this->get_boost_modules_disabled_url( $url );
221        $score_request_no_boost = $this->get_score_request_by_url( $url_no_boost );
222        if ( $score_request_no_boost && $score_request_no_boost->is_pending() ) {
223            $response = $score_request_no_boost->poll_update();
224            if ( is_wp_error( $response ) ) {
225                return $response;
226            }
227        }
228
229        // Poll update if there is an ongoing request for scores with boost enabled.
230        $score_request = $this->get_score_request_by_url( $url );
231        if ( $score_request && $score_request->is_pending() ) {
232            $polled[] = $score_request;
233            $response = $score_request->poll_update();
234            if ( is_wp_error( $response ) ) {
235                return $response;
236            }
237        }
238
239        Speed_Score_Durations::record_finished_run( $polled );
240
241        // If this is a fresh install, there might not be any speed score history. In which case, we want to fetch the initial scores.
242        // 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.
243        $history = new Speed_Score_History( $url );
244        if ( null === $history->latest_scores() && ( empty( $score_request ) || ! $score_request->is_pending() ) ) {
245            return $this->dispatch_speed_score_request( $request );
246        }
247
248        return $this->prepare_speed_score_response( $url, $score_request, $score_request_no_boost );
249    }
250
251    /**
252     * If it is time to fetch the score without boost, fetch it.
253     *
254     * @param string $url Url of the site.
255     *
256     * @return Speed_Score_Request
257     */
258    private function maybe_dispatch_no_boost_score_request( $url ) {
259
260        // Allow `jb-disable-module` URL param to fetch score without boost modules being active.
261        add_filter( 'jetpack_boost_excluded_query_parameters', array( $this, 'allow_jb_disable_module' ) );
262
263        $url_no_boost = $this->get_boost_modules_disabled_url( $url );
264
265        $history       = new Speed_Score_History( $url_no_boost );
266        $score_request = $this->get_score_request_by_url( $url_no_boost );
267        if (
268            // If there isn't already a pending request.
269            ( empty( $score_request ) || ! $score_request->is_pending() )
270            && ! empty( $this->modules )
271            && $history->is_stale()
272        ) {
273            $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.
274            $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.
275
276            // Send the request.
277            $score_request->execute();
278        }
279        remove_filter( 'jetpack_boost_excluded_query_parameters', array( $this, 'allow_jb_disable_module' ) );
280
281        return $score_request;
282    }
283
284    /**
285     * Get Speed_Score_Request instance by url.
286     *
287     * @param string $url Url to get the Speed_Score_Request instance for.
288     *
289     * @return Speed_Score_Request
290     */
291    private function get_score_request_by_url( $url ) {
292        return Speed_Score_Request::get(
293            Speed_Score_Request::generate_cache_id_from_url( $url )
294        );
295    }
296
297    /**
298     * Add query parameters to the url that would disable all boost modules.
299     *
300     * @param string $url The original URL we are measuring for score.
301     *
302     * @return string
303     */
304    private function get_boost_modules_disabled_url( $url ) {
305        return add_query_arg( 'jb-disable-modules', 'all', $url );
306    }
307
308    /**
309     * Can the user access speed scores?
310     *
311     * @return bool
312     */
313    public function can_access_speed_scores() {
314        return current_user_can( 'manage_options' );
315    }
316
317    /**
318     * Clear speed score request cache on jetpack_boost_deactivate action.
319     */
320    public function clear_speed_score_request_cache() {
321        Speed_Score_Request::clear_cache();
322    }
323
324    /**
325     * How far along the pending run is, measured from the request with Boost: the only one the dashboard waits for.
326     *
327     * @param Speed_Score_Request|null $score_request Speed score request.
328     *
329     * @return array{elapsed: int, expected: int} Seconds since dispatch, and the seconds a run usually takes on this site.
330     */
331    private function get_progress( $score_request ) {
332        $started = $score_request && $score_request->is_pending() ? $score_request->get_created() : microtime( true );
333
334        return array(
335            'elapsed'  => (int) round( microtime( true ) - $started ),
336            'expected' => Speed_Score_Durations::expected(),
337        );
338    }
339
340    /**
341     * Prepare the speed score response.
342     *
343     * @param string              $url                    URL of the speed is requested for.
344     * @param Speed_Score_Request $score_request          Speed score request.
345     * @param Speed_Score_Request $score_request_no_boost Speed score request without boost enabled.
346     *
347     * @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response
348     */
349    private function prepare_speed_score_response( $url, $score_request, $score_request_no_boost ) {
350        $history          = new Speed_Score_History( $url );
351        $url_no_boost     = $this->get_boost_modules_disabled_url( $url );
352        $history_no_boost = new Speed_Score_History( $url_no_boost );
353
354        $response = array();
355
356        // The site's own score is ready before the comparison run finishes; noBoost then reads the previous comparison.
357        $no_boost_failed = $score_request_no_boost && $score_request_no_boost->is_error();
358        if ( ( ! $score_request || $score_request->is_success() ) && ! $no_boost_failed ) {
359            $response['status'] = 'success';
360
361            $response['scores'] = array(
362                'current' => $history->latest_scores(),
363                'noBoost' => null,
364            );
365
366            // Only include noBoost scores if at least one module is enabled.
367            if ( $score_request && ! empty( $score_request->get_active_modules() ) ) {
368                $response['scores']['noBoost'] = $history_no_boost->latest_scores();
369                // Keeps the dashboard polling: only a poll collects the comparison result.
370                if ( $score_request_no_boost && $score_request_no_boost->is_pending() ) {
371                    $response['scores']['noBoostPending'] = true;
372                }
373            }
374
375            $response['scores']['isStale'] = $history->is_stale();
376
377        } elseif ( ( $score_request && $score_request->is_error() ) || ( $score_request_no_boost && $score_request_no_boost->is_error() ) ) {
378            // 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.
379            if ( $score_request && $score_request->is_error() ) {
380                // Serialized version of score request contains the status property and error description if any.
381                $response = $score_request->jsonSerialize();
382            } else {
383                $response = $score_request_no_boost->jsonSerialize();
384            }
385        } else {
386            // The request with Boost is still pending.
387            $response['status']   = 'pending';
388            $response['progress'] = $this->get_progress( $score_request );
389        }
390
391        return rest_ensure_response( $response );
392    }
393}