Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.
   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  defined('MOODLE_INTERNAL') || die();
  18  require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
  19  
  20  /**
  21   * Copy helper class.
  22   *
  23   * @package    core_backup
  24   * @copyright  2022 Catalyst IT Australia Pty Ltd
  25   * @author     Cameron Ball <cameron@cameron1729.xyz>
  26   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  27   */
  28  final class copy_helper {
  29  
  30      /**
  31       * Process raw form data from copy_form.
  32       *
  33       * @param \stdClass $formdata Raw formdata
  34       * @return \stdClass Processed data for use with create_copy
  35       */
  36      public static function process_formdata(\stdClass $formdata): \stdClass {
  37          $requiredfields = [
  38              'courseid',  // Course id integer.
  39              'fullname', // Fullname of the destination course.
  40              'shortname', // Shortname of the destination course.
  41              'category', // Category integer ID that contains the destination course.
  42              'visible', // Integer to detrmine of the copied course will be visible.
  43              'startdate', // Integer timestamp of the start of the destination course.
  44              'enddate', // Integer timestamp of the end of the destination course.
  45              'idnumber', // ID of the destination course.
  46              'userdata', // Integer to determine if the copied course will contain user data.
  47          ];
  48  
  49          $missingfields = array_diff($requiredfields, array_keys((array)$formdata));
  50          if ($missingfields) {
  51              throw new \moodle_exception('copyfieldnotfound', 'backup', '', null, implode(", ", $missingfields));
  52          }
  53  
  54          // Remove any extra stuff in the form data.
  55          $processed = (object)array_intersect_key((array)$formdata, array_flip($requiredfields));
  56          $processed->keptroles = [];
  57  
  58          // Extract roles from the form data and add to keptroles.
  59          foreach ($formdata as $key => $value) {
  60              if ((substr($key, 0, 5) === 'role_') && ($value != 0)) {
  61                  $processed->keptroles[] = $value;
  62              }
  63          }
  64  
  65          return $processed;
  66      }
  67  
  68      /**
  69       * Creates a course copy.
  70       * Sets up relevant controllers and adhoc task.
  71       *
  72       * @param \stdClass $copydata Course copy data from process_formdata
  73       * @return array $copyids The backup and restore controller ids
  74       */
  75      public static function create_copy(\stdClass $copydata): array {
  76          global $USER;
  77          $copyids = [];
  78  
  79          // Create the initial backupcontoller.
  80          $bc = new \backup_controller(\backup::TYPE_1COURSE, $copydata->courseid, \backup::FORMAT_MOODLE,
  81              \backup::INTERACTIVE_NO, \backup::MODE_COPY, $USER->id, \backup::RELEASESESSION_YES);
  82          $copyids['backupid'] = $bc->get_backupid();
  83  
  84          // Create the initial restore contoller.
  85          list($fullname, $shortname) = \restore_dbops::calculate_course_names(
  86              0, get_string('copyingcourse', 'backup'), get_string('copyingcourseshortname', 'backup'));
  87          $newcourseid = \restore_dbops::create_new_course($fullname, $shortname, $copydata->category);
  88          $rc = new \restore_controller($copyids['backupid'], $newcourseid, \backup::INTERACTIVE_NO,
  89              \backup::MODE_COPY, $USER->id, \backup::TARGET_NEW_COURSE, null,
  90              \backup::RELEASESESSION_NO, $copydata);
  91          $copyids['restoreid'] = $rc->get_restoreid();
  92  
  93          $bc->set_status(\backup::STATUS_AWAITING);
  94          $bc->get_status();
  95          $rc->save_controller();
  96  
  97          // Create the ad-hoc task to perform the course copy.
  98          $asynctask = new \core\task\asynchronous_copy_task();
  99          $asynctask->set_blocking(false);
 100          $asynctask->set_custom_data($copyids);
 101          \core\task\manager::queue_adhoc_task($asynctask);
 102  
 103          // Clean up the controller.
 104          $bc->destroy();
 105  
 106          return $copyids;
 107      }
 108  
 109      /**
 110       * Get the in progress course copy operations for a user.
 111       *
 112       * @param int $userid User id to get the course copies for.
 113       * @param int|null $courseid The optional source course id to get copies for.
 114       * @return array $copies Details of the inprogress copies.
 115       */
 116      public static function get_copies(int $userid, ?int $courseid = null): array {
 117          global $DB;
 118          $copies = [];
 119          [$insql, $inparams] = $DB->get_in_or_equal([\backup::STATUS_FINISHED_OK, \backup::STATUS_FINISHED_ERR]);
 120          $params = [
 121              $userid,
 122              \backup::EXECUTION_DELAYED,
 123              \backup::MODE_COPY,
 124              \backup::OPERATION_BACKUP,
 125              \backup::STATUS_FINISHED_OK,
 126              \backup::OPERATION_RESTORE
 127          ];
 128  
 129          // We exclude backups that finished with OK. Therefore if a backup is missing,
 130          // we can assume it finished properly.
 131          //
 132          // We exclude both failed and successful restores because both of those indicate that the whole
 133          // operation has completed.
 134          $sql = 'SELECT backupid, itemid, operation, status, timecreated, purpose
 135                    FROM {backup_controllers}
 136                   WHERE userid = ?
 137                         AND execution = ?
 138                         AND purpose = ?
 139                         AND ((operation = ? AND status <> ?) OR (operation = ? AND status NOT ' . $insql .'))
 140                ORDER BY timecreated DESC';
 141  
 142          $copyrecords = $DB->get_records_sql($sql, array_merge($params, $inparams));
 143          $idtorc = self::map_backupids_to_restore_controller($copyrecords);
 144  
 145          // Our SQL only gets controllers that have not finished successfully.
 146          // So, no restores => all restores have finished (either failed or OK) => all backups have too
 147          // Therefore there are no in progress copy operations, return early.
 148          if (empty($idtorc)) {
 149              return [];
 150          }
 151  
 152          foreach ($copyrecords as $copyrecord) {
 153              try {
 154                  $isbackup = $copyrecord->operation == \backup::OPERATION_BACKUP;
 155  
 156                  // The mapping is guaranteed to exist for restore controllers, but not
 157                  // backup controllers.
 158                  //
 159                  // When processing backups we don't actually need it, so we just coalesce
 160                  // to null.
 161                  $rc = $idtorc[$copyrecord->backupid] ?? null;
 162  
 163                  $cid = $isbackup ? $copyrecord->itemid : $rc->get_copy()->courseid;
 164                  $course = get_course($cid);
 165                  $copy = clone ($copyrecord);
 166                  $copy->backupid = $isbackup ? $copyrecord->backupid : null;
 167                  $copy->restoreid = $rc ? $rc->get_restoreid() : null;
 168                  $copy->destination = $rc ? $rc->get_copy()->shortname : null;
 169                  $copy->source = $course->shortname;
 170                  $copy->sourceid = $course->id;
 171              } catch (\Exception $e) {
 172                  continue;
 173              }
 174  
 175              // Filter out anything that's not relevant.
 176              if ($courseid) {
 177                  if ($isbackup && $copyrecord->itemid != $courseid) {
 178                      continue;
 179                  }
 180  
 181                  if (!$isbackup && $rc->get_copy()->courseid != $courseid) {
 182                      continue;
 183                  }
 184              }
 185  
 186              // A backup here means that the associated restore controller has not started.
 187              //
 188              // There's a few situations to consider:
 189              //
 190              // 1. The backup is waiting or in progress
 191              // 2. The backup failed somehow
 192              // 3. Something went wrong (e.g., solar flare) and the backup controller saved, but the restore controller didn't
 193              // 4. The restore hasn't been created yet (race condition)
 194              //
 195              // In the case of 1, we add it to the return list. In the case of 2, 3 and 4 we just ignore it and move on.
 196              // The backup cleanup task will take care of updating/deleting invalid controllers.
 197              if ($isbackup) {
 198                  if ($copyrecord->status != \backup::STATUS_FINISHED_ERR && !is_null($rc)) {
 199                      $copies[] = $copy;
 200                  }
 201  
 202                  continue;
 203              }
 204  
 205              // A backup in copyrecords, indicates that the associated backup has not
 206              // successfully finished. We shouldn't do anything with this restore record.
 207              if ($copyrecords[$rc->get_tempdir()] ?? null) {
 208                  continue;
 209              }
 210  
 211              // This is a restore record, and the backup has finished. Return it.
 212              $copies[] = $copy;
 213          }
 214  
 215          return $copies;
 216      }
 217  
 218      /**
 219       * Returns a mapping between copy controller IDs and the restore controller.
 220       * For example if there exists a copy with backup ID abc and restore ID 123
 221       * then this mapping will map both keys abc and 123 to the same (instantiated)
 222       * restore controller.
 223       *
 224       * @param array $backuprecords An array of records from {backup_controllers}
 225       * @return array An array of mappings between backup ids and restore controllers
 226       */
 227      private static function map_backupids_to_restore_controller(array $backuprecords): array {
 228          // Needed for PHP 7.3 - array_merge only accepts 0 parameters in PHP >= 7.4.
 229          if (empty($backuprecords)) {
 230              return [];
 231          }
 232  
 233          return array_merge(
 234              ...array_map(
 235                  function (\stdClass $backuprecord): array {
 236                      $iscopyrestore = $backuprecord->operation == \backup::OPERATION_RESTORE &&
 237                              $backuprecord->purpose == \backup::MODE_COPY;
 238                      $isfinished = $backuprecord->status == \backup::STATUS_FINISHED_OK;
 239  
 240                      if (!$iscopyrestore || $isfinished) {
 241                          return [];
 242                      }
 243  
 244                      $rc = \restore_controller::load_controller($backuprecord->backupid);
 245                      return [$backuprecord->backupid => $rc, $rc->get_tempdir() => $rc];
 246                  },
 247                  array_values($backuprecords)
 248              )
 249          );
 250      }
 251  
 252      /**
 253       * Detects and deletes/fails controllers associated with a course copy that are
 254       * in an invalid state.
 255       *
 256       * @param array $backuprecords An array of records from {backup_controllers}
 257       * @param int $age How old a controller needs to be (in seconds) before its considered for cleaning
 258       * @return void
 259       */
 260      public static function cleanup_orphaned_copy_controllers(array $backuprecords, int $age = MINSECS): void {
 261          global $DB;
 262  
 263          $idtorc = self::map_backupids_to_restore_controller($backuprecords);
 264  
 265          // Helpful to test if a backup exists in $backuprecords.
 266          $bidstorecord = array_combine(
 267              array_column($backuprecords, 'backupid'),
 268              $backuprecords
 269          );
 270  
 271          foreach ($backuprecords as $record) {
 272              if ($record->purpose != \backup::MODE_COPY || $record->status == \backup::STATUS_FINISHED_OK) {
 273                  continue;
 274              }
 275  
 276              $isbackup = $record->operation == \backup::OPERATION_BACKUP;
 277              $restoreexists = isset($idtorc[$record->backupid]);
 278              $nsecondsago = time() - $age;
 279  
 280              if ($isbackup) {
 281                  // Sometimes the backup controller gets created, ""something happens"" (like a solar flare)
 282                  // and the restore controller (and hence adhoc task) don't.
 283                  //
 284                  // If more than one minute has passed and the restore controller doesn't exist, it's likely that
 285                  // this backup controller is orphaned, so we should remove it as the adhoc task to process it will
 286                  // never be created.
 287                  if (!$restoreexists && $record->timecreated <= $nsecondsago) {
 288                      // It would be better to mark the backup as failed by loading the controller
 289                      // and marking it as failed with $bc->set_status(), but we can't: MDL-74711.
 290                      //
 291                      // Deleting it isn't ideal either as maybe we want to inspect the backup
 292                      // for debugging. So manually updating the column seems to be the next best.
 293                      $record->status = \backup::STATUS_FINISHED_ERR;
 294                      $DB->update_record('backup_controllers', $record);
 295                  }
 296                  continue;
 297              }
 298  
 299              if ($rc = $idtorc[$record->backupid] ?? null) {
 300                  $backuprecord = $bidstorecord[$rc->get_tempdir()] ?? null;
 301  
 302                  // Check the status of the associated backup. If it's failed, then mark this
 303                  // restore as failed too.
 304                  if ($backuprecord && $backuprecord->status == \backup::STATUS_FINISHED_ERR) {
 305                      $rc->set_status(\backup::STATUS_FINISHED_ERR);
 306                  }
 307              }
 308          }
 309      }
 310  }