Merge branch 'MDL-50602-master' of https://github.com/StudiUM/moodle
[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 stdClass();
268             $eventdata->modulename        = 'moodle';
269             $eventdata->userfrom          = $admin;
270             $eventdata->userto            = $admin;
271             $eventdata->subject           = $subject;
272             $eventdata->fullmessage       = $message;
273             $eventdata->fullmessageformat = FORMAT_PLAIN;
274             $eventdata->fullmessagehtml   = '';
275             $eventdata->smallmessage      = '';
277             $eventdata->component         = 'moodle';
278             $eventdata->name         = 'backup';
280             message_send($eventdata);
281         }
283         //Everything is finished stop backup_auto_running
284         backup_cron_automated_helper::set_state_running(false);
286         mtrace('Automated backups complete.');
288         return $status;
289     }
291     /**
292      * Gets the results from the last automated backup that was run based upon
293      * the statuses of the courses that were looked at.
294      *
295      * @global moodle_database $DB
296      * @return array
297      */
298     public static function get_backup_status_array() {
299         global $DB;
301         $result = array(
302             self::BACKUP_STATUS_ERROR => 0,
303             self::BACKUP_STATUS_OK => 0,
304             self::BACKUP_STATUS_UNFINISHED => 0,
305             self::BACKUP_STATUS_SKIPPED => 0,
306             self::BACKUP_STATUS_WARNING => 0,
307             self::BACKUP_STATUS_NOTYETRUN => 0
308         );
310         $statuses = $DB->get_records_sql('SELECT DISTINCT bc.laststatus, COUNT(bc.courseid) AS statuscount FROM {backup_courses} bc GROUP BY bc.laststatus');
312         foreach ($statuses as $status) {
313             if (empty($status->statuscount)) {
314                 $status->statuscount = 0;
315             }
316             $result[(int)$status->laststatus] += $status->statuscount;
317         }
319         return $result;
320     }
322     /**
323      * Works out the next time the automated backup should be run.
324      *
325      * @param mixed $ignoredtimezone all settings are in server timezone!
326      * @param int $now timestamp, should not be in the past, most likely time()
327      * @return int timestamp of the next execution at server time
328      */
329     public static function calculate_next_automated_backup($ignoredtimezone, $now) {
331         $config = get_config('backup');
333         $backuptime = new DateTime('@' . $now);
334         $backuptime->setTimezone(core_date::get_server_timezone_object());
335         $backuptime->setTime($config->backup_auto_hour, $config->backup_auto_minute);
337         while ($backuptime->getTimestamp() < $now) {
338             $backuptime->add(new DateInterval('P1D'));
339         }
341         // Get number of days from backup date to execute backups.
342         $automateddays = substr($config->backup_auto_weekdays, $backuptime->format('w')) . $config->backup_auto_weekdays;
343         $daysfromnow = strpos($automateddays, "1");
345         // Error, there are no days to schedule the backup for.
346         if ($daysfromnow === false) {
347             return 0;
348         }
350         if ($daysfromnow > 0) {
351             $backuptime->add(new DateInterval('P' . $daysfromnow . 'D'));
352         }
354         return $backuptime->getTimestamp();
355     }
357     /**
358      * Launches a automated backup routine for the given course
359      *
360      * @param stdClass $course
361      * @param int $starttime
362      * @param int $userid
363      * @return bool
364      */
365     public static function launch_automated_backup($course, $starttime, $userid) {
367         $outcome = self::BACKUP_STATUS_OK;
368         $config = get_config('backup');
369         $dir = $config->backup_auto_destination;
370         $storage = (int)$config->backup_auto_storage;
372         $bc = new backup_controller(backup::TYPE_1COURSE, $course->id, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO,
373                 backup::MODE_AUTOMATED, $userid);
375         try {
377             // Set the default filename.
378             $format = $bc->get_format();
379             $type = $bc->get_type();
380             $id = $bc->get_id();
381             $users = $bc->get_plan()->get_setting('users')->get_value();
382             $anonymised = $bc->get_plan()->get_setting('anonymize')->get_value();
383             $bc->get_plan()->get_setting('filename')->set_value(backup_plan_dbops::get_default_backup_filename($format, $type,
384                     $id, $users, $anonymised));
386             $bc->set_status(backup::STATUS_AWAITING);
388             $bc->execute_plan();
389             $results = $bc->get_results();
390             $outcome = self::outcome_from_results($results);
391             $file = $results['backup_destination']; // May be empty if file already moved to target location.
393             // If we need to copy the backup file to an external dir and it is not writable, change status to error.
394             // This is a feature to prevent moodledata to be filled up and break a site when the admin misconfigured
395             // the automated backups storage type and destination directory.
396             if ($storage !== 0 && (empty($dir) || !file_exists($dir) || !is_dir($dir) || !is_writable($dir))) {
397                 $bc->log('Specified backup directory is not writable - ', backup::LOG_ERROR, $dir);
398                 $dir = null;
399                 $outcome = self::BACKUP_STATUS_ERROR;
400             }
402             // Copy file only if there was no error.
403             if ($file && !empty($dir) && $storage !== 0 && $outcome != self::BACKUP_STATUS_ERROR) {
404                 $filename = backup_plan_dbops::get_default_backup_filename($format, $type, $course->id, $users, $anonymised,
405                         !$config->backup_shortname);
406                 if (!$file->copy_content_to($dir.'/'.$filename)) {
407                     $bc->log('Attempt to copy backup file to the specified directory failed - ',
408                             backup::LOG_ERROR, $dir);
409                     $outcome = self::BACKUP_STATUS_ERROR;
410                 }
411                 if ($outcome != self::BACKUP_STATUS_ERROR && $storage === 1) {
412                     if (!$file->delete()) {
413                         $outcome = self::BACKUP_STATUS_WARNING;
414                         $bc->log('Attempt to delete the backup file from course automated backup area failed - ',
415                                 backup::LOG_WARNING, $file->get_filename());
416                     }
417                 }
418             }
420         } catch (moodle_exception $e) {
421             $bc->log('backup_auto_failed_on_course', backup::LOG_ERROR, $course->shortname); // Log error header.
422             $bc->log('Exception: ' . $e->errorcode, backup::LOG_ERROR, $e->a, 1); // Log original exception problem.
423             $bc->log('Debug: ' . $e->debuginfo, backup::LOG_DEBUG, null, 1); // Log original debug information.
424             $outcome = self::BACKUP_STATUS_ERROR;
425         }
427         // Delete the backup file immediately if something went wrong.
428         if ($outcome === self::BACKUP_STATUS_ERROR) {
430             // Delete the file from file area if exists.
431             if (!empty($file)) {
432                 $file->delete();
433             }
435             // Delete file from external storage if exists.
436             if ($storage !== 0 && !empty($filename) && file_exists($dir.'/'.$filename)) {
437                 @unlink($dir.'/'.$filename);
438             }
439         }
441         $bc->destroy();
442         unset($bc);
444         return $outcome;
445     }
447     /**
448      * Returns the backup outcome by analysing its results.
449      *
450      * @param array $results returned by a backup
451      * @return int {@link self::BACKUP_STATUS_OK} and other constants
452      */
453     public static function outcome_from_results($results) {
454         $outcome = self::BACKUP_STATUS_OK;
455         foreach ($results as $code => $value) {
456             // Each possible error and warning code has to be specified in this switch
457             // which basically analyses the results to return the correct backup status.
458             switch ($code) {
459                 case 'missing_files_in_pool':
460                     $outcome = self::BACKUP_STATUS_WARNING;
461                     break;
462             }
463             // If we found the highest error level, we exit the loop.
464             if ($outcome == self::BACKUP_STATUS_ERROR) {
465                 break;
466             }
467         }
468         return $outcome;
469     }
471     /**
472      * Removes deleted courses fromn the backup_courses table so that we don't
473      * waste time backing them up.
474      *
475      * @global moodle_database $DB
476      * @return int
477      */
478     public static function remove_deleted_courses_from_schedule() {
479         global $DB;
480         $skipped = 0;
481         $sql = "SELECT bc.courseid FROM {backup_courses} bc WHERE bc.courseid NOT IN (SELECT c.id FROM {course} c)";
482         $rs = $DB->get_recordset_sql($sql);
483         foreach ($rs as $deletedcourse) {
484             //Doesn't exist, so delete from backup tables
485             $DB->delete_records('backup_courses', array('courseid'=>$deletedcourse->courseid));
486             $skipped++;
487         }
488         $rs->close();
489         return $skipped;
490     }
492     /**
493      * Gets the state of the automated backup system.
494      *
495      * @global moodle_database $DB
496      * @return int One of self::STATE_*
497      */
498     public static function get_automated_backup_state($rundirective = self::RUN_ON_SCHEDULE) {
499         global $DB;
501         $config = get_config('backup');
502         $active = (int)$config->backup_auto_active;
503         $weekdays = (string)$config->backup_auto_weekdays;
505         // In case of automated backup also check that it is scheduled for at least one weekday.
506         if ($active === self::AUTO_BACKUP_DISABLED ||
507                 ($rundirective == self::RUN_ON_SCHEDULE && $active === self::AUTO_BACKUP_MANUAL) ||
508                 ($rundirective == self::RUN_ON_SCHEDULE && strpos($weekdays, '1') === false)) {
509             return self::STATE_DISABLED;
510         } else if (!empty($config->backup_auto_running)) {
511             // Detect if the backup_auto_running semaphore is a valid one
512             // by looking for recent activity in the backup_controllers table
513             // for backups of type backup::MODE_AUTOMATED
514             $timetosee = 60 * 90; // Time to consider in order to clean the semaphore
515             $params = array( 'purpose'   => backup::MODE_AUTOMATED, 'timetolook' => (time() - $timetosee));
516             if ($DB->record_exists_select('backup_controllers',
517                 "operation = 'backup' AND type = 'course' AND purpose = :purpose AND timemodified > :timetolook", $params)) {
518                 return self::STATE_RUNNING; // Recent activity found, still running
519             } else {
520                 // No recent activity found, let's clean the semaphore
521                 mtrace('Automated backups activity not found in last ' . (int)$timetosee/60 . ' minutes. Cleaning running status');
522                 backup_cron_automated_helper::set_state_running(false);
523             }
524         }
525         return self::STATE_OK;
526     }
528     /**
529      * Sets the state of the automated backup system.
530      *
531      * @param bool $running
532      * @return bool
533      */
534     public static function set_state_running($running = true) {
535         if ($running === true) {
536             if (self::get_automated_backup_state() === self::STATE_RUNNING) {
537                 throw new backup_helper_exception('backup_automated_already_running');
538             }
539             set_config('backup_auto_running', '1', 'backup');
540         } else {
541             unset_config('backup_auto_running', 'backup');
542         }
543         return true;
544     }
546     /**
547      * Removes excess backups from a specified course.
548      *
549      * @param stdClass $course Course object
550      * @param int $now Starting time of the process
551      * @return bool Whether or not backups is being removed
552      */
553     public static function remove_excess_backups($course, $now = null) {
554         $config = get_config('backup');
555         $maxkept = (int)$config->backup_auto_max_kept;
556         $storage = $config->backup_auto_storage;
557         $deletedays = (int)$config->backup_auto_delete_days;
559         if ($maxkept == 0 && $deletedays == 0) {
560             // Means keep all backup files and never delete backup after x days.
561             return true;
562         }
564         if (!isset($now)) {
565             $now = time();
566         }
568         // Clean up excess backups in the course backup filearea.
569         $deletedcoursebackups = false;
570         if ($storage == self::STORAGE_COURSE || $storage == self::STORAGE_COURSE_AND_DIRECTORY) {
571             $deletedcoursebackups = self::remove_excess_backups_from_course($course, $now);
572         }
574         // Clean up excess backups in the specified external directory.
575         $deleteddirectorybackups = false;
576         if ($storage == self::STORAGE_DIRECTORY || $storage == self::STORAGE_COURSE_AND_DIRECTORY) {
577             $deleteddirectorybackups = self::remove_excess_backups_from_directory($course, $now);
578         }
580         if ($deletedcoursebackups || $deleteddirectorybackups) {
581             return true;
582         } else {
583             return false;
584         }
585     }
587     /**
588      * Removes excess backups in the course backup filearea from a specified course.
589      *
590      * @param stdClass $course Course object
591      * @param int $now Starting time of the process
592      * @return bool Whether or not backups are being removed
593      */
594     protected static function remove_excess_backups_from_course($course, $now) {
595         $fs = get_file_storage();
596         $context = context_course::instance($course->id);
597         $component = 'backup';
598         $filearea = 'automated';
599         $itemid = 0;
600         $backupfiles = array();
601         $backupfilesarea = $fs->get_area_files($context->id, $component, $filearea, $itemid, 'timemodified DESC', false);
602         // Store all the matching files into timemodified => stored_file array.
603         foreach ($backupfilesarea as $backupfile) {
604             $backupfiles[$backupfile->get_timemodified()] = $backupfile;
605         }
607         $backupstodelete = self::get_backups_to_delete($backupfiles, $now);
608         if ($backupstodelete) {
609             foreach ($backupstodelete as $backuptodelete) {
610                 $backuptodelete->delete();
611             }
612             mtrace('Deleted ' . count($backupstodelete) . ' old backup file(s) from the automated filearea');
613             return true;
614         } else {
615             return false;
616         }
617     }
619     /**
620      * Removes excess backups in the specified external directory from a specified course.
621      *
622      * @param stdClass $course Course object
623      * @param int $now Starting time of the process
624      * @return bool Whether or not backups are being removed
625      */
626     protected static function remove_excess_backups_from_directory($course, $now) {
627         $config = get_config('backup');
628         $dir = $config->backup_auto_destination;
630         $isnotvaliddir = !file_exists($dir) || !is_dir($dir) || !is_writable($dir);
631         if ($isnotvaliddir) {
632             mtrace('Error: ' . $dir . ' does not appear to be a valid directory');
633             return false;
634         }
636         // Calculate backup filename regex, ignoring the date/time/info parts that can be
637         // variable, depending of languages, formats and automated backup settings.
638         $filename = backup::FORMAT_MOODLE . '-' . backup::TYPE_1COURSE . '-' . $course->id . '-';
639         $regex = '#' . preg_quote($filename, '#') . '.*\.mbz$#';
641         // Store all the matching files into filename => timemodified array.
642         $backupfiles = array();
643         foreach (scandir($dir) as $backupfile) {
644             // Skip files not matching the naming convention.
645             if (!preg_match($regex, $backupfile)) {
646                 continue;
647             }
649             // Read the information contained in the backup itself.
650             try {
651                 $bcinfo = backup_general_helper::get_backup_information_from_mbz($dir . '/' . $backupfile);
652             } catch (backup_helper_exception $e) {
653                 mtrace('Error: ' . $backupfile . ' does not appear to be a valid backup (' . $e->errorcode . ')');
654                 continue;
655             }
657             // Make sure this backup concerns the course and site we are looking for.
658             if ($bcinfo->format === backup::FORMAT_MOODLE &&
659                     $bcinfo->type === backup::TYPE_1COURSE &&
660                     $bcinfo->original_course_id == $course->id &&
661                     backup_general_helper::backup_is_samesite($bcinfo)) {
662                 $backupfiles[$bcinfo->backup_date] = $backupfile;
663             }
664         }
666         $backupstodelete = self::get_backups_to_delete($backupfiles, $now);
667         if ($backupstodelete) {
668             foreach ($backupstodelete as $backuptodelete) {
669                 unlink($dir . '/' . $backuptodelete);
670             }
671             mtrace('Deleted ' . count($backupstodelete) . ' old backup file(s) from external directory');
672             return true;
673         } else {
674             return false;
675         }
676     }
678     /**
679      * Get the list of backup files to delete depending on the automated backup settings.
680      *
681      * @param array $backupfiles Existing backup files
682      * @param int $now Starting time of the process
683      * @return array Backup files to delete
684      */
685     protected static function get_backups_to_delete($backupfiles, $now) {
686         $config = get_config('backup');
687         $maxkept = (int)$config->backup_auto_max_kept;
688         $deletedays = (int)$config->backup_auto_delete_days;
689         $minkept = (int)$config->backup_auto_min_kept;
691         // Sort by keys descending (newer to older filemodified).
692         krsort($backupfiles);
693         $tokeep = $maxkept;
694         if ($deletedays > 0) {
695             $deletedayssecs = $deletedays * DAYSECS;
696             $tokeep = 0;
697             $backupfileskeys = array_keys($backupfiles);
698             foreach ($backupfileskeys as $timemodified) {
699                 $mustdeletebackup = $timemodified < ($now - $deletedayssecs);
700                 if ($mustdeletebackup || $tokeep >= $maxkept) {
701                     break;
702                 }
703                 $tokeep++;
704             }
706             if ($tokeep < $minkept) {
707                 $tokeep = $minkept;
708             }
709         }
711         if (count($backupfiles) <= $tokeep) {
712             // There are less or equal matching files than the desired number to keep, there is nothing to clean up.
713             return false;
714         } else {
715             $backupstodelete = array_splice($backupfiles, $tokeep);
716             return $backupstodelete;
717         }
718     }
720     /**
721      * Check logs to find out if a course was modified since the given time.
722      *
723      * @param int $courseid course id to check
724      * @param int $since timestamp, from which to check
725      *
726      * @return bool true if the course was modified, false otherwise. This also returns false if no readers are enabled. This is
727      * intentional, since we cannot reliably determine if any modification was made or not.
728      */
729     protected static function is_course_modified($courseid, $since) {
730         $logmang = get_log_manager();
731         $readers = $logmang->get_readers('core\log\sql_reader');
732         $where = "courseid = :courseid and timecreated > :since and crud <> 'r'";
733         $params = array('courseid' => $courseid, 'since' => $since);
734         foreach ($readers as $reader) {
735             if ($reader->get_events_select_count($where, $params)) {
736                 return true;
737             }
738         }
739         return false;
740     }