Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
53.06% covered (warning)
53.06%
78 / 147
33.33% covered (danger)
33.33%
5 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Heartbeat
53.06% covered (warning)
53.06%
78 / 147
33.33% covered (danger)
33.33%
5 / 15
393.00
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __construct
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
5.20
 cron_exec
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 generate_stats_array
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 get_environment_stats
78.57% covered (warning)
78.57%
22 / 28
0.00% covered (danger)
0.00%
0 / 1
12.19
 permit_ssl
17.86% covered (danger)
17.86%
5 / 28
0.00% covered (danger)
0.00%
0 / 1
53.89
 get_ssl_test_error
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 get_active_plugins
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
3.71
 jetpack_xmlrpc_methods
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 xmlrpc_data_response
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 deactivate
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 cli_callback
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 initialize_rest_api
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 rest_heartbeat_data
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rest_heartbeat_data_permission_check
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
1<?php
2/**
3 * Jetpack Heartbeat package.
4 *
5 * @package  automattic/jetpack-connection
6 */
7
8namespace Automattic\Jetpack;
9
10use Automattic\Jetpack\Connection\Rest_Authentication;
11use Automattic\Jetpack\Connection\REST_Connector;
12use Automattic\Jetpack\Connection\Utils;
13use Jetpack_Options;
14use WP_CLI;
15use WP_Error;
16use WP_REST_Request;
17use WP_REST_Server;
18
19/**
20 * Heartbeat sends a batch of stats to wp.com once a day
21 */
22class Heartbeat {
23
24    /**
25     * Holds the singleton instance of this class
26     *
27     * @since 1.0.0
28     * @since-jetpack 2.3.3
29     * @var Heartbeat
30     */
31    private static $instance = false;
32
33    /**
34     * Cronjob identifier
35     *
36     * @var string
37     */
38    private $cron_name = 'jetpack_v2_heartbeat';
39
40    /**
41     * Singleton
42     *
43     * @since 1.0.0
44     * @since-jetpack 2.3.3
45     * @static
46     * @return Heartbeat
47     */
48    public static function init() {
49        if ( ! self::$instance ) {
50            self::$instance = new Heartbeat();
51        }
52
53        return self::$instance;
54    }
55
56    /**
57     * Constructor for singleton
58     *
59     * @since 1.0.0
60     * @since-jetpack 2.3.3
61     */
62    private function __construct() {
63
64        // Schedule the task.
65        add_action( $this->cron_name, array( $this, 'cron_exec' ) );
66
67        if ( ! wp_next_scheduled( $this->cron_name ) ) {
68            // Deal with the old pre-3.0 weekly one.
69            $timestamp = wp_next_scheduled( 'jetpack_heartbeat' );
70            if ( $timestamp ) {
71                wp_unschedule_event( $timestamp, 'jetpack_heartbeat' );
72            }
73
74            wp_schedule_event( time(), 'daily', $this->cron_name );
75        }
76
77        add_filter( 'jetpack_xmlrpc_unauthenticated_methods', array( __CLASS__, 'jetpack_xmlrpc_methods' ) );
78
79        if ( defined( 'WP_CLI' ) && WP_CLI ) {
80            WP_CLI::add_command( 'jetpack-heartbeat', array( $this, 'cli_callback' ) );
81        }
82
83        add_action( 'rest_api_init', array( $this, 'initialize_rest_api' ) );
84    }
85
86    /**
87     * Method that gets executed on the wp-cron call
88     *
89     * @since 1.0.0
90     * @since-jetpack 2.3.3
91     * @global string $wp_version
92     */
93    public function cron_exec() {
94
95        $a8c_mc_stats = new A8c_Mc_Stats();
96
97        /*
98         * This should run daily.  Figuring in for variances in
99         * WP_CRON, don't let it run more than every 23 hours at most.
100         *
101         * i.e. if it ran less than 23 hours ago, fail out.
102         */
103        $last = (int) Jetpack_Options::get_option( 'last_heartbeat' );
104        if ( $last && ( $last + DAY_IN_SECONDS - HOUR_IN_SECONDS > time() ) ) {
105            return;
106        }
107
108        /*
109         * Check for an identity crisis
110         *
111         * If one exists:
112         * - Bump stat for ID crisis
113         * - Email site admin about potential ID crisis
114         */
115
116        // Coming Soon!
117
118        foreach ( self::generate_stats_array( 'v2-' ) as $key => $value ) {
119            if ( is_array( $value ) ) {
120                foreach ( $value as $v ) {
121                    $a8c_mc_stats->add( $key, (string) $v );
122                }
123            } else {
124                $a8c_mc_stats->add( $key, (string) $value );
125            }
126        }
127
128        Jetpack_Options::update_option( 'last_heartbeat', time() );
129
130        $a8c_mc_stats->do_server_side_stats();
131
132        /**
133         * Fires when we synchronize all registered options on heartbeat.
134         *
135         * @since 3.3.0
136         */
137        do_action( 'jetpack_heartbeat' );
138    }
139
140    /**
141     * Generates heartbeat stats data.
142     *
143     * @param string $prefix Prefix to add before stats identifier.
144     *
145     * @return array The stats array.
146     */
147    public static function generate_stats_array( $prefix = '' ) {
148
149        /**
150         * This filter is used to build the array of stats that are bumped once a day by Jetpack Heartbeat.
151         *
152         * Filter the array and add key => value pairs where
153         * * key is the stat group name
154         * * value is the stat name.
155         *
156         * Example:
157         * add_filter( 'jetpack_heartbeat_stats_array', function( $stats ) {
158         *    $stats['is-https'] = is_ssl() ? 'https' : 'http';
159         * });
160         *
161         * This will bump the stats for the 'is-https/https' or 'is-https/http' stat.
162         *
163         * @param array  $stats The stats to be filtered.
164         * @param string $prefix The prefix that will automatically be added at the begining at each stat group name.
165         */
166        $stats  = apply_filters( 'jetpack_heartbeat_stats_array', array(), $prefix );
167        $return = array();
168
169        // Apply prefix to stats.
170        foreach ( $stats as $stat => $value ) {
171            $return[ "$prefix$stat" ] = $value;
172        }
173
174        return $return;
175    }
176
177    /**
178     * Generates the site environment stats that are reported in the heartbeat.
179     *
180     * These describe the host environment (WordPress/PHP versions, site configuration, etc.)
181     * rather than the Jetpack plugin itself, so they live in the Connection package and are
182     * reported for every connected site, including standalone-connection installs.
183     *
184     * @since 8.7.9
185     *
186     * @return array The environment stats array, keyed by unprefixed stat name.
187     */
188    public static function get_environment_stats() {
189        $stats = array();
190
191        $stats['wp-version']   = get_bloginfo( 'version' );
192        $stats['php-version']  = PHP_VERSION;
193        $stats['wp-branch']    = (float) get_bloginfo( 'version' );
194        $stats['php-branch']   = (float) PHP_VERSION;
195        $stats['public']       = Jetpack_Options::get_option( 'public' );
196        $stats['ssl']          = self::permit_ssl();
197        $stats['is-https']     = is_ssl() ? 'https' : 'http';
198        $stats['language']     = get_bloginfo( 'language' );
199        $stats['charset']      = get_bloginfo( 'charset' );
200        $stats['is-multisite'] = is_multisite() ? 'multisite' : 'singlesite';
201        $stats['plugins']      = implode( ',', self::get_active_plugins() );
202
203        if ( function_exists( 'get_mu_plugins' ) ) {
204            $stats['mu-plugins'] = implode( ',', array_keys( get_mu_plugins() ) );
205        }
206
207        if ( function_exists( 'get_space_used' ) ) { // Only available in multisite.
208            $space_used = get_space_used();
209        } else {
210            // This is the same as `get_space_used`, except it does not apply the short-circuit filter.
211            $upload_dir = wp_upload_dir();
212            $space_used = get_dirsize( $upload_dir['basedir'] ) / MB_IN_BYTES;
213        }
214
215        $stats['space-used'] = $space_used;
216
217        // is-multi-network can have three values, `single-site`, `single-network`, and `multi-network`.
218        $stats['is-multi-network'] = 'single-site';
219        if ( is_multisite() ) {
220            $stats['is-multi-network'] = ( new Status() )->is_multi_network() ? 'multi-network' : 'single-network';
221        }
222
223        if ( ! empty( $_SERVER['SERVER_ADDR'] ) || ! empty( $_SERVER['LOCAL_ADDR'] ) ) {
224            $ip     = ! empty( $_SERVER['SERVER_ADDR'] ) ? wp_unslash( $_SERVER['SERVER_ADDR'] ) : wp_unslash( $_SERVER['LOCAL_ADDR'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized just below.
225            $ip_arr = array_map( 'intval', explode( '.', $ip ) );
226            if ( 4 === count( $ip_arr ) ) {
227                $stats['ip-2-octets'] = implode( '.', array_slice( $ip_arr, 0, 2 ) );
228            }
229        }
230
231        return $stats;
232    }
233
234    /**
235     * Checks whether the site can connect to WordPress.com over SSL.
236     *
237     * This is the canonical SSL connectivity check. It caches both the boolean result (in the
238     * `jetpack_https_test` transient) and a structured failure reason (via
239     * {@see self::get_ssl_test_error()}) so a single check serves both the `ssl` heartbeat stat
240     * and consumers such as the Jetpack plugin's admin notice, which renders a localized message
241     * from the reason code. This avoids duplicate network checks and keeps translated strings out
242     * of the package.
243     *
244     * @since 8.7.9
245     *
246     * @param bool $force_recheck Force the SSL recheck instead of using the cached result.
247     * @return bool Whether the site can connect to WordPress.com over SSL.
248     */
249    public static function permit_ssl( $force_recheck = false ) {
250        $ssl = false;
251        if ( ! $force_recheck ) {
252            $ssl = get_transient( 'jetpack_https_test' );
253        }
254
255        if ( $force_recheck || false === $ssl ) {
256            $error = array(
257                'code'   => '',
258                'detail' => '',
259            );
260
261            $api_base = Constants::get_constant( 'JETPACK__API_BASE' );
262            if ( ! $api_base ) {
263                $api_base = Utils::DEFAULT_JETPACK__API_BASE;
264            }
265
266            if ( ! str_starts_with( $api_base, 'https' ) ) {
267                $ssl = 0;
268            } else {
269                $ssl = 1;
270
271                if ( ! wp_http_supports( array( 'ssl' => true ) ) ) {
272                    $ssl           = 0;
273                    $error['code'] = 'no_ssl_support';
274                } else {
275                    $response = wp_remote_get( $api_base . 'test/1/' );
276                    if ( is_wp_error( $response ) ) {
277                        $ssl           = 0;
278                        $error['code'] = 'no_ssl_support';
279                    } elseif ( 'OK' !== wp_remote_retrieve_body( $response ) ) {
280                        $ssl             = 0;
281                        $error['code']   = 'bad_response';
282                        $error['detail'] = wp_remote_retrieve_body( $response );
283                    }
284                }
285            }
286            set_transient( 'jetpack_https_test', $ssl, DAY_IN_SECONDS );
287            set_transient( 'jetpack_https_test_error', $error, DAY_IN_SECONDS );
288        }
289
290        return (bool) $ssl;
291    }
292
293    /**
294     * Returns the structured reason for the last SSL connectivity failure.
295     *
296     * Consumers can map the returned reason code to a localized message. The `detail` value
297     * carries any additional context (e.g. the unexpected response body for `bad_response`).
298     *
299     * @since 8.7.9
300     *
301     * @return array {
302     *     The last SSL test error.
303     *
304     *     @type string $code   Reason code: '' (no error), 'no_ssl_support', or 'bad_response'.
305     *     @type string $detail Additional context for the failure, if any.
306     * }
307     */
308    public static function get_ssl_test_error() {
309        $error = get_transient( 'jetpack_https_test_error' );
310
311        if ( ! is_array( $error ) ) {
312            $error = array();
313        }
314
315        return array(
316            'code'   => isset( $error['code'] ) ? (string) $error['code'] : '',
317            'detail' => isset( $error['detail'] ) ? (string) $error['detail'] : '',
318        );
319    }
320
321    /**
322     * Gets all plugins currently active, regardless of whether they're traditionally
323     * activated or network activated.
324     *
325     * Ported from the Jetpack plugin so the `plugins` heartbeat stat can be generated from
326     * the Connection package. This is the canonical implementation; the Jetpack plugin's
327     * `Jetpack::get_active_plugins()` delegates to it.
328     *
329     * @since 8.7.9
330     *
331     * @return array
332     */
333    public static function get_active_plugins() {
334        $active_plugins = (array) get_option( 'active_plugins', array() );
335
336        if ( is_multisite() ) {
337            // Due to legacy code, active_sitewide_plugins stores them in the keys,
338            // whereas active_plugins stores them in the values.
339            $network_plugins = array_keys( get_site_option( 'active_sitewide_plugins', array() ) );
340            if ( $network_plugins ) {
341                $active_plugins = array_merge( $active_plugins, $network_plugins );
342            }
343        }
344
345        sort( $active_plugins );
346
347        return array_unique( $active_plugins );
348    }
349
350    /**
351     * Registers jetpack.getHeartbeatData xmlrpc method
352     *
353     * @param array $methods The list of methods to be filtered.
354     * @return array $methods
355     */
356    public static function jetpack_xmlrpc_methods( $methods ) {
357        $methods['jetpack.getHeartbeatData'] = array( __CLASS__, 'xmlrpc_data_response' );
358        return $methods;
359    }
360
361    /**
362     * Handles the response for the jetpack.getHeartbeatData xmlrpc method
363     *
364     * @param array $params The parameters received in the request.
365     * @return array $params all the stats that heartbeat handles.
366     */
367    public static function xmlrpc_data_response( $params = array() ) {
368        // The WordPress XML-RPC server sets a default param of array()
369        // if no argument is passed on the request and the method handlers get this array in $params.
370        // generate_stats_array() needs a string as first argument.
371        $params = empty( $params ) ? '' : $params;
372        return self::generate_stats_array( $params );
373    }
374
375    /**
376     * Clear scheduled events
377     *
378     * @return void
379     */
380    public function deactivate() {
381        // Deal with the old pre-3.0 weekly one.
382        $timestamp = wp_next_scheduled( 'jetpack_heartbeat' );
383        if ( $timestamp ) {
384            wp_unschedule_event( $timestamp, 'jetpack_heartbeat' );
385        }
386
387        $timestamp = wp_next_scheduled( $this->cron_name );
388        wp_unschedule_event( $timestamp, $this->cron_name );
389    }
390
391    /**
392     * Interact with the Heartbeat
393     *
394     * ## OPTIONS
395     *
396     * inspect (default): Gets the list of data that is going to be sent in the heartbeat and the date/time of the last heartbeat
397     *
398     * @param array $args Arguments passed via CLI.
399     *
400     * @return void
401     */
402    public function cli_callback( $args ) {
403
404        $allowed_args = array(
405            'inspect',
406        );
407
408        if ( isset( $args[0] ) && ! in_array( $args[0], $allowed_args, true ) ) {
409            /* translators: %s is a command like "prompt" */
410            WP_CLI::error( sprintf( __( '%s is not a valid command.', 'jetpack-connection' ), $args[0] ) );
411        }
412
413        $stats           = self::generate_stats_array();
414        $formatted_stats = array();
415
416        foreach ( $stats as $stat_name => $bin ) {
417            $formatted_stats[] = array(
418                'Stat name' => $stat_name,
419                'Bin'       => $bin,
420            );
421        }
422
423        WP_CLI\Utils\format_items( 'table', $formatted_stats, array( 'Stat name', 'Bin' ) );
424
425        $last_heartbeat = Jetpack_Options::get_option( 'last_heartbeat' );
426
427        if ( $last_heartbeat ) {
428            $last_date = gmdate( 'Y-m-d H:i:s', $last_heartbeat );
429            /* translators: %s is the full datetime of the last heart beat e.g. 2020-01-01 12:21:23 */
430            WP_CLI::line( sprintf( __( 'Last heartbeat sent at: %s', 'jetpack-connection' ), $last_date ) );
431        }
432    }
433
434    /**
435     * Initialize the heartbeat REST API.
436     *
437     * @return void
438     */
439    public function initialize_rest_api() {
440        register_rest_route(
441            'jetpack/v4',
442            '/heartbeat/data',
443            array(
444                'methods'             => WP_REST_Server::READABLE,
445                'callback'            => array( $this, 'rest_heartbeat_data' ),
446                'permission_callback' => array( $this, 'rest_heartbeat_data_permission_check' ),
447                'args'                => array(
448                    'prefix' => array(
449                        'description' => __( 'Prefix to add before the stats identifiers.', 'jetpack-connection' ),
450                        'type'        => 'string',
451                    ),
452                ),
453            )
454        );
455    }
456
457    /**
458     * Endpoint to retrieve the heartbeat data.
459     *
460     * @param WP_REST_Request $request The request data.
461     *
462     * @since 2.7.0
463     *
464     * @return array
465     */
466    public function rest_heartbeat_data( WP_REST_Request $request ) {
467        return static::generate_stats_array( $request->get_param( 'prefix' ) );
468    }
469
470    /**
471     * Check permissions for the `get_heartbeat_data` endpoint.
472     *
473     * @return true|WP_Error
474     */
475    public function rest_heartbeat_data_permission_check() {
476        if ( current_user_can( 'jetpack_connect' ) ) {
477            return true;
478        }
479
480        return Rest_Authentication::is_signed_with_blog_token()
481            ? true
482            : new WP_Error( 'invalid_permission_heartbeat_data', REST_Connector::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
483    }
484}