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