wpvulnerability/wpvulnerability-schedule.php
2026-07-09 06:28:55 +00:00

585 lines
18 KiB
PHP

<?php
/**
* Scheduling functions
*
* @package WPVulnerability
*
* @version 4.3.2
*/
defined( 'ABSPATH' ) || die( 'No script kiddies please!' );
// Ensure shared helpers are available.
if ( ! function_exists( 'wpvulnerability_cache_hours' ) ) {
require_once WPVULNERABILITY_PLUGIN_PATH . '/wpvulnerability-general.php';
}
// Add a 6-hour schedule used when cache duration is set to six hours.
add_filter( 'cron_schedules', 'wpvulnerability_add_every_six_hours' );
/**
* Registers a custom 6-hour cron schedule.
*
* @since 4.3.0
*
* @param array<string, array<string, int|string>> $schedules Existing schedules.
*
* @return array<string, array<string, int|string>> 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.
// These must be registered in this file (always loaded pre-init) so that the
// on-load notification scheduling at the bottom of this file can resolve the
// 'weekly' schedule before notifications.php is loaded at init.
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<string, array<string, int|string>> $schedules Existing schedules.
* @return array<string, array<string, int|string>> 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<string, array<string, int|string>> $schedules Existing schedules.
* @return array<string, array<string, int|string>> 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 Automatic Vulnerability Database Update.
* If the 'wpvulnerability_update_database' event is not already scheduled, schedule it to run twice daily.
*
* @since 2.0.0
*
* @return void
*/
wpvulnerability_schedule_core_events();
// 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<string, mixed> $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<string, mixed> $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 : '';
$timestamp = wpvulnerability_get_next_notification_timestamp( $config );
wp_schedule_event( $timestamp, $period_str, 'wpvulnerability_notification' );
}
}
$wpvulnerability_s_raw = is_multisite() ? get_site_option( 'wpvulnerability-config' ) : get_option( 'wpvulnerability-config' );
$wpvulnerability_s = is_array( $wpvulnerability_s_raw ) ? $wpvulnerability_s_raw : array();
wpvulnerability_schedule_notification_event( $wpvulnerability_s, false );
add_action( 'wpvulnerability_notification', 'wpvulnerability_execute_notification' ); // @phpstan-ignore return.void (function returns bool but action callbacks are not required to be void)
unset( $wpvulnerability_s );
/**
* Returns the WPVulnerability cron hooks.
*
* @since 4.3.0
*
* @return array<string> 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<mixed> $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<string, mixed>|mixed $config Plugin configuration.
* @param bool $is_main_site Whether the current site is the main site on a multisite network.
*
* @return array<int, array<string, mixed>> 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<int, array<string, mixed>> 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<string, mixed> $config Plugin configuration.
* @param bool $is_main_site Whether the current site is the main site on a multisite network.
*
* @return array<string, array<int, array<string, mixed>>> 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<string, mixed> $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<string, mixed> $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 );
}
}