PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backup, Restore, Migration & Clone / 4.9.2
WP STAGING – WordPress Backup, Restore, Migration & Clone v4.9.2
4.9.2 4.9.1 4.9.0 4.8.1 trunk 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.10.0 3.2.0 3.3.1 3.3.2 3.3.3 3.4.1 3.4.3 3.5.0 3.6.0 3.7.1 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.8.6 3.8.7 3.9.0 3.9.1 3.9.2 3.9.3 3.9.4 4.0.0 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4 4.2.0 4.2.1 4.3.0 4.3.1 4.3.2 4.4.0 4.5.0 4.6.0 4.7.0 4.7.1 4.7.2 4.7.3 4.8.0
wp-staging / Backup / BackupScheduler.php
wp-staging / Backup Last commit date
Ajax 1 day ago BackgroundProcessing 1 year ago Dto 2 weeks ago Entity 2 weeks ago Exceptions 1 year ago FileHeader 1 month ago Interfaces 7 months ago Job 2 months ago Request 1 year ago Service 1 day ago Storage 1 month ago Task 1 day ago Traits 10 months ago Utils 3 months ago AfterRestore.php 5 months ago BackupDeleter.php 2 months ago BackupDownload.php 9 months ago BackupFileIndex.php 1 year ago BackupGlitchReason.php 1 year ago BackupHeader.php 1 month ago BackupRepairer.php 7 months ago BackupRetentionHandler.php 1 month ago BackupScheduler.php 2 weeks ago BackupServiceProvider.php 2 months ago BackupValidator.php 7 months ago FileHeader.php 2 weeks ago FileHeaderAttribute.php 2 years ago WithBackupIdentifier.php 1 year ago
BackupScheduler.php
1232 lines
1 <?php
2
3 namespace WPStaging\Backup;
4
5 use DateTime;
6 use WPStaging\Backup\BackgroundProcessing\Backup\PrepareBackup;
7 use WPStaging\Backup\Dto\Job\JobBackupDataDto;
8 use WPStaging\Backup\Service\BackupsFinder;
9 use WPStaging\Backup\Task\Tasks\JobBackup\FinishBackupTask;
10 use WPStaging\Core\Cron\Cron;
11 use WPStaging\Core\WPStaging;
12 use WPStaging\Framework\BackgroundProcessing\FeatureDetection;
13 use WPStaging\Framework\Facades\Escape;
14 use WPStaging\Framework\Facades\Hooks;
15 use WPStaging\Framework\Network\HttpBasicAuth;
16 use WPStaging\Framework\Facades\Sanitize;
17 use WPStaging\Framework\Job\ProcessLock;
18 use WPStaging\Framework\Security\Capabilities;
19 use WPStaging\Framework\Security\Nonce;
20 use WPStaging\Framework\Utils\ServerVars;
21 use WPStaging\Notifications\Notifications;
22
23 use function WPStaging\functions\debug_log;
24
25 /**
26 * BackupScheduler - Manages backup scheduling and cron jobs
27 *
28 * Day-Specific Weekly Schedules:
29 * - Weekly schedules support day-specific variants (e.g., wpstg_weekly_1 for Monday, wpstg_weekly_7 for Sunday)
30 * - Day numbering uses ISO 8601 standard: 1=Monday, 2=Tuesday, ..., 7=Sunday
31 * - This makes it easy to extend to other schedules in the future
32 *
33 * Backward Compatibility:
34 * - Existing plain 'wpstg_weekly' schedules (without day suffix) continue to work
35 * - They run every 7 days from their original start time, regardless of day
36 * - The system automatically handles both old and new schedule formats
37 */
38 class BackupScheduler
39 {
40 use HttpBasicAuth;
41
42 /** @var string */
43 const OPTION_BACKUP_SCHEDULE_ERROR_REPORT = 'wpstg_backup_schedules_send_error_report';
44
45 /** @var string */
46 const OPTION_BACKUP_SCHEDULE_WARNING_REPORT = 'wpstg_backup_schedules_send_warning_report';
47
48 /** @var string */
49 const OPTION_BACKUP_SCHEDULE_GENERAL_REPORT = 'wpstg_backup_schedules_send_general_report';
50
51 /** @var string */
52 const OPTION_BACKUP_SCHEDULE_SLACK_ERROR_REPORT = 'wpstg_backup_schedules_send_slack_error_report';
53
54 /** @var string */
55 const OPTION_BACKUP_SCHEDULE_REPORT_SLACK_WEBHOOK = 'wpstg_backup_schedules_report_slack_webhook';
56
57 /** @var string */
58 const OPTION_BACKUP_SCHEDULES = 'wpstg_backup_schedules';
59
60 /** @var string */
61 const OPTION_LAST_BACKUP_FAILURE = 'wpstg_last_backup_failure';
62
63 /** @var string */
64 const CRON_WARNING_TYPE_FAILURE = 'failure';
65
66 /** @var string */
67 const CRON_WARNING_TYPE_OVERDUE = 'overdue';
68
69 /** @var string */
70 const TRANSIENT_BACKUP_SCHEDULE_ERROR_REPORT_SENT = 'wpstg.backup.schedules.error_report_sent';
71
72 /** @var string */
73 const TRANSIENT_BACKUP_SCHEDULE_WARNING_REPORT_SENT = 'wpstg.backup.schedules.warning_report_sent';
74
75 /** @var string */
76 const TRANSIENT_BACKUP_SCHEDULE_GENERAL_REPORT_SENT = 'wpstg.backup.schedules.general_report_sent';
77
78 /** @var string */
79 const TRANSIENT_BACKUP_SCHEDULE_SLACK_REPORT_SENT = 'wpstg.backup.schedules.slack_report_sent';
80
81 /** @var string */
82 const REPORT_TYPE_ERROR = 'error';
83
84 /** @var string */
85 const REPORT_TYPE_WARNING = 'warning';
86
87 /** @var string */
88 const REPORT_TYPE_GENERAL = 'general';
89
90 /** @var string */
91 const FILTER_SCHEDULES_BACKUP_INTERVAL = 'wpstg.schedulesBackup.interval';
92
93 /** @var string */
94 const FILTER_CRON_REQUEST = 'cron_request';
95
96 /** @var BackupsFinder */
97 protected $backupsFinder;
98
99 /** @var ProcessLock */
100 protected $processLock;
101
102 /** @var BackupDeleter */
103 protected $backupDeleter;
104
105 /**
106 * @var Notifications
107 */
108 protected $notifications;
109
110 /**
111 * Store cron related message
112 * @var string
113 */
114 protected $cronMessage;
115
116 /** @var int */
117 protected $numberOverdueCronjobs = 0;
118
119 /**
120 * Determines the banner text in the cron-warning-notice view.
121 * @var string
122 */
123 protected $cronWarningType = '';
124
125 /**
126 * The failure message from the last cron backup failure, if unresolved.
127 * @var string
128 */
129 protected $lastBackupFailureMessage = '';
130
131 /**
132 * @param BackupsFinder $backupsFinder
133 * @param ProcessLock $processLock
134 * @param BackupDeleter $backupDeleter
135 * @param Notifications $notifications
136 */
137 public function __construct(BackupsFinder $backupsFinder, ProcessLock $processLock, BackupDeleter $backupDeleter, Notifications $notifications)
138 {
139 $this->backupsFinder = $backupsFinder;
140 $this->processLock = $processLock;
141 $this->backupDeleter = $backupDeleter;
142 $this->notifications = $notifications;
143
144 $this->countOverdueCronjobs();
145 }
146
147 /**
148 * @return array
149 */
150 public function getSchedules(): array
151 {
152 $schedules = get_option(static::OPTION_BACKUP_SCHEDULES, []);
153 if (is_array($schedules)) {
154 return $schedules;
155 }
156
157 return [];
158 }
159
160 /**
161 * @param JobBackupDataDto $jobBackupDataDto
162 * @return void
163 */
164 public function maybeDeleteOldBackups(JobBackupDataDto $jobBackupDataDto)
165 {
166 $scheduleId = $jobBackupDataDto->getScheduleId();
167
168 // Not a scheduled backup, nothing to do.
169 if (empty($scheduleId)) {
170 return;
171 }
172
173 $schedules = get_option(static::OPTION_BACKUP_SCHEDULES, []);
174
175 $schedule = array_filter($schedules, function ($schedule) use ($scheduleId) {
176 return $schedule['scheduleId'] == $scheduleId;
177 });
178
179 if (empty($schedule)) {
180 debug_log("Could not delete old backups for schedule ID $scheduleId as the schedule rotation plan was not found in the database.");
181 return;
182 }
183
184 $schedule = array_shift($schedule);
185
186 $maxAllowedBackupFiles = absint($schedule['rotation']);
187
188 $backupFiles = $this->backupsFinder->findBackupByScheduleId($scheduleId);
189
190 // Early bail: Not enough backups to trigger the rotation
191 if (count($backupFiles) < $maxAllowedBackupFiles) {
192 return;
193 }
194
195 // Sort backups, older first
196 uasort($backupFiles, function ($backup1, $backup2) {
197 /**
198 * @var \SplFileInfo $backup1
199 * @var \SplFileInfo $backup2
200 */
201 if ($backup1->getMTime() === $backup2->getMTime()) {
202 return 0;
203 }
204
205 return $backup1->getMTime() < $backup2->getMTime() ? -1 : 1;
206 });
207
208 // Make sure array indexes are correctly ordered.
209 $backupFiles = array_values($backupFiles);
210
211 // Get exceeding backups, including an extra one for the backup that will be created right now.
212 $backupFiles = array_slice($backupFiles, 0, max(1, count($backupFiles) - $maxAllowedBackupFiles + 1));
213
214 array_map(function ($file) {
215 $this->backupDeleter->clearErrors();
216 $this->backupDeleter->deleteBackup($file);
217 $errors = $this->backupDeleter->getErrors();
218 foreach ($errors as $error) {
219 debug_log('Tried to cleanup old backups for backup plan rotation, but couldn\'t delete file: ' . $error);
220 }
221 }, $backupFiles);
222 }
223
224 /**
225 * @param JobBackupDataDto $jobBackupDataDto
226 * @param string $scheduleId
227 * @return void
228 * @throws \Exception
229 */
230 public function scheduleBackup(JobBackupDataDto $jobBackupDataDto, string $scheduleId)
231 {
232 if (!isset(wp_get_schedules()[$jobBackupDataDto->getScheduleRecurrence()])) {
233 debug_log("Tried to schedule a backup, but schedule '" . $jobBackupDataDto->getScheduleRecurrence() . "' is not registered as a WordPress cron schedule. Data DTO: " . wp_json_encode($jobBackupDataDto));
234
235 return;
236 }
237
238 $firstSchedule = new \DateTime('now', wp_timezone());
239 $time = $jobBackupDataDto->getScheduleTime();
240 $recurrence = $jobBackupDataDto->getScheduleRecurrence();
241 $dayOfWeek = Cron::extractDayFromSchedule($recurrence);
242 $this->setUpcomingDateTime($firstSchedule, $time, $dayOfWeek, $recurrence);
243
244 $backupSchedule = [
245 'scheduleId' => $scheduleId,
246 'schedule' => $jobBackupDataDto->getScheduleRecurrence(),
247 'backupType' => $jobBackupDataDto->getBackupType(),
248 'subsiteBlogId' => $jobBackupDataDto->getSubsiteBlogId(), // required for network subsite backup type
249 'time' => $time,
250 'name' => $jobBackupDataDto->getName(),
251 'rotation' => $jobBackupDataDto->getScheduleRotation(),
252 'isExportingPlugins' => $jobBackupDataDto->getIsExportingPlugins(),
253 'isExportingMuPlugins' => $jobBackupDataDto->getIsExportingMuPlugins(),
254 'isExportingThemes' => $jobBackupDataDto->getIsExportingThemes(),
255 'isExportingUploads' => $jobBackupDataDto->getIsExportingUploads(),
256 'isExportingOtherWpContentFiles' => $jobBackupDataDto->getIsExportingOtherWpContentFiles(),
257 'isExportingOtherWpRootFiles' => $jobBackupDataDto->getIsExportingOtherWpRootFiles(),
258 'isExportingDatabase' => $jobBackupDataDto->getIsExportingDatabase(),
259 'sitesToBackup' => $jobBackupDataDto->getSitesToBackup(),
260 'storages' => $jobBackupDataDto->getStorages(),
261 'firstSchedule' => $firstSchedule->getTimestamp(),
262 'isSmartExclusion' => $jobBackupDataDto->getIsSmartExclusion(),
263 'isExcludingSpamComments' => $jobBackupDataDto->getIsExcludingSpamComments(),
264 'isExcludingPostRevision' => $jobBackupDataDto->getIsExcludingPostRevision(),
265 'isExcludingDeactivatedPlugins' => $jobBackupDataDto->getIsExcludingDeactivatedPlugins(),
266 'isExcludingUnusedThemes' => $jobBackupDataDto->getIsExcludingUnusedThemes(),
267 'isExcludingLogs' => $jobBackupDataDto->getIsExcludingLogs(),
268 'isExcludingCaches' => $jobBackupDataDto->getIsExcludingCaches(),
269 'isWpCliRequest' => true, // should be true otherwise multisite backup will not work
270 'backupExcludedDirectories' => $jobBackupDataDto->getBackupExcludedDirectories(),
271 ];
272
273 if (wp_next_scheduled(Cron::ACTION_CREATE_CRON_BACKUP, [$backupSchedule])) {
274 debug_log('[Schedule Backup Cron] Early bailed when registering the cron to create a backup on a schedule, because it already exists');
275
276 return;
277 }
278
279 $this->registerScheduleInDb($backupSchedule);
280 $this->reCreateCron();
281 }
282
283 /**
284 * Registers a schedule in the Db.
285 * @param array $backupSchedule
286 * @return bool false on error or if nothing would be updated
287 */
288 protected function registerScheduleInDb(array $backupSchedule): bool
289 {
290 $backupSchedules = get_option(static::OPTION_BACKUP_SCHEDULES, []);
291 if (!is_array($backupSchedules)) {
292 $backupSchedules = [];
293 }
294
295 $backupSchedules[] = $backupSchedule;
296
297 if (!update_option(static::OPTION_BACKUP_SCHEDULES, $backupSchedules, false)) {
298 debug_log('[Schedule Backup Cron] Could not update BackupSchedules DB option');
299 return false;
300 }
301
302 return true;
303 }
304
305 /**
306 * AJAX callback that processes the backup schedule.
307 *
308 * @param array $backupData
309 * @return void
310 */
311 public function createCronBackup(array $backupData)
312 {
313 // Cron is hell to debug, so let's log everything that happens.
314 $logId = wp_generate_password(4, false);
315
316 debug_log(sprintf("[Schedule Backup Cron - %s] Received request to create a backup using Cron. Backup Data: %s", $logId, wp_json_encode($backupData)), 'info', false);
317
318 try {
319 debug_log(sprintf("[Schedule Backup Cron - %s] Preparing job", $logId), 'info', false);
320 $jobId = WPStaging::make(PrepareBackup::class)->prepare($backupData);
321 if ($jobId instanceof \WP_Error) {
322 debug_log(sprintf("[Schedule Backup Cron - %s] Failed to create backup: %s", $logId, $jobId->get_error_message()));
323 $this->saveBackupFailure($jobId->get_error_message());
324 return;
325 }
326
327 debug_log(sprintf("[Schedule Backup Cron - %s] Successfully received a Job ID: %s", $logId, $jobId), 'info', false);
328 } catch (\Exception $e) {
329 debug_log("[Schedule Backup Cron - $logId] Exception thrown while preparing the Backup: " . $e->getMessage());
330 $this->saveBackupFailure($e->getMessage());
331 }
332 }
333
334 /**
335 * Ajax callback to dismiss a schedule.
336 * @return void
337 */
338 public function dismissSchedule()
339 {
340 if (!current_user_can((new Capabilities())->manageWPSTG())) {
341 return;
342 }
343
344 if (!(new Nonce())->requestHasValidNonce(Nonce::WPSTG_NONCE)) {
345 return;
346 }
347
348 if (empty($_POST['scheduleId'])) {
349 return;
350 }
351
352 try {
353 $this->deleteSchedule(Sanitize::sanitizeString($_POST['scheduleId']));
354 wp_send_json_success();
355 } catch (\Exception $e) {
356 wp_send_json_error($e->getMessage());
357 }
358 }
359
360 /**
361 * Deletes a backup schedule.
362 *
363 * @param string $scheduleId The schedule ID to delete.
364 * @return void
365 */
366 public function deleteSchedule(string $scheduleId, $reCreateCron = true)
367 {
368 $schedules = $this->getSchedules();
369
370 $newSchedules = array_filter($schedules, function ($schedule) use ($scheduleId) {
371 return $schedule['scheduleId'] != $scheduleId;
372 });
373
374 if (!update_option(static::OPTION_BACKUP_SCHEDULES, $newSchedules, false)) {
375 debug_log('[Schedule Backup Cron] Could not update BackupSchedules DB option after removing schedule.');
376 throw new \RuntimeException('Could not unschedule event from Db.');
377 }
378
379 // When the last schedule is removed there is nothing left to fail, so a
380 // stale failure signal from the deleted schedule must not surface if the
381 // user later creates a new one.
382 if (empty($newSchedules)) {
383 delete_option(static::OPTION_LAST_BACKUP_FAILURE);
384 }
385
386 if ($reCreateCron === false) {
387 return;
388 }
389
390 $this->reCreateCron();
391 }
392
393 /**
394 * @param string|null $scheduleBeingEdit The schedule ID being edited. If this is set, it will be ignored when re-creating the Cron events.
395 * @return bool
396 * @throws \Exception
397 * @see OPTION_BACKUP_SCHEDULES The Db option that is the source of truth for Cron events.
398 * The backup schedule cron events are deleted and re-created
399 * based on what is in this db option.
400 *
401 * This way, we only care about preserving this option on Backup
402 * Restore or Push, and we don't have to worry about re-scheduling
403 * the Cron events or removing leftover schedules.
404 *
405 */
406 public function reCreateCron($scheduleBeingEdit = null): bool
407 {
408 $schedules = $this->getSchedules();
409 static::removeBackupSchedulesFromCron();
410
411 $errors = [];
412
413 foreach ($schedules as $schedule) {
414 $timeToSchedule = new \DateTime('now', wp_timezone());
415
416 /**
417 * New mechanism for recroning old jobs
418 */
419 if (isset(wp_get_schedules()[$schedule['schedule']]) && isset($schedule['firstSchedule']) && ($schedule['scheduleId'] !== $scheduleBeingEdit)) {
420 $this->setNextSchedulingDate($timeToSchedule, $schedule);
421 } else {
422 $dayOfWeek = Cron::extractDayFromSchedule($schedule['schedule']);
423 $this->setUpcomingDateTime($timeToSchedule, $schedule['time'], $dayOfWeek, $schedule['schedule']);
424 }
425
426 /** @see BackupServiceProvider::enqueueAjaxListeners */
427 $result = wp_schedule_event($timeToSchedule->format('U'), $schedule['schedule'], Cron::ACTION_CREATE_CRON_BACKUP, [$schedule]);
428
429 // Could not register Cron event.
430 // Log errors but keep trying for the other cron events or all of them will be lost
431 if ($result === false || $result instanceof \WP_Error) {
432 if ($result instanceof \WP_Error) {
433 $details = $result->get_error_message();
434 } else {
435 $details = '';
436 }
437
438 $error = '[Schedule Backup Cron] Failed to register the cron event wpstg_create_cron_backup. ' . $schedule['schedule'] . ' ' . $details;
439
440 $errors[] = $error;
441
442 debug_log($error);
443 }
444 }
445
446 if (!empty($errors)) {
447 return false;
448 }
449
450 return true;
451 }
452
453 /**
454 * Re-create scheduled backup cron events only when schedules exist.
455 *
456 * Activation calls this to avoid touching the cron option on installs that do
457 * not have any scheduled backups yet.
458 *
459 * @return bool
460 */
461 public function reCreateCronIfSchedulesExist(): bool
462 {
463 if (empty($this->getSchedules())) {
464 return true;
465 }
466
467 return $this->reCreateCron();
468 }
469
470 /**
471 * Removes all backup schedule events from WordPress Cron array.
472 *
473 * This is static so that it can be called from WP STAGING deactivation hook
474 * without having to bootstrap the entire plugin.
475 *
476 * This is a low-level function that can run when WP STAGING has not been
477 * bootstrapped, so there's no autoload nor Container available.
478 */
479 public static function removeBackupSchedulesFromCron(): bool
480 {
481 $cron = get_option('cron');
482
483 // Bail: Unexpected value - should never happen.
484 if (!is_array($cron)) {
485 return false;
486 }
487
488 // Remove any backup schedules from Cron
489 foreach ($cron as $timestamp => &$events) {
490 if (is_array($events)) {
491 foreach ($events as $callback => &$args) {
492 if ($callback === Cron::ACTION_CREATE_CRON_BACKUP) {
493 unset($cron[$timestamp][$callback]);
494 }
495 }
496 }
497 }
498
499 // After removing the backup schedule events,
500 // we might have timestamps with no events.
501 // So we remove any leftover timestamps that don't have any events.
502 $cron = array_filter($cron, function ($timestamps) {
503 return !empty($timestamps);
504 });
505
506 update_option('cron', $cron);
507
508 return true;
509 }
510
511 /**
512 * Check cron status whether it is working or not
513 * Logic is adopted from WP Crontrol plugin
514 *
515 * @return bool
516 */
517 public function checkCronStatus(): bool
518 {
519 global $wp_version;
520
521 $this->cronMessage = '';
522 $this->cronWarningType = '';
523 $this->lastBackupFailureMessage = '';
524 // Add arrays to collect warnings and general messages
525 $warningMessages = [];
526 $generalMessages = [];
527 // Track the overall result
528 $cronStatusResult = true;
529
530 // Gate 1: No schedules — nothing to warn about.
531 if ($this->isSchedulesEmpty()) {
532 return true;
533 }
534
535 // Gate 2: Unresolved backup failure (no successful backup since the last failure).
536 $lastFailure = get_option(self::OPTION_LAST_BACKUP_FAILURE);
537 if (is_array($lastFailure) && !empty($lastFailure['time'])) {
538 $lastBackupInfo = get_option(FinishBackupTask::OPTION_LAST_BACKUP, []);
539 $lastSuccessTime = is_array($lastBackupInfo) ? (int)($lastBackupInfo['endTime'] ?? 0) : 0;
540 if ((int)$lastFailure['time'] > $lastSuccessTime) {
541 $this->cronWarningType = self::CRON_WARNING_TYPE_FAILURE;
542 $this->lastBackupFailureMessage = $lastFailure['message'] ?? '';
543 }
544 }
545
546 // Gate 3: Backup cron event is overdue (30-minute grace period).
547 if ($this->cronWarningType === '' && $this->hasOverdueBackupCronJob()) {
548 $this->cronWarningType = self::CRON_WARNING_TYPE_OVERDUE;
549 }
550
551 // Gate 4: No outcome signal + DISABLE_WP_CRON set — server cron is intentional, suppress.
552 if ($this->cronWarningType === '' && defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
553 return true;
554 }
555
556 // Fall through to infrastructure checks (HTTP test, overdue count) for the Details section.
557
558 if ($this->isCronjobsOverdue()) {
559 if (WPStaging::isPro()) {
560 $overdueMessage = sprintf(
561 __('There are %s scheduled WordPress tasks overdue. This means the WordPress cron jobs are not working properly, unless this a development site or no users are visiting this website. <a href="%s">Read this article</a> to find a solution.<br><br>', 'wp-staging'),
562 $this->numberOverdueCronjobs,
563 'https://wp-staging.com/docs/wp-cron-is-not-working-correctly/'
564 );
565 $this->cronMessage .= $overdueMessage;
566 $warningMessages[] = $overdueMessage;
567
568 if (WPStaging::make(ServerVars::class)->isLitespeed()) {
569 $litespeedMessage = sprintf(
570 Escape::escapeHtml(__('This site is using LiteSpeed server, this could prevent the scheduled backups from working properly. Please read <a href="%s" target="_blank">this article here</a> if the backup scheduling is not working properly.', 'wp-staging')),
571 'https://wp-staging.com/docs/scheduled-backups-do-not-work-hosting-company-uses-the-litespeed-webserver-fix-wp-cron/'
572 );
573 $this->cronMessage .= $litespeedMessage;
574 $generalMessages[] = $litespeedMessage;
575 }
576 } else {
577 $overdueMessage = sprintf(
578 __('There are %s scheduled WordPress tasks overdue. This means the WordPress cron jobs are not working properly, unless this a development site or no users are visiting this website.<br> <a href="%s">Write to us in the forum</a> to get a solution for this issue from the WP STAGING support team.<br><br>', 'wp-staging'),
579 $this->numberOverdueCronjobs,
580 'https://wordpress.org/support/plugin/wp-staging/'
581 );
582 $this->cronMessage .= $overdueMessage;
583 $warningMessages[] = $overdueMessage;
584
585 if (WPStaging::make(ServerVars::class)->isLitespeed()) {
586 $litespeedMessage = sprintf(
587 Escape::escapeHtml(__('This site is using LiteSpeed server, this could prevent the scheduled backups from working properly. <a href="%s">Write to us in the forum</a> to get a solution for that issue.', 'wp-staging')),
588 'https://wordpress.org/support/plugin/wp-staging/'
589 );
590 $this->cronMessage .= $litespeedMessage;
591 $generalMessages[] = $litespeedMessage;
592 }
593 }
594 }
595
596 // Third party plugins that handle crons
597 $thirdPartyCronPlugins = [
598 '\HM\Cavalcade\Plugin\Job' => 'Cavalcade',
599 '\Automattic\WP\Cron_Control\Main' => 'Cron Control',
600 '\KMM\KRoN\Core' => 'KMM KRoN',
601 ];
602
603 foreach ($thirdPartyCronPlugins as $class => $plugin) {
604 if (class_exists($class)) {
605 $thirdPartyMessage = sprintf(
606 __('WP Cron is being managed by a third party plugin: %s plugin.', 'wp-staging'),
607 $plugin
608 );
609 $this->cronMessage .= $thirdPartyMessage;
610 $generalMessages[] = $thirdPartyMessage;
611
612 $cronStatusResult = true;
613 break;
614 }
615 }
616
617 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
618 if (WPStaging::isPro()) {
619 $disabledCronMessage = sprintf(
620 __('The background backup creation depends on WP-Cron but %s is set to %s in wp-config.php. Background processing might not work. Remove this constant or set its value to %s. Ignore this if you use an external cron job.', 'wp-staging'),
621 '<code>DISABLE_WP_CRON</code>',
622 '<code>true</code>',
623 '<code>false</code>'
624 );
625 } else {
626 $disabledCronMessage = sprintf(
627 __('The background backup creation depends on WP-Cron but %s is set to %s in wp-config.php. Background processing might not work. Remove this constant or set its value to %s. Ignore this if you use an external cron job. <a href="%s" target="_blank">Ask us in the forum</a> if you need more information.', 'wp-staging'),
628 '<code>DISABLE_WP_CRON</code>',
629 '<code>true</code>',
630 '<code>false</code>',
631 'https://wordpress.org/support/plugin/wp-staging/'
632 );
633 }
634
635 $this->cronMessage .= $disabledCronMessage;
636 $warningMessages[] = $disabledCronMessage;
637
638 $cronStatusResult = true;
639 }
640
641 if (defined('ALTERNATE_WP_CRON') && ALTERNATE_WP_CRON) {
642 $alternateCronMessage = sprintf(
643 __('The constant %s is set to true.', 'wp-staging'),
644 'ALTERNATE_WP_CRON'
645 );
646 $this->cronMessage .= $alternateCronMessage;
647 $generalMessages[] = $alternateCronMessage;
648
649 $cronStatusResult = true;
650 }
651
652
653 $sslverify = version_compare($wp_version, '4.0', '<');
654 $doingWpCron = sprintf('%.22F', microtime(true));
655 $urlEndpoint = add_query_arg('doing_wp_cron', $doingWpCron, site_url('wp-cron.php'));
656
657 $cronRequest = apply_filters(self::FILTER_CRON_REQUEST, [
658 'url' => $urlEndpoint,
659 'key' => $doingWpCron,
660 'args' => [
661 'timeout' => 10,
662 'blocking' => true,
663 'sslverify' => apply_filters(FeatureDetection::FILTER_HTTPS_LOCAL_SSL_VERIFY, $sslverify),
664 ],
665 ]);
666
667 $cronRequest['args']['blocking'] = true;
668
669 $authHeader = $this->getHttpAuthHeaders();
670 if (!empty($authHeader)) {
671 $cronRequest['args']['headers'] = array_merge(
672 isset($cronRequest['args']['headers']) ? $cronRequest['args']['headers'] : [],
673 $authHeader
674 );
675 }
676
677 $result = wp_remote_post($cronRequest['url'], $cronRequest['args']);
678
679 // Action hook for internal use only: used during cron failure test
680 Hooks::doAction('wpstg.tests.backup.scheduler.failing_schedule_error');
681
682 if (is_wp_error($result)) {
683 $errorCronMessage = "Can not create scheduled backups because cron jobs do not work on this site. Error: " . $result->get_error_message() . ". Can not reach endpoint: " . esc_url($urlEndpoint);
684 // Only send the error report mail if error is caused by WP STAGING
685 if ($this->isWpstgError()) {
686 $this->sendErrorReport($errorCronMessage);
687 }
688
689 $this->cronMessage .= $errorCronMessage;
690
691 $cronStatusResult = false;
692 }
693
694 if (wp_remote_retrieve_response_code($result) >= 300) {
695 $httpWarningMessage = sprintf(
696 __('Unexpected HTTP response code: %s. Cron jobs and backup schedule might still work, but we recommend checking the HTTP response of %s', 'wp-staging'),
697 intval(wp_remote_retrieve_response_code($result)),
698 esc_url($urlEndpoint)
699 );
700 $this->cronMessage .= $httpWarningMessage;
701 $warningMessages[] = $httpWarningMessage;
702
703 $cronStatusResult = false;
704 }
705
706 // Send accumulated warning and general reports ONLY ONCE at the end
707 if (!empty($warningMessages)) {
708 $this->sendWarningReport(implode("\n\n", $warningMessages));
709 }
710
711 if (!empty($generalMessages)) {
712 $this->sendGeneralReport(implode("\n\n", $generalMessages));
713 }
714
715 return $cronStatusResult;
716 }
717
718 /**
719 * @return bool
720 */
721 private function isCronjobsOverdue(): bool
722 {
723 return $this->numberOverdueCronjobs > 4;
724 }
725
726 /** @return string */
727 public function getCronMessage(): string
728 {
729 return $this->cronMessage;
730 }
731
732 /** @return int */
733 public function getOverdueCronJobsCount(): int
734 {
735 return $this->numberOverdueCronjobs;
736 }
737
738 /** @return bool */
739 public function isWpCronDisabled(): bool
740 {
741 return defined('DISABLE_WP_CRON') && DISABLE_WP_CRON;
742 }
743
744 /** @return bool */
745 public function hasOverdueCronJobs(): bool
746 {
747 return $this->isCronjobsOverdue();
748 }
749
750 /**
751 * @return string One of CRON_WARNING_TYPE_FAILURE, CRON_WARNING_TYPE_OVERDUE, or '' (no warning).
752 */
753 public function getWarningType(): string
754 {
755 return $this->cronWarningType;
756 }
757
758 /**
759 * @return string The error message from the last unresolved cron backup failure, or empty string.
760 */
761 public function getLastBackupFailureMessage(): string
762 {
763 return $this->lastBackupFailureMessage;
764 }
765
766 /**
767 * @return array An array where the first item is the timestamp, and the second is the backup callback.
768 * @throws \Exception When there is no backup scheduled or one could not be found.
769 */
770 public function getNextBackupSchedule(): array
771 {
772 $cron = get_option('cron');
773
774 // Bail: Unexpected value - should never happen.
775 if (!is_array($cron)) {
776 throw new \UnexpectedValueException();
777 }
778
779 ksort($cron, SORT_NUMERIC);
780
781 // Remove any backup schedules from Cron
782 foreach ($cron as $timestamp => &$events) {
783 if (is_array($events)) {
784 foreach ($events as $callback => &$args) {
785 if ($callback === Cron::ACTION_CREATE_CRON_BACKUP) {
786 return [$timestamp, $cron[$timestamp][$callback]];
787 }
788 }
789 }
790 }
791
792 // No results found
793 throw new \OutOfBoundsException();
794 }
795
796 /**
797 * Set date today or tomorrow for given DateTime object according to time
798 *
799 * @param DateTime $datetime
800 * @param string|array $time
801 * @param int|null $dayOfWeek Day of week (1-7, Monday-Sunday, ISO 8601) for weekly schedules
802 * @param string|null $scheduleRecurrence The schedule recurrence type
803 * @return void
804 */
805 protected function setUpcomingDateTime(DateTime &$datetime, $time, $dayOfWeek = null, $scheduleRecurrence = null)
806 {
807 if (is_array($time)) {
808 $hourAndMinute = $time;
809 } else {
810 $hourAndMinute = explode(':', $time);
811 }
812
813 // For weekly schedules with a specific day of week
814 $isWeeklySchedule = $scheduleRecurrence === Cron::WEEKLY ||
815 $scheduleRecurrence === Cron::EVERY_TWO_WEEKS ||
816 strpos($scheduleRecurrence, Cron::WEEKLY . '_') === 0;
817
818 if ($dayOfWeek !== null && $isWeeklySchedule) {
819 // Use ISO 8601 day numbers: 1 (Monday) through 7 (Sunday)
820 // PHP's date('N') returns the same format
821 $currentDayOfWeek = (int)$datetime->format('N');
822 $targetDayOfWeek = (int)$dayOfWeek;
823
824 // Convert time to comparable integer HHMM
825 $targetTimeInt = (int) sprintf('%02d%02d', $hourAndMinute[0], $hourAndMinute[1]);
826 $currentTimeInt = (int) $datetime->format('Hi');
827 $daysUntilTarget = $targetDayOfWeek - $currentDayOfWeek;
828
829 if ($daysUntilTarget < 0) {
830 $daysUntilTarget += 7;
831 }
832
833 // If same day and target time already passed then schedule next week
834 if ($daysUntilTarget === 0 && $targetTimeInt <= $currentTimeInt) {
835 $daysUntilTarget = 7;
836 }
837
838 // Apply date shift
839 if ($daysUntilTarget > 0) {
840 $datetime->add(new \DateInterval("P{$daysUntilTarget}D"));
841 }
842 } else {
843 // The event should be scheduled later today or tomorrow? Compares "Hi (Hourminute)" timestamps to figure out.
844 if ((int)sprintf('%s%s', $hourAndMinute[0], $hourAndMinute[1]) < (int)$datetime->format('Hi')) {
845 $datetime->add(new \DateInterval('P1D'));
846 }
847 }
848
849 $datetime->setTime($hourAndMinute[0], $hourAndMinute[1]);
850 }
851
852 /**
853 * Set the next scheduling date for the schedule
854 *
855 * @param DateTime $datetime
856 * @param array $schedule
857 * @return void
858 */
859 protected function setNextSchedulingDate(DateTime &$datetime, array $schedule)
860 {
861 $next = $schedule['firstSchedule'];
862 $now = $datetime->getTimestamp();
863 if ($next >= $now) {
864 $dayOfWeek = Cron::extractDayFromSchedule($schedule['schedule']);
865 $this->setUpcomingDateTime($datetime, $schedule['time'], $dayOfWeek, $schedule['schedule']);
866 return;
867 }
868
869 $recurrance = wp_get_schedules()[$schedule['schedule']];
870 while ($next < $now) {
871 $next += $recurrance['interval'];
872 }
873
874 $datetime->setTimestamp($next);
875 }
876
877 /**
878 * Detect whether the last error is caused by WP STAGING
879 *
880 * @return bool
881 */
882 protected function isWpstgError(): bool
883 {
884 $error = error_get_last();
885 if (!is_array($error)) {
886 return false;
887 }
888
889 return strpos($error['file'], WPSTG_PLUGIN_SLUG) !== false;
890 }
891
892 /**
893 * Send an error report email
894 * A Generic title will be used if no title is provided
895 * Internally uses sendEmailReport()
896 *
897 * @param string $message
898 * @param string $title
899 * @return bool
900 */
901 public function sendErrorReport(string $message, string $title = ''): bool
902 {
903 if (get_option(self::OPTION_BACKUP_SCHEDULE_ERROR_REPORT) !== 'true') {
904 return false;
905 }
906
907 if (empty($message)) {
908 return false;
909 }
910
911 if (strpos($message, 'index resource') !== false) {
912 $message .= "\r\n \r\n" . esc_html__("This can happen if another process deleted the backup while it was created. Please report this to support@wp-staging.com if it happens often. Otherwise you can ignore it.", 'wp-staging');
913 }
914
915 if (empty($title)) {
916 $title = esc_html__('WP Staging - Backup Error Report', 'wp-staging');
917 }
918
919 $this->sendEmailReport($message, $title);
920 $this->sendSlackReport($message, $title);
921
922 return true;
923 }
924
925 /**
926 * Send a warning report email
927 * A Generic title will be used if no title is provided
928 * Internally uses sendEmailReport()
929 *
930 * @param string $message
931 * @param string $title
932 * @return bool
933 */
934 public function sendWarningReport(string $message, string $title = ''): bool
935 {
936 if (get_option(self::OPTION_BACKUP_SCHEDULE_WARNING_REPORT) !== 'true') {
937 return false;
938 }
939
940 if (empty($message)) {
941 return false;
942 }
943
944 if (empty($title)) {
945 $title = esc_html__('WP Staging - Backup Warning Report', 'wp-staging');
946 }
947
948 $this->sendEmailReport($message, $title, self::REPORT_TYPE_WARNING);
949
950 return true;
951 }
952
953 /**
954 * Send a general report email
955 * A Generic title will be used if no title is provided
956 * Internally uses sendEmailReport()
957 *
958 * @param string $message
959 * @param string $title
960 * @return bool
961 */
962 public function sendGeneralReport(string $message, string $title = ''): bool
963 {
964 if (get_option(self::OPTION_BACKUP_SCHEDULE_GENERAL_REPORT) !== 'true') {
965 return false;
966 }
967
968 if (empty($message)) {
969 return false;
970 }
971
972 if (empty($title)) {
973 $title = esc_html__('WP Staging - Backup General Report', 'wp-staging');
974 }
975
976 $this->sendEmailReport($message, $title, self::REPORT_TYPE_GENERAL);
977
978 return true;
979 }
980
981 /**
982 * Send a report email
983 * A Generic title will be used if no title is provided
984 *
985 * @param string $message
986 * @param string $title
987 * @return bool
988 */
989 public function sendEmailReport(string $message, string $title = '', string $reportType = self::REPORT_TYPE_ERROR): bool
990 {
991 $optionName = $this->getReportOptionName($reportType);
992
993 if (get_option($optionName) !== 'true') {
994 return false;
995 }
996
997 $reportEmail = get_option(Notifications::OPTION_BACKUP_SCHEDULE_REPORT_EMAIL);
998 if (!filter_var($reportEmail, FILTER_VALIDATE_EMAIL)) {
999 return false;
1000 }
1001
1002 // Only send the report mail once every 5 minutes
1003 $transientName = $this->getReportTransientName($reportType);
1004 if (get_transient($transientName) !== false) {
1005 return false;
1006 }
1007
1008 if (empty($message)) {
1009 return false;
1010 }
1011
1012 if (empty($title)) {
1013 $title = $this->getDefaultReportTitle($reportType);
1014 }
1015
1016 // Set the transient to prevent sending the error report mail again for 5 minutes
1017 $transientName = $this->getReportTransientName($reportType);
1018 set_transient($transientName, true, 5 * 60);
1019
1020 if (get_option(Notifications::OPTION_SEND_EMAIL_AS_HTML, false) === 'true') {
1021 return $this->notifications->sendEmailAsHTML($reportEmail, $title, $message);
1022 }
1023
1024 return $this->notifications->sendEmail($reportEmail, $title, $message);
1025 }
1026
1027 /**
1028 * Send a report slack
1029 * A Generic title will be used if no title is provided
1030 *
1031 * @param string $message
1032 * @param string $title
1033 * @return bool
1034 */
1035 public function sendSlackReport(string $message, string $title = ''): bool
1036 {
1037 if (!WPStaging::isPro()) {
1038 return false;
1039 }
1040
1041 if (get_option(self::OPTION_BACKUP_SCHEDULE_SLACK_ERROR_REPORT) !== 'true') {
1042 return false;
1043 }
1044
1045 $webhook = get_option(self::OPTION_BACKUP_SCHEDULE_REPORT_SLACK_WEBHOOK);
1046 if (!filter_var($webhook, FILTER_VALIDATE_URL)) {
1047 return false;
1048 }
1049
1050 // Only send the error report mail once every 5 minutes
1051 if (get_transient(self::TRANSIENT_BACKUP_SCHEDULE_SLACK_REPORT_SENT) !== false) {
1052 return false;
1053 }
1054
1055 if (empty($message)) {
1056 return false;
1057 }
1058
1059 if (empty($title)) {
1060 $title = esc_html__('WP Staging - Backup Report', 'wp-staging');
1061 }
1062
1063 // Set the transient to prevent sending the error report mail again for 5 minutes
1064 set_transient(self::TRANSIENT_BACKUP_SCHEDULE_SLACK_REPORT_SENT, true, 5 * 60);
1065 return $this->notifications->sendSlack($webhook, $title, $message);
1066 }
1067
1068 /**
1069 * Get the option name for a specific report type
1070 *
1071 * @param string $reportType
1072 * @return string
1073 */
1074 private function getReportOptionName(string $reportType): string
1075 {
1076 switch ($reportType) {
1077 case self::REPORT_TYPE_WARNING:
1078 return self::OPTION_BACKUP_SCHEDULE_WARNING_REPORT;
1079 case self::REPORT_TYPE_GENERAL:
1080 return self::OPTION_BACKUP_SCHEDULE_GENERAL_REPORT;
1081 default:
1082 return self::OPTION_BACKUP_SCHEDULE_ERROR_REPORT;
1083 }
1084 }
1085
1086 /**
1087 * Get the transient name for a specific report type
1088 *
1089 * @param string $reportType
1090 * @return string
1091 */
1092 private function getReportTransientName(string $reportType): string
1093 {
1094 switch ($reportType) {
1095 case self::REPORT_TYPE_WARNING:
1096 return self::TRANSIENT_BACKUP_SCHEDULE_WARNING_REPORT_SENT;
1097 case self::REPORT_TYPE_GENERAL:
1098 return self::TRANSIENT_BACKUP_SCHEDULE_GENERAL_REPORT_SENT;
1099 case self::REPORT_TYPE_ERROR:
1100 default:
1101 return self::TRANSIENT_BACKUP_SCHEDULE_ERROR_REPORT_SENT;
1102 }
1103 }
1104
1105 /**
1106 * Get the default title for a specific report type
1107 *
1108 * @param string $reportType
1109 * @return string
1110 */
1111 private function getDefaultReportTitle(string $reportType): string
1112 {
1113 switch ($reportType) {
1114 case self::REPORT_TYPE_WARNING:
1115 return esc_html__('WP Staging - Backup Warning Report', 'wp-staging');
1116 case self::REPORT_TYPE_GENERAL:
1117 return esc_html__('WP Staging - Backup General Report', 'wp-staging');
1118 default:
1119 return esc_html__('WP Staging - Backup Error Report', 'wp-staging');
1120 }
1121 }
1122
1123 /**
1124 * @return bool
1125 */
1126 private function isSchedulesEmpty(): bool
1127 {
1128 $schedules = get_option(static::OPTION_BACKUP_SCHEDULES, []);
1129 if (empty($schedules)) {
1130 return true;
1131 }
1132
1133 return false;
1134 }
1135
1136 /**
1137 * @return array
1138 */
1139 private function getCronJobs(): array
1140 {
1141 $cron = get_option('cron');
1142 if (!is_array($cron)) {
1143 return [];
1144 }
1145
1146 $cronJobs = [];
1147 foreach ($cron as $timestamp => $hooks) {
1148 if (!is_numeric($timestamp) || !is_array($hooks)) {
1149 continue;
1150 }
1151
1152 $cronJobs[(int)$timestamp] = $hooks;
1153 }
1154
1155 return $cronJobs;
1156 }
1157
1158 /**
1159 * @return void
1160 */
1161 private function countOverdueCronjobs()
1162 {
1163 $cronJobs = $this->getCronJobs();
1164 $timeNow = time();
1165 foreach ($cronJobs as $expectedExecutionTime => $cronJob) {
1166 if ($expectedExecutionTime < $timeNow) {
1167 $this->numberOverdueCronjobs++;
1168 }
1169 }
1170 }
1171
1172 /**
1173 * Listener for the background-job failure hook.
1174 * Persists the failure so the cron-warning banner can surface it.
1175 * Only acts when the failing job was a scheduled backup.
1176 *
1177 * @param array $args Keys: jobDataDto, errorMessage
1178 * @return void
1179 */
1180 public function onBackgroundJobFailure(array $args)
1181 {
1182 $jobDataDto = isset($args['jobDataDto']) ? $args['jobDataDto'] : null;
1183 if (!($jobDataDto instanceof JobBackupDataDto)) {
1184 return;
1185 }
1186
1187 if (!($jobDataDto->getRepeatBackupOnSchedule() || !empty($jobDataDto->getScheduleId()))) {
1188 return;
1189 }
1190
1191 $errorMessage = isset($args['errorMessage']) ? (string)$args['errorMessage'] : '';
1192 $this->saveBackupFailure($errorMessage);
1193 }
1194
1195 /**
1196 * @param string $message
1197 * @return void
1198 */
1199 private function saveBackupFailure(string $message)
1200 {
1201 update_option(self::OPTION_LAST_BACKUP_FAILURE, [
1202 'time' => time(),
1203 'message' => $message,
1204 ], false);
1205 }
1206
1207 /**
1208 * Returns true if the wpstg_create_cron_backup event is scheduled but has not
1209 * fired for more than 30 minutes past its due time.
1210 *
1211 * Unlike countOverdueCronjobs(), this checks only our own hook — not all WP
1212 * cron jobs — so it stays accurate even when DISABLE_WP_CRON=true causes
1213 * unrelated core/plugin jobs to pile up in the queue.
1214 *
1215 * @return bool
1216 */
1217 private function hasOverdueBackupCronJob(): bool
1218 {
1219 $cronJobs = $this->getCronJobs();
1220 $grace = 30 * MINUTE_IN_SECONDS;
1221 $now = time();
1222
1223 foreach ($cronJobs as $timestamp => $hooks) {
1224 if (isset($hooks[Cron::ACTION_CREATE_CRON_BACKUP]) && ($timestamp + $grace) < $now) {
1225 return true;
1226 }
1227 }
1228
1229 return false;
1230 }
1231 }
1232