Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.17% covered (success)
97.17%
103 / 106
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Monitor
99.04% covered (success)
99.04%
103 / 104
90.00% covered (success)
90.00%
9 / 10
32
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%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 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 0.2.0
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, whether the user can reach WordPress.com,
63     * and the email address of their WordPress.com account, which is where their alerts go (null when unknown).
64     *
65     * @return array
66     */
67    public function get_state() {
68        $manager        = new Connection_Manager();
69        $user_connected = $manager->is_user_connected();
70        $wpcom_user     = $user_connected ? $manager->get_connected_user_data() : false;
71        return array_merge(
72            Dashboard::get_module_state( 'monitor' ),
73            array(
74                'uptimeDays'    => self::UPTIME_DAYS,
75                'userConnected' => $user_connected,
76                'alertEmail'    => is_array( $wpcom_user ) && is_email( $wpcom_user['email'] ?? '' ) ? $wpcom_user['email'] : null,
77            )
78        );
79    }
80
81    /**
82     * Register the uptime route.
83     *
84     * @return void
85     */
86    public function register_routes() {
87        register_rest_route(
88            'jetpack/v4',
89            '/protect-dashboard/uptime',
90            array(
91                'methods'             => WP_REST_Server::READABLE,
92                'callback'            => array( $this, 'get_uptime' ),
93                'permission_callback' => array( Dashboard::class, 'can_manage' ),
94                'args'                => array(
95                    'retry' => array(
96                        'description' => __( 'Ask WordPress.com again, even if it failed in the last minute.', 'jetpack-protect-pkg' ),
97                        'type'        => 'boolean',
98                        'default'     => false,
99                    ),
100                ),
101            )
102        );
103    }
104
105    /**
106     * Daily uptime for the last UPTIME_DAYS days (oldest first) and the current status, from WordPress.com.
107     *
108     * @param WP_REST_Request|null $request The request; its `retry` flag skips a remembered failure.
109     * @return array|WP_Error
110     */
111    public function get_uptime( $request = null ) {
112        if ( ! Dashboard::get_module_state( 'monitor' )['active'] ) {
113            return new WP_Error( 'monitor_inactive', __( 'Downtime monitoring is off.', 'jetpack-protect-pkg' ), array( 'status' => 409 ) );
114        }
115
116        // The endpoints check `manage_options` for the requesting user, so a blog token is refused.
117        if ( ! ( new Connection_Manager() )->is_user_connected() ) {
118            return new WP_Error( 'not_connected', __( 'Connect your WordPress.com account to see uptime.', 'jetpack-protect-pkg' ), array( 'status' => 403 ) );
119        }
120
121        $cached = get_transient( self::UPTIME_TRANSIENT );
122        if ( is_array( $cached ) && ! isset( $cached['failed'] ) ) {
123            return $cached;
124        }
125        if ( isset( $cached['failed'] ) && ! ( $request && $request['retry'] ) ) {
126            return $this->unavailable_error( $cached['failed'] );
127        }
128
129        // It has no 40-day period, so ask for 90 and keep the newest days.
130        $history = $this->request( '/jetpack-monitor-uptime?period=' . rawurlencode( '90 days' ) );
131        $days    = $this->parse_days( $history['body'] );
132        if ( ! $days ) {
133            // A 401 or 403 is about this user's token, so it must not hide the history from other admins.
134            if ( ! in_array( $history['code'], array( 401, 403 ), true ) ) {
135                $failed = array(
136                    'code'  => $history['code'],
137                    'error' => $history['error'],
138                );
139                set_transient( self::UPTIME_TRANSIENT, array( 'failed' => $failed ), MINUTE_IN_SECONDS );
140            }
141            return $this->unavailable_error( $history );
142        }
143
144        $uptime = array(
145            'days' => $days,
146            'isUp' => $this->fetch_is_up(),
147        );
148        set_transient( self::UPTIME_TRANSIENT, $uptime, null === $uptime['isUp'] ? MINUTE_IN_SECONDS : 10 * MINUTE_IN_SECONDS );
149        return $uptime;
150    }
151
152    /**
153     * Forget the cached uptime history.
154     *
155     * @return void
156     */
157    public function clear_cache() {
158        delete_transient( self::UPTIME_TRANSIENT );
159    }
160
161    /**
162     * The newest UPTIME_DAYS days of an uptime history, oldest first; empty when it has no valid days.
163     *
164     * @param mixed $body The decoded history, keyed by `Y-m-d` date.
165     * @return array
166     */
167    private function parse_days( $body ) {
168        if ( ! is_array( $body ) ) {
169            return array();
170        }
171
172        $body = array_filter(
173            $body,
174            function ( $day, $date ) {
175                return is_array( $day ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/', (string) $date );
176            },
177            ARRAY_FILTER_USE_BOTH
178        );
179        ksort( $body );
180
181        $days = array();
182        foreach ( array_slice( $body, -self::UPTIME_DAYS, null, true ) as $date => $day ) {
183            $status = $day['status'] ?? null;
184            $days[] = array(
185                'date'              => (string) $date,
186                'status'            => in_array( $status, array( 'up', 'down' ), true ) ? $status : 'monitor_inactive',
187                'downtimeInMinutes' => (int) ( $day['downtime_in_minutes'] ?? 0 ),
188            );
189        }
190        return $days;
191    }
192
193    /**
194     * Whether the site is up right now; null when unknown.
195     *
196     * @return bool|null
197     */
198    private function fetch_is_up() {
199        $body   = $this->request( '/jetpack-monitor-status' )['body'];
200        $status = is_array( $body ) ? ( $body['status'] ?? null ) : null;
201        return is_bool( $status ) ? $status : null;
202    }
203
204    /**
205     * GET a wpcom/v2 site endpoint as the current user.
206     *
207     * @param string $path Path after `/sites/<id>`.
208     * @return array The HTTP `code` (0 when the request failed), the decoded `body` (null unless the code is 200),
209     *               and an `error` string describing a transport failure (null otherwise).
210     */
211    private function request( $path ) {
212        $response = Client::wpcom_json_api_request_as_user(
213            sprintf( '/sites/%d', Jetpack_Options::get_option( 'id' ) ) . $path,
214            '2',
215            array(),
216            null,
217            'wpcom'
218        );
219        if ( is_wp_error( $response ) ) {
220            return array(
221                'code'  => 0,
222                'body'  => null,
223                'error' => $response->get_error_code() . ': ' . $response->get_error_message(),
224            );
225        }
226        $code = (int) wp_remote_retrieve_response_code( $response );
227        return array(
228            'code'  => $code,
229            'body'  => 200 === $code ? json_decode( wp_remote_retrieve_body( $response ), true ) : null,
230            'error' => null,
231        );
232    }
233
234    /**
235     * The error returned when WordPress.com can't be reached, carrying the upstream detail for debugging.
236     *
237     * @param array $request The failing request's `code` and `error`, as returned by `request()`.
238     * @return WP_Error
239     */
240    private function unavailable_error( $request ) {
241        $data = array(
242            'status'         => 502,
243            'upstreamStatus' => $request['code'],
244        );
245        if ( ! empty( $request['error'] ) ) {
246            $data['upstreamError'] = $request['error'];
247        }
248        return new WP_Error( 'uptime_unavailable', __( 'Uptime history is unavailable right now.', 'jetpack-protect-pkg' ), $data );
249    }
250}