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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body