Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.
   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  /**
  18   * Provides core\update\code_manager class.
  19   *
  20   * @package     core_plugin
  21   * @copyright   2012, 2013, 2015 David Mudrak <david@moodle.com>
  22   * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  namespace core\update;
  26  
  27  use core_component;
  28  use coding_exception;
  29  use moodle_exception;
  30  use SplFileInfo;
  31  use RecursiveDirectoryIterator;
  32  use RecursiveIteratorIterator;
  33  
  34  defined('MOODLE_INTERNAL') || die();
  35  
  36  require_once($CFG->libdir.'/filelib.php');
  37  
  38  /**
  39   * General purpose class managing the plugins source code files deployment
  40   *
  41   * The class is able and supposed to
  42   * - fetch and cache ZIP files distributed via the Moodle Plugins directory
  43   * - unpack the ZIP files in a temporary storage
  44   * - archive existing version of the plugin source code
  45   * - move (deploy) the plugin source code into the $CFG->dirroot
  46   *
  47   * @copyright 2015 David Mudrak <david@moodle.com>
  48   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  49   */
  50  class code_manager {
  51  
  52      /** @var string full path to the Moodle app directory root */
  53      protected $dirroot;
  54      /** @var string full path to the temp directory root */
  55      protected $temproot;
  56  
  57      /**
  58       * Instantiate the class instance
  59       *
  60       * @param string $dirroot full path to the moodle app directory root
  61       * @param string $temproot full path to our temp directory
  62       */
  63      public function __construct($dirroot=null, $temproot=null) {
  64          global $CFG;
  65  
  66          if (empty($dirroot)) {
  67              $dirroot = $CFG->dirroot;
  68          }
  69  
  70          if (empty($temproot)) {
  71              // Note we are using core_plugin here as that is the valid core
  72              // subsystem we are part of. The namespace of this class (core\update)
  73              // does not match it for legacy reasons.  The data stored in the
  74              // temp directory are expected to survive multiple requests and
  75              // purging caches during the upgrade, so we make use of
  76              // make_temp_directory(). The contents of it can be removed if needed,
  77              // given the site is in the maintenance mode (so that cron is not
  78              // executed) and the site is not being upgraded.
  79              $temproot = make_temp_directory('core_plugin/code_manager');
  80          }
  81  
  82          $this->dirroot = $dirroot;
  83          $this->temproot = $temproot;
  84  
  85          $this->init_temp_directories();
  86      }
  87  
  88      /**
  89       * Obtain the plugin ZIP file from the given URL
  90       *
  91       * The caller is supposed to know both downloads URL and the MD5 hash of
  92       * the ZIP contents in advance, typically by using the API requests against
  93       * the plugins directory.
  94       *
  95       * @param string $url
  96       * @param string $md5
  97       * @return string|bool full path to the file, false on error
  98       */
  99      public function get_remote_plugin_zip($url, $md5) {
 100  
 101          // Sanitize and validate the URL.
 102          $url = str_replace(array("\r", "\n"), '', $url);
 103  
 104          if (!preg_match('|^https?://|i', $url)) {
 105              $this->debug('Error fetching plugin ZIP: unsupported transport protocol: '.$url);
 106              return false;
 107          }
 108  
 109          // The cache location for the file.
 110          $distfile = $this->temproot.'/distfiles/'.$md5.'.zip';
 111  
 112          if (is_readable($distfile) and md5_file($distfile) === $md5) {
 113              return $distfile;
 114          } else {
 115              @unlink($distfile);
 116          }
 117  
 118          // Download the file into a temporary location.
 119          $tempdir = make_request_directory();
 120          $tempfile = $tempdir.'/plugin.zip';
 121          $result = $this->download_plugin_zip_file($url, $tempfile);
 122  
 123          if (!$result) {
 124              return false;
 125          }
 126  
 127          $actualmd5 = md5_file($tempfile);
 128  
 129          // Make sure the actual md5 hash matches the expected one.
 130          if ($actualmd5 !== $md5) {
 131              $this->debug('Error fetching plugin ZIP: md5 mismatch.');
 132              return false;
 133          }
 134  
 135          // If the file is empty, something went wrong.
 136          if ($actualmd5 === 'd41d8cd98f00b204e9800998ecf8427e') {
 137              return false;
 138          }
 139  
 140          // Store the file in our cache.
 141          if (!rename($tempfile, $distfile)) {
 142              return false;
 143          }
 144  
 145          return $distfile;
 146      }
 147  
 148      /**
 149       * Extracts the saved plugin ZIP file.
 150       *
 151       * Returns the list of files found in the ZIP. The format of that list is
 152       * array of (string)filerelpath => (bool|string) where the array value is
 153       * either true or a string describing the problematic file.
 154       *
 155       * @see zip_packer::extract_to_pathname()
 156       * @param string $zipfilepath full path to the saved ZIP file
 157       * @param string $targetdir full path to the directory to extract the ZIP file to
 158       * @param string $rootdir explicitly rename the root directory of the ZIP into this non-empty value
 159       * @return array list of extracted files as returned by {@link zip_packer::extract_to_pathname()}
 160       */
 161      public function unzip_plugin_file($zipfilepath, $targetdir, $rootdir = '') {
 162  
 163          // Extract the package into a temporary location.
 164          $fp = get_file_packer('application/zip');
 165          $tempdir = make_request_directory();
 166          $files = $fp->extract_to_pathname($zipfilepath, $tempdir);
 167  
 168          if (!$files) {
 169              return array();
 170          }
 171  
 172          // If requested, rename the root directory of the plugin.
 173          if (!empty($rootdir)) {
 174              $files = $this->rename_extracted_rootdir($tempdir, $rootdir, $files);
 175          }
 176  
 177          // Sometimes zip may not contain all parent directories, add them to make it consistent.
 178          foreach ($files as $path => $status) {
 179              if ($status !== true) {
 180                  continue;
 181              }
 182              $parts = explode('/', trim($path, '/'));
 183              while (array_pop($parts)) {
 184                  if (empty($parts)) {
 185                      break;
 186                  }
 187                  $dir = implode('/', $parts).'/';
 188                  if (!isset($files[$dir])) {
 189                      $files[$dir] = true;
 190                  }
 191              }
 192          }
 193  
 194          // Move the extracted files into the target location.
 195          $this->move_extracted_plugin_files($tempdir, $targetdir, $files);
 196  
 197          // Set the permissions of extracted subdirs and files.
 198          $this->set_plugin_files_permissions($targetdir, $files);
 199  
 200          return $files;
 201      }
 202  
 203      /**
 204       * Make an archive backup of the existing plugin folder.
 205       *
 206       * @param string $folderpath full path to the plugin folder
 207       * @param string $targetzip full path to the zip file to be created
 208       * @return bool true if file created, false if not
 209       */
 210      public function zip_plugin_folder($folderpath, $targetzip) {
 211  
 212          if (file_exists($targetzip)) {
 213              throw new coding_exception('Attempting to create already existing ZIP file', $targetzip);
 214          }
 215  
 216          if (!is_writable(dirname($targetzip))) {
 217              throw new coding_exception('Target ZIP location not writable', dirname($targetzip));
 218          }
 219  
 220          if (!is_dir($folderpath)) {
 221              throw new coding_exception('Attempting to ZIP non-existing source directory', $folderpath);
 222          }
 223  
 224          $files = $this->list_plugin_folder_files($folderpath);
 225          $fp = get_file_packer('application/zip');
 226          return $fp->archive_to_pathname($files, $targetzip, false);
 227      }
 228  
 229      /**
 230       * Archive the current plugin on-disk version.
 231       *
 232       * @param string $folderpath full path to the plugin folder
 233       * @param string $component
 234       * @param int $version
 235       * @param bool $overwrite overwrite existing archive if found
 236       * @return bool
 237       */
 238      public function archive_plugin_version($folderpath, $component, $version, $overwrite=false) {
 239  
 240          if ($component !== clean_param($component, PARAM_SAFEDIR)) {
 241              // This should never happen, but just in case.
 242              throw new moodle_exception('unexpected_plugin_component_format', 'core_plugin', '', null, $component);
 243          }
 244  
 245          if ((string)$version !== clean_param((string)$version, PARAM_FILE)) {
 246              // Prevent some nasty injections via $plugin->version tricks.
 247              throw new moodle_exception('unexpected_plugin_version_format', 'core_plugin', '', null, $version);
 248          }
 249  
 250          if (empty($component) or empty($version)) {
 251              return false;
 252          }
 253  
 254          if (!is_dir($folderpath)) {
 255              return false;
 256          }
 257  
 258          $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
 259  
 260          if (file_exists($archzip) and !$overwrite) {
 261              return true;
 262          }
 263  
 264          $tmpzip = make_request_directory().'/'.$version.'.zip';
 265          $zipped = $this->zip_plugin_folder($folderpath, $tmpzip);
 266  
 267          if (!$zipped) {
 268              return false;
 269          }
 270  
 271          // Assert that the file looks like a valid one.
 272          list($expectedtype, $expectedname) = core_component::normalize_component($component);
 273          $actualname = $this->get_plugin_zip_root_dir($tmpzip);
 274          if ($actualname !== $expectedname) {
 275              // This should not happen.
 276              throw new moodle_exception('unexpected_archive_structure', 'core_plugin');
 277          }
 278  
 279          make_writable_directory(dirname($archzip));
 280          return rename($tmpzip, $archzip);
 281      }
 282  
 283      /**
 284       * Return the path to the ZIP file with the archive of the given plugin version.
 285       *
 286       * @param string $component
 287       * @param int $version
 288       * @return string|bool false if not found, full path otherwise
 289       */
 290      public function get_archived_plugin_version($component, $version) {
 291  
 292          if (empty($component) or empty($version)) {
 293              return false;
 294          }
 295  
 296          $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
 297  
 298          if (file_exists($archzip)) {
 299              return $archzip;
 300          }
 301  
 302          return false;
 303      }
 304  
 305      /**
 306       * Returns list of all files in the given directory.
 307       *
 308       * Given a path like /full/path/to/mod/workshop, it returns array like
 309       *
 310       *  [workshop/] => /full/path/to/mod/workshop
 311       *  [workshop/lang/] => /full/path/to/mod/workshop/lang
 312       *  [workshop/lang/workshop.php] => /full/path/to/mod/workshop/lang/workshop.php
 313       *  ...
 314       *
 315       * Which mathes the format used by Moodle file packers.
 316       *
 317       * @param string $folderpath full path to the plugin directory
 318       * @return array (string)relpath => (string)fullpath
 319       */
 320      public function list_plugin_folder_files($folderpath) {
 321  
 322          $folder = new RecursiveDirectoryIterator($folderpath);
 323          $iterator = new RecursiveIteratorIterator($folder);
 324          $folderpathinfo = new SplFileInfo($folderpath);
 325          $strip = strlen($folderpathinfo->getPathInfo()->getRealPath()) + 1;
 326          $files = array();
 327          foreach ($iterator as $fileinfo) {
 328              if ($fileinfo->getFilename() === '..') {
 329                  continue;
 330              }
 331              if (strpos($fileinfo->getRealPath(), $folderpathinfo->getRealPath()) !== 0) {
 332                  throw new moodle_exception('unexpected_filepath_mismatch', 'core_plugin');
 333              }
 334              $key = substr($fileinfo->getRealPath(), $strip);
 335              if ($fileinfo->isDir() and substr($key, -1) !== '/') {
 336                  $key .= '/';
 337              }
 338              $files[str_replace(DIRECTORY_SEPARATOR, '/', $key)] = str_replace(DIRECTORY_SEPARATOR, '/', $fileinfo->getRealPath());
 339          }
 340          return $files;
 341      }
 342  
 343      /**
 344       * Detects the plugin's name from its ZIP file.
 345       *
 346       * Plugin ZIP packages are expected to contain a single directory and the
 347       * directory name would become the plugin name once extracted to the Moodle
 348       * dirroot.
 349       *
 350       * @param string $zipfilepath full path to the ZIP files
 351       * @return string|bool false on error
 352       */
 353      public function get_plugin_zip_root_dir($zipfilepath) {
 354  
 355          $fp = get_file_packer('application/zip');
 356          $files = $fp->list_files($zipfilepath);
 357  
 358          if (empty($files)) {
 359              return false;
 360          }
 361  
 362          $rootdirname = null;
 363          foreach ($files as $file) {
 364              $pathnameitems = explode('/', $file->pathname);
 365              if (empty($pathnameitems)) {
 366                  return false;
 367              }
 368              // Set the expected name of the root directory in the first
 369              // iteration of the loop.
 370              if ($rootdirname === null) {
 371                  $rootdirname = $pathnameitems[0];
 372              }
 373              // Require the same root directory for all files in the ZIP
 374              // package.
 375              if ($rootdirname !== $pathnameitems[0]) {
 376                  return false;
 377              }
 378          }
 379  
 380          return $rootdirname;
 381      }
 382  
 383      // This is the end, my only friend, the end ... of external public API.
 384  
 385      /**
 386       * Makes sure all temp directories exist and are writable.
 387       */
 388      protected function init_temp_directories() {
 389          make_writable_directory($this->temproot.'/distfiles');
 390          make_writable_directory($this->temproot.'/archive');
 391      }
 392  
 393      /**
 394       * Raise developer debugging level message.
 395       *
 396       * @param string $msg
 397       */
 398      protected function debug($msg) {
 399          debugging($msg, DEBUG_DEVELOPER);
 400      }
 401  
 402      /**
 403       * Download the ZIP file with the plugin package from the given location
 404       *
 405       * @param string $url URL to the file
 406       * @param string $tofile full path to where to store the downloaded file
 407       * @return bool false on error
 408       */
 409      protected function download_plugin_zip_file($url, $tofile) {
 410  
 411          if (file_exists($tofile)) {
 412              $this->debug('Error fetching plugin ZIP: target location exists.');
 413              return false;
 414          }
 415  
 416          $status = $this->download_file_content($url, $tofile);
 417  
 418          if (!$status) {
 419              $this->debug('Error fetching plugin ZIP.');
 420              @unlink($tofile);
 421              return false;
 422          }
 423  
 424          return true;
 425      }
 426  
 427      /**
 428       * Thin wrapper for the core's download_file_content() function.
 429       *
 430       * @param string $url URL to the file
 431       * @param string $tofile full path to where to store the downloaded file
 432       * @return bool
 433       */
 434      protected function download_file_content($url, $tofile) {
 435  
 436          // Prepare the parameters for the download_file_content() function.
 437          $headers = null;
 438          $postdata = null;
 439          $fullresponse = false;
 440          $timeout = 300;
 441          $connecttimeout = 20;
 442          $skipcertverify = false;
 443          $tofile = $tofile;
 444          $calctimeout = false;
 445  
 446          return download_file_content($url, $headers, $postdata, $fullresponse, $timeout,
 447              $connecttimeout, $skipcertverify, $tofile, $calctimeout);
 448      }
 449  
 450      /**
 451       * Renames the root directory of the extracted ZIP package.
 452       *
 453       * This internal helper method assumes that the plugin ZIP package has been
 454       * extracted into a temporary empty directory so the plugin folder is the
 455       * only folder there. The ZIP package is supposed to be validated so that
 456       * it contains just a single root folder.
 457       *
 458       * @param string $dirname fullpath location of the extracted ZIP package
 459       * @param string $rootdir the requested name of the root directory
 460       * @param array $files list of extracted files
 461       * @return array eventually amended list of extracted files
 462       */
 463      protected function rename_extracted_rootdir($dirname, $rootdir, array $files) {
 464  
 465          if (!is_dir($dirname)) {
 466              $this->debug('Unable to rename rootdir of non-existing content');
 467              return $files;
 468          }
 469  
 470          if (file_exists($dirname.'/'.$rootdir)) {
 471              // This typically means the real root dir already has the $rootdir name.
 472              return $files;
 473          }
 474  
 475          $found = null; // The name of the first subdirectory under the $dirname.
 476          foreach (scandir($dirname) as $item) {
 477              if (substr($item, 0, 1) === '.') {
 478                  continue;
 479              }
 480              if (is_dir($dirname.'/'.$item)) {
 481                  if ($found !== null and $found !== $item) {
 482                      // Multiple directories found.
 483                      throw new moodle_exception('unexpected_archive_structure', 'core_plugin');
 484                  }
 485                  $found = $item;
 486              }
 487          }
 488  
 489          if (!is_null($found)) {
 490              if (rename($dirname.'/'.$found, $dirname.'/'.$rootdir)) {
 491                  $newfiles = array();
 492                  foreach ($files as $filepath => $status) {
 493                      $newpath = preg_replace('~^'.preg_quote($found.'/').'~', preg_quote($rootdir.'/'), $filepath);
 494                      $newfiles[$newpath] = $status;
 495                  }
 496                  return $newfiles;
 497              }
 498          }
 499  
 500          return $files;
 501      }
 502  
 503      /**
 504       * Sets the permissions of extracted subdirs and files
 505       *
 506       * As a result of unzipping, the subdirs and files are created with
 507       * permissions set to $CFG->directorypermissions and $CFG->filepermissions.
 508       * These are too benevolent by default (777 and 666 respectively) for PHP
 509       * scripts and may lead to HTTP 500 errors in some environments.
 510       *
 511       * To fix this behaviour, we inherit the permissions of the plugin root
 512       * directory itself.
 513       *
 514       * @param string $targetdir full path to the directory the ZIP file was extracted to
 515       * @param array $files list of extracted files
 516       */
 517      protected function set_plugin_files_permissions($targetdir, array $files) {
 518  
 519          $dirpermissions = fileperms($targetdir);
 520          $filepermissions = ($dirpermissions & 0666);
 521  
 522          foreach ($files as $subpath => $notusedhere) {
 523              $path = $targetdir.'/'.$subpath;
 524              if (is_dir($path)) {
 525                  @chmod($path, $dirpermissions);
 526              } else {
 527                  @chmod($path, $filepermissions);
 528              }
 529          }
 530      }
 531  
 532      /**
 533       * Moves the extracted contents of the plugin ZIP into the target location.
 534       *
 535       * @param string $sourcedir full path to the directory the ZIP file was extracted to
 536       * @param mixed $targetdir full path to the directory where the files should be moved to
 537       * @param array $files list of extracted files
 538       */
 539      protected function move_extracted_plugin_files($sourcedir, $targetdir, array $files) {
 540          global $CFG;
 541  
 542          foreach ($files as $file => $status) {
 543              if ($status !== true) {
 544                  throw new moodle_exception('corrupted_archive_structure', 'core_plugin', '', $file, $status);
 545              }
 546  
 547              $source = $sourcedir.'/'.$file;
 548              $target = $targetdir.'/'.$file;
 549  
 550              if (is_dir($source)) {
 551                  continue;
 552  
 553              } else {
 554                  if (!is_dir(dirname($target))) {
 555                      mkdir(dirname($target), $CFG->directorypermissions, true);
 556                  }
 557                  rename($source, $target);
 558              }
 559          }
 560      }
 561  }