PluginProbe
WP Crontrol / trunk
WP Crontrol vtrunk
1.21.2 trunk 0.1 0.2 0.3 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.1 1.10.0 1.11.0 1.12.0 1.12.1 1.13.0 1.13.1 1.13.2 1.14.0 1.15.0 1.15.1 1.15.2 1.15.3 1.16.0 1.16.1 1.16.2 All 57 releases
wp-crontrol / src / bootstrap.php

bootstrap.php in WP Crontrol trunk, at src/bootstrap.php

3,012 lines 95.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Functions related to bootstrapping WP Crontrol.
4 */
5
6 namespace Crontrol;
7
8 use Crontrol\Event\Table;
9 use Crontrol\Event\PHPCronEvent;
10 use Crontrol\Event\URLCronEvent;
11 use Crontrol\Exception\CrontrolRuntimeException;
12 use Crontrol\Exception\DuplicateScheduleException;
13 use Crontrol\Exception\MissingURLException;
14 use Crontrol\Exception\MissingHashException;
15 use Crontrol\Exception\InvalidHashException;
16 use Crontrol\Exception\UnexpectedHTTPCodeException;
17 use Crontrol\Exception\HTTPFailedException;
18 use Crontrol\Exception\UnknownScheduleException;
19 use DateTimeZone;
20 use WP_Error;
21 use Exception;
22 use IntlTimeZone;
23 use ReflectionException;
24
25 use function Crontrol\Event\check_integrity;
26 use function Crontrol\Event\validate_url;
27
28 const TRANSIENT = 'crontrol-message-%d';
29 const PAUSED_OPTION = 'wp_crontrol_paused';
30
31 const MESSAGE_UNKNOWN_ERROR = 1;
32 const MESSAGE_HOOK_DELETED_ALL = 2;
33 const MESSAGE_EVENT_NONE_TO_DELETE = 3;
34 const MESSAGE_EVENT_SAVED = 4;
35 const MESSAGE_EVENT_RUN_NOW = 5;
36 const MESSAGE_EVENT_DELETED = 6;
37 const MESSAGE_EVENT_FAILED_TO_DELETE = 7;
38 const MESSAGE_EVENT_FAILED_TO_EXECUTE = 8;
39 const MESSAGE_EVENT_DELETED_SELECTED = 9;
40 const MESSAGE_EVENT_FAILED_TO_SAVE = 10;
41 const MESSAGE_HOOK_PAUSED = 11;
42 const MESSAGE_HOOK_RESUMED = 12;
43 const MESSAGE_EVENT_URL_EVENT_SAVED = 13;
44 const MESSAGE_EVENT_PHP_EVENT_SAVED = 14;
45
46 const MESSAGE_SCHEDULE_DELETED = 2;
47 const MESSAGE_SCHEDULE_SAVED = 3;
48 const MESSAGE_SCHEDULE_DUPLICATE = 4;
49
50 /**
51 * Hook onto all of the actions and filters needed by the plugin.
52 *
53 * @return void
54 */
55 function init_hooks() {
56 $plugin_file = plugin_basename( PLUGIN_FILE );
57
58 add_action( 'init', __NAMESPACE__ . '\action_init' );
59 add_action( 'admin_init', __NAMESPACE__ . '\action_handle_posts' );
60 add_action( 'admin_menu', __NAMESPACE__ . '\action_admin_menu' );
61 add_filter( "plugin_action_links_{$plugin_file}", __NAMESPACE__ . '\plugin_action_links' );
62 add_filter( "network_admin_plugin_action_links_{$plugin_file}", __NAMESPACE__ . '\network_plugin_action_links' );
63 add_filter( 'removable_query_args', __NAMESPACE__ . '\filter_removable_query_args' );
64 add_filter( 'pre_unschedule_event', __NAMESPACE__ . '\maybe_clear_doing_cron' );
65 add_filter( 'plugin_row_meta', __NAMESPACE__ . '\filter_plugin_row_meta', 10, 2 );
66
67 add_action( 'load-tools_page_wp-crontrol', __NAMESPACE__ . '\setup_manage_page' );
68
69 add_filter( 'cron_schedules', __NAMESPACE__ . '\filter_cron_schedules' );
70 add_action( PHPCronEvent::HOOK_NAME, __NAMESPACE__ . '\action_php_cron_event' );
71 add_action( URLCronEvent::HOOK_NAME, __NAMESPACE__ . '\action_url_cron_event' );
72 add_action( 'admin_enqueue_scripts', __NAMESPACE__ . '\enqueue_assets' );
73 add_action( 'crontrol/tab-header', __NAMESPACE__ . '\show_cron_status', 20 );
74 add_action( 'activated_plugin', __NAMESPACE__ . '\flush_status_cache', 10, 0 );
75 add_action( 'deactivated_plugin', __NAMESPACE__ . '\flush_status_cache', 10, 0 );
76 add_action( 'switch_theme', __NAMESPACE__ . '\flush_status_cache', 10, 0 );
77 }
78
79 /**
80 * Sets an error message to show to the current user after a redirect.
81 *
82 * @param string $message The error message text.
83 * @return bool Whether the message was saved.
84 */
85 function set_message( $message ) {
86 $key = sprintf(
87 TRANSIENT,
88 get_current_user_id()
89 );
90 return set_transient( $key, $message, 60 );
91 }
92
93 /**
94 * Gets the error message to show to the current user after a redirect.
95 *
96 * @return string The error message text.
97 */
98 function get_message() {
99 $key = sprintf(
100 TRANSIENT,
101 get_current_user_id()
102 );
103 return get_transient( $key );
104 }
105
106 /**
107 * Filters the array of row meta for each plugin in the Plugins list table.
108 *
109 * @param array<int,string> $plugin_meta An array of the plugin row's meta data.
110 * @param string $plugin_file Path to the plugin file relative to the plugins directory.
111 * @return array<int,string> An array of the plugin row's meta data.
112 */
113 function filter_plugin_row_meta( array $plugin_meta, $plugin_file ) {
114 if ( 'wp-crontrol/wp-crontrol.php' !== $plugin_file ) {
115 return $plugin_meta;
116 }
117
118 $plugin_meta[] = sprintf(
119 '<a href="%1$s"><span class="dashicons dashicons-star-filled" aria-hidden="true" style="font-size:14px;line-height:1.3"></span>%2$s</a>',
120 'https://github.com/sponsors/johnbillion',
121 esc_html_x( 'Sponsor', 'verb', 'wp-crontrol' )
122 );
123
124 return $plugin_meta;
125 }
126
127 /**
128 * Run using the 'init' action.
129 *
130 * @return void
131 */
132 function action_init() {
133 load_plugin_textdomain( 'wp-crontrol', false, dirname( plugin_basename( PLUGIN_FILE ) ) . '/languages' );
134
135 /** @var array<array-key, true>|false $paused */
136 $paused = get_option( PAUSED_OPTION );
137
138 if ( ! is_array( $paused ) ) {
139 $paused = array();
140 update_option( PAUSED_OPTION, $paused, true );
141 }
142
143 foreach ( $paused as $hook => $value ) {
144 if ( ! is_string( $hook ) ) {
145 continue;
146 }
147
148 add_action( $hook, __NAMESPACE__ . '\\pauser', -99999, 0 );
149 }
150 }
151
152 /**
153 * @return void
154 */
155 function pauser() {
156 $current = current_filter();
157
158 if ( $current ) {
159 remove_all_actions( $current );
160 }
161 }
162
163 /**
164 * Handles any POSTs and GETs made by the plugin. Run using the 'init' action.
165 *
166 * @return void
167 */
168 function action_handle_posts() {
169 if ( isset( $_POST['crontrol_action'] ) && ( 'new_cron' === $_POST['crontrol_action'] ) ) {
170 if ( ! current_user_can( 'manage_options' ) ) {
171 wp_die( esc_html__( 'You are not allowed to add new cron events.', 'wp-crontrol' ), 403 );
172 }
173 check_admin_referer( 'crontrol-new-cron' );
174
175 $cr = Request::init( wp_unslash( $_POST ) );
176
177 if ( PHPCronEvent::HOOK_NAME === $cr->hookname ) {
178 wp_die( esc_html__( 'You are not allowed to add new PHP cron events.', 'wp-crontrol' ), 403 );
179 }
180 if ( URLCronEvent::HOOK_NAME === $cr->hookname ) {
181 wp_die( esc_html__( 'You are not allowed to add new URL cron events.', 'wp-crontrol' ), 403 );
182 }
183 $args = json_decode( $cr->args, true );
184
185 if ( empty( $args ) || ! is_array( $args ) ) {
186 $args = array();
187 }
188
189 $next_run_local = ( 'custom' === $cr->next_run_date_local ) ? $cr->next_run_date_local_custom_date . ' ' . $cr->next_run_date_local_custom_time : $cr->next_run_date_local;
190
191 /**
192 * Modifies an event before it is scheduled.
193 *
194 * @param object|false $event An object containing the new event's data, or boolean false.
195 */
196 add_filter( 'schedule_event', function ( $event ) {
197 if ( ! $event ) {
198 return $event;
199 }
200
201 /**
202 * Fires after a new cron event is added.
203 *
204 * @param object $event {
205 * An object containing the event's data.
206 *
207 * @type string $hook Action hook to execute when the event is run.
208 * @type int $timestamp Unix timestamp (UTC) for when to next run the event.
209 * @type string|false $schedule How often the event should subsequently recur.
210 * @type mixed[] $args Array containing each separate argument to pass to the hook's callback function.
211 * @type int $interval The interval time in seconds for the schedule. Only present for recurring events.
212 * }
213 */
214 do_action( 'crontrol/added_new_event', $event );
215
216 return $event;
217 }, 99 );
218
219 $added = Event\add( $next_run_local, $cr->schedule, $cr->hookname, array_values( $args ) );
220
221 $redirect = array(
222 'page' => 'wp-crontrol',
223 'crontrol_message' => MESSAGE_EVENT_SAVED,
224 'crontrol_name' => rawurlencode( $cr->hookname ),
225 );
226
227 if ( is_wp_error( $added ) ) {
228 set_message( $added->get_error_message() );
229 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
230 }
231
232 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
233 exit;
234
235 } elseif ( isset( $_POST['crontrol_action'] ) && ( 'new_url_cron' === $_POST['crontrol_action'] ) ) {
236 if ( ! current_user_can_edit_url_cron_events() ) {
237 wp_die( esc_html__( 'You are not allowed to add new URL cron events.', 'wp-crontrol' ), 403 );
238 }
239 check_admin_referer( 'crontrol-new-cron' );
240
241 $cr = Request::init( wp_unslash( $_POST ) );
242
243 $next_run_local = ( 'custom' === $cr->next_run_date_local ) ? $cr->next_run_date_local_custom_date . ' ' . $cr->next_run_date_local_custom_time : $cr->next_run_date_local;
244 $args = array(
245 array(
246 'url' => $cr->url,
247 'method' => $cr->method,
248 'name' => $cr->eventname,
249 'hash' => wp_hash( $cr->url ),
250 ),
251 );
252
253 /**
254 * Modifies an event before it is scheduled.
255 *
256 * @param object|false $event An object containing the new event's data, or boolean false.
257 */
258 add_filter( 'schedule_event', function ( $event ) {
259 if ( ! $event ) {
260 return $event;
261 }
262
263 /**
264 * Fires after a new URL cron event is added.
265 *
266 * @param object $event {
267 * An object containing the event's data.
268 *
269 * @type string $hook Action hook to execute when the event is run.
270 * @type int $timestamp Unix timestamp (UTC) for when to next run the event.
271 * @type string|false $schedule How often the event should subsequently recur.
272 * @type mixed[] $args Array containing each separate argument to pass to the hook's callback function.
273 * @type int $interval The interval time in seconds for the schedule. Only present for recurring events.
274 * }
275 */
276 do_action( 'crontrol/added_new_url_event', $event );
277
278 return $event;
279 }, 99 );
280
281 $added = Event\add( $next_run_local, $cr->schedule, URLCronEvent::HOOK_NAME, $args );
282
283 $hookname = ( ! empty( $cr->eventname ) ) ? $cr->eventname : __( 'URL Cron', 'wp-crontrol' );
284 $redirect = array(
285 'page' => 'wp-crontrol',
286 'crontrol_message' => MESSAGE_EVENT_URL_EVENT_SAVED,
287 'crontrol_name' => rawurlencode( $hookname ),
288 );
289
290 if ( is_wp_error( $added ) ) {
291 set_message( $added->get_error_message() );
292 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
293 }
294
295 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
296 exit;
297
298 } elseif ( isset( $_POST['crontrol_action'] ) && ( 'new_php_cron' === $_POST['crontrol_action'] ) ) {
299 if ( ! current_user_can_edit_php_cron_events() ) {
300 wp_die( esc_html__( 'You are not allowed to add new PHP cron events.', 'wp-crontrol' ), 403 );
301 }
302 check_admin_referer( 'crontrol-new-cron' );
303
304 $cr = Request::init( wp_unslash( $_POST ) );
305
306 $next_run_local = ( 'custom' === $cr->next_run_date_local ) ? $cr->next_run_date_local_custom_date . ' ' . $cr->next_run_date_local_custom_time : $cr->next_run_date_local;
307
308 $args = array(
309 array(
310 'code' => $cr->hookcode,
311 'name' => $cr->eventname,
312 'hash' => wp_hash( $cr->hookcode ),
313 ),
314 );
315
316 /**
317 * Modifies an event before it is scheduled.
318 *
319 * @param object|false $event An object containing the new event's data, or boolean false.
320 */
321 add_filter( 'schedule_event', function ( $event ) {
322 if ( ! $event ) {
323 return $event;
324 }
325
326 /**
327 * Fires after a new PHP cron event is added.
328 *
329 * @param object $event {
330 * An object containing the event's data.
331 *
332 * @type string $hook Action hook to execute when the event is run.
333 * @type int $timestamp Unix timestamp (UTC) for when to next run the event.
334 * @type string|false $schedule How often the event should subsequently recur.
335 * @type mixed[] $args Array containing each separate argument to pass to the hook's callback function.
336 * @type int $interval The interval time in seconds for the schedule. Only present for recurring events.
337 * }
338 */
339 do_action( 'crontrol/added_new_php_event', $event );
340
341 return $event;
342 }, 99 );
343
344 $added = Event\add( $next_run_local, $cr->schedule, PHPCronEvent::HOOK_NAME, $args );
345
346 $hookname = ( ! empty( $cr->eventname ) ) ? $cr->eventname : __( 'PHP Cron', 'wp-crontrol' );
347 $redirect = array(
348 'page' => 'wp-crontrol',
349 'crontrol_message' => MESSAGE_EVENT_PHP_EVENT_SAVED,
350 'crontrol_name' => rawurlencode( $hookname ),
351 );
352
353 if ( is_wp_error( $added ) ) {
354 set_message( $added->get_error_message() );
355 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
356 }
357
358 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
359 exit;
360
361 } elseif ( isset( $_POST['crontrol_action'] ) && ( 'edit_cron' === $_POST['crontrol_action'] ) ) {
362 if ( ! current_user_can( 'manage_options' ) ) {
363 wp_die( esc_html__( 'You are not allowed to edit cron events.', 'wp-crontrol' ), 403 );
364 }
365
366 $cr = Request::init( wp_unslash( $_POST ) );
367
368 check_admin_referer( "crontrol-edit-cron_{$cr->original_hookname}_{$cr->original_sig}_{$cr->original_next_run_utc}" );
369
370 if ( PHPCronEvent::HOOK_NAME === $cr->hookname && ! current_user_can_edit_php_cron_events() ) {
371 wp_die( esc_html__( 'You are not allowed to edit PHP cron events.', 'wp-crontrol' ), 403 );
372 }
373
374 if ( URLCronEvent::HOOK_NAME === $cr->hookname && ! current_user_can_edit_url_cron_events() ) {
375 wp_die( esc_html__( 'You are not allowed to edit URL cron events.', 'wp-crontrol' ), 403 );
376 }
377
378 $args = json_decode( $cr->args, true );
379
380 if ( empty( $args ) || ! is_array( $args ) ) {
381 $args = array();
382 }
383
384 $redirect = array(
385 'page' => 'wp-crontrol',
386 'crontrol_message' => MESSAGE_EVENT_SAVED,
387 'crontrol_name' => rawurlencode( $cr->hookname ),
388 );
389
390 $original = Event\get_single( $cr->original_hookname, $cr->original_sig, $cr->original_next_run_utc );
391
392 if ( is_wp_error( $original ) ) {
393 set_message( $original->get_error_message() );
394 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
395 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
396 exit;
397 }
398
399 $deleted = Event\delete( $cr->original_hookname, $cr->original_sig, $cr->original_next_run_utc );
400
401 if ( is_wp_error( $deleted ) ) {
402 set_message( $deleted->get_error_message() );
403 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
404 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
405 exit;
406 }
407
408 $next_run_local = $cr->next_run_date_local_custom_date . ' ' . $cr->next_run_date_local_custom_time;
409
410 /**
411 * Modifies an event before it is scheduled.
412 *
413 * @param object|false $event An object containing the new event's data, or boolean false.
414 */
415 add_filter( 'schedule_event', function ( $event ) use ( $original ) {
416 if ( ! $event ) {
417 return $event;
418 }
419
420 /**
421 * Fires after a cron event is edited.
422 *
423 * @param object $event {
424 * An object containing the new event's data.
425 *
426 * @type string $hook Action hook to execute when the event is run.
427 * @type int $timestamp Unix timestamp (UTC) for when to next run the event.
428 * @type string|false $schedule How often the event should subsequently recur.
429 * @type mixed[] $args Array containing each separate argument to pass to the hook's callback function.
430 * @type int $interval The interval time in seconds for the schedule. Only present for recurring events.
431 * }
432 * @param \Crontrol\Event\Event $original The original event's data.
433 */
434 do_action( 'crontrol/edited_event', $event, $original );
435
436 return $event;
437 }, 99 );
438
439 $added = Event\add( $next_run_local, $cr->schedule, $cr->hookname, array_values( $args ) );
440
441 if ( is_wp_error( $added ) ) {
442 set_message( $added->get_error_message() );
443 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
444 }
445
446 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
447 exit;
448
449 } elseif ( isset( $_POST['crontrol_action'] ) && ( 'edit_url_cron' === $_POST['crontrol_action'] ) ) {
450 if ( ! current_user_can( 'manage_options' ) ) {
451 wp_die( esc_html__( 'You are not allowed to edit cron events.', 'wp-crontrol' ), 403 );
452 }
453
454 $cr = Request::init( wp_unslash( $_POST ) );
455
456 check_admin_referer( "crontrol-edit-cron_{$cr->original_hookname}_{$cr->original_sig}_{$cr->original_next_run_utc}" );
457
458 $args = array(
459 array(
460 'url' => $cr->url,
461 'method' => $cr->method,
462 'name' => $cr->eventname,
463 'hash' => wp_hash( $cr->url ),
464 ),
465 );
466 $hookname = ( ! empty( $cr->eventname ) ) ? $cr->eventname : __( 'URL Cron', 'wp-crontrol' );
467 $redirect = array(
468 'page' => 'wp-crontrol',
469 'crontrol_message' => MESSAGE_EVENT_URL_EVENT_SAVED,
470 'crontrol_name' => rawurlencode( $hookname ),
471 );
472
473 $original = Event\get_single( $cr->original_hookname, $cr->original_sig, $cr->original_next_run_utc );
474
475 if ( is_wp_error( $original ) ) {
476 set_message( $original->get_error_message() );
477 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
478 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
479 exit;
480 }
481
482 $deleted = Event\delete( $cr->original_hookname, $cr->original_sig, $cr->original_next_run_utc );
483
484 if ( is_wp_error( $deleted ) ) {
485 set_message( $deleted->get_error_message() );
486 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
487 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
488 exit;
489 }
490
491 $next_run_local = $cr->next_run_date_local_custom_date . ' ' . $cr->next_run_date_local_custom_time;
492
493 /**
494 * Modifies an event before it is scheduled.
495 *
496 * @param object|false $event An object containing the new event's data, or boolean false.
497 */
498 add_filter( 'schedule_event', function ( $event ) use ( $original ) {
499 if ( ! $event ) {
500 return $event;
501 }
502
503 /**
504 * Fires after a URL cron event is edited.
505 *
506 * @param object $event {
507 * An object containing the new event's data.
508 *
509 * @type string $hook Action hook to execute when the event is run.
510 * @type int $timestamp Unix timestamp (UTC) for when to next run the event.
511 * @type string|false $schedule How often the event should subsequently recur.
512 * @type mixed[] $args Array containing each separate argument to pass to the hook's callback function.
513 * @type int $interval The interval time in seconds for the schedule. Only present for recurring events.
514 * }
515 * @param \Crontrol\Event\Event $original An object containing the original event's data.
516 */
517 do_action( 'crontrol/edited_url_event', $event, $original );
518
519 return $event;
520 }, 99 );
521
522 $added = Event\add( $next_run_local, $cr->schedule, URLCronEvent::HOOK_NAME, $args );
523
524 if ( is_wp_error( $added ) ) {
525 set_message( $added->get_error_message() );
526 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
527 }
528
529 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
530 exit;
531
532 } elseif ( isset( $_POST['crontrol_action'] ) && ( 'edit_php_cron' === $_POST['crontrol_action'] ) ) {
533 if ( ! current_user_can_edit_php_cron_events() ) {
534 wp_die( esc_html__( 'You are not allowed to edit PHP cron events.', 'wp-crontrol' ), 403 );
535 }
536
537 $cr = Request::init( wp_unslash( $_POST ) );
538
539 check_admin_referer( "crontrol-edit-cron_{$cr->original_hookname}_{$cr->original_sig}_{$cr->original_next_run_utc}" );
540
541 $args = array(
542 array(
543 'code' => $cr->hookcode,
544 'name' => $cr->eventname,
545 'hash' => wp_hash( $cr->hookcode ),
546 ),
547 );
548 $hookname = ( ! empty( $cr->eventname ) ) ? $cr->eventname : __( 'PHP Cron', 'wp-crontrol' );
549 $redirect = array(
550 'page' => 'wp-crontrol',
551 'crontrol_message' => MESSAGE_EVENT_PHP_EVENT_SAVED,
552 'crontrol_name' => rawurlencode( $hookname ),
553 );
554
555 $original = Event\get_single( $cr->original_hookname, $cr->original_sig, $cr->original_next_run_utc );
556
557 if ( is_wp_error( $original ) ) {
558 set_message( $original->get_error_message() );
559 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
560 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
561 exit;
562 }
563
564 $deleted = Event\delete( $cr->original_hookname, $cr->original_sig, $cr->original_next_run_utc );
565
566 if ( is_wp_error( $deleted ) ) {
567 set_message( $deleted->get_error_message() );
568 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
569 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
570 exit;
571 }
572
573 $next_run_local = $cr->next_run_date_local_custom_date . ' ' . $cr->next_run_date_local_custom_time;
574
575 /**
576 * Modifies an event before it is scheduled.
577 *
578 * @param object|false $event An object containing the new event's data, or boolean false.
579 */
580 add_filter( 'schedule_event', function ( $event ) use ( $original ) {
581 if ( ! $event ) {
582 return $event;
583 }
584
585 /**
586 * Fires after a PHP cron event is edited.
587 *
588 * @param object $event {
589 * An object containing the new event's data.
590 *
591 * @type string $hook Action hook to execute when the event is run.
592 * @type int $timestamp Unix timestamp (UTC) for when to next run the event.
593 * @type string|false $schedule How often the event should subsequently recur.
594 * @type mixed[] $args Array containing each separate argument to pass to the hook's callback function.
595 * @type int $interval The interval time in seconds for the schedule. Only present for recurring events.
596 * }
597 * @param \Crontrol\Event\Event $original An object containing the original event's data.
598 */
599 do_action( 'crontrol/edited_php_event', $event, $original );
600
601 return $event;
602 }, 99 );
603
604 $added = Event\add( $next_run_local, $cr->schedule, PHPCronEvent::HOOK_NAME, $args );
605
606 if ( is_wp_error( $added ) ) {
607 set_message( $added->get_error_message() );
608 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
609 }
610
611 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
612 exit;
613
614 } elseif ( isset( $_POST['crontrol_new_schedule'] ) ) {
615 if ( ! current_user_can( 'manage_options' ) ) {
616 wp_die( esc_html__( 'You are not allowed to add new cron schedules.', 'wp-crontrol' ), 403 );
617 }
618 check_admin_referer( 'crontrol-new-schedule' );
619 $name = sanitize_text_field( wp_unslash( $_POST['crontrol_schedule_internal_name'] ) );
620 $interval = absint( $_POST['crontrol_schedule_interval'] );
621 $display = sanitize_text_field( wp_unslash( $_POST['crontrol_schedule_display_name'] ) );
622
623 // The internal name is used in array keys in WordPress, so it can't be purely numeric
624 // otherwise things go haywire when the schedule arrays pass through `array_merge()`.
625 if ( is_numeric( $name ) ) {
626 $name = 'schedule-' . $name;
627 }
628
629 try {
630 Schedule\add( $name, $interval, $display );
631 $redirect = array(
632 'page' => 'wp-crontrol-schedules',
633 'crontrol_message' => MESSAGE_SCHEDULE_SAVED,
634 'crontrol_name' => rawurlencode( $name ),
635 );
636 } catch ( DuplicateScheduleException $e ) {
637 $redirect = array(
638 'page' => 'wp-crontrol-schedules',
639 'crontrol_message' => MESSAGE_SCHEDULE_DUPLICATE,
640 'crontrol_name' => rawurlencode( $name ),
641 );
642 }
643 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'options-general.php' ) ) );
644 exit;
645
646 } elseif ( isset( $_GET['crontrol_action'] ) && 'delete-schedule' === $_GET['crontrol_action'] ) {
647 if ( ! current_user_can( 'manage_options' ) ) {
648 wp_die( esc_html__( 'You are not allowed to delete cron schedules.', 'wp-crontrol' ), 403 );
649 }
650 $schedule = wp_unslash( $_GET['crontrol_id'] );
651 check_admin_referer( "crontrol-delete-schedule_{$schedule}" );
652 Schedule\delete( $schedule );
653 $redirect = array(
654 'page' => 'wp-crontrol-schedules',
655 'crontrol_message' => MESSAGE_SCHEDULE_DELETED,
656 'crontrol_name' => rawurlencode( $schedule ),
657 );
658 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'options-general.php' ) ) );
659 exit;
660
661 } elseif ( ( isset( $_POST['action'] ) && 'crontrol_delete_crons' === $_POST['action'] ) || ( isset( $_POST['action2'] ) && 'crontrol_delete_crons' === $_POST['action2'] ) ) {
662 if ( ! current_user_can( 'manage_options' ) ) {
663 wp_die( esc_html__( 'You are not allowed to delete cron events.', 'wp-crontrol' ), 403 );
664 }
665 check_admin_referer( 'bulk-crontrol-events' );
666
667 if ( empty( $_POST['crontrol_bulk'] ) ) {
668 return;
669 }
670
671 /**
672 * @var array<string,array<string,string>>
673 */
674 $delete = (array) wp_unslash( $_POST['crontrol_bulk'] );
675 $deleted = 0;
676
677 foreach ( $delete as $next_run_utc => $events ) {
678 foreach ( (array) $events as $hook => $sig ) {
679 // PHP cron events can be deleted even if they're disallowed, as long as the user has permission.
680 if ( PHPCronEvent::HOOK_NAME === $hook && ! current_user_can( 'edit_files' ) ) {
681 continue;
682 }
683
684 $event = Event\get_single( urldecode( $hook ), $sig, $next_run_utc );
685 $result = Event\delete( urldecode( $hook ), $sig, $next_run_utc );
686
687 if ( ! is_wp_error( $result ) ) {
688 ++$deleted;
689
690 /** This action is documented in wp-crontrol.php */
691 do_action( 'crontrol/deleted_event', $event );
692 }
693 }
694 }
695
696 $redirect_url = wp_get_referer();
697
698 if ( ! $redirect_url ) {
699 $redirect_url = admin_url( 'tools.php?page=wp-crontrol' );
700 }
701
702 $redirect_url = remove_query_arg( array( 'crontrol_message', 'crontrol_name' ), $redirect_url );
703 $redirect_url = add_query_arg(
704 array(
705 'crontrol_name' => $deleted,
706 'crontrol_message' => MESSAGE_EVENT_DELETED_SELECTED,
707 ),
708 $redirect_url
709 );
710 wp_safe_redirect( $redirect_url );
711 exit;
712
713 } elseif ( isset( $_GET['crontrol_action'] ) && 'delete-cron' === $_GET['crontrol_action'] ) {
714 if ( ! current_user_can( 'manage_options' ) ) {
715 wp_die( esc_html__( 'You are not allowed to delete cron events.', 'wp-crontrol' ), 403 );
716 }
717 $hook = wp_unslash( $_GET['crontrol_id'] );
718 $sig = wp_unslash( $_GET['crontrol_sig'] );
719 $next_run_utc = wp_unslash( $_GET['crontrol_next_run_utc'] );
720 check_admin_referer( "crontrol-delete-cron_{$hook}_{$sig}_{$next_run_utc}" );
721
722 // PHP cron events can be deleted even if they're disallowed, as long as the user has permission.
723 if ( PHPCronEvent::HOOK_NAME === $hook && ! current_user_can( 'edit_files' ) ) {
724 wp_die( esc_html__( 'You are not allowed to delete PHP cron events.', 'wp-crontrol' ), 403 );
725 }
726
727 $redirect = array(
728 'page' => 'wp-crontrol',
729 'crontrol_message' => MESSAGE_EVENT_DELETED,
730 'crontrol_name' => rawurlencode( $hook ),
731 );
732
733 $event = Event\get_single( $hook, $sig, $next_run_utc );
734
735 if ( is_wp_error( $event ) ) {
736 set_message( $event->get_error_message() );
737 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
738 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
739 exit;
740 }
741
742 $deleted = Event\delete( $hook, $sig, $next_run_utc );
743
744 if ( is_wp_error( $deleted ) ) {
745 set_message( $deleted->get_error_message() );
746 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
747 } else {
748 /**
749 * Fires after a cron event is deleted.
750 *
751 * @param \Crontrol\Event\Event $event The event's data.
752 */
753 do_action( 'crontrol/deleted_event', $event );
754 }
755
756 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
757 exit;
758
759 } elseif ( isset( $_GET['crontrol_action'] ) && 'delete-hook' === $_GET['crontrol_action'] ) {
760 if ( ! current_user_can( 'manage_options' ) ) {
761 wp_die( esc_html__( 'You are not allowed to delete cron events.', 'wp-crontrol' ), 403 );
762 }
763 $hook = wp_unslash( $_GET['crontrol_id'] );
764 $deleted = false;
765 check_admin_referer( "crontrol-delete-hook_{$hook}" );
766
767 // Sanity checks
768 if ( PHPCronEvent::HOOK_NAME === $hook ) {
769 wp_die( esc_html__( 'You are not allowed to delete PHP cron events.', 'wp-crontrol' ), 403 );
770 }
771 if ( URLCronEvent::HOOK_NAME === $hook ) {
772 wp_die( esc_html__( 'You are not allowed to delete URL cron events.', 'wp-crontrol' ), 403 );
773 }
774
775 $deleted = wp_unschedule_hook( $hook, true );
776
777 if ( 0 === $deleted ) {
778 $redirect = array(
779 'page' => 'wp-crontrol',
780 'crontrol_message' => MESSAGE_EVENT_NONE_TO_DELETE,
781 'crontrol_name' => rawurlencode( $hook ),
782 );
783 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
784 exit;
785 } elseif ( ! is_wp_error( $deleted ) ) {
786 /**
787 * Fires after all cron events with the given hook are deleted.
788 *
789 * @param string $hook The hook name.
790 * @param int $deleted The number of events that were deleted.
791 */
792 do_action( 'crontrol/deleted_all_with_hook', $hook, $deleted );
793
794 $redirect = array(
795 'page' => 'wp-crontrol',
796 'crontrol_message' => MESSAGE_HOOK_DELETED_ALL,
797 'crontrol_name' => rawurlencode( $hook ),
798 );
799 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
800 exit;
801 } else {
802 $redirect = array(
803 'page' => 'wp-crontrol',
804 'crontrol_message' => MESSAGE_EVENT_FAILED_TO_DELETE,
805 'crontrol_name' => rawurlencode( $hook ),
806 );
807 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
808 exit;
809 }
810 } elseif ( isset( $_GET['crontrol_action'] ) && 'run-cron' === $_GET['crontrol_action'] ) {
811 if ( ! current_user_can( 'manage_options' ) ) {
812 wp_die( esc_html__( 'You are not allowed to run cron events.', 'wp-crontrol' ), 403 );
813 }
814 $hook = wp_unslash( $_GET['crontrol_id'] );
815 $sig = wp_unslash( $_GET['crontrol_sig'] );
816 check_admin_referer( "crontrol-run-cron_{$hook}_{$sig}" );
817
818 // Don't need an `edit_files` check here because PHP cron events can always be run unless they're disabled.
819 if ( ( PHPCronEvent::HOOK_NAME === $hook ) && ! php_cron_events_enabled() ) {
820 wp_die( esc_html__( 'You are not allowed to run cron events.', 'wp-crontrol' ), 403 );
821 }
822
823 if ( ( URLCronEvent::HOOK_NAME === $hook ) && ! url_cron_events_enabled() ) {
824 wp_die( esc_html__( 'You are not allowed to run cron events.', 'wp-crontrol' ), 403 );
825 }
826
827 $ran = Event\run( $hook, $sig );
828
829 $redirect = array(
830 'page' => 'wp-crontrol',
831 'crontrol_message' => MESSAGE_EVENT_RUN_NOW,
832 'crontrol_name' => rawurlencode( $hook ),
833 );
834
835 if ( is_wp_error( $ran ) ) {
836 $set = set_message( $ran->get_error_message() );
837
838 // If we can't store the error message in a transient, just display it.
839 if ( ! $set ) {
840 wp_die(
841 esc_html( $ran->get_error_message() ),
842 '',
843 array(
844 'response' => 500,
845 'back_link' => true,
846 )
847 );
848 }
849 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
850 }
851
852 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
853 exit;
854 } elseif ( isset( $_GET['crontrol_action'] ) && 'pause-hook' === $_GET['crontrol_action'] ) {
855 if ( ! current_user_can( 'manage_options' ) ) {
856 wp_die( esc_html__( 'You are not allowed to pause or resume cron events.', 'wp-crontrol' ), 403 );
857 }
858
859 $hook = wp_unslash( $_GET['crontrol_id'] );
860
861 if ( ( PHPCronEvent::HOOK_NAME === $hook ) || ( URLCronEvent::HOOK_NAME === $hook ) ) {
862 wp_die( esc_html__( 'You are not allowed to pause or resume cron events.', 'wp-crontrol' ), 403 );
863 }
864
865 check_admin_referer( "crontrol-pause-hook_{$hook}" );
866
867 $paused = Event\pause( $hook );
868
869 $redirect = array(
870 'page' => 'wp-crontrol',
871 'crontrol_message' => MESSAGE_HOOK_PAUSED,
872 'crontrol_name' => rawurlencode( $hook ),
873 );
874
875 if ( is_wp_error( $paused ) ) {
876 $set = set_message( $paused->get_error_message() );
877
878 // If we can't store the error message in a transient, just display it.
879 if ( ! $set ) {
880 wp_die(
881 esc_html( $paused->get_error_message() ),
882 '',
883 array(
884 'response' => 500,
885 'back_link' => true,
886 )
887 );
888 }
889 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
890 } else {
891 /**
892 * Fires after a cron event hook is paused.
893 *
894 * @param string $hook The event hook name.
895 */
896 do_action( 'crontrol/paused_hook', $hook );
897 }
898
899 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
900 exit;
901 } elseif ( isset( $_GET['crontrol_action'] ) && 'resume-hook' === $_GET['crontrol_action'] ) {
902 if ( ! current_user_can( 'manage_options' ) ) {
903 wp_die( esc_html__( 'You are not allowed to pause or resume cron events.', 'wp-crontrol' ), 403 );
904 }
905
906 $hook = wp_unslash( $_GET['crontrol_id'] );
907
908 if ( ( PHPCronEvent::HOOK_NAME === $hook ) || ( URLCronEvent::HOOK_NAME === $hook ) ) {
909 wp_die( esc_html__( 'You are not allowed to pause or resume cron events.', 'wp-crontrol' ), 403 );
910 }
911
912 check_admin_referer( "crontrol-resume-hook_{$hook}" );
913
914 $resumed = Event\resume( $hook );
915
916 $redirect = array(
917 'page' => 'wp-crontrol',
918 'crontrol_message' => MESSAGE_HOOK_RESUMED,
919 'crontrol_name' => rawurlencode( $hook ),
920 );
921
922 if ( is_wp_error( $resumed ) ) {
923 $set = set_message( $resumed->get_error_message() );
924
925 // If we can't store the error message in a transient, just display it.
926 if ( ! $set ) {
927 wp_die(
928 esc_html( $resumed->get_error_message() ),
929 '',
930 array(
931 'response' => 500,
932 'back_link' => true,
933 )
934 );
935 }
936 $redirect['crontrol_message'] = MESSAGE_UNKNOWN_ERROR;
937 } else {
938 /**
939 * Fires after a paused cron event hook is resumed.
940 *
941 * @param string $hook The event hook name.
942 */
943 do_action( 'crontrol/resumed_hook', $hook );
944 }
945
946 wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) );
947 exit;
948 } elseif ( isset( $_POST['crontrol_action'] ) && 'export-event-csv' === $_POST['crontrol_action'] ) {
949 check_admin_referer( 'crontrol-export-event-csv', 'crontrol_nonce' );
950
951 $type = isset( $_POST['crontrol_hooks_type'] ) ? wp_unslash( $_POST['crontrol_hooks_type'] ) : 'all';
952 $headers = array(
953 'hook',
954 'arguments',
955 'next_run',
956 'next_run_gmt',
957 'action',
958 'schedule',
959 'interval',
960 );
961 $filename = sanitize_file_name( sprintf(
962 'cron-events-%s-%s.csv',
963 $type,
964 gmdate( 'Y-m-d-H.i.s' )
965 ) );
966 $csv = fopen( 'php://output', 'w' );
967
968 if ( false === $csv ) {
969 wp_die( esc_html__( 'Could not save CSV file.', 'wp-crontrol' ) );
970 }
971
972 $events = Table::get_filtered_events( Event\get() );
973
974 header( 'Content-Type: text/csv; charset=utf-8' );
975 header(
976 sprintf(
977 'Content-Disposition: attachment; filename="%s"',
978 esc_attr( $filename )
979 )
980 );
981
982 // https://php.watch/versions/8.4/csv-functions-escape-parameter
983 fputcsv( $csv, $headers, ',', '"', '\\' );
984
985 if ( isset( $events[ $type ] ) ) {
986 foreach ( $events[ $type ] as $event ) {
987 $next_run_local = $event->get_next_run_local();
988 $next_run_utc = $event->get_next_run_utc();
989 $hook_callbacks = $event->get_callbacks();
990
991 $args = $event->get_args_display();
992
993 if ( ( PHPCronEvent::HOOK_NAME === $event->hook ) || ( URLCronEvent::HOOK_NAME === $event->hook ) ) {
994 $action = 'WP Crontrol';
995 } else {
996 $callbacks = array();
997
998 foreach ( $hook_callbacks as $callback ) {
999 $callbacks[] = $callback['callback']['name'];
1000 }
1001
1002 $action = implode( ',', $callbacks );
1003 }
1004
1005 try {
1006 $schedule_name = $event->get_schedule_name();
1007 } catch ( UnknownScheduleException $e ) {
1008 $schedule_name = $e->getMessage();
1009 }
1010
1011 $row = array(
1012 $event->hook,
1013 $args,
1014 $next_run_local,
1015 $next_run_utc,
1016 $action,
1017 $schedule_name,
1018 (int) $event->interval,
1019 );
1020 // https://php.watch/versions/8.4/csv-functions-escape-parameter
1021 fputcsv( $csv, $row, ',', '"', '\\' );
1022 }
1023 }
1024
1025 fclose( $csv );
1026
1027 exit;
1028 }
1029 }
1030
1031 /**
1032 * Adds options & management pages to the admin menu.
1033 *
1034 * Run using the 'admin_menu' action.
1035 *
1036 * @return void
1037 */
1038 function action_admin_menu() {
1039 $tabs = [];
1040 $tabs[] = add_options_page(
1041 esc_html__( 'Cron Schedules', 'wp-crontrol' ),
1042 esc_html__( 'Cron Schedules', 'wp-crontrol' ),
1043 'manage_options',
1044 'wp-crontrol-schedules',
1045 __NAMESPACE__ . '\admin_options_page'
1046 );
1047 $tabs[] = add_management_page(
1048 esc_html__( 'Cron Events', 'wp-crontrol' ),
1049 esc_html__( 'Cron Events', 'wp-crontrol' ),
1050 'manage_options',
1051 'wp-crontrol',
1052 __NAMESPACE__ . '\admin_manage_page'
1053 );
1054
1055 foreach ( $tabs as $tab ) {
1056 add_action( "load-{$tab}", __NAMESPACE__ . '\admin_help_tab' );
1057 }
1058 }
1059
1060 /**
1061 * Adds a Help tab with links to help resources.
1062 *
1063 * @return void
1064 */
1065 function admin_help_tab() {
1066 $screen = get_current_screen();
1067
1068 if ( ! $screen ) {
1069 return;
1070 }
1071
1072 $content = '<p>' . esc_html__( 'There are several places to get help with issues relating to WP-Cron:', 'wp-crontrol' ) . '</p>';
1073 $content .= '<ul>';
1074 $content .= '<li>';
1075 $content .= wp_kses(
1076 sprintf(
1077 /* translators: 1: URL to the documentation, 2: WP Crontrol */
1078 __( '<a href="%1$s">Read the %2$s website</a> which contains information about events that have missed their schedule, problems with spawning a call to the WP-Cron system, and much more.', 'wp-crontrol' ),
1079 'https://wp-crontrol.com/docs/how-to-use/',
1080 'WP Crontrol'
1081 ),
1082 array(
1083 'a' => array(
1084 'href' => array(),
1085 ),
1086 )
1087 );
1088 $content .= '</li>';
1089 $content .= '<li>';
1090 $content .= wp_kses(
1091 sprintf(
1092 /* translators: %s: URL to the documentation */
1093 __( '<a href="%s">Read the Frequently Asked Questions (FAQ)</a> which cover many common questions and answers.', 'wp-crontrol' ),
1094 'https://wordpress.org/plugins/wp-crontrol/faq/'
1095 ),
1096 array(
1097 'a' => array(
1098 'href' => array(),
1099 ),
1100 )
1101 );
1102 $content .= '</li>';
1103 $content .= '<li>';
1104 $content .= wp_kses(
1105 sprintf(
1106 /* translators: %s: URL to the documentation */
1107 __( '<a href="%s">Read the WordPress.org documentation on WP-Cron</a> for more technical details about the WP-Cron system for developers.', 'wp-crontrol' ),
1108 'https://developer.wordpress.org/plugins/cron/'
1109 ),
1110 array(
1111 'a' => array(
1112 'href' => array(),
1113 ),
1114 )
1115 );
1116 $content .= '</ul>';
1117
1118 $screen->add_help_tab(
1119 array(
1120 'id' => 'crontrol-help',
1121 'title' => __( 'Help', 'wp-crontrol' ),
1122 'content' => $content,
1123 )
1124 );
1125 }
1126
1127 /**
1128 * Adds items to the plugin's action links on the Plugins listing screen.
1129 *
1130 * @param array<string,string> $actions Array of action links.
1131 * @return array<string,string> Array of action links.
1132 */
1133 function plugin_action_links( $actions ) {
1134 $new = array(
1135 'crontrol-events' => sprintf(
1136 '<a href="%s">%s</a>',
1137 esc_url( admin_url( 'tools.php?page=wp-crontrol' ) ),
1138 esc_html__( 'Cron Events', 'wp-crontrol' )
1139 ),
1140 'crontrol-schedules' => sprintf(
1141 '<a href="%s">%s</a>',
1142 esc_url( admin_url( 'options-general.php?page=wp-crontrol-schedules' ) ),
1143 esc_html__( 'Cron Schedules', 'wp-crontrol' )
1144 ),
1145 'crontrol-help' => sprintf(
1146 '<a href="%s">%s</a>',
1147 'https://wp-crontrol.com/docs/how-to-use/',
1148 esc_html__( 'Help', 'wp-crontrol' )
1149 ),
1150 );
1151
1152 return array_merge( $new, $actions );
1153 }
1154
1155 /**
1156 * Adds items to the plugin's action links on the Network Admin -> Plugins listing screen.
1157 *
1158 * @param array<string,string> $actions Array of action links.
1159 * @return array<string,string> Array of action links.
1160 */
1161 function network_plugin_action_links( $actions ) {
1162 $new = array(
1163 'crontrol-help' => sprintf(
1164 '<a href="%s">%s</a>',
1165 'https://wp-crontrol.com/docs/how-to-use/',
1166 esc_html__( 'Help', 'wp-crontrol' )
1167 ),
1168 );
1169
1170 return array_merge( $new, $actions );
1171 }
1172
1173 /**
1174 * Gives WordPress the plugin's set of cron schedules.
1175 *
1176 * Called by the `cron_schedules` filter.
1177 *
1178 * @param array<string,array<string,(int|string)>> $scheds Array of cron schedule arrays. Usually empty.
1179 * @return array<string,array<string,(int|string)>> Array of modified cron schedule arrays.
1180 */
1181 function filter_cron_schedules( array $scheds ) {
1182 $new_scheds = get_option( 'crontrol_schedules', array() );
1183
1184 if ( ! is_array( $new_scheds ) ) {
1185 return $scheds;
1186 }
1187
1188 return array_merge( $new_scheds, $scheds );
1189 }
1190
1191 /**
1192 * Displays the Cron Schedules page for the plugin.
1193 *
1194 * @return void
1195 */
1196 function admin_options_page() {
1197 $messages = array(
1198 MESSAGE_SCHEDULE_DELETED => array(
1199 /* translators: %s: The name of the cron schedule. */
1200 __( 'Deleted the cron schedule %s.', 'wp-crontrol' ),
1201 'success',
1202 ),
1203 MESSAGE_SCHEDULE_SAVED => array(
1204 /* translators: %s: The name of the cron schedule. */
1205 __( 'Added the cron schedule %s.', 'wp-crontrol' ),
1206 'success',
1207 ),
1208 MESSAGE_SCHEDULE_DUPLICATE => array(
1209 /* translators: %s: The name of the cron schedule. */
1210 __( 'A schedule with the name %s already exists.', 'wp-crontrol' ),
1211 'error',
1212 ),
1213 );
1214
1215 if ( isset( $_GET['crontrol_message'], $_GET['crontrol_name'], $messages[ $_GET['crontrol_message'] ] ) ) {
1216 $hook = wp_unslash( $_GET['crontrol_name'] );
1217 $message = intval( $_GET['crontrol_message'] );
1218
1219 wp_admin_notice(
1220 sprintf(
1221 esc_html( $messages[ $message ][0] ),
1222 '<strong>' . esc_html( $hook ) . '</strong>'
1223 ),
1224 array(
1225 'id' => 'crontrol-message',
1226 'type' => $messages[ $message ][1],
1227 'dismissible' => true,
1228 )
1229 );
1230 }
1231
1232 $table = new Schedule_List_Table();
1233
1234 $table->prepare_items();
1235
1236 ?>
1237 <div class="wrap">
1238
1239 <?php do_tabs(); ?>
1240
1241 <h1><?php esc_html_e( 'Cron Schedules', 'wp-crontrol' ); ?></h1>
1242
1243 <?php $table->views(); ?>
1244
1245 <div id="col-container" class="wp-clearfix">
1246 <div id="col-left">
1247 <div class="col-wrap">
1248 <div class="form-wrap">
1249 <h2><?php esc_html_e( 'Add Cron Schedule', 'wp-crontrol' ); ?></h2>
1250 <p><?php esc_html_e( 'Adding a new schedule allows you to schedule recurring events at the given interval.', 'wp-crontrol' ); ?></p>
1251 <form method="post" action="options-general.php?page=wp-crontrol-schedules">
1252 <div class="form-field form-required">
1253 <label for="crontrol_schedule_interval">
1254 <?php esc_html_e( 'Interval (seconds)', 'wp-crontrol' ); ?>
1255 </label>
1256 <input type="number" value="" id="crontrol_schedule_interval" name="crontrol_schedule_interval" min="1" step="1" required/>
1257 </div>
1258 <div class="form-field form-required">
1259 <label for="crontrol_schedule_display_name">
1260 <?php esc_html_e( 'Display Name', 'wp-crontrol' ); ?>
1261 </label>
1262 <input type="text" value="" id="crontrol_schedule_display_name" name="crontrol_schedule_display_name" required/>
1263 </div>
1264 <div class="form-field form-required">
1265 <label for="crontrol_schedule_internal_name">
1266 <?php esc_html_e( 'Internal Name', 'wp-crontrol' ); ?>
1267 </label>
1268 <input type="text" value="" id="crontrol_schedule_internal_name" name="crontrol_schedule_internal_name" required/>
1269 </div>
1270 <p class="submit">
1271 <input type="submit" class="button button-primary" value="<?php esc_attr_e( 'Add Cron Schedule', 'wp-crontrol' ); ?>" name="crontrol_new_schedule"/>
1272 </p>
1273 <?php wp_nonce_field( 'crontrol-new-schedule' ); ?>
1274 </form>
1275 </div>
1276 </div>
1277 </div>
1278 <div id="col-right">
1279 <div class="col-wrap">
1280 <?php $table->display(); ?>
1281 </div>
1282 </div>
1283 </div>
1284 <?php
1285 }
1286
1287 /**
1288 * Clears the doing cron status when an event is unscheduled.
1289 *
1290 * What on earth does this function do, and why?
1291 *
1292 * Good question. The purpose of this function is to prevent other overdue cron events from firing when an event is run
1293 * manually with the "Run now" action. WP Crontrol works very hard to ensure that when cron event runs manually that it
1294 * runs in the exact same way it would run as part of its schedule - via a properly spawned cron with a queued event in
1295 * place. It does this by queueing an event at time `1` (1 second into 1st January 1970) and then immediately spawning
1296 * cron (see the `Event\run()` function).
1297 *
1298 * The problem this causes is if other events are due then they will all run too, and this isn't desirable because if a
1299 * site has a large number of stuck events due to a problem with the cron runner then it's not desirable for all those
1300 * events to run when another is manually run. This happens because WordPress core will attempt to run all due events
1301 * whenever cron is spawned.
1302 *
1303 * The code in this function prevents multiple events from running by changing the value of the `doing_cron` transient
1304 * when an event gets unscheduled during a manual run, which prevents wp-cron.php from iterating more than one event.
1305 *
1306 * The `pre_unschedule_event` filter is used for this because it's just about the only hook available within this loop.
1307 *
1308 * Refs:
1309 * - https://core.trac.wordpress.org/browser/trunk/src/wp-cron.php?rev=47198&marks=127,141#L122
1310 *
1311 * @param mixed $pre The pre-flight value of the event unschedule short-circuit. Not used.
1312 * @return mixed The unaltered pre-flight value.
1313 */
1314 function maybe_clear_doing_cron( $pre ) {
1315 if ( wp_doing_cron() && isset( $_GET['crontrol-single-event'] ) ) {
1316 delete_transient( 'doing_cron' );
1317 }
1318
1319 return $pre;
1320 }
1321
1322 /**
1323 * Gets the status of WP-Cron functionality on the site by performing a test spawn if necessary. Cached for one hour when all is well.
1324 *
1325 * @param bool $cache Whether to use the cached result from previous calls.
1326 * @return true|WP_Error Boolean true if the cron spawner is working as expected, or a `WP_Error` object if not.
1327 */
1328 function test_cron_spawn( $cache = true ) {
1329 $cron_runner_plugins = array(
1330 '\HM\Cavalcade\Plugin\Job' => 'Cavalcade',
1331 '\Automattic\WP\Cron_Control\Main' => 'Cron Control',
1332 );
1333
1334 foreach ( $cron_runner_plugins as $class => $plugin ) {
1335 if ( class_exists( $class ) ) {
1336 return new WP_Error( 'crontrol_info', sprintf(
1337 /* translators: %s: The name of the plugin that controls the running of cron events. */
1338 __( 'WP-Cron spawning is being managed by the %s plugin.', 'wp-crontrol' ),
1339 $plugin
1340 ) );
1341 }
1342 }
1343
1344 if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
1345 return new WP_Error( 'crontrol_info', sprintf(
1346 /* translators: %s: The name of the PHP constant that is set. */
1347 __( 'The %s constant is set to true. WP-Cron spawning is disabled.', 'wp-crontrol' ),
1348 'DISABLE_WP_CRON'
1349 ) );
1350 }
1351
1352 if ( defined( 'ALTERNATE_WP_CRON' ) && ALTERNATE_WP_CRON ) {
1353 return new WP_Error( 'crontrol_info', sprintf(
1354 /* translators: %s: The name of the PHP constant that is set. */
1355 __( 'The %s constant is set to true.', 'wp-crontrol' ),
1356 'ALTERNATE_WP_CRON'
1357 ) );
1358 }
1359
1360 $cached_status = get_transient( 'crontrol-cron-test-ok' );
1361
1362 if ( $cache && $cached_status ) {
1363 return true;
1364 }
1365
1366 $doing_wp_cron = sprintf( '%.22F', microtime( true ) );
1367
1368 $cron_request = apply_filters( 'cron_request', array(
1369 'url' => add_query_arg( 'doing_wp_cron', $doing_wp_cron, site_url( 'wp-cron.php' ) ),
1370 'key' => $doing_wp_cron,
1371 'args' => array(
1372 'timeout' => 3,
1373 'blocking' => true,
1374 'sslverify' => apply_filters( 'https_local_ssl_verify', false ),
1375 ),
1376 ), $doing_wp_cron );
1377
1378 $cron_request['args']['blocking'] = true;
1379
1380 $result = wp_remote_post( $cron_request['url'], $cron_request['args'] );
1381
1382 if ( is_wp_error( $result ) ) {
1383 return $result;
1384 } elseif ( wp_remote_retrieve_response_code( $result ) >= 300 ) {
1385 return new WP_Error( 'unexpected_http_response_code', sprintf(
1386 /* translators: %s: The HTTP response code. */
1387 __( 'Unexpected HTTP response code: %s', 'wp-crontrol' ),
1388 intval( wp_remote_retrieve_response_code( $result ) )
1389 ) );
1390 } else {
1391 set_transient( 'crontrol-cron-test-ok', 1, 3600 );
1392 return true;
1393 }
1394 }
1395
1396 /**
1397 * Deletes the cached value of the cron status check.
1398 *
1399 * @return void
1400 */
1401 function flush_status_cache() {
1402 delete_transient( 'crontrol-cron-test-ok' );
1403 }
1404
1405 /**
1406 * Shows the status of WP-Cron functionality on the site. Only displays a message when there's a problem.
1407 *
1408 * @return void
1409 */
1410 function show_cron_status() {
1411 if ( empty( $_GET['page'] ) || 'wp-crontrol' !== $_GET['page'] ) {
1412 return;
1413 }
1414
1415 if ( 'UTC' !== date_default_timezone_get() ) {
1416 wp_admin_notice(
1417 sprintf(
1418 '%1$s<br><br><a href="%2$s">%3$s</a>',
1419 esc_html__( 'PHP default timezone is not set to UTC. This may cause issues with cron event timings.', 'wp-crontrol' ),
1420 'https://wp-crontrol.com/help/php-default-timezone/',
1421 esc_html__( 'More information', 'wp-crontrol' )
1422 ),
1423 array(
1424 'id' => 'crontrol-timezone-warning',
1425 'type' => 'warning',
1426 )
1427 );
1428 }
1429
1430 $status = test_cron_spawn();
1431
1432 if ( is_wp_error( $status ) ) {
1433 if ( 'crontrol_info' === $status->get_error_code() ) {
1434 wp_admin_notice(
1435 esc_html( $status->get_error_message() ),
1436 array(
1437 'id' => 'crontrol-status-notice',
1438 'type' => 'info',
1439 )
1440 );
1441 } else {
1442 wp_admin_notice(
1443 sprintf(
1444 '%1$s<br><br><a href="%2$s">%3$s</a>',
1445 sprintf(
1446 /* translators: %s: Error message text. */
1447 esc_html__( 'There was a problem spawning a call to the WP-Cron system on your site. This means WP-Cron events on your site may not work. The problem was: %s', 'wp-crontrol' ),
1448 '<br><br><strong>' . esc_html( $status->get_error_message() ) . '</strong>'
1449 ),
1450 'https://wp-crontrol.com/help/problems-spawning-wp-cron/',
1451 esc_html__( 'More information', 'wp-crontrol' )
1452 ),
1453 array(
1454 'id' => 'crontrol-status-error',
1455 'type' => 'error',
1456 )
1457 );
1458 }
1459 }
1460 }
1461
1462 /**
1463 * Get the display name for the site's timezone.
1464 *
1465 * @return string The name and UTC offset for the site's timezone.
1466 */
1467 function get_timezone_name() {
1468 /** @var string */
1469 $timezone_string = get_option( 'timezone_string', '' );
1470 $gmt_offset = get_option( 'gmt_offset', 0 );
1471 $offset_string = get_utc_offset();
1472
1473 if ( 'UTC' === $timezone_string || ( empty( $gmt_offset ) && empty( $timezone_string ) ) ) {
1474 return 'UTC';
1475 }
1476
1477 if ( '' === $timezone_string ) {
1478 return $offset_string;
1479 }
1480
1481 $parts = explode( '/', $timezone_string );
1482 array_shift( $parts );
1483 $location = str_replace( '_', ' ', implode( ', ', array_reverse( $parts ) ) );
1484 $time = time();
1485
1486 try {
1487 $timezone = new DateTimeZone( $timezone_string );
1488 $transitions = $timezone->getTransitions( $time, $time );
1489
1490 if ( empty( $transitions ) ) {
1491 return sprintf(
1492 '(%s) %s',
1493 $offset_string,
1494 $location,
1495 );
1496 }
1497
1498 $transition = reset( $transitions );
1499 $name = $transition['abbr'];
1500
1501 if ( class_exists( 'IntlTimeZone' ) ) {
1502 $intl_tz = IntlTimeZone::createTimeZone( $timezone_string );
1503 $name = $intl_tz->getDisplayName( $transition['isdst'] );
1504 }
1505
1506 return sprintf(
1507 '%1$s - %2$s (%3$s)',
1508 $name,
1509 $location,
1510 $offset_string,
1511 );
1512 } catch ( Exception $e ) {
1513 return sprintf(
1514 '(%s) %s',
1515 $offset_string,
1516 $location,
1517 );
1518 }
1519 }
1520
1521 /**
1522 * Returns the display name for the location of the site's timezone.
1523 *
1524 * @return string The name of the site's timezone location.
1525 */
1526 function get_timezone_location(): string {
1527 /** @var string */
1528 $timezone_string = get_option( 'timezone_string', '' );
1529 $gmt_offset = get_option( 'gmt_offset', 0 );
1530
1531 if ( 'UTC' === $timezone_string || ( empty( $gmt_offset ) && empty( $timezone_string ) ) ) {
1532 return 'UTC';
1533 }
1534
1535 if ( '' === $timezone_string ) {
1536 return get_utc_offset();
1537 }
1538
1539 $parts = explode( '/', $timezone_string );
1540
1541 return str_replace( '_', ' ', end( $parts ) );
1542 }
1543
1544 /**
1545 * Returns a display value for a UTC offset.
1546 *
1547 * Examples:
1548 * - UTC
1549 * - UTC+4
1550 * - UTC-6
1551 *
1552 * @return string The UTC offset display value.
1553 */
1554 function get_utc_offset() {
1555 $offset = get_option( 'gmt_offset', 0 );
1556
1557 if ( empty( $offset ) ) {
1558 return 'UTC';
1559 }
1560
1561 if ( 0 <= $offset ) {
1562 $formatted_offset = '+' . (string) $offset;
1563 } else {
1564 $formatted_offset = (string) $offset;
1565 }
1566 $formatted_offset = str_replace(
1567 array( '.25', '.5', '.75' ),
1568 array( ':15', ':30', ':45' ),
1569 $formatted_offset
1570 );
1571 return 'UTC' . $formatted_offset;
1572 }
1573
1574 /**
1575 * Shows the form used to add/edit cron events.
1576 *
1577 * @param bool $editing Whether the form is for the event editor.
1578 * @return void
1579 */
1580 function show_cron_form( $editing ) {
1581 $display_args = '';
1582 $edit_id = null;
1583 $existing = null;
1584
1585 if ( $editing && ! empty( $_GET['crontrol_id'] ) ) {
1586 $edit_id = wp_unslash( $_GET['crontrol_id'] );
1587
1588 $existing = Event\find(
1589 $edit_id,
1590 intval( $_GET['crontrol_next_run_utc'] ),
1591 $_GET['crontrol_sig']
1592 );
1593
1594 if ( empty( $existing ) ) {
1595 $search_url = add_query_arg(
1596 array(
1597 'page' => 'wp-crontrol',
1598 's' => rawurlencode( $edit_id ),
1599 ),
1600 admin_url( 'tools.php' )
1601 );
1602 ?>
1603 <div id="crontrol_form" class="wrap narrow">
1604 <?php do_tabs(); ?>
1605
1606 <?php
1607 wp_admin_notice(
1608 sprintf(
1609 '%1$s<br><br>%2$s',
1610 sprintf(
1611 /* translators: %s: The name of the cron event. */
1612 esc_html__( 'The %s event you are trying to edit does not exist.', 'wp-crontrol' ),
1613 '<b>' . esc_html( $edit_id ) . '</b>'
1614 ),
1615 wp_kses(
1616 sprintf(
1617 /* translators: 1: The time since the event was scheduled, 2: The URL to search for the event. */
1618 __( 'The event probably ran %1$s ago. <a href="%2$s">Click here to see if it was rescheduled</a>.', 'wp-crontrol' ),
1619 human_time_diff( intval( $_GET['crontrol_next_run_utc'] ), time() ),
1620 esc_url( $search_url )
1621 ),
1622 array(
1623 'a' => array(
1624 'href' => array(),
1625 ),
1626 )
1627 )
1628 ),
1629 array(
1630 'id' => 'crontrol-event-not-found',
1631 'type' => 'error',
1632 )
1633 );
1634 ?>
1635 </div>
1636 <?php
1637 return;
1638 }
1639 }
1640
1641 $is_editing_php = ( $existing instanceof PHPCronEvent );
1642 $is_editing_url = ( $existing instanceof URLCronEvent );
1643 $hook_name_editable = true;
1644
1645 if ( $existing ) {
1646 $other_fields = wp_nonce_field( "crontrol-edit-cron_{$existing->hook}_{$existing->sig}_{$existing->timestamp}", '_wpnonce', true, false );
1647 $other_fields .= sprintf( '<input name="crontrol_original_hookname" type="hidden" value="%s" />',
1648 esc_attr( $existing->hook )
1649 );
1650 $other_fields .= sprintf( '<input name="crontrol_original_sig" type="hidden" value="%s" />',
1651 esc_attr( $existing->sig )
1652 );
1653 $other_fields .= sprintf( '<input name="crontrol_original_next_run_utc" type="hidden" value="%s" />',
1654 esc_attr( (string) $existing->timestamp )
1655 );
1656 if ( $existing->has_invalid_args() ) {
1657 $display_args = wp_json_encode( $existing->args );
1658
1659 if ( false === $display_args ) {
1660 $display_args = var_export( $existing->args, true );
1661 }
1662 } elseif ( $existing->args !== [] ) {
1663 $display_args = wp_json_encode( $existing->args );
1664
1665 if ( false === $display_args ) {
1666 $display_args = '';
1667 }
1668 }
1669 $button = __( 'Update Event', 'wp-crontrol' );
1670 $next_run_gmt = gmdate( 'Y-m-d H:i:s', $existing->timestamp );
1671 $next_run_date_local = get_date_from_gmt( $next_run_gmt, 'Y-m-d' );
1672 $next_run_time_local = get_date_from_gmt( $next_run_gmt, 'H:i:s' );
1673 $hook_name_editable = $existing->hook_name_editable();
1674 } else {
1675 $other_fields = wp_nonce_field( 'crontrol-new-cron', '_wpnonce', true, false );
1676 $existing = Event\Event::create_new();
1677 $button = __( 'Add Event', 'wp-crontrol' );
1678 $suggestion = strtotime( '+1 hour' );
1679 $next_run_date_local = get_date_from_gmt( gmdate( 'Y-m-d H:i:s', $suggestion ), 'Y-m-d' );
1680 $next_run_time_local = get_date_from_gmt( gmdate( 'Y-m-d H:\0\0:\0\0', $suggestion ), 'H:i:s' );
1681 }
1682
1683 if ( $is_editing_php && isset( $existing->args['code'] ) ) {
1684 // Support the args array format used prior to WP Crontrol 1.16.2
1685 $existing->args = array(
1686 array(
1687 'code' => $existing->args['code'],
1688 'name' => $existing->args['name'] ?? '',
1689 'hash' => null,
1690 ),
1691 );
1692 }
1693
1694 $can_create_php = current_user_can_edit_php_cron_events();
1695 $can_create_url = current_user_can_edit_url_cron_events();
1696
1697 if ( $editing ) {
1698 $allowed = $existing->editable( new \Crontrol\Context\WordPressUserContext(), new \Crontrol\Context\WordPressFeatureContext() );
1699 } else {
1700 $allowed = true;
1701 }
1702 ?>
1703 <div id="crontrol_form" class="wrap narrow">
1704 <?php
1705 if ( $allowed ) {
1706 if ( $editing ) {
1707 $heading = __( 'Edit Cron Event', 'wp-crontrol' );
1708 } else {
1709 $heading = __( 'Add Cron Event', 'wp-crontrol' );
1710 }
1711
1712 do_tabs();
1713
1714 printf(
1715 '<h1>%s</h1>',
1716 esc_html( $heading )
1717 );
1718
1719 if ( $is_editing_php ) {
1720 $cron_type = 'php';
1721 } elseif ( $is_editing_url ) {
1722 $cron_type = 'url';
1723 } else {
1724 $cron_type = 'standard';
1725 }
1726 ?>
1727 <form method="post" action="<?php echo esc_url( admin_url( 'tools.php?page=wp-crontrol' ) ); ?>" class="crontrol-edit-event crontrol-edit-event-<?php echo esc_attr( $cron_type ); ?>">
1728 <?php
1729 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1730 echo $other_fields;
1731 ?>
1732 <table class="form-table"><tbody>
1733 <?php
1734 if ( $editing ) {
1735 if ( $is_editing_php ) {
1736 $action = 'edit_php_cron';
1737 } elseif ( $is_editing_url ) {
1738 $action = 'edit_url_cron';
1739 } else {
1740 $action = 'edit_cron';
1741 }
1742
1743 printf(
1744 '<input type="hidden" name="crontrol_action" value="%s"/>',
1745 esc_attr( $action )
1746 );
1747 } elseif ( ! $can_create_php && ! $can_create_url ) {
1748 echo '<input type="hidden" name="crontrol_action" value="new_cron"/>';
1749 } else {
1750 ?>
1751 <tr class="hide-if-no-js">
1752 <th scope="row">
1753 <?php esc_html_e( 'Event Type', 'wp-crontrol' ); ?>
1754 </th>
1755 <td>
1756 <fieldset>
1757 <legend class="screen-reader-text">
1758 <?php esc_html_e( 'Event Type', 'wp-crontrol' ); ?>
1759 </legend>
1760 <p>
1761 <label>
1762 <input type="radio" name="crontrol_action" value="new_cron" checked>
1763 <?php esc_html_e( 'Standard cron event', 'wp-crontrol' ); ?>
1764 </label>
1765 </p>
1766 <?php if ( $can_create_url ) { ?>
1767 <p>
1768 <label>
1769 <input type="radio" name="crontrol_action" value="new_url_cron">
1770 <?php esc_html_e( 'URL cron event', 'wp-crontrol' ); ?>
1771 </label>
1772 </p>
1773 <?php } ?>
1774 <?php if ( $can_create_php ) { ?>
1775 <p>
1776 <label>
1777 <input type="radio" name="crontrol_action" value="new_php_cron">
1778 <?php esc_html_e( 'PHP cron event', 'wp-crontrol' ); ?>
1779 </label>
1780 </p>
1781 <?php } ?>
1782 </fieldset>
1783 </td>
1784 </tr>
1785 <?php
1786 }
1787
1788 if ( $is_editing_url || ! $editing ) {
1789 ?>
1790 <tr class="crontrol-event-url">
1791 <th valign="top" scope="row">
1792 <label for="crontrol_url">
1793 <?php esc_html_e( 'URL', 'wp-crontrol' ); ?>
1794 </label>
1795 </th>
1796 <td>
1797 <?php
1798 if ( $is_editing_url && $existing->integrity_failed() ) {
1799 wp_admin_notice(
1800 sprintf(
1801 '%1$s<br><br><a href="%2$s">%3$s</a>',
1802 esc_html__( 'The URL in this event needs to be checked for integrity. This event will not run until you re-save it.', 'wp-crontrol' ),
1803 'https://wp-crontrol.com/help/check-cron-events/',
1804 esc_html__( 'Read what to do', 'wp-crontrol' )
1805 ),
1806 array(
1807 'type' => 'error',
1808 'additional_classes' => array( 'inline' ),
1809 )
1810 );
1811 }
1812 ?>
1813 <input type="url" class="regular-text code" id="crontrol_url" name="crontrol_url" value="<?php echo esc_attr( $is_editing_url ? $existing->args[0]['url'] : '' ); ?>" />
1814 <?php do_action( 'crontrol/manage/url', $existing ); ?>
1815 </td>
1816 </tr>
1817 <tr class="crontrol-event-url">
1818 <th valign="top" scope="row">
1819 <label for="crontrol_method">
1820 <?php esc_html_e( 'HTTP Method', 'wp-crontrol' ); ?>
1821 </label>
1822 </th>
1823 <td>
1824 <select id="crontrol_method" name="crontrol_method">
1825 <option value="GET">GET</option>
1826 <option value="POST" <?php selected( $editing ? $existing->args[0]['method'] === 'POST' : false ); ?>>POST</option>
1827 <option value="HEAD" <?php selected( $editing ? $existing->args[0]['method'] === 'HEAD' : false ); ?>>HEAD</option>
1828 <option value="DELETE" <?php selected( $editing ? $existing->args[0]['method'] === 'DELETE' : false ); ?>>DELETE</option>
1829 </select>
1830 <?php do_action( 'crontrol/manage/method', $existing ); ?>
1831 </td>
1832 </tr>
1833 <tr class="crontrol-event-url">
1834 <th valign="top" scope="row">
1835 <label for="crontrol_eventname">
1836 <?php esc_html_e( 'Display Name (optional)', 'wp-crontrol' ); ?>
1837 </label>
1838 </th>
1839 <td>
1840 <input type="text" class="regular-text" id="crontrol_eventname" name="crontrol_eventname" value="<?php echo esc_attr( $editing ? $existing->args[0]['name'] : '' ); ?>"/>
1841 <?php do_action( 'crontrol/manage/eventname', $existing ); ?>
1842 </td>
1843 </tr>
1844 <?php
1845 }
1846
1847 if ( $is_editing_php || ( ! $editing && $can_create_php ) ) {
1848 ?>
1849 <tr class="crontrol-event-php">
1850 <th scope="row">
1851 <label for="crontrol_hookcode">
1852 <?php esc_html_e( 'PHP Code', 'wp-crontrol' ); ?>
1853 </label>
1854 </th>
1855 <td>
1856 <?php
1857 if ( $is_editing_php && $existing->integrity_failed() ) {
1858 wp_admin_notice(
1859 sprintf(
1860 '%1$s<br><br><a href="%2$s">%3$s</a>',
1861 esc_html__( 'The PHP code in this event needs to be checked for integrity. This event will not run until you re-save it.', 'wp-crontrol' ),
1862 'https://wp-crontrol.com/help/check-cron-events/',
1863 esc_html__( 'Read what to do', 'wp-crontrol' )
1864 ),
1865 array(
1866 'type' => 'error',
1867 'additional_classes' => array( 'inline' ),
1868 )
1869 );
1870 }
1871 ?>
1872 <p class="description">
1873 <?php
1874 printf(
1875 /* translators: The PHP tag name */
1876 esc_html__( 'The opening %s tag must not be included.', 'wp-crontrol' ),
1877 '<code>&lt;?php</code>'
1878 );
1879 ?>
1880 </p>
1881 <p><textarea class="large-text code" rows="10" cols="50" id="crontrol_hookcode" name="crontrol_hookcode"><?php echo esc_textarea( $editing ? $existing->args[0]['code'] : '' ); ?></textarea></p>
1882 <?php do_action( 'crontrol/manage/hookcode', $existing ); ?>
1883 </td>
1884 </tr>
1885 <tr class="crontrol-event-php">
1886 <th scope="row">
1887 <label for="crontrol_eventname">
1888 <?php esc_html_e( 'Display Name (optional)', 'wp-crontrol' ); ?>
1889 </label>
1890 </th>
1891 <td>
1892 <input type="text" class="regular-text" id="crontrol_eventname" name="crontrol_eventname" value="<?php echo esc_attr( $editing ? $existing->args[0]['name'] : '' ); ?>"/>
1893 <?php do_action( 'crontrol/manage/eventname', $existing ); ?>
1894 </td>
1895 </tr>
1896 <?php
1897 }
1898
1899 if ( ! $is_editing_url && ! $is_editing_php ) {
1900 ?>
1901 <tr class="crontrol-event-standard">
1902 <th scope="row">
1903 <?php if ( ! $hook_name_editable ) { ?>
1904 <?php esc_html_e( 'Hook Name', 'wp-crontrol' ); ?>
1905 <?php } else { ?>
1906 <label for="crontrol_hookname">
1907 <?php esc_html_e( 'Hook Name', 'wp-crontrol' ); ?>
1908 </label>
1909 <?php } ?>
1910 </th>
1911 <td>
1912 <?php if ( ! $hook_name_editable ) { ?>
1913 <input type="hidden" name="crontrol_hookname" value="<?php echo esc_attr( $existing->hook ); ?>" />
1914 <?php echo esc_html( $existing->hook ); ?>
1915 <?php } else { ?>
1916 <input type="text" autocorrect="off" autocapitalize="off" spellcheck="false" class="regular-text" id="crontrol_hookname" name="crontrol_hookname" value="<?php echo esc_attr( $existing->hook ); ?>" required />
1917 <?php } ?>
1918 <?php do_action( 'crontrol/manage/hookname', $existing ); ?>
1919 </td>
1920 </tr>
1921 <tr class="crontrol-event-standard">
1922 <th scope="row">
1923 <label for="crontrol_args">
1924 <?php esc_html_e( 'Arguments (optional)', 'wp-crontrol' ); ?>
1925 </label>
1926 </th>
1927 <td>
1928 <?php
1929 if ( $editing && $existing->has_invalid_args() ) {
1930 wp_admin_notice(
1931 sprintf(
1932 '%1$s<br><br>%2$s',
1933 esc_html__( 'This event has invalid arguments and will not run correctly.', 'wp-crontrol' ),
1934 sprintf(
1935 /* translators: %s: The PHP type of the invalid value */
1936 esc_html__( 'Arguments should be an array but %s was provided. The event will likely fail with a PHP error when it attempts to run.', 'wp-crontrol' ),
1937 esc_html( gettype( $existing->args ) )
1938 )
1939 ),
1940 array(
1941 'type' => 'error',
1942 'additional_classes' => array( 'inline' ),
1943 )
1944 );
1945 }
1946 ?>
1947 <input type="text" autocorrect="off" autocapitalize="off" spellcheck="false" class="regular-text code" id="crontrol_args" name="crontrol_args" value="<?php echo esc_attr( $display_args ); ?>" aria-describedby="crontrol_args_description"/>
1948 <?php do_action( 'crontrol/manage/args', $existing ); ?>
1949 <p class="description" id="crontrol_args_description">
1950 <?php
1951 printf(
1952 /* translators: 1, 2, and 3: Example values for an input field. */
1953 esc_html__( 'Use a JSON encoded array, e.g. %1$s, %2$s, or %3$s', 'wp-crontrol' ),
1954 '<code>[25]</code>',
1955 '<code>["asdf"]</code>',
1956 '<code>["i","want",25,"cakes"]</code>'
1957 );
1958 ?>
1959 </p>
1960 </td>
1961 </tr>
1962 <?php
1963 }
1964 ?>
1965 <tr>
1966 <th scope="row">
1967 <label for="crontrol_next_run_date_local">
1968 <?php esc_html_e( 'Next Run', 'wp-crontrol' ); ?>
1969 </label>
1970 </th>
1971 <td>
1972 <?php if ( $editing ) { ?>
1973 <input type="hidden" name="crontrol_next_run_date_local" value="custom">
1974 <?php
1975 printf(
1976 '<input type="date" autocorrect="off" autocapitalize="off" spellcheck="false" name="crontrol_next_run_date_local_custom_date" id="crontrol_next_run_date_local_custom_date" value="%1$s" placeholder="yyyy-mm-dd" pattern="\d{4}-\d{2}-\d{2}" />
1977 <input type="time" autocorrect="off" autocapitalize="off" spellcheck="false" name="crontrol_next_run_date_local_custom_time" id="crontrol_next_run_date_local_custom_time" value="%2$s" step="1" placeholder="hh:mm:ss" pattern="\d{2}:\d{2}:\d{2}" />',
1978 esc_attr( $next_run_date_local ),
1979 esc_attr( $next_run_time_local )
1980 );
1981 ?>
1982 <?php } else { ?>
1983 <fieldset>
1984 <legend class="screen-reader-text">
1985 <?php esc_html_e( 'Next Run', 'wp-crontrol' ); ?>
1986 </legend>
1987 <p>
1988 <label>
1989 <input type="radio" name="crontrol_next_run_date_local" value="now" checked>
1990 <?php esc_html_e( 'Now', 'wp-crontrol' ); ?>
1991 </label>
1992 </p>
1993 <p>
1994 <label>
1995 <input type="radio" name="crontrol_next_run_date_local" value="+1 day">
1996 <?php esc_html_e( 'Tomorrow', 'wp-crontrol' ); ?>
1997 </label>
1998 </p>
1999 <p>
2000 <label>
2001 <input type="radio" name="crontrol_next_run_date_local" value="custom" id="crontrol_next_run_date_local_custom" <?php checked( $editing ); ?>>
2002 <?php
2003 printf(
2004 /* translators: %s: An input field for specifying a date and time */
2005 esc_html__( 'At this time: %s', 'wp-crontrol' ),
2006 sprintf(
2007 '<br><br>
2008 <input type="date" autocorrect="off" autocapitalize="off" spellcheck="false" name="crontrol_next_run_date_local_custom_date" id="crontrol_next_run_date_local_custom_date" value="%1$s" placeholder="yyyy-mm-dd" pattern="\d{4}-\d{2}-\d{2}" />
2009 <input type="time" autocorrect="off" autocapitalize="off" spellcheck="false" name="crontrol_next_run_date_local_custom_time" id="crontrol_next_run_date_local_custom_time" value="%2$s" step="1" placeholder="hh:mm:ss" pattern="\d{2}:\d{2}:\d{2}" />',
2010 esc_attr( $next_run_date_local ),
2011 esc_attr( $next_run_time_local )
2012 )
2013 );
2014 ?>
2015 </label>
2016 </p>
2017 </fieldset>
2018 <?php } ?>
2019
2020 <?php do_action( 'crontrol/manage/next_run', $existing ); ?>
2021
2022 <p class="description">
2023 <?php
2024 printf(
2025 /* translators: %s Timezone name. */
2026 esc_html__( 'Timezone: %s', 'wp-crontrol' ),
2027 esc_html( get_timezone_name() )
2028 );
2029 ?>
2030 </p>
2031 </td>
2032 </tr>
2033 <tr>
2034 <th scope="row">
2035 <label for="crontrol_schedule">
2036 <?php echo esc_html_x( 'Schedule', 'noun', 'wp-crontrol' ); ?>
2037 </label>
2038 </th>
2039 <td>
2040 <?php Schedule\dropdown( $existing->schedule ); ?>
2041 <?php do_action( 'crontrol/manage/schedule', $existing ); ?>
2042 </td>
2043 </tr>
2044 </tbody></table>
2045 <p class="submit">
2046 <input type="submit" class="button button-primary" value="<?php echo esc_attr( $button ); ?>"/>
2047 </p>
2048 <p class="description">
2049 <?php
2050 echo esc_html( sprintf(
2051 /* translators: 1: Date and time, 2: Timezone */
2052 __( 'Site time when page loaded: %1$s, %2$s', 'wp-crontrol' ),
2053 date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ),
2054 get_timezone_name()
2055 ) );
2056 ?>
2057 </p>
2058 </form>
2059 <?php } else { ?>
2060 <div class="error inline">
2061 <p><?php esc_html_e( 'You cannot add, edit, or delete PHP cron events because your user account does not have the ability to edit files.', 'wp-crontrol' ); ?></p>
2062 </div>
2063 <?php } ?>
2064 </div>
2065 <?php
2066 }
2067
2068 /**
2069 * Displays the Cron Events page for the plugin.
2070 *
2071 * @return void
2072 */
2073 function admin_manage_page() {
2074 $messages = array(
2075 MESSAGE_EVENT_RUN_NOW => array(
2076 /* translators: %s: The name of the cron event. */
2077 __( 'Scheduled the cron event %s to run now. The original event will not be affected.', 'wp-crontrol' ),
2078 'success',
2079 ),
2080 MESSAGE_HOOK_DELETED_ALL => array(
2081 /* translators: %s: The name of the cron event. */
2082 __( 'Deleted all %s cron events.', 'wp-crontrol' ),
2083 'success',
2084 ),
2085 MESSAGE_EVENT_NONE_TO_DELETE => array(
2086 /* translators: %s: The name of the cron event. */
2087 __( 'There are no %s cron events to delete.', 'wp-crontrol' ),
2088 'info',
2089 ),
2090 MESSAGE_EVENT_SAVED => array(
2091 /* translators: %s: The name of the cron event. */
2092 __( 'Saved the cron event %s.', 'wp-crontrol' ),
2093 'success',
2094 ),
2095 MESSAGE_EVENT_DELETED => array(
2096 /* translators: %s: The name of the cron event. */
2097 __( 'Deleted the cron event %s.', 'wp-crontrol' ),
2098 'success',
2099 ),
2100 MESSAGE_EVENT_FAILED_TO_DELETE => array(
2101 /* translators: %s: The name of the cron event. */
2102 __( 'Failed to the delete the cron event %s.', 'wp-crontrol' ),
2103 'error',
2104 ),
2105 MESSAGE_EVENT_FAILED_TO_EXECUTE => array(
2106 /* translators: %s: The name of the cron event. */
2107 __( 'Failed to the execute the cron event %s.', 'wp-crontrol' ),
2108 'error',
2109 ),
2110 MESSAGE_EVENT_DELETED_SELECTED => array(
2111 __( 'Deleted the selected cron events.', 'wp-crontrol' ),
2112 'success',
2113 ),
2114 MESSAGE_EVENT_FAILED_TO_SAVE => array(
2115 /* translators: %s: The name of the cron event. */
2116 __( 'Failed to save the cron event %s.', 'wp-crontrol' ),
2117 'error',
2118 ),
2119 MESSAGE_HOOK_PAUSED => array(
2120 /* translators: %s: The name of the cron event. */
2121 __( 'Paused the %s hook.', 'wp-crontrol' ),
2122 'success',
2123 ),
2124 MESSAGE_HOOK_RESUMED => array(
2125 /* translators: %s: The name of the cron event. */
2126 __( 'Resumed the %s hook.', 'wp-crontrol' ),
2127 'success',
2128 ),
2129 MESSAGE_EVENT_URL_EVENT_SAVED => array(
2130 __( 'URL cron event saved.', 'wp-crontrol' ),
2131 'success',
2132 ),
2133 MESSAGE_EVENT_PHP_EVENT_SAVED => array(
2134 __( 'PHP cron event saved.', 'wp-crontrol' ),
2135 'success',
2136 ),
2137 MESSAGE_UNKNOWN_ERROR => array(
2138 __( 'An unknown error occurred.', 'wp-crontrol' ),
2139 'error',
2140 ),
2141 );
2142
2143 if ( isset( $_GET['crontrol_name'], $_GET['crontrol_message'], $messages[ $_GET['crontrol_message'] ] ) ) {
2144 $hook = wp_unslash( $_GET['crontrol_name'] );
2145 $message = intval( $_GET['crontrol_message'] );
2146
2147 if ( MESSAGE_UNKNOWN_ERROR === $message ) {
2148 $error = get_message();
2149
2150 if ( $error ) {
2151 $messages[ MESSAGE_UNKNOWN_ERROR ][0] = $error;
2152 }
2153 }
2154
2155 wp_admin_notice(
2156 sprintf(
2157 esc_html( $messages[ $message ][0] ),
2158 '<strong>' . esc_html( $hook ) . '</strong>'
2159 ),
2160 array(
2161 'id' => 'crontrol-message',
2162 'type' => $messages[ $message ][1],
2163 'dismissible' => true,
2164 )
2165 );
2166 }
2167
2168 $tabs = get_tab_states();
2169
2170 switch ( true ) {
2171 case $tabs['events']:
2172 $table = Event\get_list_table();
2173 ?>
2174 <div class="wrap">
2175 <?php do_tabs(); ?>
2176
2177 <h1 class="wp-heading-inline"><?php esc_html_e( 'Cron Events', 'wp-crontrol' ); ?></h1>
2178
2179 <?php echo '<a href="' . esc_url( admin_url( 'tools.php?page=wp-crontrol&crontrol_action=new-cron' ) ) . '" class="page-title-action">' . esc_html__( 'Add Cron Event', 'wp-crontrol' ) . '</a>'; ?>
2180
2181 <hr class="wp-header-end">
2182
2183 <?php $table->views(); ?>
2184
2185 <form id="posts-filter" method="get" action="tools.php">
2186 <input type="hidden" name="page" value="wp-crontrol" />
2187 <?php $table->search_box( esc_html__( 'Search Hook Names', 'wp-crontrol' ), 'cron-event' ); ?>
2188 </form>
2189
2190 <form method="post" action="tools.php?page=wp-crontrol">
2191 <div class="table-responsive">
2192 <?php $table->display(); ?>
2193 </div>
2194 </form>
2195
2196 <p class="description">
2197 <?php
2198 echo esc_html( sprintf(
2199 /* translators: 1: Date and time, 2: Timezone */
2200 __( 'Site time when page loaded: %1$s, %2$s', 'wp-crontrol' ),
2201 date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ),
2202 get_timezone_name()
2203 ) );
2204 ?>
2205 </p>
2206 </div>
2207 <?php
2208
2209 break;
2210
2211 case $tabs['add-event']:
2212 show_cron_form( false );
2213 break;
2214
2215 case $tabs['edit-event']:
2216 show_cron_form( true );
2217 break;
2218
2219 }
2220 }
2221
2222 /**
2223 * Get the states of the various cron-related tabs.
2224 *
2225 * @return array<string,bool> Array of states keyed by tab name.
2226 */
2227 function get_tab_states() {
2228 $tabs = array(
2229 'events' => ( ! empty( $_GET['page'] ) && 'wp-crontrol' === $_GET['page'] && empty( $_GET['crontrol_action'] ) ),
2230 'schedules' => ( ! empty( $_GET['page'] ) && 'wp-crontrol-schedules' === $_GET['page'] ),
2231 'add-event' => ( ! empty( $_GET['crontrol_action'] ) && 'new-cron' === $_GET['crontrol_action'] ),
2232 'edit-event' => ( ! empty( $_GET['crontrol_action'] ) && 'edit-cron' === $_GET['crontrol_action'] ),
2233 );
2234
2235 $tabs = apply_filters( 'crontrol/tabs', $tabs );
2236
2237 return $tabs;
2238 }
2239
2240 /**
2241 * Output the cron-related tabs if we're on a cron-related admin screen.
2242 *
2243 * @return void
2244 */
2245 function do_tabs() {
2246 $tabs = get_tab_states();
2247 $tab = array_filter( $tabs );
2248
2249 if ( ! $tab ) {
2250 return;
2251 }
2252
2253 $tab = array_keys( $tab );
2254 $tab = reset( $tab );
2255 $links = array(
2256 'events' => array(
2257 'tools.php?page=wp-crontrol',
2258 __( 'Cron Events', 'wp-crontrol' ),
2259 ),
2260 'schedules' => array(
2261 'options-general.php?page=wp-crontrol-schedules',
2262 __( 'Cron Schedules', 'wp-crontrol' ),
2263 ),
2264 );
2265
2266 $links = apply_filters( 'crontrol/links', $links );
2267
2268 ?>
2269 <div id="crontrol-header">
2270 <nav class="nav-tab-wrapper" aria-label="<?php echo esc_attr( __( 'Secondary menu', 'wp-crontrol' ) ); ?>">
2271 <?php
2272 foreach ( $links as $id => $link ) {
2273 if ( ! empty( $tabs[ $id ] ) ) {
2274 printf(
2275 '<a href="%1$s" class="nav-tab nav-tab-active" aria-current="page" id="crontrol_tab_%2$s">%3$s</a>',
2276 esc_url( $link[0] ),
2277 esc_attr( $id ),
2278 esc_html( $link[1] )
2279 );
2280 } else {
2281 printf(
2282 '<a href="%1$s" class="nav-tab" id="crontrol_tab_%2$s">%3$s</a>',
2283 esc_url( $link[0] ),
2284 esc_attr( $id ),
2285 esc_html( $link[1] )
2286 );
2287 }
2288 }
2289
2290 if ( $tabs['add-event'] ) {
2291 printf(
2292 '<span class="nav-tab nav-tab-active">%s</span>',
2293 esc_html__( 'Add Cron Event', 'wp-crontrol' )
2294 );
2295 } elseif ( $tabs['edit-event'] ) {
2296 printf(
2297 '<span class="nav-tab nav-tab-active">%s</span>',
2298 esc_html__( 'Edit Cron Event', 'wp-crontrol' )
2299 );
2300 }
2301 ?>
2302 </nav>
2303 <?php
2304 do_action( 'crontrol/tab-header', $tab, $tabs );
2305 ?>
2306 </div>
2307 <?php
2308 }
2309
2310 /**
2311 * Returns an array of the callback functions that are attached to the given hook name.
2312 *
2313 * @param string $name The hook name.
2314 * @return array<int,array<string,mixed>> Array of callbacks attached to the hook.
2315 * @phpstan-return array<int,array{
2316 * priority: int,
2317 * callback: array<string,mixed>,
2318 * }>
2319 */
2320 function get_hook_callbacks( $name ) {
2321 global $wp_filter;
2322
2323 $actions = array();
2324
2325 if ( isset( $wp_filter[ $name ] ) ) {
2326 // See http://core.trac.wordpress.org/ticket/17817.
2327 $action = $wp_filter[ $name ];
2328
2329 /**
2330 * @var int $priority
2331 */
2332 foreach ( $action as $priority => $callbacks ) {
2333 foreach ( $callbacks as $callback ) {
2334 $callback = populate_callback( $callback );
2335
2336 if ( __NAMESPACE__ . '\\pauser()' === $callback['name'] ) {
2337 continue;
2338 }
2339
2340 $actions[] = array(
2341 'priority' => $priority,
2342 'callback' => $callback,
2343 );
2344 }
2345 }
2346 }
2347
2348 return $actions;
2349 }
2350
2351 /**
2352 * Populates the details of the given callback function.
2353 *
2354 * @param array<string,mixed> $callback A callback entry.
2355 * @phpstan-param array{
2356 * function: string|array<int,mixed>|object,
2357 * accepted_args: int,
2358 * } $callback
2359 * @return array<string,mixed> The updated callback entry.
2360 */
2361 function populate_callback( array $callback ) {
2362 // If Query Monitor is installed, use its rich callback analysis.
2363 if ( method_exists( '\QM_Util', 'populate_callback' ) ) {
2364 $qm_callback = \QM_Util::populate_callback( $callback );
2365
2366 if ( ( ! isset( $qm_callback['name'] ) ) && is_object( $callback['function'] ) && is_a( $callback['function'], 'Closure' ) ) {
2367 $qm_callback['name'] = '{closure:/}';
2368 }
2369
2370 return $qm_callback;
2371 }
2372
2373 if ( is_string( $callback['function'] ) && ( false !== strpos( $callback['function'], '::' ) ) ) {
2374 $callback['function'] = explode( '::', $callback['function'] );
2375 }
2376
2377 if ( is_array( $callback['function'] ) ) {
2378 if ( is_object( $callback['function'][0] ) ) {
2379 $class = get_class( $callback['function'][0] );
2380 $access = '->';
2381 } else {
2382 $class = $callback['function'][0];
2383 $access = '::';
2384 }
2385
2386 $callback['name'] = $class . $access . $callback['function'][1] . '()';
2387 } elseif ( is_object( $callback['function'] ) ) {
2388 if ( is_a( $callback['function'], 'Closure' ) ) {
2389 try {
2390 $reflection = new \ReflectionFunction( $callback['function'] );
2391 $file = str_replace( ABSPATH, '', $reflection->getFileName() ?: '' );
2392 $line = $reflection->getStartLine();
2393
2394 $name = sprintf(
2395 /* translators: A Closure is a type of PHP function. 1: File name, 2: Line number */
2396 __( 'Closure in %1$s at line %2$d', 'wp-crontrol' ),
2397 $file,
2398 $line
2399 );
2400 } catch ( ReflectionException $e ) {
2401 $name = 'Closure';
2402 }
2403
2404 $callback['name'] = $name;
2405 } else {
2406 $class = get_class( $callback['function'] );
2407
2408 $callback['name'] = $class . '->__invoke()';
2409 }
2410 } else {
2411 $callback['name'] = $callback['function'] . '()';
2412 }
2413
2414 if ( ! method_exists( '\QM_Util', 'populate_callback' ) && ! is_callable( $callback['function'] ) ) {
2415 $callback['error'] = new WP_Error(
2416 'not_callable',
2417 sprintf(
2418 /* translators: %s: Function name */
2419 __( 'Function %s does not exist', 'wp-crontrol' ),
2420 $callback['name']
2421 )
2422 );
2423 }
2424
2425 return $callback;
2426 }
2427
2428 /**
2429 * Returns a user-friendly representation of the callback function.
2430 *
2431 * @param mixed[] $callback The callback entry.
2432 * @return string The displayable version of the callback name.
2433 */
2434 function output_callback( array $callback ) {
2435 $qm = WP_PLUGIN_DIR . '/query-monitor/query-monitor.php';
2436 $html = plugin_dir_path( $qm ) . 'output/Html.php';
2437
2438 if ( ! empty( $callback['callback']['error'] ) ) {
2439 $return = '<code>' . $callback['callback']['name'] . '</code>';
2440 $return .= '<br><span class="status-crontrol-error"><span class="dashicons dashicons-warning" aria-hidden="true"></span> ';
2441 $return .= esc_html( $callback['callback']['error']->get_error_message() );
2442 $return .= '</span>';
2443 return $return;
2444 }
2445
2446 // If Query Monitor is installed, use its rich callback output.
2447 if ( class_exists( '\QueryMonitor' ) && file_exists( $html ) ) {
2448 require_once $html;
2449
2450 if ( class_exists( '\QM_Output_Html' ) ) {
2451 return \QM_Output_Html::output_filename(
2452 $callback['callback']['name'],
2453 $callback['callback']['file'],
2454 $callback['callback']['line']
2455 );
2456 }
2457 }
2458
2459 return '<code>' . $callback['callback']['name'] . '</code>';
2460 }
2461
2462 /**
2463 * Pretty-prints the difference in two times.
2464 *
2465 * @param int $older_date Unix timestamp.
2466 * @param int $newer_date Unix timestamp.
2467 * @return string The pretty time_since value
2468 * @link http://binarybonsai.com/code/timesince.txt
2469 */
2470 function time_since( $older_date, $newer_date ) {
2471 return interval( $newer_date - $older_date );
2472 }
2473
2474 /**
2475 * Converts a period of time in seconds into a human-readable format representing the interval.
2476 *
2477 * Intervals less than an hour are displayed in minutes, and intervals less than a minute are
2478 * displayed in seconds. All intervals are displayed in the two largest units.
2479 *
2480 * The `$accurate` parameter can be used to display an interval of less than an hour in minutes and seconds.
2481 *
2482 * Example:
2483 *
2484 * echo \Crontrol\interval( 40 );
2485 * // 40 seconds
2486 * echo \Crontrol\interval( 450 );
2487 * // 7 minutes
2488 * echo \Crontrol\interval( 450, true );
2489 * // 7 minutes 30 seconds
2490 * echo \Crontrol\interval( 5678 );
2491 * // 1 hour 34 minutes
2492 *
2493 * @param int|float $since A period of time in seconds.
2494 * @param bool $accurate Whether to display the interval in minutes and seconds.
2495 * @return string An interval represented as a string.
2496 */
2497 function interval( $since, bool $accurate = false ) {
2498 // Array of time period chunks.
2499 $chunks = array(
2500 /* translators: %s: The number of years in an interval of time. */
2501 array( YEAR_IN_SECONDS, _n_noop( '%s year', '%s years', 'wp-crontrol' ) ),
2502 /* translators: %s: The number of months in an interval of time. */
2503 array( MONTH_IN_SECONDS, _n_noop( '%s month', '%s months', 'wp-crontrol' ) ),
2504 /* translators: %s: The number of weeks in an interval of time. */
2505 array( WEEK_IN_SECONDS, _n_noop( '%s week', '%s weeks', 'wp-crontrol' ) ),
2506 /* translators: %s: The number of days in an interval of time. */
2507 array( DAY_IN_SECONDS, _n_noop( '%s day', '%s days', 'wp-crontrol' ) ),
2508 /* translators: %s: The number of hours in an interval of time. */
2509 array( HOUR_IN_SECONDS, _n_noop( '%s hour', '%s hours', 'wp-crontrol' ) ),
2510 /* translators: %s: The number of minutes in an interval of time. */
2511 array( MINUTE_IN_SECONDS, _n_noop( '%s minute', '%s minutes', 'wp-crontrol' ) ),
2512 /* translators: %s: The number of seconds in an interval of time. */
2513 array( 1, _n_noop( '%s second', '%s seconds', 'wp-crontrol' ) ),
2514 );
2515
2516 if ( $since <= 0 ) {
2517 return __( 'now', 'wp-crontrol' );
2518 }
2519
2520 if ( ( ! $accurate ) && ( $since >= MINUTE_IN_SECONDS ) && ( $since < HOUR_IN_SECONDS ) ) {
2521 $num = intval( floor( $since / MINUTE_IN_SECONDS ) );
2522 return sprintf(
2523 /* translators: %s: The number of minutes in an interval of time. */
2524 _n( '%s minute', '%s minutes', $num, 'wp-crontrol' ),
2525 $num
2526 );
2527 }
2528
2529 /**
2530 * We only want to output two chunks of time here, eg:
2531 * x years, xx months
2532 * x days, xx hours
2533 * so there's only two bits of calculation below:
2534 */
2535
2536 // Step one: the first chunk.
2537 foreach ( array_keys( $chunks ) as $i ) {
2538 $seconds = $chunks[ $i ][0];
2539 $name = $chunks[ $i ][1];
2540
2541 // Finding the biggest chunk (if the chunk fits, break).
2542 $count = (int) floor( $since / $seconds );
2543 if ( $count ) {
2544 break;
2545 }
2546 }
2547
2548 // Set output var.
2549 $output = sprintf( translate_nooped_plural( $name, $count, 'wp-crontrol' ), $count );
2550
2551 // Step two: the second chunk.
2552 if ( $i + 1 < count( $chunks ) ) {
2553 $seconds2 = $chunks[ $i + 1 ][0];
2554 $name2 = $chunks[ $i + 1 ][1];
2555 $count2 = (int) floor( ( $since - ( $seconds * $count ) ) / $seconds2 );
2556 if ( $count2 ) {
2557 // Add to output var.
2558 $output .= ' ' . sprintf( translate_nooped_plural( $name2, $count2, 'wp-crontrol' ), $count2 );
2559 }
2560 }
2561
2562 return $output;
2563 }
2564
2565 /**
2566 * Sets up the Events listing screen.
2567 *
2568 * @return void
2569 */
2570 function setup_manage_page() {
2571 // Initialise the list table
2572 Event\get_list_table();
2573 }
2574
2575 /**
2576 * Registers the stylesheet and JavaScript for the admin areas.
2577 *
2578 * @param string $hook_suffix The admin screen ID.
2579 * @return void
2580 */
2581 function enqueue_assets( $hook_suffix ) {
2582 $tab = get_tab_states();
2583
2584 if ( ! array_filter( $tab ) ) {
2585 return;
2586 }
2587
2588 wp_enqueue_style(
2589 'wp-crontrol',
2590 plugin_dir_url( PLUGIN_FILE ) . 'css/wp-crontrol.css',
2591 array(
2592 'dashicons',
2593 ),
2594 WP_CRONTROL_VERSION
2595 );
2596 wp_enqueue_script(
2597 'wp-crontrol',
2598 plugin_dir_url( PLUGIN_FILE ) . 'js/wp-crontrol.js',
2599 array(),
2600 WP_CRONTROL_VERSION,
2601 true
2602 );
2603
2604 $vars = array(
2605 'confirmDeleteEvent' => __( 'Are you sure you want to delete this event?', 'wp-crontrol' ),
2606 'confirmDeleteHook' => __( 'Are you sure you want to delete all events with this hook?', 'wp-crontrol' ),
2607 );
2608
2609 if ( ! empty( $tab['add-event'] ) || ! empty( $tab['edit-event'] ) ) {
2610 if ( current_user_can_edit_php_cron_events() ) {
2611 $settings = wp_enqueue_code_editor( array(
2612 'type' => 'text/x-php',
2613 ) );
2614
2615 if ( false !== $settings ) {
2616 $vars['codeEditor'] = $settings;
2617 }
2618 }
2619 }
2620
2621 wp_localize_script( 'wp-crontrol', 'wpCrontrol', $vars );
2622 }
2623
2624 /**
2625 * Filters the list of query arguments which get removed from admin area URLs in WordPress.
2626 *
2627 * @param array<int,string> $args List of removable query arguments.
2628 * @return array<int,string> Updated list of removable query arguments.
2629 */
2630 function filter_removable_query_args( array $args ) {
2631 return array_merge( $args, array(
2632 'crontrol_message',
2633 'crontrol_name',
2634 ) );
2635 }
2636
2637 /**
2638 * Returns an array of cron event hooks that are persistently added by WordPress core.
2639 *
2640 * @return array<int,string> Array of hook names.
2641 */
2642 function get_persistent_core_hooks() {
2643 $hooks = array(
2644 'wp_update_plugins', // 2.7.0
2645 'wp_update_themes', // 2.7.0
2646 'wp_version_check', // 2.7.0
2647 'wp_scheduled_delete', // 2.9.0
2648 'update_network_counts', // 3.1.0
2649 'wp_scheduled_auto_draft_delete', // 3.4.0
2650 'delete_expired_transients', // 4.9.0
2651 'wp_privacy_delete_old_export_files', // 4.9.6
2652 'recovery_mode_clean_expired_keys', // 5.2.0
2653 'wp_site_health_scheduled_check', // 5.4.0
2654 'wp_https_detection', // 5.7.0
2655 'wp_privacy_personal_data_cleanup_requests', // 7.1.0
2656 );
2657
2658 if ( ! is_multisite() ) {
2659 $hooks[] = 'wp_update_user_counts'; // 6.0.0
2660 }
2661
2662 return $hooks;
2663 }
2664
2665 /**
2666 * Returns an array of all cron event hooks that are added by WordPress core.
2667 *
2668 * @return array<int,string> Array of hook names.
2669 */
2670 function get_all_core_hooks() {
2671 $hooks = array_merge(
2672 get_persistent_core_hooks(),
2673 array(
2674 'do_pings', // 2.1.0
2675 'publish_future_post', // 2.1.0
2676 'importer_scheduled_cleanup', // 2.5.0
2677 'upgrader_scheduled_cleanup', // 3.2.2
2678 'wp_maybe_auto_update', // 3.7.0
2679 'wp_split_shared_term_batch', // 4.3.0
2680 'wp_update_comment_type_batch', // 5.5.0
2681 'wp_delete_temp_updater_backups', // 6.3.0
2682 )
2683 );
2684
2685 if ( is_multisite() ) {
2686 $hooks[] = 'wp_update_user_counts'; // 6.0.0
2687 }
2688
2689 return $hooks;
2690 }
2691
2692 /**
2693 * Returns an array of cron schedules that are added by WordPress core.
2694 *
2695 * @return array<int,string> Array of schedule names.
2696 */
2697 function get_core_schedules() {
2698 return array(
2699 'hourly',
2700 'twicedaily',
2701 'daily',
2702 'weekly',
2703 );
2704 }
2705
2706 /**
2707 * Encodes some input as JSON for output.
2708 *
2709 * @param mixed $input The input.
2710 * @param bool $pretty Whether to pretty print the output. Default true.
2711 * @return string The JSON-encoded output.
2712 */
2713 function json_output( $input, $pretty = true ) {
2714 $json_options = JSON_UNESCAPED_SLASHES;
2715
2716 if ( $pretty ) {
2717 $json_options |= JSON_PRETTY_PRINT;
2718 }
2719
2720 $output = wp_json_encode( $input, $json_options );
2721
2722 if ( false === $output ) {
2723 $output = '';
2724 }
2725
2726 return $output;
2727 }
2728
2729 /**
2730 * Fetches the URL in a URL cron event using the HTTP API.
2731 *
2732 * If the `CRONTROL_DISALLOW_URL_EVENTS` constant is defined and set to `true` then URL cron events will be disabled
2733 * completely. Any existing URL cron events will remain in place but their URL will not be fetched, and no URL cron
2734 * events can be added, edited, or manually run. Users with permission to edit URL cron events will still be able to
2735 * delete these events.
2736 *
2737 * The URL that's saved in a URL cron event is protected with an integrity check which prevents it from being fetched
2738 * if the URL is tampered with.
2739 *
2740 * URL cron events are secured via an integrity check that makes use of an HMAC to store a hash of the URL alongside
2741 * the code when the event is saved. When the event runs, the hash is checked to ensure the integrity of the URL and
2742 * confirm that it has not been tampered with. WP Crontrol will not fetch the URL if the hashes do not match or if
2743 * a stored hash is not present.
2744 *
2745 * If an attacker with database-level access were to modify the URL in an event in an attempt to fetch an arbitrary
2746 * URL (for example to perform an SSRF), the HTTP request would not be performed.
2747 *
2748 * @link https://wp-crontrol.com/docs/url-cron-events/
2749 *
2750 * @throws \Crontrol\Exception\MissingURLException
2751 * @throws \Crontrol\Exception\MissingHashException
2752 * @throws \Crontrol\Exception\InvalidHashException
2753 * @throws \Crontrol\Exception\HTTPFailedException
2754 * @throws \Crontrol\Exception\UnexpectedHTTPCodeException
2755 *
2756 * @param ?string $url The URL to fetch.
2757 * @param string $method The HTTP method to use, defaults to 'GET'.
2758 * @param ?string $hash The stored hash to check the integrity of the URL.
2759 */
2760 function handle_url_cron_event( $url, $method, $hash ): void {
2761 if ( empty( $url ) ) {
2762 throw new MissingURLException(
2763 sprintf(
2764 'The URL is missing for a URL cron event; for more information see %s',
2765 esc_url_raw( admin_url( 'tools.php?page=wp-crontrol&crontrol_hooks_type=url' ) ),
2766 )
2767 );
2768 }
2769
2770 if ( empty( $hash ) ) {
2771 throw new MissingHashException(
2772 sprintf(
2773 'The stored hash is missing for a URL cron event; for more information see %s',
2774 esc_url_raw( admin_url( 'tools.php?page=wp-crontrol&crontrol_hooks_type=url' ) ),
2775 )
2776 );
2777 }
2778
2779 // Check the integrity of the URL.
2780 if ( ! check_integrity( $url, $hash ) ) {
2781 throw new InvalidHashException(
2782 sprintf(
2783 'The stored hash for a URL cron event is not valid; for more information see %s',
2784 esc_url_raw( admin_url( 'tools.php?page=wp-crontrol&crontrol_hooks_type=url' ) ),
2785 )
2786 );
2787 }
2788
2789 // Ensure the URL is valid before making the request.
2790 validate_url( $url );
2791
2792 $request_args = array(
2793 'timeout' => 30,
2794 'method' => $method,
2795 'user-agent' => sprintf(
2796 'WP Crontrol; %s',
2797 home_url( '/' )
2798 ),
2799 );
2800 $response = wp_safe_remote_request( $url, $request_args );
2801
2802 if ( is_wp_error( $response ) ) {
2803 throw new HTTPFailedException(
2804 sprintf(
2805 'Failed to fetch URL %s: %s',
2806 $url,
2807 $response->get_error_message()
2808 )
2809 );
2810 }
2811
2812 $code = wp_remote_retrieve_response_code( $response );
2813 $message = wp_remote_retrieve_response_message( $response );
2814
2815 if ( $code < 200 || $code >= 300 ) {
2816 throw new UnexpectedHTTPCodeException(
2817 sprintf(
2818 'Unexpected response code for URL %s: HTTP %s %s',
2819 $url,
2820 $code,
2821 $message
2822 )
2823 );
2824 }
2825 }
2826
2827 /**
2828 * Action handler for URL cron events.
2829 *
2830 * @see \Crontrol\handle_url_cron_event()
2831 *
2832 * @param array<string,string> $args The event args array.
2833 * @phpstan-param array{
2834 * url?: string,
2835 * name?: string,
2836 * method?: string,
2837 * hash?: string,
2838 * } $args
2839 */
2840 function action_url_cron_event( array $args ): void {
2841 if ( ! url_cron_events_enabled() ) {
2842 return;
2843 }
2844
2845 $url = $args['url'] ?? null;
2846 $method = $args['method'] ?? 'GET';
2847 $hash = $args['hash'] ?? null;
2848
2849 try {
2850 handle_url_cron_event( $url, $method, $hash );
2851 } catch ( CrontrolRuntimeException $e ) {
2852 // phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_trigger_error,WordPress.Security.EscapeOutput.OutputNotEscaped
2853 trigger_error(
2854 sprintf(
2855 /* translators: %s: Message text. */
2856 __( 'WP Crontrol: %s', 'wp-crontrol' ),
2857 $e->getMessage()
2858 ),
2859 E_USER_WARNING
2860 );
2861 // phpcs:enable WordPress.PHP.DevelopmentFunctions.error_log_trigger_error,WordPress.Security.EscapeOutput.OutputNotEscaped
2862 }
2863 }
2864
2865 /**
2866 * Evaluates the code in a PHP cron event using eval.
2867 *
2868 * Security: Only users with the `edit_files` capability can manage PHP cron events. This means if a user cannot edit
2869 * files on the site (eg. through the Plugin Editor or Theme Editor) then they cannot edit or add a PHP cron event. By
2870 * default, only Administrators have this capability, and with Multisite enabled only Super Admins have this capability.
2871 *
2872 * If file editing has been disabled via the `DISALLOW_FILE_MODS` or `DISALLOW_FILE_EDIT` configuration constants then
2873 * no user will have the `edit_files` capability, which means editing or adding a PHP cron event will not be permitted.
2874 *
2875 * Therefore, the user access level required to execute arbitrary PHP code does not change with WP Crontrol activated.
2876 *
2877 * If the `CRONTROL_DISALLOW_PHP_EVENTS` constant is defined and set to `true`, then PHP cron events will be disabled
2878 * completely. Any existing PHP cron events will remain in place (and can be deleted if user permissions allow) but their
2879 * PHP code will not be executed when the event runs, and no PHP cron events can be added, edited, or run.
2880 *
2881 * The PHP code that's saved in a PHP cron event is protected with an integrity check which prevents it from being executed
2882 * if the code is tampered with.
2883 *
2884 * PHP cron events are secured via an integrity check that makes use of an HMAC to store a hash of the PHP code alongside
2885 * the code when the event is saved. When the event runs, the hash is checked to ensure the integrity of the PHP code and
2886 * confirm that it has not been tampered with. WP Crontrol will not execute the PHP code if the hashes do not match or if
2887 * a stored hash is not present.
2888 *
2889 * If an attacker with database-level access were to modify the PHP code in an event in an attempt to execute arbitrary
2890 * code, the code would no longer execute.
2891 *
2892 * @link https://wp-crontrol.com/docs/php-cron-events/
2893 *
2894 * @throws \Crontrol\Exception\MissingHashException
2895 * @throws \Crontrol\Exception\InvalidHashException
2896 *
2897 * @param ?string $code The PHP code to evaluate.
2898 * @param ?string $hash The stored hash to check the integrity of the PHP code.
2899 */
2900 function handle_php_cron_event( $code, $hash ): void {
2901 if ( empty( $hash ) ) {
2902 throw new MissingHashException(
2903 sprintf(
2904 'The stored hash is missing for a PHP cron event; for more information see %s',
2905 esc_url_raw( admin_url( 'tools.php?page=wp-crontrol&crontrol_hooks_type=php' ) ),
2906 ),
2907 );
2908 }
2909
2910 // Check the integrity of the PHP code.
2911 if ( ! check_integrity( $code, $hash ) ) {
2912 throw new InvalidHashException(
2913 sprintf(
2914 'The stored hash for a PHP cron event is not valid; for more information see %s',
2915 esc_url_raw( admin_url( 'tools.php?page=wp-crontrol&crontrol_hooks_type=php' ) ),
2916 ),
2917 );
2918 }
2919
2920 // Please see the function description above for information about the safety of this code.
2921 // phpcs:ignore Squiz.PHP.Eval.Discouraged
2922 eval( $code );
2923 }
2924
2925 /**
2926 * Action handler for PHP cron events.
2927 *
2928 * @see \Crontrol\handle_php_cron_event()
2929 *
2930 * @param array<string,string>|string $args The event args array, or a string containing the PHP code to evaluate.
2931 * @phpstan-param array{
2932 * code?: string,
2933 * name?: string,
2934 * hash?: string,
2935 * }|string $args
2936 */
2937 function action_php_cron_event( $args ): void {
2938 if ( ! php_cron_events_enabled() ) {
2939 return;
2940 }
2941
2942 if ( is_string( $args ) ) {
2943 // Prior to WP Crontrol 1.16.2, PHP cron events were saved with the associative arguments array at the top
2944 // level. This means arguments are passed as individual parameters to this function and the first parameter
2945 // contains the PHP code.
2946 $code = $args;
2947 $hash = null;
2948 } else {
2949 // Since WP Crontrol 1.16.2, PHP cron events are stored with the associative arguments array as the first element
2950 // in the args list. This means arguments are passed as a single associative array parameter to this function.
2951 $code = $args['code'] ?? null;
2952 $hash = $args['hash'] ?? null;
2953 }
2954
2955 try {
2956 handle_php_cron_event( $code, $hash );
2957 } catch ( CrontrolRuntimeException $e ) {
2958 // phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_trigger_error,WordPress.Security.EscapeOutput.OutputNotEscaped
2959 trigger_error(
2960 sprintf(
2961 /* translators: %s: Message text. */
2962 __( 'WP Crontrol: %s', 'wp-crontrol' ),
2963 $e->getMessage()
2964 ),
2965 E_USER_WARNING
2966 );
2967 // phpcs:enable WordPress.PHP.DevelopmentFunctions.error_log_trigger_error,WordPress.Security.EscapeOutput.OutputNotEscaped
2968 }
2969 }
2970
2971 /**
2972 * Returns whether PHP cron events are enabled.
2973 *
2974 * The PHP cron event functionality can be disabled by defining the `CRONTROL_DISALLOW_PHP_EVENTS` constant and setting
2975 * its value to `true`. This constant can be defined in the `wp-config.php` file.
2976 */
2977 function php_cron_events_enabled(): bool {
2978 if ( defined( 'CRONTROL_DISALLOW_PHP_EVENTS' ) && CRONTROL_DISALLOW_PHP_EVENTS ) {
2979 return false;
2980 }
2981
2982 return true;
2983 }
2984
2985 /**
2986 * Returns whether PHP cron events are enabled and can be edited by the current user.
2987 */
2988 function current_user_can_edit_php_cron_events(): bool {
2989 return ( php_cron_events_enabled() && current_user_can( 'edit_files' ) );
2990 }
2991
2992 /**
2993 * Returns whether URL cron events are enabled.
2994 *
2995 * The URL cron event functionality can be disabled by defining the `CRONTROL_DISALLOW_URL_EVENTS` constant and setting
2996 * its value to `true`. This constant can be defined in the `wp-config.php` file.
2997 */
2998 function url_cron_events_enabled(): bool {
2999 if ( defined( 'CRONTROL_DISALLOW_URL_EVENTS' ) && CRONTROL_DISALLOW_URL_EVENTS ) {
3000 return false;
3001 }
3002
3003 return true;
3004 }
3005
3006 /**
3007 * Returns whether URL cron events are enabled and can be edited by the current user.
3008 */
3009 function current_user_can_edit_url_cron_events(): bool {
3010 return ( url_cron_events_enabled() && current_user_can( 'manage_options' ) );
3011 }
3012