PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backup, Restore, Migration & Clone / 4.3.2
WP STAGING – WordPress Backup, Restore, Migration & Clone v4.3.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 8 months ago BackgroundProcessing 1 year ago Dto 8 months ago Entity 10 months ago Exceptions 1 year ago Interfaces 1 year ago Job 8 months ago Request 1 year ago Service 8 months ago Storage 8 months ago Task 8 months ago Traits 10 months ago AfterRestore.php 1 year ago BackupDeleter.php 1 year ago BackupDownload.php 8 months ago BackupFileIndex.php 1 year ago BackupGlitchReason.php 1 year ago BackupHeader.php 8 months ago BackupRepairer.php 1 year ago BackupRetentionHandler.php 1 year ago BackupScheduler.php 11 months ago BackupServiceProvider.php 10 months ago BackupValidator.php 8 months ago FileHeader.php 8 months ago FileHeaderAttribute.php 2 years ago WithBackupIdentifier.php 1 year ago
BackupScheduler.php
808 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\Facades\Escape;
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\Framework\Utils\ServerVars;
17 use WPStaging\Notifications\Notifications;
18
19 use function WPStaging\functions\debug_log;
20
21 class BackupScheduler
22 {
23 /** @var string */
24 const OPTION_BACKUP_SCHEDULE_ERROR_REPORT = 'wpstg_backup_schedules_send_error_report';
25
26 /** @var string */
27 const TRANSIENT_BACKUP_SCHEDULE_REPORT_SENT = 'wpstg.backup.schedules.report_sent';
28
29 /** @var string */
30 const OPTION_BACKUP_SCHEDULE_SLACK_ERROR_REPORT = 'wpstg_backup_schedules_send_slack_error_report';
31
32 /** @var string */
33 const OPTION_BACKUP_SCHEDULE_REPORT_SLACK_WEBHOOK = 'wpstg_backup_schedules_report_slack_webhook';
34
35 /** @var string */
36 const TRANSIENT_BACKUP_SCHEDULE_SLACK_REPORT_SENT = 'wpstg.backup.schedules.slack_report_sent';
37
38 /** @var string */
39 const OPTION_BACKUP_SCHEDULES = 'wpstg_backup_schedules';
40
41 /** @var BackupsFinder */
42 protected $backupsFinder;
43
44 /** @var ProcessLock */
45 protected $processLock;
46
47 /** @var BackupDeleter */
48 protected $backupDeleter;
49
50 /**
51 * @var Notifications
52 */
53 protected $notifications;
54
55 /**
56 * Store cron related message
57 * @var string
58 */
59 protected $cronMessage;
60
61 /** @var int */
62 protected $numberOverdueCronjobs = 0;
63
64 /**
65 * @param BackupsFinder $backupsFinder
66 * @param ProcessLock $processLock
67 * @param BackupDeleter $backupDeleter
68 * @param Notifications $notifications
69 */
70 public function __construct(BackupsFinder $backupsFinder, ProcessLock $processLock, BackupDeleter $backupDeleter, Notifications $notifications)
71 {
72 $this->backupsFinder = $backupsFinder;
73 $this->processLock = $processLock;
74 $this->backupDeleter = $backupDeleter;
75 $this->notifications = $notifications;
76
77 $this->countOverdueCronjobs();
78 }
79
80 /**
81 * @return array
82 */
83 public function getSchedules(): array
84 {
85 $schedules = get_option(static::OPTION_BACKUP_SCHEDULES, []);
86 if (is_array($schedules)) {
87 return $schedules;
88 }
89
90 return [];
91 }
92
93 /**
94 * @param JobBackupDataDto $jobBackupDataDto
95 * @return void
96 */
97 public function maybeDeleteOldBackups(JobBackupDataDto $jobBackupDataDto)
98 {
99 $scheduleId = $jobBackupDataDto->getScheduleId();
100
101 // Not a scheduled backup, nothing to do.
102 if (empty($scheduleId)) {
103 return;
104 }
105
106 $schedules = get_option(static::OPTION_BACKUP_SCHEDULES, []);
107
108 $schedule = array_filter($schedules, function ($schedule) use ($scheduleId) {
109 return $schedule['scheduleId'] == $scheduleId;
110 });
111
112 if (empty($schedule)) {
113 debug_log("Could not delete old backups for schedule ID $scheduleId as the schedule rotation plan was not found in the database.");
114 return;
115 }
116
117 $schedule = array_shift($schedule);
118
119 $maxAllowedBackupFiles = absint($schedule['rotation']);
120
121 $backupFiles = $this->backupsFinder->findBackupByScheduleId($scheduleId);
122
123 // Early bail: Not enough backups to trigger the rotation
124 if (count($backupFiles) < $maxAllowedBackupFiles) {
125 return;
126 }
127
128 // Sort backups, older first
129 uasort($backupFiles, function ($backup1, $backup2) {
130 /**
131 * @var \SplFileInfo $backup1
132 * @var \SplFileInfo $backup2
133 */
134 if ($backup1->getMTime() === $backup2->getMTime()) {
135 return 0;
136 }
137
138 return $backup1->getMTime() < $backup2->getMTime() ? -1 : 1;
139 });
140
141 // Make sure array indexes are correctly ordered.
142 $backupFiles = array_values($backupFiles);
143
144 // Get exceeding backups, including an extra one for the backup that will be created right now.
145 $backupFiles = array_slice($backupFiles, 0, max(1, count($backupFiles) - $maxAllowedBackupFiles + 1));
146
147 array_map(function ($file) {
148 $this->backupDeleter->clearErrors();
149 $this->backupDeleter->deleteBackup($file);
150 $errors = $this->backupDeleter->getErrors();
151 foreach ($errors as $error) {
152 debug_log('Tried to cleanup old backups for backup plan rotation, but couldn\'t delete file: ' . $error);
153 }
154 }, $backupFiles);
155 }
156
157 /**
158 * @param JobBackupDataDto $jobBackupDataDto
159 * @param string $scheduleId
160 * @return void
161 * @throws \Exception
162 */
163 public function scheduleBackup(JobBackupDataDto $jobBackupDataDto, string $scheduleId)
164 {
165 if (!isset(wp_get_schedules()[$jobBackupDataDto->getScheduleRecurrence()])) {
166 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));
167
168 return;
169 }
170
171 $firstSchedule = new \DateTime('now', wp_timezone());
172 $time = $jobBackupDataDto->getScheduleTime();
173 $this->setUpcomingDateTime($firstSchedule, $time);
174
175 $backupSchedule = [
176 'scheduleId' => $scheduleId,
177 'schedule' => $jobBackupDataDto->getScheduleRecurrence(),
178 'backupType' => $jobBackupDataDto->getBackupType(),
179 'subsiteBlogId' => $jobBackupDataDto->getSubsiteBlogId(), // required for network subsite backup type
180 'time' => $time,
181 'name' => $jobBackupDataDto->getName(),
182 'rotation' => $jobBackupDataDto->getScheduleRotation(),
183 'isExportingPlugins' => $jobBackupDataDto->getIsExportingPlugins(),
184 'isExportingMuPlugins' => $jobBackupDataDto->getIsExportingMuPlugins(),
185 'isExportingThemes' => $jobBackupDataDto->getIsExportingThemes(),
186 'isExportingUploads' => $jobBackupDataDto->getIsExportingUploads(),
187 'isExportingOtherWpContentFiles' => $jobBackupDataDto->getIsExportingOtherWpContentFiles(),
188 'isExportingOtherWpRootFiles' => $jobBackupDataDto->getIsExportingOtherWpRootFiles(),
189 'isExportingDatabase' => $jobBackupDataDto->getIsExportingDatabase(),
190 'sitesToBackup' => $jobBackupDataDto->getSitesToBackup(),
191 'storages' => $jobBackupDataDto->getStorages(),
192 'firstSchedule' => $firstSchedule->getTimestamp(),
193 'isSmartExclusion' => $jobBackupDataDto->getIsSmartExclusion(),
194 'isExcludingSpamComments' => $jobBackupDataDto->getIsExcludingSpamComments(),
195 'isExcludingPostRevision' => $jobBackupDataDto->getIsExcludingPostRevision(),
196 'isExcludingDeactivatedPlugins' => $jobBackupDataDto->getIsExcludingDeactivatedPlugins(),
197 'isExcludingUnusedThemes' => $jobBackupDataDto->getIsExcludingUnusedThemes(),
198 'isExcludingLogs' => $jobBackupDataDto->getIsExcludingLogs(),
199 'isExcludingCaches' => $jobBackupDataDto->getIsExcludingCaches(),
200 'isWpCliRequest' => true, // should be true otherwise multisite backup will not work
201 'backupExcludedDirectories' => $jobBackupDataDto->getBackupExcludedDirectories(),
202 ];
203
204 if (wp_next_scheduled(Cron::ACTION_CREATE_CRON_BACKUP, [$backupSchedule])) {
205 debug_log('[Schedule Backup Cron] Early bailed when registering the cron to create a backup on a schedule, because it already exists');
206
207 return;
208 }
209
210 $this->registerScheduleInDb($backupSchedule);
211 $this->reCreateCron();
212 }
213
214 /**
215 * Registers a schedule in the Db.
216 * @param array $backupSchedule
217 * @return bool false on error or if nothing would be updated
218 */
219 protected function registerScheduleInDb(array $backupSchedule): bool
220 {
221 $backupSchedules = get_option(static::OPTION_BACKUP_SCHEDULES, []);
222 if (!is_array($backupSchedules)) {
223 $backupSchedules = [];
224 }
225
226 $backupSchedules[] = $backupSchedule;
227
228 if (!update_option(static::OPTION_BACKUP_SCHEDULES, $backupSchedules, false)) {
229 debug_log('[Schedule Backup Cron] Could not update BackupSchedules DB option');
230 return false;
231 }
232
233 return true;
234 }
235
236 /**
237 * AJAX callback that processes the backup schedule.
238 *
239 * @param array $backupData
240 * @return void
241 */
242 public function createCronBackup(array $backupData)
243 {
244 // Cron is hell to debug, so let's log everything that happens.
245 $logId = wp_generate_password(4, false);
246
247 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);
248
249 try {
250 debug_log(sprintf("[Schedule Backup Cron - %s] Preparing job", $logId), 'info', false);
251 $jobId = WPStaging::make(PrepareBackup::class)->prepare($backupData);
252 if ($jobId instanceof \WP_Error) {
253 debug_log(sprintf("[Schedule Backup Cron - %s] Failed to create backup: %s", $logId, $jobId->get_error_message()));
254 return;
255 }
256
257 debug_log(sprintf("[Schedule Backup Cron - %s] Successfully received a Job ID: %s", $logId, $jobId), 'info', false);
258 } catch (\Exception $e) {
259 debug_log("[Schedule Backup Cron - $logId] Exception thrown while preparing the Backup: " . $e->getMessage());
260 }
261 }
262
263 /**
264 * Ajax callback to dismiss a schedule.
265 * @return void
266 */
267 public function dismissSchedule()
268 {
269 if (!current_user_can((new Capabilities())->manageWPSTG())) {
270 return;
271 }
272
273 if (!(new Nonce())->requestHasValidNonce(Nonce::WPSTG_NONCE)) {
274 return;
275 }
276
277 if (empty($_POST['scheduleId'])) {
278 return;
279 }
280
281 try {
282 $this->deleteSchedule(Sanitize::sanitizeString($_POST['scheduleId']));
283 wp_send_json_success();
284 } catch (\Exception $e) {
285 wp_send_json_error($e->getMessage());
286 }
287 }
288
289 /**
290 * Deletes a backup schedule.
291 *
292 * @param string $scheduleId The schedule ID to delete.
293 * @return void
294 */
295 public function deleteSchedule(string $scheduleId, $reCreateCron = true)
296 {
297 $schedules = $this->getSchedules();
298
299 $newSchedules = array_filter($schedules, function ($schedule) use ($scheduleId) {
300 return $schedule['scheduleId'] != $scheduleId;
301 });
302
303 if (!update_option(static::OPTION_BACKUP_SCHEDULES, $newSchedules, false)) {
304 debug_log('[Schedule Backup Cron] Could not update BackupSchedules DB option after removing schedule.');
305 throw new \RuntimeException('Could not unschedule event from Db.');
306 }
307
308 if ($reCreateCron === false) {
309 return;
310 }
311
312 $this->reCreateCron();
313 }
314
315 /**
316 * @param string|null $scheduleBeingEdit The schedule ID being edited. If this is set, it will be ignored when re-creating the Cron events.
317 * @return bool
318 * @throws \Exception
319 * @see OPTION_BACKUP_SCHEDULES The Db option that is the source of truth for Cron events.
320 * The backup schedule cron events are deleted and re-created
321 * based on what is in this db option.
322 *
323 * This way, we only care about preserving this option on Backup
324 * Restore or Push, and we don't have to worry about re-scheduling
325 * the Cron events or removing leftover schedules.
326 *
327 */
328 public function reCreateCron($scheduleBeingEdit = null): bool
329 {
330 $schedules = $this->getSchedules();
331 static::removeBackupSchedulesFromCron();
332
333 $errors = [];
334
335 foreach ($schedules as $schedule) {
336 $timeToSchedule = new \DateTime('now', wp_timezone());
337
338 /**
339 * New mechanism for recroning old jobs
340 */
341 if (isset(wp_get_schedules()[$schedule['schedule']]) && isset($schedule['firstSchedule']) && ($schedule['scheduleId'] !== $scheduleBeingEdit)) {
342 $this->setNextSchedulingDate($timeToSchedule, $schedule);
343 } else {
344 $this->setUpcomingDateTime($timeToSchedule, $schedule['time']);
345 }
346
347 /** @see BackupServiceProvider::enqueueAjaxListeners */
348 $result = wp_schedule_event($timeToSchedule->format('U'), $schedule['schedule'], Cron::ACTION_CREATE_CRON_BACKUP, [$schedule]);
349
350 // Could not register Cron event.
351 // Log errors but keep trying for the other cron events or all of them will be lost
352 if ($result === false || $result instanceof \WP_Error) {
353 if ($result instanceof \WP_Error) {
354 $details = $result->get_error_message();
355 } else {
356 $details = '';
357 }
358
359 $error = '[Schedule Backup Cron] Failed to register the cron event wpstg_create_cron_backup. ' . $schedule['schedule'] . ' ' . $details;
360
361 $errors[] = $error;
362
363 debug_log($error);
364 }
365 }
366
367 if (!empty($errors)) {
368 return false;
369 }
370
371 return true;
372 }
373
374 /**
375 * Removes all backup schedule events from WordPress Cron array.
376 *
377 * This is static so that it can be called from WP STAGING deactivation hook
378 * without having to bootstrap the entire plugin.
379 *
380 * This is a low-level function that can run when WP STAGING has not been
381 * bootstrapped, so there's no autoload nor Container available.
382 */
383 public static function removeBackupSchedulesFromCron(): bool
384 {
385 $cron = get_option('cron');
386
387 // Bail: Unexpected value - should never happen.
388 if (!is_array($cron)) {
389 return false;
390 }
391
392 // Remove any backup schedules from Cron
393 foreach ($cron as $timestamp => &$events) {
394 if (is_array($events)) {
395 foreach ($events as $callback => &$args) {
396 if ($callback === Cron::ACTION_CREATE_CRON_BACKUP) {
397 unset($cron[$timestamp][$callback]);
398 }
399 }
400 }
401 }
402
403 // After removing the backup schedule events,
404 // we might have timestamps with no events.
405 // So we remove any leftover timestamps that don't have any events.
406 $cron = array_filter($cron, function ($timestamps) {
407 return !empty($timestamps);
408 });
409
410 update_option('cron', $cron);
411
412 return true;
413 }
414
415 /**
416 * Check cron status whether it is working or not
417 * Logic is adopted from WP Crontrol plugin
418 *
419 * @return bool
420 */
421 public function checkCronStatus(): bool
422 {
423 global $wp_version;
424
425 $this->cronMessage = '';
426
427 if ($this->isCronjobsOverdue()) {
428 if (WPStaging::isPro()) {
429 $this->cronMessage .= sprintf(
430 __('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'),
431 $this->numberOverdueCronjobs,
432 'https://wp-staging.com/docs/wp-cron-is-not-working-correctly/'
433 );
434
435 if (WPStaging::make(ServerVars::class)->isLitespeed()) {
436 $this->cronMessage .= sprintf(
437 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')),
438 'https://wp-staging.com/docs/scheduled-backups-do-not-work-hosting-company-uses-the-litespeed-webserver-fix-wp-cron/'
439 );
440 }
441 } else {
442 $this->cronMessage .= sprintf(
443 __('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'),
444 $this->numberOverdueCronjobs,
445 'https://wordpress.org/support/plugin/wp-staging/'
446 );
447
448 if (WPStaging::make(ServerVars::class)->isLitespeed()) {
449 $this->cronMessage .= sprintf(
450 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')),
451 'https://wordpress.org/support/plugin/wp-staging/'
452 );
453 }
454 }
455 }
456
457 // Third party plugins that handle crons
458 $thirdPartyCronPlugins = [
459 '\HM\Cavalcade\Plugin\Job' => 'Cavalcade',
460 '\Automattic\WP\Cron_Control\Main' => 'Cron Control',
461 '\KMM\KRoN\Core' => 'KMM KRoN',
462 ];
463
464 foreach ($thirdPartyCronPlugins as $class => $plugin) {
465 if (class_exists($class)) {
466 $this->cronMessage .= sprintf(
467 __('WP Cron is being managed by a third party plugin: %s plugin.', 'wp-staging'),
468 $plugin
469 );
470
471 return true;
472 }
473 }
474
475 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
476 if (WPStaging::isPro()) {
477 $this->cronMessage .= sprintf(
478 __('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'),
479 '<code>DISABLE_WP_CRON</code>',
480 '<code>true</code>',
481 '<code>false</code>'
482 );
483 } else {
484 $this->cronMessage .= sprintf(
485 __('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'),
486 '<code>DISABLE_WP_CRON</code>',
487 '<code>true</code>',
488 '<code>false</code>',
489 'https://wordpress.org/support/plugin/wp-staging/'
490 );
491 }
492
493 return true;
494 }
495
496 if (defined('ALTERNATE_WP_CRON') && ALTERNATE_WP_CRON) {
497 $this->cronMessage .= sprintf(
498 __('The constant %s is set to true.', 'wp-staging'),
499 'ALTERNATE_WP_CRON'
500 );
501
502 return true;
503 }
504
505 // Don't do the next time expensive checking if no schedules are set
506 if ($this->isSchedulesEmpty()) {
507 return true;
508 }
509
510 $sslverify = version_compare($wp_version, '4.0', '<');
511 $doingWpCron = sprintf('%.22F', microtime(true));
512 $urlEndpoint = add_query_arg('doing_wp_cron', $doingWpCron, site_url('wp-cron.php'));
513
514 $cronRequest = apply_filters('cron_request', [
515 'url' => $urlEndpoint,
516 'key' => $doingWpCron,
517 'args' => [
518 'timeout' => 10,
519 'blocking' => true,
520 'sslverify' => apply_filters('https_local_ssl_verify', $sslverify),
521 ],
522 ]);
523
524 $cronRequest['args']['blocking'] = true;
525
526 $result = wp_remote_post($cronRequest['url'], $cronRequest['args']);
527
528 if (is_wp_error($result)) {
529 $this->cronMessage .= "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);
530 // Only send the error report mail if error is caused by WP STAGING
531 if ($this->isWpstgError()) {
532 $this->sendErrorReport($this->cronMessage);
533 }
534
535 return false;
536 }
537
538 if (wp_remote_retrieve_response_code($result) >= 300) {
539 $this->cronMessage .= sprintf(
540 __('Unexpected HTTP response code: %s. Cron jobs and backup schedule might still work, but we recommend checking the HTTP response of %s', 'wp-staging'),
541 intval(wp_remote_retrieve_response_code($result)),
542 esc_url($urlEndpoint)
543 );
544
545 return false;
546 }
547
548 return true;
549 }
550
551 /**
552 * @return bool
553 */
554 private function isCronjobsOverdue(): bool
555 {
556 return $this->numberOverdueCronjobs > 4;
557 }
558
559 /** @return string */
560 public function getCronMessage(): string
561 {
562 return $this->cronMessage;
563 }
564
565 /**
566 * @return array An array where the first item is the timestamp, and the second is the backup callback.
567 * @throws \Exception When there is no backup scheduled or one could not be found.
568 */
569 public function getNextBackupSchedule(): array
570 {
571 $cron = get_option('cron');
572
573 // Bail: Unexpected value - should never happen.
574 if (!is_array($cron)) {
575 throw new \UnexpectedValueException();
576 }
577
578 ksort($cron, SORT_NUMERIC);
579
580 // Remove any backup schedules from Cron
581 foreach ($cron as $timestamp => &$events) {
582 if (is_array($events)) {
583 foreach ($events as $callback => &$args) {
584 if ($callback === Cron::ACTION_CREATE_CRON_BACKUP) {
585 return [$timestamp, $cron[$timestamp][$callback]];
586 }
587 }
588 }
589 }
590
591 // No results found
592 throw new \OutOfBoundsException();
593 }
594
595 /**
596 * Set date today or tomorrow for given DateTime object according to time
597 *
598 * @param DateTime $datetime
599 * @param string|array $time
600 * @return void
601 */
602 protected function setUpcomingDateTime(DateTime &$datetime, $time)
603 {
604 if (is_array($time)) {
605 $hourAndMinute = $time;
606 } else {
607 $hourAndMinute = explode(':', $time);
608 }
609
610 // The event should be scheduled later today or tomorrow? Compares "Hi (Hourminute)" timestamps to figure out.
611 if ((int)sprintf('%s%s', $hourAndMinute[0], $hourAndMinute[1]) < (int)$datetime->format('Hi')) {
612 $datetime->add(new \DateInterval('P1D'));
613 }
614
615 $datetime->setTime($hourAndMinute[0], $hourAndMinute[1]);
616 }
617
618 /**
619 * Set the next scheduling date for the schedule
620 *
621 * @param DateTime $datetime
622 * @param array $schedule
623 * @return void
624 */
625 protected function setNextSchedulingDate(DateTime &$datetime, array $schedule)
626 {
627 $next = $schedule['firstSchedule'];
628 $now = $datetime->getTimestamp();
629 if ($next >= $now) {
630 $this->setUpcomingDateTime($datetime, $schedule['time']);
631 return;
632 }
633
634 $recurrance = wp_get_schedules()[$schedule['schedule']];
635 while ($next < $now) {
636 $next += $recurrance['interval'];
637 }
638
639 $datetime->setTimestamp($next);
640 }
641
642 /**
643 * Detect whether the last error is caused by WP STAGING
644 *
645 * @return bool
646 */
647 protected function isWpstgError(): bool
648 {
649 $error = error_get_last();
650 if (!is_array($error)) {
651 return false;
652 }
653
654 return strpos($error['file'], WPSTG_PLUGIN_SLUG) !== false;
655 }
656
657 /**
658 * Send an error report email
659 * A Generic title will be used if no title is provided
660 * Internally use of sendEmailReport()
661 *
662 * @param string $message
663 * @param string $title
664 * @return bool
665 */
666 public function sendErrorReport(string $message, string $title = ''): bool
667 {
668 if (empty($message)) {
669 return false;
670 }
671
672 if (strpos($message, 'index resource') !== false) {
673 $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');
674 }
675
676 if (empty($title)) {
677 $title = esc_html__('WP Staging - Backup Error Report', 'wp-staging');
678 }
679
680 $this->sendEmailReport($message, $title);
681 $this->sendSlackReport($message, $title);
682
683 return true;
684 }
685
686 /**
687 * Send a report email
688 * A Generic title will be used if no title is provided
689 *
690 * @param string $message
691 * @param string $title
692 * @return bool
693 */
694 public function sendEmailReport(string $message, string $title = ''): bool
695 {
696 if (get_option(self::OPTION_BACKUP_SCHEDULE_ERROR_REPORT) !== 'true') {
697 return false;
698 }
699
700 $reportEmail = get_option(Notifications::OPTION_BACKUP_SCHEDULE_REPORT_EMAIL);
701 if (!filter_var($reportEmail, FILTER_VALIDATE_EMAIL)) {
702 return false;
703 }
704
705 // Only send the error report mail once every 5 minutes
706 if (get_transient(self::TRANSIENT_BACKUP_SCHEDULE_REPORT_SENT) !== false) {
707 return false;
708 }
709
710 if (empty($message)) {
711 return false;
712 }
713
714 if (empty($title)) {
715 $title = esc_html__('WP Staging - Backup Report', 'wp-staging');
716 }
717
718 // Set the transient to prevent sending the error report mail again for 5 minutes
719 set_transient(self::TRANSIENT_BACKUP_SCHEDULE_REPORT_SENT, true, 5 * 60);
720 if (get_option(Notifications::OPTION_SEND_EMAIL_AS_HTML, false) === 'true') {
721 return $this->notifications->sendEmailAsHTML($reportEmail, $title, $message);
722 }
723
724 return $this->notifications->sendEmail($reportEmail, $title, $message);
725 }
726
727 /**
728 * Send a report slack
729 * A Generic title will be used if no title is provided
730 *
731 * @param string $message
732 * @param string $title
733 * @return bool
734 */
735 public function sendSlackReport(string $message, string $title = ''): bool
736 {
737 if (!WPStaging::isPro()) {
738 return false;
739 }
740
741 if (get_option(self::OPTION_BACKUP_SCHEDULE_SLACK_ERROR_REPORT) !== 'true') {
742 return false;
743 }
744
745 $webhook = get_option(self::OPTION_BACKUP_SCHEDULE_REPORT_SLACK_WEBHOOK);
746 if (!filter_var($webhook, FILTER_VALIDATE_URL)) {
747 return false;
748 }
749
750 // Only send the error report mail once every 5 minutes
751 if (get_transient(self::TRANSIENT_BACKUP_SCHEDULE_SLACK_REPORT_SENT) !== false) {
752 return false;
753 }
754
755 if (empty($message)) {
756 return false;
757 }
758
759 if (empty($title)) {
760 $title = esc_html__('WP Staging - Backup Report', 'wp-staging');
761 }
762
763 // Set the transient to prevent sending the error report mail again for 5 minutes
764 set_transient(self::TRANSIENT_BACKUP_SCHEDULE_SLACK_REPORT_SENT, true, 5 * 60);
765 return $this->notifications->sendSlack($webhook, $title, $message);
766 }
767
768 /**
769 * @return bool
770 */
771 private function isSchedulesEmpty(): bool
772 {
773 $schedules = get_option(static::OPTION_BACKUP_SCHEDULES, []);
774 if (empty($schedules)) {
775 return true;
776 }
777
778 return false;
779 }
780
781 /**
782 * @return array
783 */
784 private function getCronJobs(): array
785 {
786 $cron = get_option('cron');
787 if (!is_array($cron)) {
788 return [];
789 }
790
791 return $cron;
792 }
793
794 /**
795 * @return void
796 */
797 private function countOverdueCronjobs()
798 {
799 $cronJobs = $this->getCronJobs();
800 $timeNow = time();
801 foreach ($cronJobs as $expectedExecutionTime => $cronJob) {
802 if ($expectedExecutionTime < $timeNow) {
803 $this->numberOverdueCronjobs++;
804 }
805 }
806 }
807 }
808