Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.06% covered (success)
97.06%
99 / 102
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Monitor
99.00% covered (success)
99.00%
99 / 100
90.00% covered (success)
90.00%
9 / 10
29
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_key
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_state
100.00% covered (success)
100.00%
7 / 7
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
 get_uptime
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
11
 clear_cache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parse_days
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 fetch_is_up
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 request
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 unavailable_error
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Protect dashboard: the Monitor section, for downtime monitoring.
4 *
5 * @package automattic/jetpack-protect
6 */
7
8namespace Automattic\Jetpack\Protect\Sections;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Automattic\Jetpack\Protect\Dashboard;
13use Automattic\Jetpack\Protect\Dashboard_Section;
14use Jetpack_Options;
15use WP_Error;
16use WP_REST_Request;
17use WP_REST_Server;
18
19if ( ! defined( 'ABSPATH' ) ) {
20    exit( 0 );
21}
22
23/**
24 * The Monitor module's state and its uptime history.
25 *
26 * @since $$next-version$$
27 */
28class Monitor implements Dashboard_Section {
29
30    /**
31     * Days of uptime the section shows.
32     *
33     * @var int
34     */
35    const UPTIME_DAYS = 40;
36
37    /**
38     * Transient caching the uptime history, or why fetching it failed.
39     *
40     * @var string
41     */
42    const UPTIME_TRANSIENT = 'jetpack_protect_dashboard_uptime';
43
44    /**
45     * Drop the cached history whenever Monitor is turned on or off.
46     */
47    public function __construct() {
48        add_action( 'jetpack_activate_module_monitor', array( $this, 'clear_cache' ) );
49        add_action( 'jetpack_deactivate_module_monitor', array( $this, 'clear_cache' ) );
50    }
51
52    /**
53     * The section's key.
54     *
55     * @return string
56     */
57    public function get_key() {
58        return 'monitor';
59    }
60
61    /**
62     * Whether Monitor can run here and is on, the days of uptime shown, and whether the user can reach WordPress.com.
63     *
64     * @return array
65     */
66    public function get_state() {
67        return array_merge(
68            Dashboard::get_module_state( 'monitor' ),
69            array(
70                'uptimeDays'    => self::UPTIME_DAYS,
71                'userConnected' => ( new Connection_Manager() )->is_user_connected(),
72            )
73        );
74    }
75
76    /**
77     * Register the uptime route.
78     *
79     * @return void
80     */
81    public function register_routes() {
82        register_rest_route(
83            'jetpack/v4',
84            '/protect-dashboard/uptime',
85            array(
86                'methods'             => WP_REST_Server::READABLE,
87                'callback'            => array( $this, 'get_uptime' ),
88                'permission_callback' => array( Dashboard::class, 'can_manage' ),
89                'args'                => array(
90                    'retry' => array(
91                        'description' => __( 'Ask WordPress.com again, even if it failed in the last minute.', 'jetpack-protect-pkg' ),
92                        'type'        => 'boolean',
93                        'default'     => false,
94                    ),
95                ),
96            )
97        );
98    }
99
100    /**
101     * Daily uptime for the last UPTIME_DAYS days (oldest first) and the current status, from WordPress.com.
102     *
103     * @param WP_REST_Request|null $request The request; its `retry` flag skips a remembered failure.
104     * @return array|WP_Error
105     */
106    public function get_uptime( $request = null ) {
107        if ( ! Dashboard::get_module_state( 'monitor' )['active'] ) {
108            return new WP_Error( 'monitor_inactive', __( 'Downtime monitoring is off.', 'jetpack-protect-pkg' ), array( 'status' => 409 ) );
109        }
110
111        // The endpoints check `manage_options` for the requesting user, so a blog token is refused.
112        if ( ! ( new Connection_Manager() )->is_user_connected() ) {
113            return new WP_Error( 'not_connected', __( 'Connect your WordPress.com account to see uptime.', 'jetpack-protect-pkg' ), array( 'status' => 403 ) );
114        }
115
116        $cached = get_transient( self::UPTIME_TRANSIENT );
117        if ( is_array( $cached ) && ! isset( $cached['failed'] ) ) {
118            return $cached;
119        }
120        if ( isset( $cached['failed'] ) && ! ( $request && $request['retry'] ) ) {
121            return $this->unavailable_error( $cached['failed'] );
122        }
123
124        // It has no 40-day period, so ask for 90 and keep the newest days.
125        $history = $this->request( '/jetpack-monitor-uptime?period=' . rawurlencode( '90 days' ) );
126        $days    = $this->parse_days( $history['body'] );
127        if ( ! $days ) {
128            // A 401 or 403 is about this user's token, so it must not hide the history from other admins.
129            if ( ! in_array( $history['code'], array( 401, 403 ), true ) ) {
130                $failed = array(
131                    'code'  => $history['code'],
132                    'error' => $history['error'],
133                );
134                set_transient( self::UPTIME_TRANSIENT, array( 'failed' => $failed ), MINUTE_IN_SECONDS );
135            }
136            return $this->unavailable_error( $history );
137        }
138
139        $uptime = array(
140            'days' => $days,
141            'isUp' => $this->fetch_is_up(),
142        );
143        set_transient( self::UPTIME_TRANSIENT, $uptime, null === $uptime['isUp'] ? MINUTE_IN_SECONDS : 10 * MINUTE_IN_SECONDS );
144        return $uptime;
145    }
146
147    /**
148     * Forget the cached uptime history.
149     *
150     * @return void
151     */
152    public function clear_cache() {
153        delete_transient( self::UPTIME_TRANSIENT );
154    }
155
156    /**
157     * The newest UPTIME_DAYS days of an uptime history, oldest first; empty when it has no valid days.
158     *
159     * @param mixed $body The decoded history, keyed by `Y-m-d` date.
160     * @return array
161     */
162    private function parse_days( $body ) {
163        if ( ! is_array( $body ) ) {
164            return array();
165        }
166
167        $body = array_filter(
168            $body,
169            function ( $day, $date ) {
170                return is_array( $day ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/', (string) $date );
171            },
172            ARRAY_FILTER_USE_BOTH
173        );
174        ksort( $body );
175
176        $days = array();
177        foreach ( array_slice( $body, -self::UPTIME_DAYS, null, true ) as $date => $day ) {
178            $status = $day['status'] ?? null;
179            $days[] = array(
180                'date'              => (string) $date,
181                'status'            => in_array( $status, array( 'up', 'down' ), true ) ? $status : 'monitor_inactive',
182                'downtimeInMinutes' => (int) ( $day['downtime_in_minutes'] ?? 0 ),
183            );
184        }
185        return $days;
186    }
187
188    /**
189     * Whether the site is up right now; null when unknown.
190     *
191     * @return bool|null
192     */
193    private function fetch_is_up() {
194        $body   = $this->request( '/jetpack-monitor-status' )['body'];
195        $status = is_array( $body ) ? ( $body['status'] ?? null ) : null;
196        return is_bool( $status ) ? $status : null;
197    }
198
199    /**
200     * GET a wpcom/v2 site endpoint as the current user.
201     *
202     * @param string $path Path after `/sites/<id>`.
203     * @return array The HTTP `code` (0 when the request failed), the decoded `body` (null unless the code is 200),
204     *               and an `error` string describing a transport failure (null otherwise).
205     */
206    private function request( $path ) {
207        $response = Client::wpcom_json_api_request_as_user(
208            sprintf( '/sites/%d', Jetpack_Options::get_option( 'id' ) ) . $path,
209            '2',
210            array(),
211            null,
212            'wpcom'
213        );
214        if ( is_wp_error( $response ) ) {
215            return array(
216                'code'  => 0,
217                'body'  => null,
218                'error' => $response->get_error_code() . ': ' . $response->get_error_message(),
219            );
220        }
221        $code = (int) wp_remote_retrieve_response_code( $response );
222        return array(
223            'code'  => $code,
224            'body'  => 200 === $code ? json_decode( wp_remote_retrieve_body( $response ), true ) : null,
225            'error' => null,
226        );
227    }
228
229    /**
230     * The error returned when WordPress.com can't be reached, carrying the upstream detail for debugging.
231     *
232     * @param array $request The failing request's `code` and `error`, as returned by `request()`.
233     * @return WP_Error
234     */
235    private function unavailable_error( $request ) {
236        $data = array(
237            'status'         => 502,
238            'upstreamStatus' => $request['code'],
239        );
240        if ( ! empty( $request['error'] ) ) {
241            $data['upstreamError'] = $request['error'];
242        }
243        return new WP_Error( 'uptime_unavailable', __( 'Uptime history is unavailable right now.', 'jetpack-protect-pkg' ), $data );
244    }
245}