PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.3
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-connection / src / class-heartbeat.php

class-heartbeat.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.3, at jetpack_vendor/automattic/jetpack-connection/src/class-heartbeat.php

485 lines 13.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Jetpack Heartbeat package.
4 *
5 * @package automattic/jetpack-connection
6 */
7
8 namespace Automattic\Jetpack;
9
10 use Automattic\Jetpack\Connection\Rest_Authentication;
11 use Automattic\Jetpack\Connection\REST_Connector;
12 use Automattic\Jetpack\Connection\Utils;
13 use Jetpack_Options;
14 use WP_CLI;
15 use WP_Error;
16 use WP_REST_Request;
17 use WP_REST_Server;
18
19 /**
20 * Heartbeat sends a batch of stats to wp.com once a day
21 */
22 class 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 }
485