MDL-61578 backup: Prevent backup logging triggering fresh backups.
[moodle.git] / backup / util / helper / backup_cron_helper.class.php
1 <?php
3 // This file is part of Moodle - http://moodle.org/
4 //
5 // Moodle is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
9 //
10 // Moodle is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 // GNU General Public License for more details.
14 //
15 // You should have received a copy of the GNU General Public License
16 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
18 /**
19  * Utility helper for automated backups run through cron.
20  *
21  * @package    core
22  * @subpackage backup
23  * @copyright  2010 Sam Hemelryk
24  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25  */
27 defined('MOODLE_INTERNAL') || die();
29 /**
30  * This class is an abstract class with methods that can be called to aid the
31  * running of automated backups over cron.
32  */
33 abstract class backup_cron_automated_helper {
35     /** Automated backups are active and ready to run */
36     const STATE_OK = 0;
37     /** Automated backups are disabled and will not be run */
38     const STATE_DISABLED = 1;
39     /** Automated backups are all ready running! */
40     const STATE_RUNNING = 2;
42     /** Course automated backup completed successfully */
43     const BACKUP_STATUS_OK = 1;
44     /** Course automated backup errored */
45     const BACKUP_STATUS_ERROR = 0;
46     /** Course automated backup never finished */
47     const BACKUP_STATUS_UNFINISHED = 2;
48     /** Course automated backup was skipped */
49     const BACKUP_STATUS_SKIPPED = 3;
50     /** Course automated backup had warnings */
51     const BACKUP_STATUS_WARNING = 4;
52     /** Course automated backup has yet to be run */
53     const BACKUP_STATUS_NOTYETRUN = 5;
55     /** Run if required by the schedule set in config. Default. **/
56     const RUN_ON_SCHEDULE = 0;
57     /** Run immediately. **/
58     const RUN_IMMEDIATELY = 1;
60     const AUTO_BACKUP_DISABLED = 0;
61     const AUTO_BACKUP_ENABLED = 1;
62     const AUTO_BACKUP_MANUAL = 2;
64     /** Automated backup storage in course backup filearea */
65     const STORAGE_COURSE = 0;
66     /** Automated backup storage in specified directory */
67     const STORAGE_DIRECTORY = 1;
68     /** Automated backup storage in course backup filearea and specified directory */
69     const STORAGE_COURSE_AND_DIRECTORY = 2;
71     /**
72      * Runs the automated backups if required
73      *
74      * @global moodle_database $DB
75      */
76     public static function run_automated_backup($rundirective = self::RUN_ON_SCHEDULE) {
77         global $CFG, $DB;
79         $status = true;
80         $emailpending = false;
81         $now = time();
82         $config = get_config('backup');
84         mtrace("Checking automated backup status",'...');
85         $state = backup_cron_automated_helper::get_automated_backup_state($rundirective);
86         if ($state === backup_cron_automated_helper::STATE_DISABLED) {
87             mtrace('INACTIVE');
88             return $state;
89         } else if ($state === backup_cron_automated_helper::STATE_RUNNING) {
90             mtrace('RUNNING');
91             if ($rundirective == self::RUN_IMMEDIATELY) {
92                 mtrace('Automated backups are already running. If this script is being run by cron this constitues an error. You will need to increase the time between executions within cron.');
93             } else {
94                 mtrace("automated backup are already running. Execution delayed");
95             }
96             return $state;
97         } else {
98             mtrace('OK');
99         }
100         backup_cron_automated_helper::set_state_running();
102         mtrace("Getting admin info");
103         $admin = get_admin();
104         if (!$admin) {
105             mtrace("Error: No admin account was found");
106             $state = false;
107         }
109         if ($status) {
110             mtrace("Checking courses");
111             mtrace("Skipping deleted courses", '...');
112             mtrace(sprintf("%d courses", backup_cron_automated_helper::remove_deleted_courses_from_schedule()));
113         }
115         if ($status) {
117             mtrace('Running required automated backups...');
118             cron_trace_time_and_memory();
120             // This could take a while!
121             core_php_time_limit::raise();
122             raise_memory_limit(MEMORY_EXTRA);
124             $nextstarttime = backup_cron_automated_helper::calculate_next_automated_backup(null, $now);
125             $showtime = "undefined";
126             if ($nextstarttime > 0) {
127                 $showtime = date('r', $nextstarttime);
128             }
130             $rs = $DB->get_recordset('course');
131             foreach ($rs as $course) {
132                 $backupcourse = $DB->get_record('backup_courses', array('courseid' => $course->id));
133                 if (!$backupcourse) {
134                     $backupcourse = new stdClass;
135                     $backupcourse->courseid = $course->id;
136                     $backupcourse->laststatus = self::BACKUP_STATUS_NOTYETRUN;
137                     $DB->insert_record('backup_courses', $backupcourse);
138                     $backupcourse = $DB->get_record('backup_courses', array('courseid' => $course->id));
139                 }
141                 // The last backup is considered as successful when OK or SKIPPED.
142                 $lastbackupwassuccessful =  ($backupcourse->laststatus == self::BACKUP_STATUS_SKIPPED ||
143                                             $backupcourse->laststatus == self::BACKUP_STATUS_OK) && (
144                                             $backupcourse->laststarttime > 0 && $backupcourse->lastendtime > 0);
146                 // Assume that we are not skipping anything.
147                 $skipped = false;
148                 $skippedmessage = '';
150                 // Check if we are going to be running the backup now.
151                 $shouldrunnow = (($backupcourse->nextstarttime > 0 && $backupcourse->nextstarttime < $now)
152                     || $rundirective == self::RUN_IMMEDIATELY);
154                 // If config backup_auto_skip_hidden is set to true, skip courses that are not visible.
155                 if ($shouldrunnow && $config->backup_auto_skip_hidden) {
156                     $skipped = ($config->backup_auto_skip_hidden && !$course->visible);
157                     $skippedmessage = 'Not visible';
158                 }
160                 // If config backup_auto_skip_modif_days is set to true, skip courses
161                 // that have not been modified since the number of days defined.
162                 if ($shouldrunnow && !$skipped && $lastbackupwassuccessful && $config->backup_auto_skip_modif_days) {
163                     $timenotmodifsincedays = $now - ($config->backup_auto_skip_modif_days * DAYSECS);
164                     // Check log if there were any modifications to the course content.
165                     $logexists = self::is_course_modified($course->id, $timenotmodifsincedays);
166                     $skipped = ($course->timemodified <= $timenotmodifsincedays && !$logexists);
167                     $skippedmessage = 'Not modified in the past '.$config->backup_auto_skip_modif_days.' days';
168                 }
170                 // If config backup_auto_skip_modif_prev is set to true, skip courses
171                 // that have not been modified since previous backup.
172                 if ($shouldrunnow && !$skipped && $lastbackupwassuccessful && $config->backup_auto_skip_modif_prev) {
173                     // Check log if there were any modifications to the course content.
174                     $logexists = self::is_course_modified($course->id, $backupcourse->laststarttime);
175                     $skipped = ($course->timemodified <= $backupcourse->laststarttime && !$logexists);
176                     $skippedmessage = 'Not modified since previous backup';
177                 }
179                 // Check if the course is not scheduled to run right now.
180                 if (!$shouldrunnow) {
181                     $backupcourse->nextstarttime = $nextstarttime;
182                     $DB->update_record('backup_courses', $backupcourse);
183                     mtrace('Skipping ' . $course->fullname . ' (Not scheduled for backup until ' . $showtime . ')');
184                 } else {
185                     if ($skipped) { // Must have been skipped for a reason.
186                         $backupcourse->laststatus = self::BACKUP_STATUS_SKIPPED;
187                         $backupcourse->nextstarttime = $nextstarttime;
188                         $DB->update_record('backup_courses', $backupcourse);
189                         mtrace('Skipping ' . $course->fullname . ' (' . $skippedmessage . ')');
190                         mtrace('Backup of \'' . $course->fullname . '\' is scheduled on ' . $showtime);
191                     } else {
192                         // Backup every non-skipped courses.
193                         mtrace('Backing up '.$course->fullname.'...');
195                         // We have to send an email because we have included at least one backup.
196                         $emailpending = true;
198                         // Only make the backup if laststatus isn't 2-UNFINISHED (uncontrolled error).
199                         if ($backupcourse->laststatus != self::BACKUP_STATUS_UNFINISHED) {
200                             // Set laststarttime.
201                             $starttime = time();
203                             $backupcourse->laststarttime = time();
204                             $backupcourse->laststatus = self::BACKUP_STATUS_UNFINISHED;
205                             $DB->update_record('backup_courses', $backupcourse);
207                             $backupcourse->laststatus = self::launch_automated_backup($course, $backupcourse->laststarttime,
208                                     $admin->id);
209                             $backupcourse->lastendtime = time();
210                             $backupcourse->nextstarttime = $nextstarttime;
212                             $DB->update_record('backup_courses', $backupcourse);
214                             mtrace("complete - next execution: $showtime");
215                         }
216                     }
218                     // Remove excess backups.
219                     $removedcount = self::remove_excess_backups($course, $now);
220                 }
221             }
222             $rs->close();
223         }
225         //Send email to admin if necessary
226         if ($emailpending) {
227             mtrace("Sending email to admin");
228             $message = "";
230             $count = backup_cron_automated_helper::get_backup_status_array();
231             $haserrors = ($count[self::BACKUP_STATUS_ERROR] != 0 || $count[self::BACKUP_STATUS_UNFINISHED] != 0);
233             // Build the message text.
234             // Summary.
235             $message .= get_string('summary') . "\n";
236             $message .= "==================================================\n";
237             $message .= '  ' . get_string('courses') . '; ' . array_sum($count) . "\n";
238             $message .= '  ' . get_string('ok') . '; ' . $count[self::BACKUP_STATUS_OK] . "\n";
239             $message .= '  ' . get_string('skipped') . '; ' . $count[self::BACKUP_STATUS_SKIPPED] . "\n";
240             $message .= '  ' . get_string('error') . '; ' . $count[self::BACKUP_STATUS_ERROR] . "\n";
241             $message .= '  ' . get_string('unfinished') . '; ' . $count[self::BACKUP_STATUS_UNFINISHED] . "\n";
242             $message .= '  ' . get_string('warning') . '; ' . $count[self::BACKUP_STATUS_WARNING] . "\n";
243             $message .= '  ' . get_string('backupnotyetrun') . '; ' . $count[self::BACKUP_STATUS_NOTYETRUN]."\n\n";
245             //Reference
246             if ($haserrors) {
247                 $message .= "  ".get_string('backupfailed')."\n\n";
248                 $dest_url = "$CFG->wwwroot/report/backups/index.php";
249                 $message .= "  ".get_string('backuptakealook','',$dest_url)."\n\n";
250                 //Set message priority
251                 $admin->priority = 1;
252                 //Reset unfinished to error
253                 $DB->set_field('backup_courses','laststatus','0', array('laststatus'=>'2'));
254             } else {
255                 $message .= "  ".get_string('backupfinished')."\n";
256             }
258             //Build the message subject
259             $site = get_site();
260             $prefix = format_string($site->shortname, true, array('context' => context_course::instance(SITEID))).": ";
261             if ($haserrors) {
262                 $prefix .= "[".strtoupper(get_string('error'))."] ";
263             }
264             $subject = $prefix.get_string('automatedbackupstatus', 'backup');
266             //Send the message
267             $eventdata = new \core\message\message();
268             $eventdata->courseid          = SITEID;
269             $eventdata->modulename        = 'moodle';
270             $eventdata->userfrom          = $admin;
271             $eventdata->userto            = $admin;
272             $eventdata->subject           = $subject;
273             $eventdata->fullmessage       = $message;
274             $eventdata->fullmessageformat = FORMAT_PLAIN;
275             $eventdata->fullmessagehtml   = '';
276             $eventdata->smallmessage      = '';
278             $eventdata->component         = 'moodle';
279             $eventdata->name         = 'backup';
281             message_send($eventdata);
282         }
284         //Everything is finished stop backup_auto_running
285         backup_cron_automated_helper::set_state_running(false);
287         mtrace('Automated backups complete.');
289         return $status;
290     }
292     /**
293      * Gets the results from the last automated backup that was run based upon
294      * the statuses of the courses that were looked at.
295      *
296      * @global moodle_database $DB
297      * @return array
298      */
299     public static function get_backup_status_array() {
300         global $DB;
302         $result = array(
303             self::BACKUP_STATUS_ERROR => 0,
304             self::BACKUP_STATUS_OK => 0,
305             self::BACKUP_STATUS_UNFINISHED => 0,
306             self::BACKUP_STATUS_SKIPPED => 0,
307             self::BACKUP_STATUS_WARNING => 0,
308             self::BACKUP_STATUS_NOTYETRUN => 0
309         );
311         $statuses = $DB->get_records_sql('SELECT DISTINCT bc.laststatus, COUNT(bc.courseid) AS statuscount FROM {backup_courses} bc GROUP BY bc.laststatus');
313         foreach ($statuses as $status) {
314             if (empty($status->statuscount)) {
315                 $status->statuscount = 0;
316             }
317             $result[(int)$status->laststatus] += $status->statuscount;
318         }
320         return $result;
321     }
323     /**
324      * Works out the next time the automated backup should be run.
325      *
326      * @param mixed $ignoredtimezone all settings are in server timezone!
327      * @param int $now timestamp, should not be in the past, most likely time()
328      * @return int timestamp of the next execution at server time
329      */
330     public static function calculate_next_automated_backup($ignoredtimezone, $now) {
332         $config = get_config('backup');
334         $backuptime = new DateTime('@' . $now);
335         $backuptime->setTimezone(core_date::get_server_timezone_object());
336         $backuptime->setTime($config->backup_auto_hour, $config->backup_auto_minute);
338         while ($backuptime->getTimestamp() < $now) {
339             $backuptime->add(new DateInterval('P1D'));
340         }
342         // Get number of days from backup date to execute backups.
343         $automateddays = substr($config->backup_auto_weekdays, $backuptime->format('w')) . $config->backup_auto_weekdays;
344         $daysfromnow = strpos($automateddays, "1");
346         // Error, there are no days to schedule the backup for.
347         if ($daysfromnow === false) {
348             return 0;
349         }
351         if ($daysfromnow > 0) {
352             $backuptime->add(new DateInterval('P' . $daysfromnow . 'D'));
353         }
355         return $backuptime->getTimestamp();
356     }
358     /**
359      * Launches a automated backup routine for the given course
360      *
361      * @param stdClass $course
362      * @param int $starttime
363      * @param int $userid
364      * @return bool
365      */
366     public static function launch_automated_backup($course, $starttime, $userid) {
368         $outcome = self::BACKUP_STATUS_OK;
369         $config = get_config('backup');
370         $dir = $config->backup_auto_destination;
371         $storage = (int)$config->backup_auto_storage;
373         $bc = new backup_controller(backup::TYPE_1COURSE, $course->id, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO,
374                 backup::MODE_AUTOMATED, $userid);
376         try {
378             // Set the default filename.
379             $format = $bc->get_format();
380             $type = $bc->get_type();
381             $id = $bc->get_id();
382             $users = $bc->get_plan()->get_setting('users')->get_value();
383             $anonymised = $bc->get_plan()->get_setting('anonymize')->get_value();
384             $bc->get_plan()->get_setting('filename')->set_value(backup_plan_dbops::get_default_backup_filename($format, $type,
385                     $id, $users, $anonymised));
387             $bc->set_status(backup::STATUS_AWAITING);
389             $bc->execute_plan();
390             $results = $bc->get_results();
391             $outcome = self::outcome_from_results($results);
392             $file = $results['backup_destination']; // May be empty if file already moved to target location.
394             // If we need to copy the backup file to an external dir and it is not writable, change status to error.
395             // This is a feature to prevent moodledata to be filled up and break a site when the admin misconfigured
396             // the automated backups storage type and destination directory.
397             if ($storage !== 0 && (empty($dir) || !file_exists($dir) || !is_dir($dir) || !is_writable($dir))) {
398                 $bc->log('Specified backup directory is not writable - ', backup::LOG_ERROR, $dir);
399                 $dir = null;
400                 $outcome = self::BACKUP_STATUS_ERROR;
401             }
403             // Copy file only if there was no error.
404             if ($file && !empty($dir) && $storage !== 0 && $outcome != self::BACKUP_STATUS_ERROR) {
405                 $filename = backup_plan_dbops::get_default_backup_filename($format, $type, $course->id, $users, $anonymised,
406                         !$config->backup_shortname);
407                 if (!$file->copy_content_to($dir.'/'.$filename)) {
408                     $bc->log('Attempt to copy backup file to the specified directory failed - ',
409                             backup::LOG_ERROR, $dir);
410                     $outcome = self::BACKUP_STATUS_ERROR;
411                 }
412                 if ($outcome != self::BACKUP_STATUS_ERROR && $storage === 1) {
413                     if (!$file->delete()) {
414                         $outcome = self::BACKUP_STATUS_WARNING;
415                         $bc->log('Attempt to delete the backup file from course automated backup area failed - ',
416                                 backup::LOG_WARNING, $file->get_filename());
417                     }
418                 }
419             }
421         } catch (moodle_exception $e) {
422             $bc->log('backup_auto_failed_on_course', backup::LOG_ERROR, $course->shortname); // Log error header.
423             $bc->log('Exception: ' . $e->errorcode, backup::LOG_ERROR, $e->a, 1); // Log original exception problem.
424             $bc->log('Debug: ' . $e->debuginfo, backup::LOG_DEBUG, null, 1); // Log original debug information.
425             $outcome = self::BACKUP_STATUS_ERROR;
426         }
428         // Delete the backup file immediately if something went wrong.
429         if ($outcome === self::BACKUP_STATUS_ERROR) {
431             // Delete the file from file area if exists.
432             if (!empty($file)) {
433                 $file->delete();
434             }
436             // Delete file from external storage if exists.
437             if ($storage !== 0 && !empty($filename) && file_exists($dir.'/'.$filename)) {
438                 @unlink($dir.'/'.$filename);
439             }
440         }
442         $bc->destroy();
443         unset($bc);
445         return $outcome;
446     }
448     /**
449      * Returns the backup outcome by analysing its results.
450      *
451      * @param array $results returned by a backup
452      * @return int {@link self::BACKUP_STATUS_OK} and other constants
453      */
454     public static function outcome_from_results($results) {
455         $outcome = self::BACKUP_STATUS_OK;
456         foreach ($results as $code => $value) {
457             // Each possible error and warning code has to be specified in this switch
458             // which basically analyses the results to return the correct backup status.
459             switch ($code) {
460                 case 'missing_files_in_pool':
461                     $outcome = self::BACKUP_STATUS_WARNING;
462                     break;
463             }
464             // If we found the highest error level, we exit the loop.
465             if ($outcome == self::BACKUP_STATUS_ERROR) {
466                 break;
467             }
468         }
469         return $outcome;
470     }
472     /**
473      * Removes deleted courses fromn the backup_courses table so that we don't
474      * waste time backing them up.
475      *
476      * @global moodle_database $DB
477      * @return int
478      */
479     public static function remove_deleted_courses_from_schedule() {
480         global $DB;
481         $skipped = 0;
482         $sql = "SELECT bc.courseid FROM {backup_courses} bc WHERE bc.courseid NOT IN (SELECT c.id FROM {course} c)";
483         $rs = $DB->get_recordset_sql($sql);
484         foreach ($rs as $deletedcourse) {
485             //Doesn't exist, so delete from backup tables
486             $DB->delete_records('backup_courses', array('courseid'=>$deletedcourse->courseid));
487             $skipped++;
488         }
489         $rs->close();
490         return $skipped;
491     }
493     /**
494      * Gets the state of the automated backup system.
495      *
496      * @global moodle_database $DB
497      * @return int One of self::STATE_*
498      */
499     public static function get_automated_backup_state($rundirective = self::RUN_ON_SCHEDULE) {
500         global $DB;
502         $config = get_config('backup');
503         $active = (int)$config->backup_auto_active;
504         $weekdays = (string)$config->backup_auto_weekdays;
506         // In case of automated backup also check that it is scheduled for at least one weekday.
507         if ($active === self::AUTO_BACKUP_DISABLED ||
508                 ($rundirective == self::RUN_ON_SCHEDULE && $active === self::AUTO_BACKUP_MANUAL) ||
509                 ($rundirective == self::RUN_ON_SCHEDULE && strpos($weekdays, '1') === false)) {
510             return self::STATE_DISABLED;
511         } else if (!empty($config->backup_auto_running)) {
512             // Detect if the backup_auto_running semaphore is a valid one
513             // by looking for recent activity in the backup_controllers table
514             // for backups of type backup::MODE_AUTOMATED
515             $timetosee = 60 * 90; // Time to consider in order to clean the semaphore
516             $params = array( 'purpose'   => backup::MODE_AUTOMATED, 'timetolook' => (time() - $timetosee));
517             if ($DB->record_exists_select('backup_controllers',
518                 "operation = 'backup' AND type = 'course' AND purpose = :purpose AND timemodified > :timetolook", $params)) {
519                 return self::STATE_RUNNING; // Recent activity found, still running
520             } else {
521                 // No recent activity found, let's clean the semaphore
522                 mtrace('Automated backups activity not found in last ' . (int)$timetosee/60 . ' minutes. Cleaning running status');
523                 backup_cron_automated_helper::set_state_running(false);
524             }
525         }
526         return self::STATE_OK;
527     }
529     /**
530      * Sets the state of the automated backup system.
531      *
532      * @param bool $running
533      * @return bool
534      */
535     public static function set_state_running($running = true) {
536         if ($running === true) {
537             if (self::get_automated_backup_state() === self::STATE_RUNNING) {
538                 throw new backup_helper_exception('backup_automated_already_running');
539             }
540             set_config('backup_auto_running', '1', 'backup');
541         } else {
542             unset_config('backup_auto_running', 'backup');
543         }
544         return true;
545     }
547     /**
548      * Removes excess backups from a specified course.
549      *
550      * @param stdClass $course Course object
551      * @param int $now Starting time of the process
552      * @return bool Whether or not backups is being removed
553      */
554     public static function remove_excess_backups($course, $now = null) {
555         $config = get_config('backup');
556         $maxkept = (int)$config->backup_auto_max_kept;
557         $storage = $config->backup_auto_storage;
558         $deletedays = (int)$config->backup_auto_delete_days;
560         if ($maxkept == 0 && $deletedays == 0) {
561             // Means keep all backup files and never delete backup after x days.
562             return true;
563         }
565         if (!isset($now)) {
566             $now = time();
567         }
569         // Clean up excess backups in the course backup filearea.
570         $deletedcoursebackups = false;
571         if ($storage == self::STORAGE_COURSE || $storage == self::STORAGE_COURSE_AND_DIRECTORY) {
572             $deletedcoursebackups = self::remove_excess_backups_from_course($course, $now);
573         }
575         // Clean up excess backups in the specified external directory.
576         $deleteddirectorybackups = false;
577         if ($storage == self::STORAGE_DIRECTORY || $storage == self::STORAGE_COURSE_AND_DIRECTORY) {
578             $deleteddirectorybackups = self::remove_excess_backups_from_directory($course, $now);
579         }
581         if ($deletedcoursebackups || $deleteddirectorybackups) {
582             return true;
583         } else {
584             return false;
585         }
586     }
588     /**
589      * Removes excess backups in the course backup filearea from a specified course.
590      *
591      * @param stdClass $course Course object
592      * @param int $now Starting time of the process
593      * @return bool Whether or not backups are being removed
594      */
595     protected static function remove_excess_backups_from_course($course, $now) {
596         $fs = get_file_storage();
597         $context = context_course::instance($course->id);
598         $component = 'backup';
599         $filearea = 'automated';
600         $itemid = 0;
601         $backupfiles = array();
602         $backupfilesarea = $fs->get_area_files($context->id, $component, $filearea, $itemid, 'timemodified DESC', false);
603         // Store all the matching files into timemodified => stored_file array.
604         foreach ($backupfilesarea as $backupfile) {
605             $backupfiles[$backupfile->get_timemodified()] = $backupfile;
606         }
608         $backupstodelete = self::get_backups_to_delete($backupfiles, $now);
609         if ($backupstodelete) {
610             foreach ($backupstodelete as $backuptodelete) {
611                 $backuptodelete->delete();
612             }
613             mtrace('Deleted ' . count($backupstodelete) . ' old backup file(s) from the automated filearea');
614             return true;
615         } else {
616             return false;
617         }
618     }
620     /**
621      * Removes excess backups in the specified external directory from a specified course.
622      *
623      * @param stdClass $course Course object
624      * @param int $now Starting time of the process
625      * @return bool Whether or not backups are being removed
626      */
627     protected static function remove_excess_backups_from_directory($course, $now) {
628         $config = get_config('backup');
629         $dir = $config->backup_auto_destination;
631         $isnotvaliddir = !file_exists($dir) || !is_dir($dir) || !is_writable($dir);
632         if ($isnotvaliddir) {
633             mtrace('Error: ' . $dir . ' does not appear to be a valid directory');
634             return false;
635         }
637         // Calculate backup filename regex, ignoring the date/time/info parts that can be
638         // variable, depending of languages, formats and automated backup settings.
639         $filename = backup::FORMAT_MOODLE . '-' . backup::TYPE_1COURSE . '-' . $course->id . '-';
640         $regex = '#' . preg_quote($filename, '#') . '.*\.mbz$#';
642         // Store all the matching files into filename => timemodified array.
643         $backupfiles = array();
644         foreach (scandir($dir) as $backupfile) {
645             // Skip files not matching the naming convention.
646             if (!preg_match($regex, $backupfile)) {
647                 continue;
648             }
650             // Read the information contained in the backup itself.
651             try {
652                 $bcinfo = backup_general_helper::get_backup_information_from_mbz($dir . '/' . $backupfile);
653             } catch (backup_helper_exception $e) {
654                 mtrace('Error: ' . $backupfile . ' does not appear to be a valid backup (' . $e->errorcode . ')');
655                 continue;
656             }
658             // Make sure this backup concerns the course and site we are looking for.
659             if ($bcinfo->format === backup::FORMAT_MOODLE &&
660                     $bcinfo->type === backup::TYPE_1COURSE &&
661                     $bcinfo->original_course_id == $course->id &&
662                     backup_general_helper::backup_is_samesite($bcinfo)) {
663                 $backupfiles[$bcinfo->backup_date] = $backupfile;
664             }
665         }
667         $backupstodelete = self::get_backups_to_delete($backupfiles, $now);
668         if ($backupstodelete) {
669             foreach ($backupstodelete as $backuptodelete) {
670                 unlink($dir . '/' . $backuptodelete);
671             }
672             mtrace('Deleted ' . count($backupstodelete) . ' old backup file(s) from external directory');
673             return true;
674         } else {
675             return false;
676         }
677     }
679     /**
680      * Get the list of backup files to delete depending on the automated backup settings.
681      *
682      * @param array $backupfiles Existing backup files
683      * @param int $now Starting time of the process
684      * @return array Backup files to delete
685      */
686     protected static function get_backups_to_delete($backupfiles, $now) {
687         $config = get_config('backup');
688         $maxkept = (int)$config->backup_auto_max_kept;
689         $deletedays = (int)$config->backup_auto_delete_days;
690         $minkept = (int)$config->backup_auto_min_kept;
692         // Sort by keys descending (newer to older filemodified).
693         krsort($backupfiles);
694         $tokeep = $maxkept;
695         if ($deletedays > 0) {
696             $deletedayssecs = $deletedays * DAYSECS;
697             $tokeep = 0;
698             $backupfileskeys = array_keys($backupfiles);
699             foreach ($backupfileskeys as $timemodified) {
700                 $mustdeletebackup = $timemodified < ($now - $deletedayssecs);
701                 if ($mustdeletebackup || $tokeep >= $maxkept) {
702                     break;
703                 }
704                 $tokeep++;
705             }
707             if ($tokeep < $minkept) {
708                 $tokeep = $minkept;
709             }
710         }
712         if (count($backupfiles) <= $tokeep) {
713             // There are less or equal matching files than the desired number to keep, there is nothing to clean up.
714             return false;
715         } else {
716             $backupstodelete = array_splice($backupfiles, $tokeep);
717             return $backupstodelete;
718         }
719     }
721     /**
722      * Check logs to find out if a course was modified since the given time.
723      *
724      * @param int $courseid course id to check
725      * @param int $since timestamp, from which to check
726      *
727      * @return bool true if the course was modified, false otherwise. This also returns false if no readers are enabled. This is
728      * intentional, since we cannot reliably determine if any modification was made or not.
729      */
730     protected static function is_course_modified($courseid, $since) {
731         $logmang = get_log_manager();
732         $readers = $logmang->get_readers('core\log\sql_reader');
733         $params = array('courseid' => $courseid, 'since' => $since);
735         foreach ($readers as $readerpluginname => $reader) {
736             $where = "courseid = :courseid and timecreated > :since and crud <> 'r'";
738             // Prevent logs of prevous backups causing a false positive.
739             if ($readerpluginname != 'logstore_legacy') {
740                 $where .= " and target <> 'course_backup'";
741             }
743             if ($reader->get_events_select_count($where, $params)) {
744                 return true;
745             }
746         }
747         return false;
748     }