PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backups, Restore, Migration & Clone / 4.10.0
WP STAGING – WordPress Backups, Restore, Migration & Clone v4.10.0
4.10.0 4.9.5 4.9.4 4.9.3 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 5 days ago BackgroundProcessing 1 year ago Dto 5 days ago Entity 2 weeks ago Exceptions 1 year ago FileHeader 2 months ago Interfaces 8 months ago Job 3 months ago Request 1 year ago Service 5 days ago Storage 2 months ago Task 5 days ago Traits 11 months ago Utils 1 week ago AfterRestore.php 7 months ago BackupDeleter.php 1 week ago BackupDownload.php 10 months ago BackupFileIndex.php 1 year ago BackupGlitchReason.php 1 year ago BackupHeader.php 2 months ago BackupNextOffer.php 5 days ago BackupRepairer.php 8 months ago BackupRetentionHandler.php 2 months ago BackupScheduler.php 5 days ago BackupServiceProvider.php 3 months ago BackupValidator.php 1 week ago FileHeader.php 1 month ago FileHeaderAttribute.php 2 years ago WithBackupIdentifier.php 1 week ago
BackupScheduler.php
1054 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\Facades\Sanitize;
13 use WPStaging\Framework\Job\ProcessLock;
14 use WPStaging\Framework\Security\Capabilities;
15 use WPStaging\Framework\Security\Nonce;
16 use WPStaging\Notifications\Notifications;
17
18 use function WPStaging\functions\debug_log;
19
20 /**
21 * BackupScheduler - Manages backup scheduling and cron jobs
22 *
23 * Day-Specific Weekly Schedules:
24 * - Weekly schedules support day-specific variants (e.g., wpstg_weekly_1 for Monday, wpstg_weekly_7 for Sunday)
25 * - Day numbering uses ISO 8601 standard: 1=Monday, 2=Tuesday, ..., 7=Sunday
26 * - This makes it easy to extend to other schedules in the future
27 *
28 * Backward Compatibility:
29 * - Existing plain 'wpstg_weekly' schedules (without day suffix) continue to work
30 * - They run every 7 days from their original start time, regardless of day
31 * - The system automatically handles both old and new schedule formats
32 */
33 class BackupScheduler
34 {
35 /** @var string */
36 const OPTION_BACKUP_SCHEDULE_ERROR_REPORT = 'wpstg_backup_schedules_send_error_report';
37
38 /** @var string */
39 const OPTION_BACKUP_SCHEDULE_WARNING_REPORT = 'wpstg_backup_schedules_send_warning_report';
40
41 /** @var string */
42 const OPTION_BACKUP_SCHEDULE_GENERAL_REPORT = 'wpstg_backup_schedules_send_general_report';
43
44 /** @var string */
45 const OPTION_BACKUP_SCHEDULE_SLACK_ERROR_REPORT = 'wpstg_backup_schedules_send_slack_error_report';
46
47 /** @var string */
48 const OPTION_BACKUP_SCHEDULE_REPORT_SLACK_WEBHOOK = 'wpstg_backup_schedules_report_slack_webhook';
49
50 /** @var string */
51 const OPTION_BACKUP_SCHEDULES = 'wpstg_backup_schedules';
52
53 /** @var string */
54 const OPTION_LAST_BACKUP_FAILURE = 'wpstg_last_backup_failure';
55
56 /** @var string */
57 const CRON_WARNING_TYPE_FAILURE = 'failure';
58
59 /** @var string */
60 const CRON_WARNING_TYPE_OVERDUE = 'overdue';
61
62 /** @var int Seconds a cron task may run late before it counts as overdue. */
63 const OVERDUE_GRACE_PERIOD = 30 * MINUTE_IN_SECONDS;
64
65 /** @var string */
66 const TRANSIENT_BACKUP_SCHEDULE_ERROR_REPORT_SENT = 'wpstg.backup.schedules.error_report_sent';
67
68 /** @var string */
69 const TRANSIENT_BACKUP_SCHEDULE_WARNING_REPORT_SENT = 'wpstg.backup.schedules.warning_report_sent';
70
71 /** @var string */
72 const TRANSIENT_BACKUP_SCHEDULE_GENERAL_REPORT_SENT = 'wpstg.backup.schedules.general_report_sent';
73
74 /** @var string */
75 const TRANSIENT_BACKUP_SCHEDULE_SLACK_REPORT_SENT = 'wpstg.backup.schedules.slack_report_sent';
76
77 /** @var string */
78 const REPORT_TYPE_ERROR = 'error';
79
80 /** @var string */
81 const REPORT_TYPE_WARNING = 'warning';
82
83 /** @var string */
84 const REPORT_TYPE_GENERAL = 'general';
85
86 /** @var string */
87 const FILTER_SCHEDULES_BACKUP_INTERVAL = 'wpstg.schedulesBackup.interval';
88
89 /** @var BackupsFinder */
90 protected $backupsFinder;
91
92 /** @var ProcessLock */
93 protected $processLock;
94
95 /** @var BackupDeleter */
96 protected $backupDeleter;
97
98 /**
99 * @var Notifications
100 */
101 protected $notifications;
102
103 /** @var int */
104 protected $numberOverdueCronjobs = 0;
105
106 /**
107 * Determines the banner text in the cron-warning-notice view.
108 * @var string
109 */
110 protected $cronWarningType = '';
111
112 /**
113 * The failure message from the last cron backup failure, if unresolved.
114 * @var string
115 */
116 protected $lastBackupFailureMessage = '';
117
118 /**
119 * @param BackupsFinder $backupsFinder
120 * @param ProcessLock $processLock
121 * @param BackupDeleter $backupDeleter
122 * @param Notifications $notifications
123 */
124 public function __construct(BackupsFinder $backupsFinder, ProcessLock $processLock, BackupDeleter $backupDeleter, Notifications $notifications)
125 {
126 $this->backupsFinder = $backupsFinder;
127 $this->processLock = $processLock;
128 $this->backupDeleter = $backupDeleter;
129 $this->notifications = $notifications;
130
131 $this->countOverdueCronjobs();
132 }
133
134 /**
135 * @return array
136 */
137 public function getSchedules(): array
138 {
139 $schedules = get_option(static::OPTION_BACKUP_SCHEDULES, []);
140 if (is_array($schedules)) {
141 return $schedules;
142 }
143
144 return [];
145 }
146
147 /**
148 * @param JobBackupDataDto $jobBackupDataDto
149 * @return void
150 */
151 public function maybeDeleteOldBackups(JobBackupDataDto $jobBackupDataDto)
152 {
153 $scheduleId = $jobBackupDataDto->getScheduleId();
154
155 // Not a scheduled backup, nothing to do.
156 if (empty($scheduleId)) {
157 return;
158 }
159
160 $schedules = get_option(static::OPTION_BACKUP_SCHEDULES, []);
161
162 $schedule = array_filter($schedules, function ($schedule) use ($scheduleId) {
163 return $schedule['scheduleId'] == $scheduleId;
164 });
165
166 if (empty($schedule)) {
167 debug_log("Could not delete old backups for schedule ID $scheduleId as the schedule rotation plan was not found in the database.");
168 return;
169 }
170
171 $schedule = array_shift($schedule);
172
173 $maxAllowedBackupFiles = absint($schedule['rotation']);
174
175 $backupFiles = $this->backupsFinder->findBackupByScheduleId($scheduleId);
176
177 // Early bail: Not enough backups to trigger the rotation
178 if (count($backupFiles) < $maxAllowedBackupFiles) {
179 return;
180 }
181
182 // Sort backups, older first
183 uasort($backupFiles, function ($backup1, $backup2) {
184 /**
185 * @var \SplFileInfo $backup1
186 * @var \SplFileInfo $backup2
187 */
188 if ($backup1->getMTime() === $backup2->getMTime()) {
189 return 0;
190 }
191
192 return $backup1->getMTime() < $backup2->getMTime() ? -1 : 1;
193 });
194
195 // Make sure array indexes are correctly ordered.
196 $backupFiles = array_values($backupFiles);
197
198 // Get exceeding backups, including an extra one for the backup that will be created right now.
199 $backupFiles = array_slice($backupFiles, 0, max(1, count($backupFiles) - $maxAllowedBackupFiles + 1));
200
201 array_map(function ($file) {
202 $this->backupDeleter->clearErrors();
203 $this->backupDeleter->deleteBackup($file);
204 $errors = $this->backupDeleter->getErrors();
205 foreach ($errors as $error) {
206 debug_log('Tried to cleanup old backups for backup plan rotation, but couldn\'t delete file: ' . $error);
207 }
208 }, $backupFiles);
209 }
210
211 /**
212 * @param JobBackupDataDto $jobBackupDataDto
213 * @param string $scheduleId
214 * @return void
215 * @throws \Exception
216 */
217 public function scheduleBackup(JobBackupDataDto $jobBackupDataDto, string $scheduleId)
218 {
219 if (!isset(wp_get_schedules()[$jobBackupDataDto->getScheduleRecurrence()])) {
220 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));
221
222 return;
223 }
224
225 $firstSchedule = new \DateTime('now', wp_timezone());
226 $time = $jobBackupDataDto->getScheduleTime();
227 $recurrence = $jobBackupDataDto->getScheduleRecurrence();
228 $dayOfWeek = Cron::extractDayFromSchedule($recurrence);
229 $this->setUpcomingDateTime($firstSchedule, $time, $dayOfWeek, $recurrence);
230
231 $backupSchedule = [
232 'scheduleId' => $scheduleId,
233 'schedule' => $jobBackupDataDto->getScheduleRecurrence(),
234 'backupType' => $jobBackupDataDto->getBackupType(),
235 'subsiteBlogId' => $jobBackupDataDto->getSubsiteBlogId(), // required for network subsite backup type
236 'time' => $time,
237 'name' => $jobBackupDataDto->getName(),
238 'rotation' => $jobBackupDataDto->getScheduleRotation(),
239 'isExportingPlugins' => $jobBackupDataDto->getIsExportingPlugins(),
240 'isExportingMuPlugins' => $jobBackupDataDto->getIsExportingMuPlugins(),
241 'isExportingThemes' => $jobBackupDataDto->getIsExportingThemes(),
242 'isExportingUploads' => $jobBackupDataDto->getIsExportingUploads(),
243 'isExportingOtherWpContentFiles' => $jobBackupDataDto->getIsExportingOtherWpContentFiles(),
244 'isExportingOtherWpRootFiles' => $jobBackupDataDto->getIsExportingOtherWpRootFiles(),
245 'isExportingDatabase' => $jobBackupDataDto->getIsExportingDatabase(),
246 'sitesToBackup' => $jobBackupDataDto->getSitesToBackup(),
247 'storages' => $jobBackupDataDto->getStorages(),
248 'firstSchedule' => $firstSchedule->getTimestamp(),
249 'isSmartExclusion' => $jobBackupDataDto->getIsSmartExclusion(),
250 'isExcludingSpamComments' => $jobBackupDataDto->getIsExcludingSpamComments(),
251 'isExcludingPostRevision' => $jobBackupDataDto->getIsExcludingPostRevision(),
252 'isExcludingDeactivatedPlugins' => $jobBackupDataDto->getIsExcludingDeactivatedPlugins(),
253 'isExcludingUnusedThemes' => $jobBackupDataDto->getIsExcludingUnusedThemes(),
254 'isExcludingLogs' => $jobBackupDataDto->getIsExcludingLogs(),
255 'isExcludingCaches' => $jobBackupDataDto->getIsExcludingCaches(),
256 'isWpCliRequest' => true, // should be true otherwise multisite backup will not work
257 'backupExcludedDirectories' => $jobBackupDataDto->getBackupExcludedDirectories(),
258 ];
259
260 if (wp_next_scheduled(Cron::ACTION_CREATE_CRON_BACKUP, [$backupSchedule])) {
261 debug_log('[Schedule Backup Cron] Early bailed when registering the cron to create a backup on a schedule, because it already exists');
262
263 return;
264 }
265
266 $this->registerScheduleInDb($backupSchedule);
267 $this->reCreateCron();
268 }
269
270 /**
271 * Registers a schedule in the Db.
272 * @param array $backupSchedule
273 * @return bool false on error or if nothing would be updated
274 */
275 protected function registerScheduleInDb(array $backupSchedule): bool
276 {
277 $backupSchedules = get_option(static::OPTION_BACKUP_SCHEDULES, []);
278 if (!is_array($backupSchedules)) {
279 $backupSchedules = [];
280 }
281
282 $backupSchedules[] = $backupSchedule;
283
284 if (!update_option(static::OPTION_BACKUP_SCHEDULES, $backupSchedules, false)) {
285 debug_log('[Schedule Backup Cron] Could not update BackupSchedules DB option');
286 return false;
287 }
288
289 return true;
290 }
291
292 /**
293 * AJAX callback that processes the backup schedule.
294 *
295 * @param array $backupData
296 * @return void
297 */
298 public function createCronBackup(array $backupData)
299 {
300 // Cron is hell to debug, so let's log everything that happens.
301 $logId = wp_generate_password(4, false);
302
303 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);
304
305 try {
306 debug_log(sprintf("[Schedule Backup Cron - %s] Preparing job", $logId), 'info', false);
307 $jobId = WPStaging::make(PrepareBackup::class)->prepare($backupData);
308 if ($jobId instanceof \WP_Error) {
309 debug_log(sprintf("[Schedule Backup Cron - %s] Failed to create backup: %s", $logId, $jobId->get_error_message()));
310 $this->saveBackupFailure($jobId->get_error_message());
311 return;
312 }
313
314 debug_log(sprintf("[Schedule Backup Cron - %s] Successfully received a Job ID: %s", $logId, $jobId), 'info', false);
315 } catch (\Exception $e) {
316 debug_log("[Schedule Backup Cron - $logId] Exception thrown while preparing the Backup: " . $e->getMessage());
317 $this->saveBackupFailure($e->getMessage());
318 }
319 }
320
321 /**
322 * Ajax callback to dismiss a schedule.
323 * @return void
324 */
325 public function dismissSchedule()
326 {
327 if (!current_user_can((new Capabilities())->manageWPSTG())) {
328 return;
329 }
330
331 if (!(new Nonce())->requestHasValidNonce(Nonce::WPSTG_NONCE)) {
332 return;
333 }
334
335 if (empty($_POST['scheduleId'])) {
336 return;
337 }
338
339 try {
340 $this->deleteSchedule(Sanitize::sanitizeString($_POST['scheduleId']));
341 wp_send_json_success();
342 } catch (\Exception $e) {
343 wp_send_json_error($e->getMessage());
344 }
345 }
346
347 /**
348 * Deletes a backup schedule.
349 *
350 * @param string $scheduleId The schedule ID to delete.
351 * @return void
352 */
353 public function deleteSchedule(string $scheduleId, $reCreateCron = true)
354 {
355 $schedules = $this->getSchedules();
356
357 $newSchedules = array_filter($schedules, function ($schedule) use ($scheduleId) {
358 return $schedule['scheduleId'] != $scheduleId;
359 });
360
361 if (!update_option(static::OPTION_BACKUP_SCHEDULES, $newSchedules, false)) {
362 debug_log('[Schedule Backup Cron] Could not update BackupSchedules DB option after removing schedule.');
363 throw new \RuntimeException('Could not unschedule event from Db.');
364 }
365
366 // When the last schedule is removed there is nothing left to fail, so a
367 // stale failure signal from the deleted schedule must not surface if the
368 // user later creates a new one.
369 if (empty($newSchedules)) {
370 delete_option(static::OPTION_LAST_BACKUP_FAILURE);
371 }
372
373 if ($reCreateCron === false) {
374 return;
375 }
376
377 $this->reCreateCron();
378 }
379
380 /**
381 * @param string|null $scheduleBeingEdit The schedule ID being edited. If this is set, it will be ignored when re-creating the Cron events.
382 * @return bool
383 * @throws \Exception
384 * @see OPTION_BACKUP_SCHEDULES The Db option that is the source of truth for Cron events.
385 * The backup schedule cron events are deleted and re-created
386 * based on what is in this db option.
387 *
388 * This way, we only care about preserving this option on Backup
389 * Restore or Push, and we don't have to worry about re-scheduling
390 * the Cron events or removing leftover schedules.
391 *
392 */
393 public function reCreateCron($scheduleBeingEdit = null): bool
394 {
395 $schedules = $this->getSchedules();
396 static::removeBackupSchedulesFromCron();
397
398 $errors = [];
399
400 foreach ($schedules as $schedule) {
401 $timeToSchedule = new \DateTime('now', wp_timezone());
402
403 /**
404 * New mechanism for recroning old jobs
405 */
406 if (isset(wp_get_schedules()[$schedule['schedule']]) && isset($schedule['firstSchedule']) && ($schedule['scheduleId'] !== $scheduleBeingEdit)) {
407 $this->setNextSchedulingDate($timeToSchedule, $schedule);
408 } else {
409 $dayOfWeek = Cron::extractDayFromSchedule($schedule['schedule']);
410 $this->setUpcomingDateTime($timeToSchedule, $schedule['time'], $dayOfWeek, $schedule['schedule']);
411 }
412
413 /** @see BackupServiceProvider::enqueueAjaxListeners */
414 $result = wp_schedule_event($timeToSchedule->format('U'), $schedule['schedule'], Cron::ACTION_CREATE_CRON_BACKUP, [$schedule]);
415
416 // Could not register Cron event.
417 // Log errors but keep trying for the other cron events or all of them will be lost
418 if ($result === false || $result instanceof \WP_Error) {
419 if ($result instanceof \WP_Error) {
420 $details = $result->get_error_message();
421 } else {
422 $details = '';
423 }
424
425 $error = '[Schedule Backup Cron] Failed to register the cron event wpstg_create_cron_backup. ' . $schedule['schedule'] . ' ' . $details;
426
427 $errors[] = $error;
428
429 debug_log($error);
430 }
431 }
432
433 if (!empty($errors)) {
434 return false;
435 }
436
437 return true;
438 }
439
440 /**
441 * Re-create scheduled backup cron events only when schedules exist.
442 *
443 * Activation calls this to avoid touching the cron option on installs that do
444 * not have any scheduled backups yet.
445 *
446 * @return bool
447 */
448 public function reCreateCronIfSchedulesExist(): bool
449 {
450 if (empty($this->getSchedules())) {
451 return true;
452 }
453
454 return $this->reCreateCron();
455 }
456
457 /**
458 * Removes all backup schedule events from WordPress Cron array.
459 *
460 * This is static so that it can be called from WP STAGING deactivation hook
461 * without having to bootstrap the entire plugin.
462 *
463 * This is a low-level function that can run when WP STAGING has not been
464 * bootstrapped, so there's no autoload nor Container available.
465 */
466 public static function removeBackupSchedulesFromCron(): bool
467 {
468 $cron = get_option('cron');
469
470 // Bail: Unexpected value - should never happen.
471 if (!is_array($cron)) {
472 return false;
473 }
474
475 // Remove any backup schedules from Cron
476 foreach ($cron as $timestamp => &$events) {
477 if (is_array($events)) {
478 foreach ($events as $callback => &$args) {
479 if ($callback === Cron::ACTION_CREATE_CRON_BACKUP) {
480 unset($cron[$timestamp][$callback]);
481 }
482 }
483 }
484 }
485
486 // After removing the backup schedule events,
487 // we might have timestamps with no events.
488 // So we remove any leftover timestamps that don't have any events.
489 $cron = array_filter($cron, function ($timestamps) {
490 return !empty($timestamps);
491 });
492
493 update_option('cron', $cron);
494
495 return true;
496 }
497
498 /**
499 * Whether the scheduled backups look healthy.
500 *
501 * Warns only on a real problem: a failed or overdue scheduled backup. Nothing else warns.
502 *
503 * @return bool True when no warning is shown.
504 */
505 public function checkCronStatus(): bool
506 {
507 $this->cronWarningType = '';
508 $this->lastBackupFailureMessage = '';
509
510 if ($this->isSchedulesEmpty()) {
511 return true;
512 }
513
514 $this->detectScheduledBackupWarning();
515
516 return $this->cronWarningType === '';
517 }
518
519 /**
520 * Sets the warning type when the last scheduled backup failed or its cron event is overdue.
521 *
522 * @return void
523 */
524 private function detectScheduledBackupWarning()
525 {
526 $lastFailure = get_option(self::OPTION_LAST_BACKUP_FAILURE);
527 if (is_array($lastFailure) && !empty($lastFailure['time']) && (int)$lastFailure['time'] > $this->getLastScheduledBackupSuccessTime()) {
528 $this->cronWarningType = self::CRON_WARNING_TYPE_FAILURE;
529 $this->lastBackupFailureMessage = $lastFailure['message'] ?? '';
530 return;
531 }
532
533 if ($this->hasOverdueOrMissingBackupCronJob()) {
534 $this->cronWarningType = self::CRON_WARNING_TYPE_OVERDUE;
535 }
536 }
537
538 /**
539 * A manual backup must not hide a broken schedule, so only a scheduled success counts.
540 *
541 * @return int Timestamp of the last scheduled success, 0 if none or the last was manual.
542 */
543 private function getLastScheduledBackupSuccessTime(): int
544 {
545 $lastBackupInfo = $this->getLastBackupInfo();
546 if (empty($lastBackupInfo['endTime'])) {
547 return 0;
548 }
549
550 $jobDataDto = isset($lastBackupInfo['JobBackupDataDto']) ? $lastBackupInfo['JobBackupDataDto'] : null;
551 if (!($jobDataDto instanceof JobBackupDataDto) || !$jobDataDto->isScheduledBackup()) {
552 return 0;
553 }
554
555 return (int)$lastBackupInfo['endTime'];
556 }
557
558 /**
559 * @return array
560 */
561 private function getLastBackupInfo(): array
562 {
563 $lastBackupInfo = get_option(FinishBackupTask::OPTION_LAST_BACKUP, []);
564
565 return is_array($lastBackupInfo) ? $lastBackupInfo : [];
566 }
567
568 /** @return int */
569 public function getOverdueCronJobsCount(): int
570 {
571 return $this->numberOverdueCronjobs;
572 }
573
574 /** @return bool */
575 public function isWpCronDisabled(): bool
576 {
577 return defined('DISABLE_WP_CRON') && DISABLE_WP_CRON;
578 }
579
580 /** @return bool */
581 public function hasOverdueCronJobs(): bool
582 {
583 return $this->numberOverdueCronjobs > 4;
584 }
585
586 /**
587 * @return string One of CRON_WARNING_TYPE_FAILURE, CRON_WARNING_TYPE_OVERDUE, or '' (no warning).
588 */
589 public function getWarningType(): string
590 {
591 return $this->cronWarningType;
592 }
593
594 /**
595 * @return string The error message from the last unresolved cron backup failure, or empty string.
596 */
597 public function getLastBackupFailureMessage(): string
598 {
599 return $this->lastBackupFailureMessage;
600 }
601
602 /**
603 * @return array An array where the first item is the timestamp, and the second is the backup callback.
604 * @throws \Exception When there is no backup scheduled or one could not be found.
605 */
606 public function getNextBackupSchedule(): array
607 {
608 $cron = get_option('cron');
609
610 // Bail: Unexpected value - should never happen.
611 if (!is_array($cron)) {
612 throw new \UnexpectedValueException();
613 }
614
615 ksort($cron, SORT_NUMERIC);
616
617 // Remove any backup schedules from Cron
618 foreach ($cron as $timestamp => &$events) {
619 if (is_array($events)) {
620 foreach ($events as $callback => &$args) {
621 if ($callback === Cron::ACTION_CREATE_CRON_BACKUP) {
622 return [$timestamp, $cron[$timestamp][$callback]];
623 }
624 }
625 }
626 }
627
628 // No results found
629 throw new \OutOfBoundsException();
630 }
631
632 /**
633 * Set date today or tomorrow for given DateTime object according to time
634 *
635 * @param DateTime $datetime
636 * @param string|array $time
637 * @param int|null $dayOfWeek Day of week (1-7, Monday-Sunday, ISO 8601) for weekly schedules
638 * @param string|null $scheduleRecurrence The schedule recurrence type
639 * @return void
640 */
641 protected function setUpcomingDateTime(DateTime &$datetime, $time, $dayOfWeek = null, $scheduleRecurrence = null)
642 {
643 if (is_array($time)) {
644 $hourAndMinute = $time;
645 } else {
646 $hourAndMinute = explode(':', $time);
647 }
648
649 // For weekly schedules with a specific day of week
650 $isWeeklySchedule = $scheduleRecurrence === Cron::WEEKLY ||
651 $scheduleRecurrence === Cron::EVERY_TWO_WEEKS ||
652 strpos($scheduleRecurrence, Cron::WEEKLY . '_') === 0;
653
654 if ($dayOfWeek !== null && $isWeeklySchedule) {
655 // Use ISO 8601 day numbers: 1 (Monday) through 7 (Sunday)
656 // PHP's date('N') returns the same format
657 $currentDayOfWeek = (int)$datetime->format('N');
658 $targetDayOfWeek = (int)$dayOfWeek;
659
660 // Convert time to comparable integer HHMM
661 $targetTimeInt = (int) sprintf('%02d%02d', $hourAndMinute[0], $hourAndMinute[1]);
662 $currentTimeInt = (int) $datetime->format('Hi');
663 $daysUntilTarget = $targetDayOfWeek - $currentDayOfWeek;
664
665 if ($daysUntilTarget < 0) {
666 $daysUntilTarget += 7;
667 }
668
669 // If same day and target time already passed then schedule next week
670 if ($daysUntilTarget === 0 && $targetTimeInt <= $currentTimeInt) {
671 $daysUntilTarget = 7;
672 }
673
674 // Apply date shift
675 if ($daysUntilTarget > 0) {
676 $datetime->add(new \DateInterval("P{$daysUntilTarget}D"));
677 }
678 } else {
679 // The event should be scheduled later today or tomorrow? Compares "Hi (Hourminute)" timestamps to figure out.
680 if ((int)sprintf('%s%s', $hourAndMinute[0], $hourAndMinute[1]) < (int)$datetime->format('Hi')) {
681 $datetime->add(new \DateInterval('P1D'));
682 }
683 }
684
685 $datetime->setTime($hourAndMinute[0], $hourAndMinute[1]);
686 }
687
688 /**
689 * Set the next scheduling date for the schedule
690 *
691 * @param DateTime $datetime
692 * @param array $schedule
693 * @return void
694 */
695 protected function setNextSchedulingDate(DateTime &$datetime, array $schedule)
696 {
697 $next = $schedule['firstSchedule'];
698 $now = $datetime->getTimestamp();
699 if ($next >= $now) {
700 $dayOfWeek = Cron::extractDayFromSchedule($schedule['schedule']);
701 $this->setUpcomingDateTime($datetime, $schedule['time'], $dayOfWeek, $schedule['schedule']);
702 return;
703 }
704
705 $recurrance = wp_get_schedules()[$schedule['schedule']];
706 while ($next < $now) {
707 $next += $recurrance['interval'];
708 }
709
710 $datetime->setTimestamp($next);
711 }
712
713 /**
714 * Send an error report email
715 * A Generic title will be used if no title is provided
716 * Internally uses sendEmailReport()
717 *
718 * @param string $message
719 * @param string $title
720 * @return bool
721 */
722 public function sendErrorReport(string $message, string $title = ''): bool
723 {
724 if (get_option(self::OPTION_BACKUP_SCHEDULE_ERROR_REPORT) !== 'true') {
725 return false;
726 }
727
728 if (empty($message)) {
729 return false;
730 }
731
732 if (strpos($message, 'index resource') !== false) {
733 $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');
734 }
735
736 if (empty($title)) {
737 $title = esc_html__('WP Staging - Backup Error Report', 'wp-staging');
738 }
739
740 $this->sendEmailReport($message, $title);
741 $this->sendSlackReport($message, $title);
742
743 return true;
744 }
745
746 /**
747 * Send a warning report email
748 * A Generic title will be used if no title is provided
749 * Internally uses sendEmailReport()
750 *
751 * @param string $message
752 * @param string $title
753 * @return bool
754 */
755 public function sendWarningReport(string $message, string $title = ''): bool
756 {
757 if (get_option(self::OPTION_BACKUP_SCHEDULE_WARNING_REPORT) !== 'true') {
758 return false;
759 }
760
761 if (empty($message)) {
762 return false;
763 }
764
765 if (empty($title)) {
766 $title = esc_html__('WP Staging - Backup Warning Report', 'wp-staging');
767 }
768
769 $this->sendEmailReport($message, $title, self::REPORT_TYPE_WARNING);
770
771 return true;
772 }
773
774 /**
775 * Send a general report email
776 * A Generic title will be used if no title is provided
777 * Internally uses sendEmailReport()
778 *
779 * @param string $message
780 * @param string $title
781 * @return bool
782 */
783 public function sendGeneralReport(string $message, string $title = ''): bool
784 {
785 if (get_option(self::OPTION_BACKUP_SCHEDULE_GENERAL_REPORT) !== 'true') {
786 return false;
787 }
788
789 if (empty($message)) {
790 return false;
791 }
792
793 if (empty($title)) {
794 $title = esc_html__('WP Staging - Backup General Report', 'wp-staging');
795 }
796
797 $this->sendEmailReport($message, $title, self::REPORT_TYPE_GENERAL);
798
799 return true;
800 }
801
802 /**
803 * Send a report email
804 * A Generic title will be used if no title is provided
805 *
806 * @param string $message
807 * @param string $title
808 * @return bool
809 */
810 public function sendEmailReport(string $message, string $title = '', string $reportType = self::REPORT_TYPE_ERROR): bool
811 {
812 $optionName = $this->getReportOptionName($reportType);
813
814 if (get_option($optionName) !== 'true') {
815 return false;
816 }
817
818 $reportEmail = get_option(Notifications::OPTION_BACKUP_SCHEDULE_REPORT_EMAIL);
819 if (!filter_var($reportEmail, FILTER_VALIDATE_EMAIL)) {
820 return false;
821 }
822
823 // Only send the report mail once every 5 minutes
824 $transientName = $this->getReportTransientName($reportType);
825 if (get_transient($transientName) !== false) {
826 return false;
827 }
828
829 if (empty($message)) {
830 return false;
831 }
832
833 if (empty($title)) {
834 $title = $this->getDefaultReportTitle($reportType);
835 }
836
837 // Set the transient to prevent sending the error report mail again for 5 minutes
838 $transientName = $this->getReportTransientName($reportType);
839 set_transient($transientName, true, 5 * 60);
840
841 if (get_option(Notifications::OPTION_SEND_EMAIL_AS_HTML, false) === 'true') {
842 return $this->notifications->sendEmailAsHTML($reportEmail, $title, $message);
843 }
844
845 return $this->notifications->sendEmail($reportEmail, $title, $message);
846 }
847
848 /**
849 * Send a report slack
850 * A Generic title will be used if no title is provided
851 *
852 * @param string $message
853 * @param string $title
854 * @return bool
855 */
856 public function sendSlackReport(string $message, string $title = ''): bool
857 {
858 if (!WPStaging::isPro()) {
859 return false;
860 }
861
862 if (get_option(self::OPTION_BACKUP_SCHEDULE_SLACK_ERROR_REPORT) !== 'true') {
863 return false;
864 }
865
866 $webhook = get_option(self::OPTION_BACKUP_SCHEDULE_REPORT_SLACK_WEBHOOK);
867 if (!filter_var($webhook, FILTER_VALIDATE_URL)) {
868 return false;
869 }
870
871 // Only send the error report mail once every 5 minutes
872 if (get_transient(self::TRANSIENT_BACKUP_SCHEDULE_SLACK_REPORT_SENT) !== false) {
873 return false;
874 }
875
876 if (empty($message)) {
877 return false;
878 }
879
880 if (empty($title)) {
881 $title = esc_html__('WP Staging - Backup Report', 'wp-staging');
882 }
883
884 // Set the transient to prevent sending the error report mail again for 5 minutes
885 set_transient(self::TRANSIENT_BACKUP_SCHEDULE_SLACK_REPORT_SENT, true, 5 * 60);
886 return $this->notifications->sendSlack($webhook, $title, $message);
887 }
888
889 /**
890 * Get the option name for a specific report type
891 *
892 * @param string $reportType
893 * @return string
894 */
895 private function getReportOptionName(string $reportType): string
896 {
897 switch ($reportType) {
898 case self::REPORT_TYPE_WARNING:
899 return self::OPTION_BACKUP_SCHEDULE_WARNING_REPORT;
900 case self::REPORT_TYPE_GENERAL:
901 return self::OPTION_BACKUP_SCHEDULE_GENERAL_REPORT;
902 default:
903 return self::OPTION_BACKUP_SCHEDULE_ERROR_REPORT;
904 }
905 }
906
907 /**
908 * Get the transient name for a specific report type
909 *
910 * @param string $reportType
911 * @return string
912 */
913 private function getReportTransientName(string $reportType): string
914 {
915 switch ($reportType) {
916 case self::REPORT_TYPE_WARNING:
917 return self::TRANSIENT_BACKUP_SCHEDULE_WARNING_REPORT_SENT;
918 case self::REPORT_TYPE_GENERAL:
919 return self::TRANSIENT_BACKUP_SCHEDULE_GENERAL_REPORT_SENT;
920 case self::REPORT_TYPE_ERROR:
921 default:
922 return self::TRANSIENT_BACKUP_SCHEDULE_ERROR_REPORT_SENT;
923 }
924 }
925
926 /**
927 * Get the default title for a specific report type
928 *
929 * @param string $reportType
930 * @return string
931 */
932 private function getDefaultReportTitle(string $reportType): string
933 {
934 switch ($reportType) {
935 case self::REPORT_TYPE_WARNING:
936 return esc_html__('WP Staging - Backup Warning Report', 'wp-staging');
937 case self::REPORT_TYPE_GENERAL:
938 return esc_html__('WP Staging - Backup General Report', 'wp-staging');
939 default:
940 return esc_html__('WP Staging - Backup Error Report', 'wp-staging');
941 }
942 }
943
944 /**
945 * @return bool
946 */
947 private function isSchedulesEmpty(): bool
948 {
949 $schedules = get_option(static::OPTION_BACKUP_SCHEDULES, []);
950 if (empty($schedules)) {
951 return true;
952 }
953
954 return false;
955 }
956
957 /**
958 * @return array
959 */
960 private function getCronJobs(): array
961 {
962 $cron = get_option('cron');
963 if (!is_array($cron)) {
964 return [];
965 }
966
967 $cronJobs = [];
968 foreach ($cron as $timestamp => $hooks) {
969 if (!is_numeric($timestamp) || !is_array($hooks)) {
970 continue;
971 }
972
973 $cronJobs[(int)$timestamp] = $hooks;
974 }
975
976 return $cronJobs;
977 }
978
979 /**
980 * @return void
981 */
982 private function countOverdueCronjobs()
983 {
984 $cronJobs = $this->getCronJobs();
985 $timeNow = time();
986 foreach ($cronJobs as $expectedExecutionTime => $cronJob) {
987 if (($expectedExecutionTime + self::OVERDUE_GRACE_PERIOD) < $timeNow) {
988 $this->numberOverdueCronjobs++;
989 }
990 }
991 }
992
993 /**
994 * Listener for the background-job failure hook.
995 * Persists the failure so the cron-warning banner can surface it.
996 * Only acts when the failing job was a scheduled backup.
997 *
998 * @param array $args Keys: jobDataDto, errorMessage
999 * @return void
1000 */
1001 public function onBackgroundJobFailure(array $args)
1002 {
1003 $jobDataDto = isset($args['jobDataDto']) ? $args['jobDataDto'] : null;
1004 if (!($jobDataDto instanceof JobBackupDataDto)) {
1005 return;
1006 }
1007
1008 if (!$jobDataDto->isScheduledBackup()) {
1009 return;
1010 }
1011
1012 $errorMessage = isset($args['errorMessage']) ? (string)$args['errorMessage'] : '';
1013 $this->saveBackupFailure($errorMessage);
1014 }
1015
1016 /**
1017 * @param string $message
1018 * @return void
1019 */
1020 private function saveBackupFailure(string $message)
1021 {
1022 update_option(self::OPTION_LAST_BACKUP_FAILURE, [
1023 'time' => time(),
1024 'message' => $message,
1025 ], false);
1026 }
1027
1028 /**
1029 * Schedules exist at this point, so a backup cron event that is missing from the queue —
1030 * or past due beyond the grace period — means scheduled backups are not executing.
1031 * Unlike countOverdueCronjobs(), this checks only our own backup event.
1032 *
1033 * @return bool
1034 */
1035 private function hasOverdueOrMissingBackupCronJob(): bool
1036 {
1037 $eventExists = false;
1038 $now = time();
1039
1040 foreach ($this->getCronJobs() as $timestamp => $hooks) {
1041 if (!isset($hooks[Cron::ACTION_CREATE_CRON_BACKUP])) {
1042 continue;
1043 }
1044
1045 $eventExists = true;
1046 if (($timestamp + self::OVERDUE_GRACE_PERIOD) < $now) {
1047 return true;
1048 }
1049 }
1050
1051 return !$eventExists;
1052 }
1053 }
1054