> $schedules Existing schedules. * * @return array> Modified schedules. */ function wpvulnerability_add_every_six_hours( $schedules ) { $label = did_action( 'init' ) ? __( 'Every 6 hours', 'wpvulnerability' ) : 'Every 6 hours'; $schedules['wpvulnerability_six_hours'] = array( 'interval' => 6 * HOUR_IN_SECONDS, 'display' => $label, ); return $schedules; } // Add weekly and daily schedules used by the notification cron. // Registered here because this file is always loaded, while the notification // scheduling that resolves them runs on init for every request type. add_filter( 'cron_schedules', 'wpvulnerability_add_every_week' ); add_filter( 'cron_schedules', 'wpvulnerability_add_every_day' ); /** * Registers a custom weekly cron schedule. * * @since 2.0.0 * * @param array> $schedules Existing schedules. * @return array> Schedules with the weekly interval added. */ function wpvulnerability_add_every_week( $schedules ) { $schedules['weekly'] = array( 'interval' => 604800, 'display' => did_action( 'init' ) ? __( 'Every week', 'wpvulnerability' ) : 'Every week', ); return $schedules; } /** * Registers a custom daily cron schedule. * * @since 2.0.0 * * @param array> $schedules Existing schedules. * @return array> Schedules with the daily interval added. */ function wpvulnerability_add_every_day( $schedules ) { $schedules['daily'] = array( 'interval' => 86400, 'display' => did_action( 'init' ) ? __( 'Every day', 'wpvulnerability' ) : 'Every day', ); return $schedules; } // Remove legacy scheduled events on subsites in multisite installs. if ( is_multisite() && ! is_main_site() ) { wpvulnerability_clear_plugin_cron_hooks(); } // Schedule the automatic vulnerability database updates on init. Scheduling at // file scope called wp_get_schedules() before init, firing other plugins' // cron_schedules callbacks (and their __() calls) too early on WordPress 6.7+. add_action( 'init', 'wpvulnerability_schedule_core_events', 20 ); // Hook the event to the function that updates the database. add_action( 'wpvulnerability_update_database', 'wpvulnerability_update_database_data' ); /** * Calculate the next notification timestamp based on plugin settings. * * @since 4.1.1 * * @param array $config Plugin configuration. * @return int Timestamp for next notification. */ function wpvulnerability_get_next_notification_timestamp( $config ) { $hour_raw = $config['hour'] ?? 0; $hour = is_numeric( $hour_raw ) ? max( 0, min( 23, (int) $hour_raw ) ) : 0; $min_raw = $config['minute'] ?? 0; $minute = is_numeric( $min_raw ) ? max( 0, min( 59, (int) $min_raw ) ) : 0; $timezone = wp_timezone(); $current_time = new DateTime( 'now', $timezone ); $scheduled_time = new DateTime( 'now', $timezone ); $scheduled_time->setTime( $hour, $minute, 0 ); if ( isset( $config['period'] ) && 'weekly' === $config['period'] ) { $day_raw = $config['day'] ?? 'monday'; $day = is_scalar( $day_raw ) ? strtolower( (string) $day_raw ) : 'monday'; $weekdays = array( 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday' ); $day_index = array_search( $day, $weekdays, true ); if ( false === $day_index ) { $day_index = 1; // Monday. } while ( (int) $scheduled_time->format( 'w' ) !== $day_index || $scheduled_time->getTimestamp() <= $current_time->getTimestamp() ) { $scheduled_time->modify( '+1 day' ); } } elseif ( $scheduled_time->getTimestamp() <= $current_time->getTimestamp() ) { $scheduled_time->modify( '+1 day' ); } return (int) $scheduled_time->getTimestamp(); } /** * Schedule vulnerability notifications. * * When $force is false, the function first checks the current cron schedule * for the notification hook and returns early if it already matches the * desired period from $config. This avoids rewriting the wp_options 'cron' * row on every page load when the schedule is already correct. * * Callers that change notification settings (period, hour, minute, day) must * keep $force as true so the event is rescheduled with the new timestamp. * * @since 4.1.1 * * @param array $config Plugin configuration. * @param bool $force Whether to reschedule unconditionally. Defaults to true. * * @return void */ function wpvulnerability_schedule_notification_event( $config, $force = true ) { if ( ! $force ) { $desired = ''; if ( isset( $config['period'] ) && in_array( $config['period'], array( 'daily', 'weekly' ), true ) && ( ! is_multisite() || is_main_site() ) ) { $desired = (string) $config['period']; // Narrowed to 'daily'|'weekly' by in_array check above. } $current = wp_get_schedule( 'wpvulnerability_notification' ); if ( false === $current ) { $current = ''; } if ( $current === $desired ) { return; } } wp_clear_scheduled_hook( 'wpvulnerability_notification' ); if ( ! isset( $config['period'] ) || 'never' === $config['period'] ) { return; } if ( ! is_multisite() || is_main_site() ) { $period_val = $config['period']; $period_str = is_scalar( $period_val ) ? (string) $period_val : ''; // Whitelist the period: a corrupted value would make the event unschedulable. if ( ! in_array( $period_str, array( 'daily', 'weekly' ), true ) ) { return; } $timestamp = wpvulnerability_get_next_notification_timestamp( $config ); wp_schedule_event( $timestamp, $period_str, 'wpvulnerability_notification' ); } } add_action( 'wpvulnerability_notification', 'wpvulnerability_execute_notification' ); // @phpstan-ignore return.void (function returns bool but action callbacks are not required to be void) /** * Schedules the notification event on init. * * Runs at init so wp_get_schedules() is never called before translations can load. * * @since 5.1.3 * * @return void */ function wpvulnerability_schedule_notification_boot() { $config_raw = is_multisite() ? get_site_option( 'wpvulnerability-config' ) : get_option( 'wpvulnerability-config' ); $config = is_array( $config_raw ) ? $config_raw : array(); wpvulnerability_schedule_notification_event( $config, false ); } add_action( 'init', 'wpvulnerability_schedule_notification_boot', 20 ); /** * Returns the WPVulnerability cron hooks. * * @since 4.3.0 * * @return array List of cron hooks belonging to the plugin. */ function wpvulnerability_get_plugin_cron_hooks() { return array( 'wpvulnerability_update_database', 'wpvulnerability_cleanup_logs', 'wpvulnerability_notification', ); } /** * Determines the schedule slug for database updates based on cache hours. * * @since 4.3.0 * * @return string Schedule identifier. */ function wpvulnerability_get_update_schedule_slug() { $cache_hours = wpvulnerability_cache_hours(); $mapping = array( 1 => 'hourly', 6 => 'wpvulnerability_six_hours', 12 => 'twicedaily', 24 => 'daily', ); $schedule = isset( $mapping[ $cache_hours ] ) ? $mapping[ $cache_hours ] : 'twicedaily'; $schedules = function_exists( 'wp_get_schedules' ) ? wp_get_schedules() : array(); if ( ! isset( $schedules[ $schedule ] ) ) { return 'twicedaily'; } return $schedule; } /** * Retrieves the main site ID with backwards compatibility. * * @since 4.3.0 * * @return int Main site ID. */ function wpvulnerability_get_main_site_id() { if ( function_exists( 'get_main_site_id' ) ) { return (int) get_main_site_id(); } $current_site = get_current_site(); if ( isset( $current_site->blog_id ) ) { return (int) $current_site->blog_id; } return (int) get_current_blog_id(); } /** * Clears all WPVulnerability cron hooks for the current site. * * @since 4.3.0 * * @return void */ function wpvulnerability_clear_plugin_cron_hooks() { $hooks = wpvulnerability_get_plugin_cron_hooks(); foreach ( $hooks as $hook ) { wp_clear_scheduled_hook( $hook ); } } /** * Schedules core WPVulnerability cron events for the current site. * * @since 4.3.0 * * @return void */ function wpvulnerability_schedule_core_events() { if ( is_multisite() && ! is_main_site() ) { return; } $update_schedule = wpvulnerability_get_update_schedule_slug(); $current_schedule = wp_get_schedule( 'wpvulnerability_update_database' ); if ( $current_schedule !== $update_schedule ) { wp_clear_scheduled_hook( 'wpvulnerability_update_database' ); wp_schedule_event( time(), $update_schedule, 'wpvulnerability_update_database' ); } if ( ! wp_next_scheduled( 'wpvulnerability_cleanup_logs' ) ) { wp_schedule_event( time(), 'daily', 'wpvulnerability_cleanup_logs' ); } } /** * Returns the notification schedule string based on settings. * * @since 4.3.0 * * @param array $config Plugin configuration. * * @return string Notification schedule name or empty string when disabled. */ function wpvulnerability_get_notification_schedule_from_config( $config ) { if ( ! isset( $config['period'] ) ) { return ''; } $period_raw = $config['period']; $period = is_scalar( $period_raw ) ? strtolower( trim( (string) $period_raw ) ) : ''; if ( ! in_array( $period, array( 'daily', 'weekly' ), true ) ) { return ''; } return $period; } /** * Builds the list of expected cron events for the current site. * * @since 4.3.0 * * @param array|mixed $config Plugin configuration. * @param bool $is_main_site Whether the current site is the main site on a multisite network. * * @return array> Expected cron events. */ function wpvulnerability_get_expected_cron_events( $config, $is_main_site ) { if ( ! is_array( $config ) ) { $config = is_multisite() ? get_site_option( 'wpvulnerability-config' ) : get_option( 'wpvulnerability-config' ); if ( ! is_array( $config ) ) { $config = array(); } } $expect_main_site = ( ! is_multisite() || $is_main_site ); $update_schedule = wpvulnerability_get_update_schedule_slug(); $expected_events = array( array( 'hook' => 'wpvulnerability_update_database', 'schedule' => $update_schedule, 'should_exist' => $expect_main_site, 'label' => __( 'Database updates', 'wpvulnerability' ), ), array( 'hook' => 'wpvulnerability_cleanup_logs', 'schedule' => 'daily', 'should_exist' => $expect_main_site, 'label' => __( 'Log cleanup', 'wpvulnerability' ), ), ); $notification_schedule = ''; if ( $expect_main_site ) { $notification_schedule = wpvulnerability_get_notification_schedule_from_config( $config ); } $expected_events[] = array( 'hook' => 'wpvulnerability_notification', 'schedule' => $notification_schedule, 'should_exist' => ( '' !== $notification_schedule ), 'label' => __( 'Notifications', 'wpvulnerability' ), ); return $expected_events; } /** * Collects scheduled WPVulnerability cron entries for the current site. * * Uses only public WordPress Cron API functions (wp_next_scheduled and * wp_get_schedule) to avoid relying on the private _get_cron_array() function. * As a result, only hooks registered by wpvulnerability_get_plugin_cron_hooks() * are inspected; duplicate-instance detection is not performed. * * @since 4.3.0 * @since 5.0.0 Replaced _get_cron_array() with wp_next_scheduled()/wp_get_schedule(). * * @return array> Scheduled cron entries. */ function wpvulnerability_get_cron_snapshot() { $hooks = wpvulnerability_get_plugin_cron_hooks(); $events = array(); foreach ( $hooks as $hook ) { $timestamp = wp_next_scheduled( $hook ); if ( false === $timestamp ) { continue; } $schedule = wp_get_schedule( $hook ); $events[] = array( 'hook' => $hook, 'timestamp' => (int) $timestamp, 'schedule' => is_string( $schedule ) ? sanitize_key( $schedule ) : '', ); } return $events; } /** * Builds a status report comparing expected and actual cron events. * * @since 4.3.0 * * @param array $config Plugin configuration. * @param bool $is_main_site Whether the current site is the main site on a multisite network. * * @return array>> Report including expected rows and unexpected hooks. */ function wpvulnerability_get_cron_status( $config, $is_main_site ) { $expected = wpvulnerability_get_expected_cron_events( $config, $is_main_site ); $snapshot = wpvulnerability_get_cron_snapshot(); $expected_rows = array(); $extra_events = array(); foreach ( $expected as $item ) { $hook_raw = $item['hook'] ?? ''; $hook = is_scalar( $hook_raw ) ? (string) $hook_raw : ''; $expected_rows[ $hook ] = $item; $expected_rows[ $hook ]['schedules_found'] = array(); $expected_rows[ $hook ]['next_run'] = null; $expected_rows[ $hook ]['count'] = 0; $expected_rows[ $hook ]['messages'] = array(); } foreach ( $snapshot as $event ) { $hook_raw = $event['hook'] ?? ''; $hook = is_scalar( $hook_raw ) ? (string) $hook_raw : ''; $ts_raw = $event['timestamp'] ?? 0; $timestamp = is_numeric( $ts_raw ) ? (int) $ts_raw : 0; $sched_raw = $event['schedule'] ?? ''; $schedule = is_scalar( $sched_raw ) ? (string) $sched_raw : ''; if ( isset( $expected_rows[ $hook ] ) ) { ++$expected_rows[ $hook ]['count']; if ( null === $expected_rows[ $hook ]['next_run'] || $timestamp < $expected_rows[ $hook ]['next_run'] ) { $expected_rows[ $hook ]['next_run'] = $timestamp; } if ( '' !== $schedule ) { $expected_rows[ $hook ]['schedules_found'][ $schedule ] = true; } continue; } if ( 0 === strpos( $hook, 'wpvulnerability_' ) ) { if ( ! isset( $extra_events[ $hook ] ) ) { $extra_events[ $hook ] = array( 'hook' => $hook, 'count' => 0, 'next_run' => null, 'schedules' => array(), ); } ++$extra_events[ $hook ]['count']; if ( null === $extra_events[ $hook ]['next_run'] || $timestamp < $extra_events[ $hook ]['next_run'] ) { $extra_events[ $hook ]['next_run'] = $timestamp; } if ( '' !== $schedule ) { $extra_events[ $hook ]['schedules'][ $schedule ] = true; } } } foreach ( $expected_rows as $hook => $row ) { $sched_raw = $row['schedule'] ?? ''; $expected_schedule = is_scalar( $sched_raw ) ? (string) $sched_raw : ''; $should_exist = isset( $row['should_exist'] ) ? (bool) $row['should_exist'] : false; $found_schedules = array_keys( $row['schedules_found'] ); if ( ! $should_exist ) { if ( $row['count'] > 0 ) { if ( 'wpvulnerability_notification' === $hook ) { $row['status'] = 'needs_attention'; $row['messages'][] = __( 'Notifications are scheduled, but settings currently disable them. Please review the notifications tab.', 'wpvulnerability' ); } else { $row['status'] = 'unexpected'; $row['messages'][] = __( 'This event should not be scheduled for this site.', 'wpvulnerability' ); } } else { $row['status'] = 'not_expected'; $row['messages'][] = __( 'Not expected for this site.', 'wpvulnerability' ); } } elseif ( 0 === $row['count'] ) { $row['status'] = 'missing'; $row['messages'][] = __( 'No instances found.', 'wpvulnerability' ); } else { $mismatched_schedule = ( '' !== $expected_schedule && ! in_array( $expected_schedule, $found_schedules, true ) ); $duplicate_events = ( $row['count'] > 1 ); if ( $mismatched_schedule ) { $row['messages'][] = __( 'Scheduled with an unexpected interval.', 'wpvulnerability' ); } if ( $duplicate_events ) { $row['messages'][] = __( 'Multiple instances detected.', 'wpvulnerability' ); } if ( empty( $row['messages'] ) ) { $row['status'] = 'ok'; $row['messages'][] = __( 'Scheduled as expected.', 'wpvulnerability' ); } else { $row['status'] = 'needs_attention'; } } $row['schedules_found'] = $found_schedules; $expected_rows[ $hook ] = $row; } foreach ( $extra_events as $hook => $row ) { $extra_events[ $hook ]['schedules'] = array_keys( $row['schedules'] ); } return array( 'expected' => array_values( $expected_rows ), 'unexpected' => array_values( $extra_events ), ); } /** * Repairs WPVulnerability cron events for the current site. * * @since 4.3.0 * * @param array $config Plugin configuration. * * @return void */ function wpvulnerability_repair_cron_events( $config ) { wpvulnerability_clear_plugin_cron_hooks(); wpvulnerability_schedule_core_events(); wpvulnerability_schedule_notification_event( $config ); } /** * Repairs WPVulnerability cron events across all sites in a network. * * @since 4.3.0 * * @param array $config Plugin configuration. * * @return void */ function wpvulnerability_repair_network_cron_events( $config ) { if ( ! is_multisite() ) { return; } $sites = get_sites( array( 'fields' => 'ids', ) ); if ( empty( $sites ) ) { return; } $main_site_id = wpvulnerability_get_main_site_id(); $current_blog_id = get_current_blog_id(); $sanitized_config = $config; foreach ( $sites as $site_id ) { switch_to_blog( (int) $site_id ); wpvulnerability_clear_plugin_cron_hooks(); if ( (int) $site_id === (int) $main_site_id ) { wpvulnerability_schedule_core_events(); wpvulnerability_schedule_notification_event( $sanitized_config ); } restore_current_blog(); } if ( get_current_blog_id() !== $current_blog_id ) { switch_to_blog( $current_blog_id ); } }