Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
52.05% |
89 / 171 |
|
33.33% |
5 / 15 |
CRAP | |
0.00% |
0 / 1 |
| Scheduled_Updates | |
52.35% |
89 / 170 |
|
33.33% |
5 / 15 |
320.43 | |
0.00% |
0 / 1 |
| init | |
93.10% |
27 / 29 |
|
0.00% |
0 / 1 |
5.01 | |||
| load_rest_api_endpoints | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
| run_scheduled_update | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
12 | |||
| update_option_cron | |
94.44% |
17 / 18 |
|
0.00% |
0 / 1 |
3.00 | |||
| clear_cron_cache | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| clear_cron_cache_pre | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
3.33 | |||
| set_scheduled_update_status | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
20 | |||
| get_scheduled_update_status | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| allowlist_scheduled_plugins | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
42 | |||
| maybe_disable_autoupdates | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| enable_autoupdates | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
3.01 | |||
| add_is_managed_extension_field | |
7.69% |
1 / 13 |
|
0.00% |
0 / 1 |
1.79 | |||
| deleted_plugin | |
90.48% |
19 / 21 |
|
0.00% |
0 / 1 |
11.10 | |||
| generate_schedule_id | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| is_managed_plugin | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Scheduled Updates |
| 4 | * |
| 5 | * @package automattic/scheduled-updates |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack; |
| 9 | |
| 10 | // Load dependencies. |
| 11 | require_once __DIR__ . '/pluggable.php'; |
| 12 | |
| 13 | /** |
| 14 | * Scheduled Updates class. |
| 15 | */ |
| 16 | class Scheduled_Updates { |
| 17 | |
| 18 | /** |
| 19 | * The version of the package. |
| 20 | * |
| 21 | * @var string |
| 22 | */ |
| 23 | const PACKAGE_VERSION = '0.14.6'; |
| 24 | |
| 25 | /** |
| 26 | * The cron event hook for the scheduled plugins update. |
| 27 | * |
| 28 | * @var string |
| 29 | */ |
| 30 | const PLUGIN_CRON_HOOK = 'jetpack_scheduled_plugins_update'; |
| 31 | |
| 32 | /** |
| 33 | * The cron event filter for the scheduled plugins update. |
| 34 | * |
| 35 | * @var string |
| 36 | */ |
| 37 | const PLUGIN_CRON_SYNC_HOOK = 'jetpack_scheduled_plugins_update_sync'; |
| 38 | |
| 39 | /** |
| 40 | * Initialize the class. |
| 41 | */ |
| 42 | public static function init() { |
| 43 | /* |
| 44 | * We want to load the REST API endpoints in all environments. |
| 45 | * On WP.com they're needed for registering the routes with public-api and pass-through to self-hosted sites. |
| 46 | */ |
| 47 | add_action( 'plugins_loaded', array( __CLASS__, 'load_rest_api_endpoints' ), 20 ); |
| 48 | |
| 49 | // Never load on Simple sites. |
| 50 | if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { |
| 51 | return; |
| 52 | } |
| 53 | |
| 54 | if ( ! ( method_exists( 'Automattic\Jetpack\Current_Plan', 'supports' ) && Current_Plan::supports( 'scheduled-updates' ) ) ) { |
| 55 | return; |
| 56 | } |
| 57 | |
| 58 | add_action( self::PLUGIN_CRON_HOOK, array( __CLASS__, 'run_scheduled_update' ), 10, 10 ); |
| 59 | add_filter( 'auto_update_plugin', array( __CLASS__, 'allowlist_scheduled_plugins' ), 10, 2 ); |
| 60 | add_action( 'deleted_plugin', array( __CLASS__, 'deleted_plugin' ), 10, 2 ); |
| 61 | |
| 62 | add_action( 'rest_api_init', array( __CLASS__, 'add_is_managed_extension_field' ) ); |
| 63 | add_action( 'rest_api_init', array( Scheduled_Updates_Logs::class, 'add_log_fields' ) ); |
| 64 | add_action( 'rest_api_init', array( Scheduled_Updates_Active::class, 'add_active_field' ) ); |
| 65 | add_action( 'rest_api_init', array( Scheduled_Updates_Health_Paths::class, 'add_health_check_paths_field' ) ); |
| 66 | |
| 67 | add_filter( 'plugins_list', array( Scheduled_Updates_Admin::class, 'add_scheduled_updates_context' ) ); |
| 68 | add_filter( 'views_plugins', array( Scheduled_Updates_Admin::class, 'add_scheduled_updates_view' ) ); |
| 69 | add_filter( 'plugin_auto_update_setting_html', array( Scheduled_Updates_Admin::class, 'show_scheduled_updates' ), 10, 2 ); |
| 70 | |
| 71 | add_action( 'jetpack_scheduled_update_created', array( __CLASS__, 'maybe_disable_autoupdates' ), 10, 3 ); |
| 72 | |
| 73 | add_action( 'jetpack_scheduled_update_updated', array( Scheduled_Updates_Logs::class, 'replace_logs_schedule_id' ), 10, 2 ); |
| 74 | |
| 75 | add_action( 'jetpack_scheduled_update_deleted', array( __CLASS__, 'enable_autoupdates' ), 10, 2 ); |
| 76 | add_action( 'jetpack_scheduled_update_deleted', array( Scheduled_Updates_Active::class, 'clear' ) ); |
| 77 | add_action( 'jetpack_scheduled_update_deleted', array( Scheduled_Updates_Health_Paths::class, 'clear' ) ); |
| 78 | add_action( 'jetpack_scheduled_update_deleted', array( Scheduled_Updates_Logs::class, 'delete_logs_schedule_id' ), 10, 3 ); |
| 79 | |
| 80 | // Update cron sync option after options update. |
| 81 | $callback = array( __CLASS__, 'update_option_cron' ); |
| 82 | |
| 83 | // Main cron saving. |
| 84 | add_action( 'add_option_cron', $callback ); |
| 85 | add_action( 'update_option_cron', $callback ); |
| 86 | |
| 87 | // Logs saving. |
| 88 | add_action( 'add_option_' . Scheduled_Updates_Logs::OPTION_NAME, $callback ); |
| 89 | add_action( 'update_option_' . Scheduled_Updates_Logs::OPTION_NAME, $callback ); |
| 90 | |
| 91 | // Active flag saving. |
| 92 | add_action( 'add_option_' . Scheduled_Updates_Active::OPTION_NAME, $callback ); |
| 93 | add_action( 'update_option_' . Scheduled_Updates_Active::OPTION_NAME, $callback ); |
| 94 | |
| 95 | add_filter( 'pre_schedule_event', array( __CLASS__, 'clear_cron_cache_pre' ), 10, 2 ); |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * Load the REST API endpoints. |
| 100 | * |
| 101 | * @suppress PhanUndeclaredFunction |
| 102 | */ |
| 103 | public static function load_rest_api_endpoints() { |
| 104 | if ( ! function_exists( 'wpcom_rest_api_v2_load_plugin' ) ) { |
| 105 | return; |
| 106 | } |
| 107 | |
| 108 | $endpoints = glob( __DIR__ . '/wpcom-endpoints/*.php' ); |
| 109 | foreach ( array_filter( (array) $endpoints, 'is_file' ) as $endpoint ) { |
| 110 | require_once $endpoint; |
| 111 | } |
| 112 | |
| 113 | wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Update_Schedules' ); |
| 114 | wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Update_Schedules_Capabilities' ); |
| 115 | wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Update_Schedules_Logs' ); |
| 116 | wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Update_Schedules_Active' ); |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * Run the scheduled update. |
| 121 | * |
| 122 | * @param string ...$plugins List of plugins to update. |
| 123 | */ |
| 124 | public static function run_scheduled_update( ...$plugins ) { |
| 125 | $schedule_id = self::generate_schedule_id( $plugins ); |
| 126 | |
| 127 | if ( ! Scheduled_Updates_Active::get( $schedule_id ) ) { |
| 128 | // The schedule is not active, return. |
| 129 | return false; |
| 130 | } |
| 131 | |
| 132 | $available_updates = get_site_transient( 'update_plugins' ); |
| 133 | $plugins_to_update = $available_updates->response ?? array(); |
| 134 | $plugins_to_update = array_intersect_key( $plugins_to_update, array_flip( $plugins ) ); |
| 135 | |
| 136 | if ( empty( $plugins_to_update ) ) { |
| 137 | |
| 138 | // Log a start and success. |
| 139 | Scheduled_Updates_Logs::log( |
| 140 | $schedule_id, |
| 141 | Scheduled_Updates_Logs::PLUGIN_UPDATES_START, |
| 142 | 'no_plugins_to_update' |
| 143 | ); |
| 144 | Scheduled_Updates_Logs::log( |
| 145 | $schedule_id, |
| 146 | Scheduled_Updates_Logs::PLUGIN_UPDATES_SUCCESS, |
| 147 | 'no_plugins_to_update' |
| 148 | ); |
| 149 | |
| 150 | return false; |
| 151 | } |
| 152 | |
| 153 | $response = ( new Connection\Client() )->wpcom_json_api_request_as_blog( |
| 154 | sprintf( '/sites/%d/hosting/scheduled-update', \Jetpack_Options::get_option( 'id' ) ), |
| 155 | '2', |
| 156 | array( 'method' => 'POST' ), |
| 157 | array( |
| 158 | 'health_check_paths' => Scheduled_Updates_Health_Paths::get( $schedule_id ), |
| 159 | 'plugins' => $plugins_to_update, |
| 160 | 'schedule_id' => $schedule_id, |
| 161 | ), |
| 162 | 'wpcom' |
| 163 | ); |
| 164 | |
| 165 | return is_array( $response ); |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * Save the schedules for sync after cron option saving. |
| 170 | */ |
| 171 | public static function update_option_cron() { |
| 172 | // Do not update the option if the filter returns false. |
| 173 | if ( ! apply_filters( self::PLUGIN_CRON_SYNC_HOOK, true ) ) { |
| 174 | return; |
| 175 | } |
| 176 | |
| 177 | Scheduled_Updates_Logs::add_log_fields(); |
| 178 | Scheduled_Updates_Active::add_active_field(); |
| 179 | Scheduled_Updates_Health_Paths::add_health_check_paths_field(); |
| 180 | |
| 181 | $endpoint = new \WPCOM_REST_API_V2_Endpoint_Update_Schedules(); |
| 182 | $events = $endpoint->get_items( new \WP_REST_Request() ); |
| 183 | $updated_at = time(); |
| 184 | |
| 185 | if ( ! is_wp_error( $events ) ) { |
| 186 | $option = array_map( |
| 187 | function ( $event ) use ( $updated_at ) { |
| 188 | $ret = (object) $event; |
| 189 | // Add updated_at field to ensure the option is always on sync. |
| 190 | $ret->updated_at = $updated_at; |
| 191 | |
| 192 | return $ret; |
| 193 | }, |
| 194 | $events->get_data() |
| 195 | ); |
| 196 | update_option( self::PLUGIN_CRON_HOOK, $option ); |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | /** |
| 201 | * Clear the cron cache. |
| 202 | */ |
| 203 | public static function clear_cron_cache() { |
| 204 | wp_cache_delete( 'cron', 'options' ); |
| 205 | wp_load_alloptions( true ); |
| 206 | } |
| 207 | |
| 208 | /** |
| 209 | * Reload the cron cache in pre_schedule_event hook. Returns null to prevent short-circuit. |
| 210 | * |
| 211 | * @param null|bool|\WP_Error $result The value to return instead. Default null to continue adding the event. |
| 212 | * @param object $event The event object. |
| 213 | */ |
| 214 | public static function clear_cron_cache_pre( $result, $event ) { |
| 215 | // If the transient is set and an external event is about to run, it means that the cron cache must be refreshed. |
| 216 | if ( self::PLUGIN_CRON_HOOK !== $event->hook && get_transient( 'pre_schedule_event_clear_cron_cache' ) ) { |
| 217 | self::clear_cron_cache(); |
| 218 | } |
| 219 | |
| 220 | return $result; |
| 221 | } |
| 222 | |
| 223 | /** |
| 224 | * Updates last status of a scheduled update. |
| 225 | * |
| 226 | * @param string $schedule_id Request ID. |
| 227 | * @param int|null $timestamp Timestamp of the last run. |
| 228 | * @param string|null $status Status of the last run. |
| 229 | * @return false|array Updated statuses or false if not found. |
| 230 | */ |
| 231 | public static function set_scheduled_update_status( $schedule_id, $timestamp, $status ) { |
| 232 | $events = wp_get_scheduled_events( self::PLUGIN_CRON_HOOK ); |
| 233 | |
| 234 | if ( empty( $events[ $schedule_id ] ) ) { |
| 235 | // Scheduled update not found. |
| 236 | return false; |
| 237 | } |
| 238 | |
| 239 | $statuses = get_option( 'jetpack_scheduled_update_statuses', array() ); |
| 240 | $option = array(); |
| 241 | |
| 242 | // Reset the last statuses for the schedule. |
| 243 | foreach ( array_keys( $events ) as $status_id ) { |
| 244 | if ( ! empty( $statuses[ $status_id ] ) ) { |
| 245 | $option[ $status_id ] = $statuses[ $status_id ]; |
| 246 | } else { |
| 247 | $option[ $status_id ] = null; |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | // Update the last status for the schedule. |
| 252 | $option[ $schedule_id ] = array( |
| 253 | 'last_run_timestamp' => $timestamp, |
| 254 | 'last_run_status' => $status, |
| 255 | ); |
| 256 | |
| 257 | update_option( 'jetpack_scheduled_update_statuses', $option ); |
| 258 | |
| 259 | return $option; |
| 260 | } |
| 261 | |
| 262 | /** |
| 263 | * Get the last status of a scheduled update. |
| 264 | * |
| 265 | * @param string $schedule_id Request ID. |
| 266 | * @return array|false Last status of the scheduled update or false if not found. |
| 267 | */ |
| 268 | public static function get_scheduled_update_status( $schedule_id ) { |
| 269 | return Scheduled_Updates_Logs::infer_status_from_logs( $schedule_id ); |
| 270 | } |
| 271 | |
| 272 | /** |
| 273 | * Allow plugins that are part of scheduled updates to be updated automatically. |
| 274 | * |
| 275 | * @param bool|null $update Whether to update. The value of null is internally used |
| 276 | * to detect whether nothing has hooked into this filter. |
| 277 | * @param object $item The update offer. |
| 278 | * @return bool |
| 279 | */ |
| 280 | public static function allowlist_scheduled_plugins( $update, $item ) { |
| 281 | if ( Constants::get_constant( 'SCHEDULED_AUTOUPDATE' ) ) { |
| 282 | if ( ! function_exists( 'wp_get_scheduled_events' ) ) { |
| 283 | require_once __DIR__ . '/pluggable.php'; |
| 284 | } |
| 285 | |
| 286 | $events = wp_get_scheduled_events( self::PLUGIN_CRON_HOOK ); |
| 287 | foreach ( $events as $event ) { |
| 288 | if ( isset( $item->plugin ) && in_array( $item->plugin, $event->args, true ) ) { |
| 289 | return true; |
| 290 | } |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | return $update; |
| 295 | } |
| 296 | |
| 297 | /** |
| 298 | * Maybe disable autoupdates. |
| 299 | * |
| 300 | * @param string $id The ID of the schedule. |
| 301 | * @param object $event The event object. |
| 302 | * @param \WP_REST_Request $request The request object. |
| 303 | */ |
| 304 | public static function maybe_disable_autoupdates( $id, $event, $request ) { |
| 305 | require_once ABSPATH . 'wp-admin/includes/update.php'; |
| 306 | |
| 307 | if ( wp_is_auto_update_enabled_for_type( 'plugin' ) ) { |
| 308 | // Remove the plugins that are now updated on a schedule from the auto-update list. |
| 309 | $auto_update_plugins = get_option( 'auto_update_plugins', array() ); |
| 310 | $auto_update_plugins = array_diff( $auto_update_plugins, $request['plugins'] ); |
| 311 | update_option( 'auto_update_plugins', $auto_update_plugins ); |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | /** |
| 316 | * Enable autoupdates. |
| 317 | * |
| 318 | * @param string $id The ID of the schedule. |
| 319 | * @param object $event The deleted event object. |
| 320 | */ |
| 321 | public static function enable_autoupdates( $id, $event ) { |
| 322 | require_once ABSPATH . 'wp-admin/includes/update.php'; |
| 323 | |
| 324 | if ( ! wp_is_auto_update_enabled_for_type( 'plugin' ) ) { |
| 325 | return; |
| 326 | } |
| 327 | |
| 328 | $events = wp_get_scheduled_events( static::PLUGIN_CRON_HOOK ); |
| 329 | unset( $events[ $id ] ); |
| 330 | |
| 331 | // Find the plugins that are not part of any other schedule. |
| 332 | $plugins = $event->args; |
| 333 | foreach ( wp_list_pluck( $events, 'args' ) as $args ) { |
| 334 | $plugins = array_diff( $plugins, $args ); |
| 335 | } |
| 336 | |
| 337 | // Add the plugins that are no longer updated on a schedule to the auto-update list. |
| 338 | $auto_update_plugins = get_option( 'auto_update_plugins', array() ); |
| 339 | $auto_update_plugins = array_unique( array_merge( $auto_update_plugins, $plugins ) ); |
| 340 | usort( $auto_update_plugins, 'strnatcasecmp' ); |
| 341 | update_option( 'auto_update_plugins', $auto_update_plugins ); |
| 342 | } |
| 343 | |
| 344 | /** |
| 345 | * Registers the is_managed field for the plugin REST API. |
| 346 | */ |
| 347 | public static function add_is_managed_extension_field() { |
| 348 | register_rest_field( |
| 349 | 'plugin', |
| 350 | 'is_managed', |
| 351 | array( |
| 352 | /** |
| 353 | * Populates the is_managed field. |
| 354 | * |
| 355 | * @param array $data Prepared response array. |
| 356 | * @return bool |
| 357 | */ |
| 358 | 'get_callback' => function ( $data ) { |
| 359 | return self::is_managed_plugin( $data['plugin'] ); |
| 360 | }, |
| 361 | 'schema' => array( |
| 362 | 'description' => 'Whether the plugin is managed by the host.', |
| 363 | 'type' => 'boolean', |
| 364 | ), |
| 365 | ) |
| 366 | ); |
| 367 | } |
| 368 | |
| 369 | /** |
| 370 | * Hook run when a plugin is deleted. |
| 371 | * |
| 372 | * @param string $plugin_file Path to the plugin file relative to the plugins directory. |
| 373 | * @param bool $deleted Whether the plugin deletion was successful. |
| 374 | */ |
| 375 | public static function deleted_plugin( $plugin_file, $deleted ) { |
| 376 | if ( ! $deleted ) { |
| 377 | return; |
| 378 | } |
| 379 | |
| 380 | $events = wp_get_scheduled_events( self::PLUGIN_CRON_HOOK ); |
| 381 | |
| 382 | if ( ! count( $events ) ) { |
| 383 | return; |
| 384 | } |
| 385 | |
| 386 | foreach ( $events as $id => $event ) { |
| 387 | // Continue if the plugin is not part of the schedule. |
| 388 | if ( ! in_array( $plugin_file, $event->args, true ) ) { |
| 389 | continue; |
| 390 | } |
| 391 | |
| 392 | // Remove the schedule. |
| 393 | $result = wp_unschedule_event( $event->timestamp, self::PLUGIN_CRON_HOOK, $event->args, true ); |
| 394 | |
| 395 | if ( is_wp_error( $result ) || false === $result ) { |
| 396 | continue; |
| 397 | } |
| 398 | |
| 399 | $plugins = array_values( array_diff( $event->args, array( $plugin_file ) ) ); |
| 400 | |
| 401 | if ( ! count( $plugins ) ) { |
| 402 | continue; |
| 403 | } |
| 404 | |
| 405 | // There are still plugins to update. Schedule a new event. |
| 406 | $event = wp_schedule_event( $event->timestamp, $event->schedule, self::PLUGIN_CRON_HOOK, $plugins, true ); |
| 407 | |
| 408 | if ( is_wp_error( $event ) || false === $event ) { |
| 409 | continue; |
| 410 | } |
| 411 | |
| 412 | $schedule_id = self::generate_schedule_id( $plugins ); |
| 413 | $status = self::get_scheduled_update_status( $id ); |
| 414 | |
| 415 | // Inherit the status from the previous schedule. |
| 416 | if ( $status ) { |
| 417 | Scheduled_Updates_Logs::replace_logs_schedule_id( $id, $schedule_id ); |
| 418 | } |
| 419 | } |
| 420 | } |
| 421 | |
| 422 | /** |
| 423 | * Generates a unique schedule ID. |
| 424 | * |
| 425 | * @see wp_schedule_event() |
| 426 | * |
| 427 | * @param array $args Schedule arguments. |
| 428 | * @return string |
| 429 | */ |
| 430 | public static function generate_schedule_id( $args ) { |
| 431 | return md5( serialize( $args ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize |
| 432 | } |
| 433 | |
| 434 | /** |
| 435 | * Check if a plugin is managed by the host. |
| 436 | * |
| 437 | * Users could have their own plugins folder with symlinks pointing to it, so we need to check if the |
| 438 | * link target is within the `/wordpress` directory to determine if the plugin is managed. |
| 439 | * |
| 440 | * @see p9o2xV-3Nx-p2#comment-8728 |
| 441 | * |
| 442 | * @param string $plugin The plugin to check. |
| 443 | * @return bool |
| 444 | */ |
| 445 | public static function is_managed_plugin( $plugin ) { |
| 446 | $folder = WP_PLUGIN_DIR . '/' . strtok( $plugin, '/' ); |
| 447 | $target = is_link( $folder ) ? realpath( $folder ) : false; |
| 448 | |
| 449 | return $target && 0 === strpos( $target, '/wordpress/' ); |
| 450 | } |
| 451 | } |