PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 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 All 508 releases
← All changes | jetpack_vendor/automattic/jetpack-connection/src/class-heartbeat.php +484 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,484 @@
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 +}