PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.3.0
Defender Security – Malware Scanner, Login Security & Firewall v6.3.0
6.3.0 6.2.3 6.2.4 6.2.0 6.2.1 6.2.2 6.1.0 5.3.1 5.4.0 5.4.1 5.5.0 5.5.1 5.6.0 5.6.1 5.6.2 5.7.0 5.7.1 5.7.2 5.8.0 5.8.1 5.9.0 6.0.0 6.0.1 3.0.1 3.1.0 All 141 releases
defender-security / src / component / class-network-cron-manager.php

class-network-cron-manager.php in Defender Security – Malware Scanner, Login Security & Firewall 6.3.0, at src/component/class-network-cron-manager.php

417 lines 11.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Network Cron Manager for WP Defender.
4 *
5 * This file contains the Network_Cron_Manager class which manages
6 * centralized cron jobs across a multisite network with locking.
7 *
8 * @package WP_Defender\Component
9 */
10
11 namespace WP_Defender\Component;
12
13 use WP_Defender\Component;
14
15 /**
16 * Network Cron Manager class.
17 *
18 * Provides centralized cron management with locking and multisite execution control.
19 */
20 class Network_Cron_Manager extends Component {
21 /**
22 * Log file name constant.
23 *
24 * @var string
25 */
26 public const LOG_FILE_NAME = 'network-cron-manager';
27
28 /**
29 * Array of registered hook configurations.
30 *
31 * @var array
32 */
33 private $callbacks = array();
34
35 /**
36 * Prefix for lock keys.
37 *
38 * @var string
39 */
40 private $lock_prefix = 'wpdef_cron_manager_lock_';
41
42 /**
43 * Prefix for last run timestamp keys.
44 *
45 * @var string
46 */
47 private $lastrun_prefix = 'wpdef_cron_manager_lastrun_';
48
49 /**
50 * Option name for storing callbacks.
51 *
52 * @var string
53 */
54 private $callbacks_option = 'wpdef_cron_manager_callbacks';
55
56 /**
57 * Constructor.
58 *
59 * Initializes the cron manager and hooks into WordPress.
60 */
61 public function __construct() {
62 add_action( 'shutdown', array( $this, 'check_and_execute_callbacks' ), PHP_INT_MAX );
63 }
64
65 /**
66 * Load callbacks from network options.
67 */
68 public function load_callbacks() {
69 if ( array() === $this->callbacks ) {
70 $this->callbacks = get_network_option( get_main_network_id(), $this->callbacks_option, array() );
71 }
72 }
73
74 /**
75 * Save callbacks to network options.
76 */
77 private function save_callbacks() {
78 update_network_option( get_main_network_id(), $this->callbacks_option, $this->callbacks );
79 }
80
81 /**
82 * Register a callback for cron execution.
83 *
84 * Automatically handles both multisite and single-site setups:
85 * - For multisite: Uses Network Cron Manager execution with start_time support
86 * - For single-site: Uses WordPress native cron with proper scheduling
87 *
88 * @param string $hook_name The hook name.
89 * @param callable $callback The callback function.
90 * @param int $interval_seconds The interval in seconds.
91 * @param int|string|null $start_time When to start: timestamp, 'next Thursday', etc. Defaults to defender_get_current_time().
92 * @param array $args Arguments for the callback.
93 *
94 * @return bool|void False on validation failure, void on success.
95 */
96 public function register_callback( string $hook_name, callable $callback, int $interval_seconds, $start_time = null, array $args = array() ) {
97 $hook_name = sanitize_key( $hook_name );
98 if ( '' === $hook_name ) {
99 $this->log( 'Task registration failed: Invalid task name provided', self::LOG_FILE_NAME );
100 return false;
101 }
102 if ( ! is_callable( $callback ) ) {
103 $this->log( "Task registration failed: Task '{$hook_name}' function cannot be called", self::LOG_FILE_NAME );
104 return false;
105 }
106 if ( ! is_numeric( $interval_seconds ) || $interval_seconds < 1 ) {
107 $this->log( "Task registration failed: Task '{$hook_name}' has invalid run interval ({$interval_seconds} seconds)", self::LOG_FILE_NAME );
108 return false;
109 }
110
111 if ( is_multisite() ) {
112 if ( wp_next_scheduled( $hook_name ) ) {
113 wp_clear_scheduled_hook( $hook_name );
114 $this->log( "Cleared existing WordPress cron event for '{$hook_name}' to prevent conflicts", self::LOG_FILE_NAME );
115 }
116
117 // Only save if this is a new callback or something changed.
118 if ( ! isset( $this->callbacks[ $hook_name ] ) || $this->callbacks[ $hook_name ]['interval'] !== $interval_seconds || $this->callbacks[ $hook_name ]['start_time'] !== $start_time ) {
119 $this->callbacks[ $hook_name ] = array(
120 'interval' => $interval_seconds,
121 'start_time' => $start_time,
122 'args' => $args,
123 );
124 $this->save_callbacks();
125 }
126 } elseif ( ! wp_next_scheduled( $hook_name ) ) {
127 $start_timestamp = $this->calculate_start_time( $start_time );
128 $schedule = $this->get_schedule_name( $interval_seconds );
129 wp_schedule_event( $start_timestamp, $schedule, $hook_name );
130 }
131
132 // Add the action to the hook (applies to both multisite and single-site).
133 add_action( $hook_name, $callback );
134 }
135
136 /**
137 * Calculate the start time for a cron event.
138 *
139 * @param int|string|null $start_time The start time specification.
140 *
141 * @return int The timestamp when the cron should first run.
142 */
143 private function calculate_start_time( $start_time = null ) {
144 if ( null === $start_time ) {
145 return defender_get_current_time();
146 }
147
148 if ( is_numeric( $start_time ) ) {
149 return (int) $start_time;
150 }
151
152 if ( is_string( $start_time ) ) {
153 $timestamp = strtotime( $start_time );
154
155 return false !== $timestamp ? $timestamp : defender_get_current_time();
156 }
157
158 return defender_get_current_time();
159 }
160
161 /**
162 * Get WordPress cron schedule name from interval seconds.
163 *
164 * @param int $interval_seconds The interval in seconds.
165 *
166 * @return string The schedule name.
167 */
168 private function get_schedule_name( $interval_seconds ) {
169 $schedules = wp_get_schedules();
170
171 foreach ( $schedules as $schedule_name => $schedule_data ) {
172 if ( isset( $schedule_data['interval'] ) && $schedule_data['interval'] === $interval_seconds ) {
173 return $schedule_name;
174 }
175 }
176
177 switch ( $interval_seconds ) {
178 case 12 * HOUR_IN_SECONDS:
179 return 'twicedaily';
180 case DAY_IN_SECONDS:
181 return 'daily';
182 case WEEK_IN_SECONDS:
183 return 'weekly';
184 case HOUR_IN_SECONDS:
185 default:
186 return 'hourly';
187 }
188 }
189
190 /**
191 * Check and execute registered callbacks.
192 */
193 public function check_and_execute_callbacks() {
194 // Early exit during plugin deletion/uninstall.
195 if ( defined( 'WP_UNINSTALL_PLUGIN' ) ) {
196 return;
197 }
198
199 // Skip if page post ID exists, or during AJAX/cron execution to prevent concurrent runs.
200 $post_data = defender_get_data_from_request( null, 'p' );
201 if (
202 ( is_array( $post_data ) && array() !== $post_data )
203 || defined( 'DOING_AJAX' )
204 || defined( 'DOING_CRON' )
205 ) {
206 return;
207 }
208
209 if ( did_action( 'after_setup_theme' ) > 0 ) {
210 $this->load_callbacks();
211 }
212
213 if ( array() === $this->callbacks ) {
214 return;
215 }
216 foreach ( $this->callbacks as $hook_name => $config ) {
217 $this->execute_callback( $hook_name, $config );
218 }
219 }
220
221 /**
222 * Execute a specific callback.
223 *
224 * @param string $hook_name The hook name.
225 * @param array $config The callback configuration.
226 */
227 private function execute_callback( $hook_name, $config ) {
228 if ( ! $this->should_execute( $hook_name, $config ) ) {
229 return;
230 }
231 if ( ! $this->acquire_lock( $hook_name ) ) {
232 return;
233 }
234
235 try {
236 // Execute the hook with arguments.
237 do_action( $hook_name, ...$config['args'] );
238 $this->update_last_run( $hook_name );
239 } catch ( \Exception $exception ) {
240 $this->log( "Task '{$hook_name}' failed to run: " . $exception->getMessage(), self::LOG_FILE_NAME );
241 } finally {
242 $this->release_lock( $hook_name );
243 }
244 }
245
246 /**
247 * Check if a callback should be executed based on interval and start_time.
248 *
249 * @param string $hook_name The hook name.
250 * @param array $config The callback configuration.
251 *
252 * @return bool Whether the callback should execute.
253 */
254 private function should_execute( $hook_name, $config ) {
255 $interval = $config['interval'];
256
257 /**
258 * Filter to modify execution intervals for network cron jobs.
259 *
260 * @param int $interval The interval in seconds.
261 * @param string $hook_name The hook name being executed.
262 */
263 $interval = apply_filters( 'wpdef_network_cron_interval', $interval, $hook_name );
264 $interval = ! is_int( $interval ) ? (int) $interval : $interval;
265 $last_run = $this->get_last_run( $hook_name );
266 $current_time = defender_get_current_time();
267
268 if ( ! $last_run && isset( $config['start_time'] ) && null !== $config['start_time'] ) {
269 $start_timestamp = $this->calculate_start_time( $config['start_time'] );
270 if ( $current_time < $start_timestamp ) {
271 return false;
272 }
273 }
274
275 if ( $last_run ) {
276 $time_diff = $current_time - $last_run;
277 if ( $time_diff < $interval ) {
278 return false;
279 }
280 }
281
282 return true;
283 }
284
285 /**
286 * Acquire a lock for a specific hook.
287 *
288 * @param string $hook_name The hook name.
289 * @return bool Whether the lock was acquired.
290 */
291 private function acquire_lock( $hook_name ) {
292 /**
293 * Filter to modify lock timeout for network cron jobs.
294 *
295 * @param int $timeout The timeout in seconds.
296 * @param string $hook_name The hook name being locked.
297 */
298 $lock_timeout = apply_filters( 'wpdef_network_cron_lock_timeout', 300, $hook_name );
299 $lock_timeout = ! is_int( $lock_timeout ) ? (int) $lock_timeout : $lock_timeout;
300 $lock_key = $this->lock_prefix . $hook_name;
301 $existing_lock = get_network_option( get_main_network_id(), $lock_key );
302 if ( 0 < $existing_lock && ( defender_get_current_time() - $existing_lock ) < $lock_timeout ) {
303 return false;
304 }
305 $lock_value = defender_get_current_time();
306 return update_network_option( get_main_network_id(), $lock_key, $lock_value );
307 }
308
309 /**
310 * Release a lock for a specific hook.
311 *
312 * @param string $hook_name The hook name.
313 */
314 private function release_lock( $hook_name ) {
315 $lock_key = $this->lock_prefix . $hook_name;
316 delete_network_option( get_main_network_id(), $lock_key );
317 }
318
319 /**
320 * Get the last run timestamp for a hook.
321 *
322 * @param string $hook_name The hook name.
323 * @return int The last run timestamp.
324 */
325 private function get_last_run( $hook_name ) {
326 $lastrun_key = $this->lastrun_prefix . $hook_name;
327 return get_network_option( get_main_network_id(), $lastrun_key, 0 );
328 }
329
330 /**
331 * Update the last run timestamp for a hook.
332 *
333 * @param string $hook_name The hook name.
334 */
335 private function update_last_run( $hook_name ) {
336 $lastrun_key = $this->lastrun_prefix . $hook_name;
337 $timestamp = defender_get_current_time();
338 update_network_option( get_main_network_id(), $lastrun_key, $timestamp );
339 }
340
341 /**
342 * Get all registered callbacks.
343 *
344 * @return array The registered callbacks.
345 */
346 public function get_callbacks() {
347 return $this->callbacks;
348 }
349
350 /**
351 * Remove all Network Cron Manager data during uninstallation.
352 */
353 public function remove_data() {
354 if ( ! is_multisite() ) {
355 return;
356 }
357
358 $network_id = get_main_network_id();
359
360 delete_network_option( $network_id, $this->callbacks_option );
361
362 global $wpdb;
363 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
364 $wpdb->prepare(
365 "DELETE FROM {$wpdb->sitemeta} WHERE meta_key LIKE %s OR meta_key LIKE %s",
366 $this->lock_prefix . '%',
367 $this->lastrun_prefix . '%'
368 )
369 );
370 }
371
372 /**
373 * Remove a specific callback.
374 *
375 * - For multisite: Remove the callback from the network option and release the lock.
376 * - For single-site: Unschedule the task.
377 *
378 * @param string $hook_name The hook name.
379 * @return bool Whether the callback was removed.
380 */
381 public function remove_callback( string $hook_name ) {
382 $hook_name = sanitize_key( $hook_name );
383
384 if ( '' === $hook_name ) {
385 return false;
386 }
387
388 if ( is_multisite() ) {
389 // Ensure callbacks are loaded.
390 $this->load_callbacks();
391
392 if ( isset( $this->callbacks[ $hook_name ] ) ) {
393 unset( $this->callbacks[ $hook_name ] );
394 $this->save_callbacks();
395 $this->log( "Task '{$hook_name}' removed.", self::LOG_FILE_NAME );
396 }
397
398 // Cleanup hook history.
399 $last_run_key = $this->lastrun_prefix . $hook_name;
400 delete_network_option( get_main_network_id(), $last_run_key );
401
402 // Release the lock.
403 $this->release_lock( $hook_name );
404 } else {
405 // Unschedule the task.
406 $timestamp = wp_next_scheduled( $hook_name );
407
408 if ( $timestamp ) {
409 wp_unschedule_event( $timestamp, $hook_name );
410 $this->log( "Task '{$hook_name}' unscheduled.", self::LOG_FILE_NAME );
411 }
412 }
413
414 return true;
415 }
416 }
417