Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.15% covered (warning)
88.15%
253 / 287
34.78% covered (danger)
34.78%
8 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_Update_Schedules
88.15% covered (warning)
88.15%
253 / 287
34.78% covered (danger)
34.78%
8 / 23
85.60
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
1 / 1
1
 get_items_permissions_check
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
5.02
 get_items
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 create_item_permissions_check
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
6.84
 create_item
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
4.05
 get_item_permissions_check
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
5.02
 get_item
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 update_item_permissions_check
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
6.84
 update_item
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 delete_item_permissions_check
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 delete_item
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
5.07
 prepare_response_for_collection
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 prepare_item_for_response
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 validate_plugins_param
70.00% covered (warning)
70.00%
14 / 20
0.00% covered (danger)
0.00%
0 / 1
6.97
 is_plugin_installed
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 validate_plugin_param
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 sanitize_plugin_param
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 validate_themes_param
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 validate_schedule
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 transform_error_response
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 get_item_schema
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
2
 get_object_params
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Endpoint to manage plugin and theme update schedules.
4 *
5 * Example: https://public-api.wordpress.com/wpcom/v2/update-schedules
6 *
7 * @package automattic/scheduled-updates
8 */
9
10use Automattic\Jetpack\Scheduled_Updates;
11
12/**
13 * Class WPCOM_REST_API_V2_Endpoint_Update_Schedules
14 */
15class WPCOM_REST_API_V2_Endpoint_Update_Schedules extends WP_REST_Controller {
16    /**
17     * The pattern for a plugin basename.
18     *
19     * @var string
20     */
21    const PATTERN = '[^.\/]+(?:\/[^.\/]+)?';
22
23    /**
24     * The namespace of this controller's route.
25     *
26     * @var string
27     */
28    public $namespace = 'wpcom/v2';
29
30    /**
31     * The base of this controller's route.
32     *
33     * @var string
34     */
35    public $rest_base = 'update-schedules';
36
37    /**
38     * WPCOM_REST_API_V2_Endpoint_Atomic_Hosting_Update_Schedule constructor.
39     */
40    public function __construct() {
41        // Priority 11 to make it easier for rest field schemas to make it into get_object_params().
42        add_action( 'rest_api_init', array( $this, 'register_routes' ), 11 );
43    }
44
45    /**
46     * Register routes.
47     */
48    public function register_routes() {
49        register_rest_route(
50            $this->namespace,
51            '/' . $this->rest_base,
52            array(
53                array(
54                    // @phan-suppress-next-line PhanPluginMixedKeyNoKey -- `register_rest_route()` requires mixed key/no-key for `$args`, and then https://github.com/phan/phan/issues/4852 puts the error on the wrong line.
55                    'methods'             => WP_REST_Server::READABLE,
56                    'callback'            => array( $this, 'get_items' ),
57                    'permission_callback' => array( $this, 'get_items_permissions_check' ),
58                ),
59                array(
60                    'methods'             => WP_REST_Server::CREATABLE,
61                    'callback'            => array( $this, 'create_item' ),
62                    'permission_callback' => array( $this, 'create_item_permissions_check' ),
63                    'args'                => $this->get_object_params( WP_REST_Server::CREATABLE ),
64                ),
65                'schema' => array( $this, 'get_public_item_schema' ),
66            )
67        );
68
69        register_rest_route(
70            $this->namespace,
71            '/' . $this->rest_base . '/(?P<schedule_id>[\w]+)',
72            array(
73                array(
74                    // @phan-suppress-next-line PhanPluginMixedKeyNoKey -- `register_rest_route()` requires mixed key/no-key for `$args`, and then https://github.com/phan/phan/issues/4852 puts the error on the wrong line.
75                    'methods'             => WP_REST_Server::READABLE,
76                    'callback'            => array( $this, 'get_item' ),
77                    'permission_callback' => array( $this, 'get_item_permissions_check' ),
78                    'args'                => array(
79                        'schedule_id' => array(
80                            'description' => 'ID of the schedule.',
81                            'type'        => 'string',
82                            'required'    => true,
83                        ),
84                    ),
85                ),
86                array(
87                    'methods'             => WP_REST_Server::EDITABLE,
88                    'callback'            => array( $this, 'update_item' ),
89                    'permission_callback' => array( $this, 'update_item_permissions_check' ),
90                    'args'                => array_merge(
91                        array(
92                            'schedule_id' => array(
93                                'description' => 'ID of the schedule.',
94                                'type'        => 'string',
95                                'required'    => true,
96                            ),
97                        ),
98                        $this->get_object_params( WP_REST_Server::EDITABLE )
99                    ),
100                ),
101                array(
102                    'methods'             => WP_REST_Server::DELETABLE,
103                    'callback'            => array( $this, 'delete_item' ),
104                    'permission_callback' => array( $this, 'delete_item_permissions_check' ),
105                ),
106                'schema' => array( $this, 'get_public_item_schema' ),
107            )
108        );
109    }
110
111    /**
112     * Permission check for retrieving schedules.
113     *
114     * @param WP_REST_Request $request Request object.
115     * @return bool|WP_Error
116     */
117    public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
118        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
119            return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to access this endpoint.', 'jetpack-scheduled-updates' ), array( 'status' => 403 ) );
120        }
121
122        if ( get_option( 'wpcom_is_staging_site' ) ) {
123            return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to access this endpoint.', 'jetpack-scheduled-updates' ), array( 'status' => 403 ) );
124        }
125
126        return current_user_can( 'update_plugins' );
127    }
128
129    /**
130     * Returns a list of update schedules.
131     *
132     * @param WP_REST_Request $request Request object.
133     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
134     */
135    public function get_items( $request ) {
136        $events   = wp_get_scheduled_events( Scheduled_Updates::PLUGIN_CRON_HOOK );
137        $response = array();
138
139        foreach ( $events as $schedule_id => $event ) {
140            // Add the schedule_id to the object.
141            $event->schedule_id = $schedule_id;
142
143            // Run through the prepare_item_for_response method to add any registered rest fields.
144            $response[ $schedule_id ] = $this->prepare_response_for_collection(
145                $this->prepare_item_for_response( $event, $request )
146            );
147        }
148
149        return rest_ensure_response( $response );
150    }
151
152    /**
153     * Permission check for creating a new schedule.
154     *
155     * @param WP_REST_Request $request Request object.
156     * @return bool|WP_Error
157     */
158    public function create_item_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
159        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
160            return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to access this endpoint.', 'jetpack-scheduled-updates' ), array( 'status' => 403 ) );
161        }
162
163        if ( get_option( 'wpcom_is_staging_site' ) ) {
164            return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to access this endpoint.', 'jetpack-scheduled-updates' ), array( 'status' => 403 ) );
165        }
166
167        if ( ! ( method_exists( 'Automattic\Jetpack\Current_Plan', 'supports' ) && Automattic\Jetpack\Current_Plan::supports( 'scheduled-updates' ) ) ) {
168            return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to access this endpoint.', 'jetpack-scheduled-updates' ), array( 'status' => 403 ) );
169        }
170
171        return current_user_can( 'update_plugins' );
172    }
173
174    /**
175     * Creates a new update schedule.
176     *
177     * @param WP_REST_Request $request Request object.
178     * @return WP_REST_Response|WP_Error
179     */
180    public function create_item( $request ) {
181        $result = $this->validate_schedule( $request );
182        if ( is_wp_error( $result ) ) {
183            return $result;
184        }
185
186        $schedule = $request['schedule'];
187        $plugins  = $request['plugins'];
188        usort( $plugins, 'strnatcasecmp' );
189
190        $event = wp_schedule_event( $schedule['timestamp'], $schedule['interval'], Scheduled_Updates::PLUGIN_CRON_HOOK, $plugins, true );
191
192        if ( is_wp_error( $event ) ) {
193            // If the schedule could not be created, return an error.
194            $event->add_data( array( 'status' => 404 ) );
195
196            return $event;
197        }
198
199        $id    = Scheduled_Updates::generate_schedule_id( $plugins );
200        $event = wp_get_scheduled_event( Scheduled_Updates::PLUGIN_CRON_HOOK, $plugins, $schedule['timestamp'] );
201
202        if ( ! $event ) {
203            return new WP_Error( 'rest_invalid_schedule', __( 'The schedule could not be created.', 'jetpack-scheduled-updates' ), array( 'status' => 404 ) );
204        }
205
206        /**
207         * Fires when a scheduled update is created.
208         *
209         * @param string          $id      The ID of the schedule.
210         * @param object          $event   The event object.
211         * @param WP_REST_Request $request The request object.
212         */
213        do_action( 'jetpack_scheduled_update_created', $id, $event, $request );
214
215        $event->schedule_id = $id;
216        $this->update_additional_fields_for_object( $event, $request );
217
218        // Clear the case and add a transient to clear it again if in 10 seconds another event is scheduled.
219        Scheduled_Updates::clear_cron_cache();
220        set_transient( 'pre_schedule_event_clear_cron_cache', true, 10 );
221
222        return rest_ensure_response( $id );
223    }
224
225    /**
226     * Permission check for retrieving a specific schedule.
227     *
228     * @param WP_REST_Request $request Request object.
229     * @return bool|WP_Error
230     */
231    public function get_item_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
232        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
233            return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to access this endpoint.', 'jetpack-scheduled-updates' ), array( 'status' => 403 ) );
234        }
235
236        if ( get_option( 'wpcom_is_staging_site' ) ) {
237            return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to access this endpoint.', 'jetpack-scheduled-updates' ), array( 'status' => 403 ) );
238        }
239
240        return current_user_can( 'update_plugins' );
241    }
242
243    /**
244     * Returns information about an update schedule.
245     *
246     * @param WP_REST_Request $request Request object.
247     * @return WP_REST_Response|WP_Error The scheduled event or a WP_Error if the schedule could not be found.
248     */
249    public function get_item( $request ) {
250        $events = wp_get_scheduled_events( Scheduled_Updates::PLUGIN_CRON_HOOK );
251
252        if ( empty( $events[ $request['schedule_id'] ] ) ) {
253            return new WP_Error( 'rest_invalid_schedule', __( 'The schedule could not be found.', 'jetpack-scheduled-updates' ), array( 'status' => 404 ) );
254        }
255
256        // Add the schedule_id to the object.
257        $events[ $request['schedule_id'] ]->schedule_id = $request['schedule_id'];
258
259        return $this->prepare_item_for_response( $events[ $request['schedule_id'] ], $request );
260    }
261
262    /**
263     * Permission check for updating a specific schedule.
264     *
265     * @param WP_REST_Request $request Request object.
266     * @return bool|WP_Error
267     */
268    public function update_item_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
269        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
270            return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to access this endpoint.', 'jetpack-scheduled-updates' ), array( 'status' => 403 ) );
271        }
272
273        if ( get_option( 'wpcom_is_staging_site' ) ) {
274            return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to access this endpoint.', 'jetpack-scheduled-updates' ), array( 'status' => 403 ) );
275        }
276
277        if ( ! ( method_exists( 'Automattic\Jetpack\Current_Plan', 'supports' ) && Automattic\Jetpack\Current_Plan::supports( 'scheduled-updates' ) ) ) {
278            return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to access this endpoint.', 'jetpack-scheduled-updates' ), array( 'status' => 403 ) );
279        }
280
281        return current_user_can( 'update_plugins' );
282    }
283
284    /**
285     * Updates an existing update schedule.
286     *
287     * @param WP_REST_Request $request Request object.
288     * @return WP_REST_Response|WP_Error The updated event or a WP_Error if the schedule could not be found.
289     */
290    public function update_item( $request ) {
291        $result = $this->validate_schedule( $request );
292        if ( is_wp_error( $result ) ) {
293            return $result;
294        }
295
296        // Prevent the sync option to be updated during deletion. This will ensure that the sync is performed only once.
297        // Context: https://github.com/Automattic/jetpack/issues/27763
298        add_filter( Scheduled_Updates::PLUGIN_CRON_SYNC_HOOK, '__return_false' );
299        $deleted = $this->delete_item( $request );
300
301        if ( is_wp_error( $deleted ) ) {
302            return $deleted;
303        }
304
305        // Re-enable the sync option before creation.
306        remove_filter( Scheduled_Updates::PLUGIN_CRON_SYNC_HOOK, '__return_false' );
307        $item = $this->create_item( $request );
308
309        if ( ! is_wp_error( $item ) ) {
310            /**
311             * Fires when a scheduled update is updated.
312             *
313             * @param string          $old_id  The ID of the schedule to update.
314             * @param string          $new_id  The ID of the updated event.
315             * @param WP_REST_Request $request The request object.
316             */
317            do_action( 'jetpack_scheduled_update_updated', $request['schedule_id'], $item->data, $request );
318        }
319
320        return $item;
321    }
322
323    /**
324     * Permission check for deleting a specific schedule.
325     *
326     * @param WP_REST_Request $request Request object.
327     * @return bool|WP_Error
328     */
329    public function delete_item_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
330        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
331            return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to access this endpoint.', 'jetpack-scheduled-updates' ), array( 'status' => 403 ) );
332        }
333
334        if ( get_option( 'wpcom_is_staging_site' ) ) {
335            return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to access this endpoint.', 'jetpack-scheduled-updates' ), array( 'status' => 403 ) );
336        }
337
338        return current_user_can( 'update_plugins' );
339    }
340
341    /**
342     * Deletes an existing update schedule.
343     *
344     * @param WP_REST_Request $request Request object.
345     * @return WP_REST_Response|WP_Error
346     */
347    public function delete_item( $request ) {
348        $events = wp_get_scheduled_events( Scheduled_Updates::PLUGIN_CRON_HOOK );
349
350        if ( ! isset( $events[ $request['schedule_id'] ] ) ) {
351            return new WP_Error( 'rest_invalid_schedule', __( 'The schedule could not be found.', 'jetpack-scheduled-updates' ), array( 'status' => 404 ) );
352        }
353
354        $event  = $events[ $request['schedule_id'] ];
355        $result = wp_unschedule_event( $event->timestamp, Scheduled_Updates::PLUGIN_CRON_HOOK, $event->args, true );
356
357        if ( is_wp_error( $result ) ) {
358            return $result;
359        }
360
361        if ( false === $result ) {
362            return new WP_Error( 'unschedule_event_error', __( 'Error during unschedule of the event.', 'jetpack-scheduled-updates' ), array( 'status' => 500 ) );
363        }
364
365        /**
366         * Fires when a scheduled update is deleted.
367         *
368         * @param string          $id      The ID of the schedule to delete.
369         * @param object          $event   The deleted event object.
370         * @param WP_REST_Request $request The request object.
371         */
372        do_action( 'jetpack_scheduled_update_deleted', $request['schedule_id'], $event, $request );
373
374        if ( 'DELETE' === $request->get_method() ) {
375            // In a direct call clear the case and a transient to clear it again if in 10 seconds another event is scheduled.
376            Scheduled_Updates::clear_cron_cache();
377            set_transient( 'pre_schedule_event_clear_cron_cache', true, 10 );
378        }
379
380        return rest_ensure_response( true );
381    }
382
383    /**
384     * Prepares a response for insertion into a collection.
385     *
386     * @param WP_REST_Response $response Response object.
387     * @return array|mixed Response data, ready for insertion into collection data.
388     */
389    public function prepare_response_for_collection( $response ) {
390        if ( ! ( $response instanceof WP_REST_Response ) ) {
391            return $response;
392        }
393
394        $data = (array) $response->get_data();
395
396        // Only call rest_get_server() if we're in a REST API request.
397        if ( did_action( 'rest_api_init' ) ) {
398            $server = rest_get_server();
399            $links  = $server::get_compact_response_links( $response );
400
401            if ( ! empty( $links ) ) {
402                $data['_links'] = $links;
403            }
404        }
405
406        return $data;
407    }
408
409    /**
410     * Prepares the scheduled update for the REST response.
411     *
412     * @param object          $item    WP Cron event.
413     * @param WP_REST_Request $request Request object.
414     * @return WP_REST_Response Response object on success.
415     */
416    public function prepare_item_for_response( $item, $request ) {
417        $item = (array) $item;
418        $item = apply_filters( 'jetpack_scheduled_response_item', $item, $request );
419        $item = $this->add_additional_fields_to_object( $item, $request );
420
421        // Remove schedule ID, not needed in the response.
422        unset( $item['schedule_id'] );
423
424        return rest_ensure_response( $item );
425    }
426
427    /**
428     * Checks that the "plugins" parameter is not empty.
429     *
430     * @param array           $plugins List of plugins to update.
431     * @param WP_REST_Request $request Request object.
432     * @param string          $param   The parameter name.
433     * @return bool|WP_Error
434     */
435    public function validate_plugins_param( $plugins, $request, $param ) {
436        $result = rest_validate_request_arg( $plugins, $request, $param );
437        if ( is_wp_error( $result ) ) {
438            return $result;
439        }
440
441        // We don't need to check if plugins are installed if we're on WPCOM.
442        $installed_plugins = defined( 'IS_WPCOM' ) && IS_WPCOM ? $plugins : array_filter( $plugins, array( $this, 'is_plugin_installed' ) );
443
444        if ( empty( $installed_plugins ) ) {
445            add_filter( 'rest_request_after_callbacks', array( $this, 'transform_error_response' ) );
446
447            return new \WP_Error(
448                'rest_invalid_param',
449                __( 'The specified plugins are not installed on the website. Please make sure the plugins are installed before attempting to schedule updates.', 'jetpack-scheduled-updates' ),
450                array( 'status' => 400 )
451            );
452        }
453
454        $unmanaged_plugins = array_diff( $plugins, array_filter( $plugins, array( Scheduled_Updates::class, 'is_managed_plugin' ) ) );
455        if ( empty( $unmanaged_plugins ) ) {
456            add_filter( 'rest_request_after_callbacks', array( $this, 'transform_error_response' ) );
457
458            return new \WP_Error(
459                'rest_invalid_param',
460                __( 'The specified plugins are managed and auto-updated by WordPress.com.', 'jetpack-scheduled-updates' ),
461                array( 'status' => 400 )
462            );
463        }
464
465        return true;
466    }
467
468    /**
469     * Check if a plugin is installed.
470     *
471     * @param string $plugin The plugin to check.
472     * @return bool
473     */
474    public function is_plugin_installed( $plugin ) {
475        if ( ! function_exists( 'validate_plugin' ) ) {
476            require_once ABSPATH . 'wp-admin/includes/plugin.php';
477        }
478
479        return 0 === validate_plugin( $plugin );
480    }
481
482    /**
483     * Checks that the "plugin" parameter is a valid path.
484     *
485     * @param string $file The plugin file parameter.
486     * @return bool
487     */
488    public function validate_plugin_param( $file ) {
489        if ( ! is_string( $file ) || ! preg_match( '/' . self::PATTERN . '/u', $file ) ) {
490            return false;
491        }
492
493        return 0 === validate_file( plugin_basename( $file ) );
494    }
495
496    /**
497     * Sanitizes the "plugin" parameter to be a proper plugin file with ".php" appended.
498     *
499     * @param string $file The plugin file parameter.
500     * @return string
501     */
502    public function sanitize_plugin_param( $file ) {
503        if ( ! str_ends_with( $file, '.php' ) ) {
504            $file .= '.php';
505        }
506
507        return plugin_basename( sanitize_text_field( $file ) );
508    }
509
510    /**
511     * Checks that the "themes" parameter is empty.
512     *
513     * @param array $themes List of themes to update.
514     * @return bool|WP_Error
515     */
516    public function validate_themes_param( $themes ) {
517        if ( ! empty( $themes ) ) {
518            return new WP_Error( 'rest_forbidden', __( 'Sorry, you cannot schedule theme updates at this time.', 'jetpack-scheduled-updates' ), array( 'status' => 403 ) );
519        }
520
521        return true;
522    }
523
524    /**
525     * Validates the submitted schedule.
526     *
527     * @param WP_REST_Request $request Request object.
528     * @return bool|WP_Error
529     */
530    public function validate_schedule( $request ) {
531        $events = wp_get_scheduled_events( Scheduled_Updates::PLUGIN_CRON_HOOK );
532
533        $plugins = $request['plugins'];
534        usort( $plugins, 'strnatcasecmp' );
535
536        foreach ( $events as $key => $event ) {
537
538            // We'll update this schedule, so none of the checks apply.
539            if ( isset( $request['schedule_id'] ) && $key === $request['schedule_id'] ) {
540                continue;
541            }
542
543            if ( $request['schedule']['timestamp'] === $event->timestamp ) {
544                return new WP_Error( 'rest_forbidden', __( 'Sorry, you cannot create a schedule with the same time as an existing schedule.', 'jetpack-scheduled-updates' ), array( 'status' => 403 ) );
545            }
546
547            if ( $event->args === $plugins ) {
548                return new WP_Error( 'rest_forbidden', __( 'Sorry, you cannot create a schedule with the same plugins as an existing schedule.', 'jetpack-scheduled-updates' ), array( 'status' => 403 ) );
549            }
550        }
551
552        return true;
553    }
554
555    /**
556     * Transforms nested error message for the plugins parameter to a top-level error.
557     *
558     * @see WP_REST_Request::has_valid_params()
559     *
560     * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client.
561     * @return mixed
562     */
563    public function transform_error_response( $response ) {
564        if ( is_wp_error( $response ) && 'rest_invalid_param' === $response->get_error_code() && isset( $response->get_error_data()['details']['plugins'] ) ) {
565            $error    = $response->get_error_data()['details']['plugins'];
566            $response = new WP_Error( $error['code'], $error['message'], $error['data'] );
567        }
568
569        return $response;
570    }
571
572    /**
573     * Retrieves the update schedule's schema, conforming to JSON Schema.
574     *
575     * @return array Item schema data.
576     */
577    public function get_item_schema() {
578        if ( $this->schema ) {
579            return $this->add_additional_fields_schema( $this->schema );
580        }
581
582        $schema = array(
583            '$schema'    => 'http://json-schema.org/draft-04/schema#',
584            'title'      => 'update-schedule',
585            'type'       => 'object',
586            'properties' => array(
587                'hook'      => array(
588                    'description' => 'The hook name.',
589                    'type'        => 'string',
590                    'readonly'    => true,
591                ),
592                'timestamp' => array(
593                    'description' => 'Unix timestamp (UTC) for when to next run the event.',
594                    'type'        => 'integer',
595                    'readonly'    => true,
596                ),
597                'schedule'  => array(
598                    'description' => 'How often the event should subsequently recur.',
599                    'type'        => 'string',
600                    'enum'        => array( 'daily', 'weekly' ),
601                ),
602                'args'      => array(
603                    'description' => 'The plugins to be updated on this schedule.',
604                    'type'        => 'array',
605                ),
606                'interval'  => array(
607                    'description' => 'The interval time in seconds for the schedule.',
608                    'type'        => 'integer',
609                ),
610            ),
611        );
612
613        $this->schema = $schema;
614
615        return $this->add_additional_fields_schema( $this->schema );
616    }
617
618    /**
619     * Retrieves the query params for scheduled updates.
620     *
621     * @param string $method HTTP method of the request.
622     *                       The arguments for `CREATABLE` requests are checked for required values and may fall back to
623     *                       a given default. This is not done on `EDITABLE` requests.
624     * @return array[] Array of query parameters.
625     */
626    public function get_object_params( $method ) {
627        $endpoint_args = array(
628            'title'      => 'update-schedule',
629            'properties' => array(
630                'plugins'  => array(
631                    'description' => 'List of plugin slugs to update.',
632                    'type'        => 'array',
633                    'maxItems'    => 10,
634                    'required'    => true,
635                    'arg_options' => array(
636                        'validate_callback' => array( $this, 'validate_plugins_param' ),
637                    ),
638                    'items'       => array(
639                        'type'        => 'string',
640                        'arg_options' => array(
641                            'validate_callback' => array( $this, 'validate_plugin_param' ),
642                            'sanitize_callback' => array( $this, 'sanitize_plugin_param' ),
643                        ),
644                    ),
645                ),
646                'themes'   => array(
647                    'description'       => 'List of theme slugs to update.',
648                    'type'              => 'array',
649                    'required'          => false,
650                    'validate_callback' => array( $this, 'validate_themes_param' ),
651                ),
652                'schedule' => array(
653                    'description' => 'Update schedule.',
654                    'type'        => 'object',
655                    'required'    => true,
656                    'properties'  => array(
657                        'interval'  => array(
658                            'description' => 'Interval for the schedule.',
659                            'type'        => 'string',
660                            'enum'        => array( 'daily', 'weekly' ),
661                            'required'    => true,
662                        ),
663                        'timestamp' => array(
664                            'description' => 'Unix timestamp (UTC) for when to first run the schedule.',
665                            'type'        => 'integer',
666                            'required'    => true,
667                        ),
668                    ),
669                ),
670            ),
671        );
672
673        return rest_get_endpoint_args_for_schema( $this->add_additional_fields_schema( $endpoint_args ), $method );
674    }
675}