Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
52.05% covered (warning)
52.05%
89 / 171
33.33% covered (danger)
33.33%
5 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Scheduled_Updates
52.35% covered (warning)
52.35%
89 / 170
33.33% covered (danger)
33.33%
5 / 15
320.43
0.00% covered (danger)
0.00%
0 / 1
 init
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
5.01
 load_rest_api_endpoints
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 run_scheduled_update
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
12
 update_option_cron
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
3.00
 clear_cron_cache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 clear_cron_cache_pre
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 set_scheduled_update_status
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 get_scheduled_update_status
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 allowlist_scheduled_plugins
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 maybe_disable_autoupdates
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 enable_autoupdates
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 add_is_managed_extension_field
7.69% covered (danger)
7.69%
1 / 13
0.00% covered (danger)
0.00%
0 / 1
1.79
 deleted_plugin
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
11.10
 generate_schedule_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_managed_plugin
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Scheduled Updates
4 *
5 * @package automattic/scheduled-updates
6 */
7
8namespace Automattic\Jetpack;
9
10// Load dependencies.
11require_once __DIR__ . '/pluggable.php';
12
13/**
14 * Scheduled Updates class.
15 */
16class 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}