| 1 |
<?php |
| 2 |
/** |
| 3 |
* Automatic Scanning scheduler. |
| 4 |
* |
| 5 |
* Registers and maintains the recurring WP-Cron event that drives unattended |
| 6 |
* scans. Free ships the Monthly frequency; Pro adds Weekly to the allowlist. |
| 7 |
* The allowlist is enforced server-side so a Free user can never run Weekly. |
| 8 |
* |
| 9 |
* @package SureCookie\Inc\Modules\AutomaticScanning |
| 10 |
* @since 1.2.0 |
| 11 |
*/ |
| 12 |
|
| 13 |
namespace SureCookie\Inc\Modules\AutomaticScanning; |
| 14 |
|
| 15 |
use SureCookie\Inc\Functions\Settings; |
| 16 |
use SureCookie\Inc\Traits\GetInstance; |
| 17 |
|
| 18 |
if ( ! defined( 'ABSPATH' ) ) { |
| 19 |
exit; // Exit if accessed directly. |
| 20 |
} |
| 21 |
|
| 22 |
/** |
| 23 |
* Scheduler |
| 24 |
* |
| 25 |
* @since 1.2.0 |
| 26 |
*/ |
| 27 |
class Scheduler { |
| 28 |
use GetInstance; |
| 29 |
|
| 30 |
/** |
| 31 |
* Recurring cron hook that runs an automatic scan. |
| 32 |
* |
| 33 |
* @since 1.2.0 |
| 34 |
*/ |
| 35 |
public const RUN_HOOK = 'surecookie_auto_scan_run'; |
| 36 |
|
| 37 |
/** |
| 38 |
* Custom cron schedule key for the monthly cadence. |
| 39 |
* |
| 40 |
* @since 1.2.0 |
| 41 |
*/ |
| 42 |
private const MONTHLY_SCHEDULE = 'surecookie_monthly'; |
| 43 |
|
| 44 |
/** |
| 45 |
* Custom cron schedule key for the weekly cadence (Pro frequency). |
| 46 |
* |
| 47 |
* @since 1.2.0 |
| 48 |
*/ |
| 49 |
private const WEEKLY_SCHEDULE = 'surecookie_weekly'; |
| 50 |
|
| 51 |
/** |
| 52 |
* Constructor. |
| 53 |
* |
| 54 |
* @since 1.2.0 |
| 55 |
*/ |
| 56 |
private function __construct() { |
| 57 |
add_filter( 'cron_schedules', [ $this, 'register_schedules' ] ); // phpcs:ignore WordPress.WP.CronInterval.ChangeDetected -- Weekly/monthly cadences are well above the 15-minute floor. |
| 58 |
|
| 59 |
// Reschedule the moment settings are saved - this fires after the option is |
| 60 |
// written, so it reconciles against the new frequency. admin_init is a |
| 61 |
// self-heal fallback (re-creates a lost event); scheduling is admin-driven, |
| 62 |
// so no front-end reconcile is needed. |
| 63 |
add_action( 'update_option_' . SURECOOKIE_SETTINGS_OPTION, [ $this, 'maybe_schedule' ] ); |
| 64 |
add_action( 'admin_init', [ $this, 'maybe_schedule' ] ); |
| 65 |
|
| 66 |
// Enforce the frequency allowlist server-side so a disallowed value (e.g. Weekly on Free) reverts to Monthly on save. |
| 67 |
add_filter( 'pre_update_option_' . SURECOOKIE_SETTINGS_OPTION, [ $this, 'gate_frequency' ] ); |
| 68 |
} |
| 69 |
|
| 70 |
/** |
| 71 |
* Register the weekly and monthly cron intervals (not provided by core). |
| 72 |
* |
| 73 |
* @param array<string, array{interval:int, display:string}> $schedules Existing schedules. |
| 74 |
* @since 1.2.0 |
| 75 |
* @return array<string, array{interval:int, display:string}> |
| 76 |
*/ |
| 77 |
public function register_schedules( $schedules = [] ) { |
| 78 |
if ( ! is_array( $schedules ) ) { |
| 79 |
$schedules = []; |
| 80 |
} |
| 81 |
|
| 82 |
if ( ! isset( $schedules[ self::MONTHLY_SCHEDULE ] ) ) { |
| 83 |
$schedules[ self::MONTHLY_SCHEDULE ] = [ |
| 84 |
'interval' => MONTH_IN_SECONDS, |
| 85 |
'display' => __( 'Once Monthly (SureCookie)', 'surecookie' ), |
| 86 |
]; |
| 87 |
} |
| 88 |
|
| 89 |
if ( ! isset( $schedules[ self::WEEKLY_SCHEDULE ] ) ) { |
| 90 |
$schedules[ self::WEEKLY_SCHEDULE ] = [ |
| 91 |
'interval' => WEEK_IN_SECONDS, |
| 92 |
'display' => __( 'Once Weekly (SureCookie)', 'surecookie' ), |
| 93 |
]; |
| 94 |
} |
| 95 |
|
| 96 |
return $schedules; |
| 97 |
} |
| 98 |
|
| 99 |
/** |
| 100 |
* Allowed scan frequencies. Pro adds 'weekly' only when licensed. |
| 101 |
* |
| 102 |
* @since 1.2.0 |
| 103 |
* @return array<int, string> |
| 104 |
*/ |
| 105 |
public static function allowed_frequencies(): array { |
| 106 |
/** |
| 107 |
* Filters the scan frequencies available for Automatic Scanning. |
| 108 |
* |
| 109 |
* Free exposes only Monthly. Pro adds 'weekly' (when licensed) - the |
| 110 |
* Weekly option is otherwise locked in the UI and rejected on save. |
| 111 |
* |
| 112 |
* @since 1.2.0 |
| 113 |
* @param array<int, string> $frequencies Allowed frequency keys. |
| 114 |
*/ |
| 115 |
$allowed = apply_filters( 'surecookie_auto_scan_frequencies', [ 'monthly' ] ); |
| 116 |
|
| 117 |
if ( ! is_array( $allowed ) || empty( $allowed ) ) { |
| 118 |
return [ 'monthly' ]; |
| 119 |
} |
| 120 |
|
| 121 |
return array_values( array_unique( array_map( 'strval', $allowed ) ) ); |
| 122 |
} |
| 123 |
|
| 124 |
/** |
| 125 |
* Resolve the effective frequency, clamped to the allowlist. |
| 126 |
* |
| 127 |
* @since 1.2.0 |
| 128 |
* @return string |
| 129 |
*/ |
| 130 |
public static function effective_frequency(): string { |
| 131 |
$frequency = (string) Settings::get( 'auto_scan_frequency' ); |
| 132 |
$allowed = self::allowed_frequencies(); |
| 133 |
|
| 134 |
return in_array( $frequency, $allowed, true ) ? $frequency : ( $allowed[0] ?? 'monthly' ); |
| 135 |
} |
| 136 |
|
| 137 |
/** |
| 138 |
* Ensure the recurring event matches the current settings. |
| 139 |
* |
| 140 |
* @since 1.2.0 |
| 141 |
* @return void |
| 142 |
*/ |
| 143 |
public function maybe_schedule(): void { |
| 144 |
if ( ! (bool) Settings::get( 'auto_scan_enabled' ) ) { |
| 145 |
$this->unschedule(); |
| 146 |
return; |
| 147 |
} |
| 148 |
|
| 149 |
$desired = $this->schedule_key( self::effective_frequency() ); |
| 150 |
$current = wp_get_schedule( self::RUN_HOOK ); |
| 151 |
|
| 152 |
if ( $current === $desired ) { |
| 153 |
return; // Already scheduled with the correct recurrence. |
| 154 |
} |
| 155 |
|
| 156 |
// Recurrence changed (or not scheduled) - reset to the desired cadence, |
| 157 |
// anchored to an off-peak night slot in the site's timezone. |
| 158 |
$this->unschedule(); |
| 159 |
wp_schedule_event( $this->first_run_timestamp(), $desired, self::RUN_HOOK ); |
| 160 |
} |
| 161 |
|
| 162 |
/** |
| 163 |
* Unschedule all instances of the recurring event. |
| 164 |
* |
| 165 |
* @since 1.2.0 |
| 166 |
* @return void |
| 167 |
*/ |
| 168 |
public function unschedule(): void { |
| 169 |
wp_unschedule_hook( self::RUN_HOOK ); |
| 170 |
} |
| 171 |
|
| 172 |
/** |
| 173 |
* Clamp a disallowed frequency to the first allowed value before save. |
| 174 |
* |
| 175 |
* @param mixed $value Incoming settings option value. |
| 176 |
* @since 1.2.0 |
| 177 |
* @return mixed |
| 178 |
*/ |
| 179 |
public function gate_frequency( $value = null ) { |
| 180 |
if ( is_array( $value ) && isset( $value['auto_scan_frequency'] ) ) { |
| 181 |
$allowed = self::allowed_frequencies(); |
| 182 |
|
| 183 |
if ( ! in_array( (string) $value['auto_scan_frequency'], $allowed, true ) ) { |
| 184 |
$value['auto_scan_frequency'] = $allowed[0] ?? 'monthly'; |
| 185 |
} |
| 186 |
} |
| 187 |
|
| 188 |
return $value; |
| 189 |
} |
| 190 |
|
| 191 |
/** |
| 192 |
* Timestamp of the next scheduled automatic scan (0 when not scheduled). |
| 193 |
* |
| 194 |
* @since 1.2.0 |
| 195 |
* @return int |
| 196 |
*/ |
| 197 |
public static function next_run(): int { |
| 198 |
$timestamp = wp_next_scheduled( self::RUN_HOOK ); |
| 199 |
|
| 200 |
return $timestamp ? (int) $timestamp : 0; |
| 201 |
} |
| 202 |
|
| 203 |
/** |
| 204 |
* Map a frequency to a registered cron schedule key. |
| 205 |
* |
| 206 |
* @param string $frequency Frequency key. |
| 207 |
* @since 1.2.0 |
| 208 |
* @return string |
| 209 |
*/ |
| 210 |
private function schedule_key( string $frequency ): string { |
| 211 |
return $frequency === 'weekly' ? self::WEEKLY_SCHEDULE : self::MONTHLY_SCHEDULE; |
| 212 |
} |
| 213 |
|
| 214 |
/** |
| 215 |
* Timestamp of the first run: a random slot inside the 01:00–05:00 window in |
| 216 |
* the SITE's timezone (the next night; tonight if it hasn't passed yet). |
| 217 |
* |
| 218 |
* The random minute staggers sites across the 4-hour window so they don't all |
| 219 |
* hit the SaaS at once (no thundering herd), and it avoids the UTC-midnight |
| 220 |
* daily-budget reset boundary. The weekly/monthly recurrence then preserves |
| 221 |
* this night-time slot. Returns a non-immediate time, so enabling the feature |
| 222 |
* never triggers an instant scan. |
| 223 |
* |
| 224 |
* @since 1.2.0 |
| 225 |
* @return int UTC timestamp for wp_schedule_event(). |
| 226 |
*/ |
| 227 |
private function first_run_timestamp(): int { |
| 228 |
$now = new \DateTimeImmutable( 'now', wp_timezone() ); |
| 229 |
$slot = $now->setTime( 0, 0, 0 ) |
| 230 |
->modify( '+' . wp_rand( 1 * HOUR_IN_SECONDS, ( 5 * HOUR_IN_SECONDS ) - 1 ) . ' seconds' ); |
| 231 |
|
| 232 |
if ( $slot <= $now ) { |
| 233 |
$slot = $slot->modify( '+1 day' ); // Tonight's window already passed → tomorrow. |
| 234 |
} |
| 235 |
|
| 236 |
return $slot->getTimestamp(); |
| 237 |
} |
| 238 |
} |
| 239 |
|