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 validation class to check the plugin ZIP contents 19 * 20 * Uses fragments of the local_plugins_archive_validator class copyrighted by 21 * Marina Glancy that is part of the local_plugins plugin. 22 * 23 * @package core_plugin 24 * @subpackage validation 25 * @copyright 2013, 2015 David Mudrak <david@moodle.com> 26 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 27 */ 28 29 namespace core\update; 30 31 use core_component; 32 use core_plugin_manager; 33 use help_icon; 34 use coding_exception; 35 36 defined('MOODLE_INTERNAL') || die(); 37 38 if (!defined('T_ML_COMMENT')) { 39 define('T_ML_COMMENT', T_COMMENT); 40 } else { 41 define('T_DOC_COMMENT', T_ML_COMMENT); 42 } 43 44 /** 45 * Validates the contents of extracted plugin ZIP file 46 * 47 * @copyright 2013, 2015 David Mudrak <david@moodle.com> 48 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 49 */ 50 class validator { 51 52 /** Critical error message level, causes the validation fail. */ 53 const ERROR = 'error'; 54 55 /** Warning message level, validation does not fail but the admin should be always informed. */ 56 const WARNING = 'warning'; 57 58 /** Information message level that the admin should be aware of. */ 59 const INFO = 'info'; 60 61 /** Debugging message level, should be displayed in debugging mode only. */ 62 const DEBUG = 'debug'; 63 64 /** @var string full path to the extracted ZIP contents */ 65 protected $extractdir = null; 66 67 /** @var array as returned by {@link zip_packer::extract_to_pathname()} */ 68 protected $extractfiles = null; 69 70 /** @var bool overall result of validation */ 71 protected $result = null; 72 73 /** @var string the name of the plugin root directory */ 74 protected $rootdir = null; 75 76 /** @var array explicit list of expected/required characteristics of the ZIP */ 77 protected $assertions = null; 78 79 /** @var array of validation log messages */ 80 protected $messages = array(); 81 82 /** @var array|null array of relevant data obtained from version.php */ 83 protected $versionphp = null; 84 85 /** @var string|null the name of found English language file without the .php extension */ 86 protected $langfilename = null; 87 88 /** 89 * Factory method returning instance of the validator 90 * 91 * @param string $zipcontentpath full path to the extracted ZIP contents 92 * @param array $zipcontentfiles (string)filerelpath => (bool|string)true or error 93 * @return \core\update\validator 94 */ 95 public static function instance($zipcontentpath, array $zipcontentfiles) { 96 return new static($zipcontentpath, $zipcontentfiles); 97 } 98 99 /** 100 * Set the expected plugin type, fail the validation otherwise 101 * 102 * @param string $required plugin type 103 */ 104 public function assert_plugin_type($required) { 105 $this->assertions['plugintype'] = $required; 106 } 107 108 /** 109 * Set the expectation that the plugin can be installed into the given Moodle version 110 * 111 * @param string $required Moodle version we are about to install to 112 */ 113 public function assert_moodle_version($required) { 114 $this->assertions['moodleversion'] = $required; 115 } 116 117 /** 118 * Execute the validation process against all explicit and implicit requirements 119 * 120 * Returns true if the validation passes (all explicit and implicit requirements 121 * pass) and the plugin can be installed. Returns false if the validation fails 122 * (some explicit or implicit requirement fails) and the plugin must not be 123 * installed. 124 * 125 * @return bool 126 */ 127 public function execute() { 128 129 $this->result = ( 130 $this->validate_files_layout() 131 and $this->validate_version_php() 132 and $this->validate_language_pack() 133 and $this->validate_target_location() 134 ); 135 136 return $this->result; 137 } 138 139 /** 140 * Returns overall result of the validation. 141 * 142 * Null is returned if the validation has not been executed yet. Otherwise 143 * this method returns true (the installation can continue) or false (it is not 144 * safe to continue with the installation). 145 * 146 * @return bool|null 147 */ 148 public function get_result() { 149 return $this->result; 150 } 151 152 /** 153 * Return the list of validation log messages 154 * 155 * Each validation message is a plain object with properties level, msgcode 156 * and addinfo. 157 * 158 * @return array of (int)index => (stdClass) validation message 159 */ 160 public function get_messages() { 161 return $this->messages; 162 } 163 164 /** 165 * Returns human readable localised name of the given log level. 166 * 167 * @param string $level e.g. self::INFO 168 * @return string 169 */ 170 public function message_level_name($level) { 171 return get_string('validationmsglevel_'.$level, 'core_plugin'); 172 } 173 174 /** 175 * If defined, returns human readable validation code. 176 * 177 * Otherwise, it simply returns the code itself as a fallback. 178 * 179 * @param string $msgcode 180 * @return string 181 */ 182 public function message_code_name($msgcode) { 183 184 $stringman = get_string_manager(); 185 186 if ($stringman->string_exists('validationmsg_'.$msgcode, 'core_plugin')) { 187 return get_string('validationmsg_'.$msgcode, 'core_plugin'); 188 } 189 190 return $msgcode; 191 } 192 193 /** 194 * Returns help icon for the message code if defined. 195 * 196 * @param string $msgcode 197 * @return \help_icon|false 198 */ 199 public function message_help_icon($msgcode) { 200 201 $stringman = get_string_manager(); 202 203 if ($stringman->string_exists('validationmsg_'.$msgcode.'_help', 'core_plugin')) { 204 return new help_icon('validationmsg_'.$msgcode, 'core_plugin'); 205 } 206 207 return false; 208 } 209 210 /** 211 * Localizes the message additional info if it exists. 212 * 213 * @param string $msgcode 214 * @param array|string|null $addinfo value for the $a placeholder in the string 215 * @return string 216 */ 217 public function message_code_info($msgcode, $addinfo) { 218 219 $stringman = get_string_manager(); 220 221 if ($addinfo !== null and $stringman->string_exists('validationmsg_'.$msgcode.'_info', 'core_plugin')) { 222 return get_string('validationmsg_'.$msgcode.'_info', 'core_plugin', $addinfo); 223 } 224 225 return ''; 226 } 227 228 /** 229 * Return the information provided by the the plugin's version.php 230 * 231 * If version.php was not found in the plugin, null is returned. Otherwise 232 * the array is returned. It may be empty if no information was parsed 233 * (which should not happen). 234 * 235 * @return null|array 236 */ 237 public function get_versionphp_info() { 238 return $this->versionphp; 239 } 240 241 /** 242 * Returns the name of the English language file without the .php extension 243 * 244 * This can be used as a suggestion for fixing the plugin root directory in the 245 * ZIP file during the upload. If no file was found, or multiple PHP files are 246 * located in lang/en/ folder, then null is returned. 247 * 248 * @return null|string 249 */ 250 public function get_language_file_name() { 251 return $this->langfilename; 252 } 253 254 /** 255 * Returns the rootdir of the extracted package (after eventual renaming) 256 * 257 * @return string|null 258 */ 259 public function get_rootdir() { 260 return $this->rootdir; 261 } 262 263 // End of external API. 264 265 /** 266 * No public constructor, use {@link self::instance()} instead. 267 * 268 * @param string $zipcontentpath full path to the extracted ZIP contents 269 * @param array $zipcontentfiles (string)filerelpath => (bool|string)true or error 270 */ 271 protected function __construct($zipcontentpath, array $zipcontentfiles) { 272 $this->extractdir = $zipcontentpath; 273 $this->extractfiles = $zipcontentfiles; 274 } 275 276 // Validation methods. 277 278 /** 279 * Returns false if files in the ZIP do not have required layout. 280 * 281 * @return bool 282 */ 283 protected function validate_files_layout() { 284 285 if (!is_array($this->extractfiles) or count($this->extractfiles) < 4) { 286 // We need the English language pack with the name of the plugin at least. 287 $this->add_message(self::ERROR, 'filesnumber'); 288 return false; 289 } 290 291 foreach ($this->extractfiles as $filerelname => $filestatus) { 292 if ($filestatus !== true) { 293 $this->add_message(self::ERROR, 'filestatus', array('file' => $filerelname, 'status' => $filestatus)); 294 return false; 295 } 296 } 297 298 foreach (array_keys($this->extractfiles) as $filerelname) { 299 if (!file_exists($this->extractdir.'/'.$filerelname)) { 300 $this->add_message(self::ERROR, 'filenotexists', array('file' => $filerelname)); 301 return false; 302 } 303 } 304 305 foreach (array_keys($this->extractfiles) as $filerelname) { 306 $matches = array(); 307 if (!preg_match("#^([^/]+)/#", $filerelname, $matches) 308 or (!is_null($this->rootdir) and $this->rootdir !== $matches[1])) { 309 $this->add_message(self::ERROR, 'onedir'); 310 return false; 311 } 312 $this->rootdir = $matches[1]; 313 } 314 315 if ($this->rootdir !== clean_param($this->rootdir, PARAM_PLUGIN)) { 316 $this->add_message(self::ERROR, 'rootdirinvalid', $this->rootdir); 317 return false; 318 } else { 319 $this->add_message(self::INFO, 'rootdir', $this->rootdir); 320 } 321 322 return is_dir($this->extractdir.'/'.$this->rootdir); 323 } 324 325 /** 326 * Returns false if the version.php file does not declare required information. 327 * 328 * @return bool 329 */ 330 protected function validate_version_php() { 331 332 if (!isset($this->assertions['plugintype'])) { 333 throw new coding_exception('Required plugin type must be set before calling this'); 334 } 335 336 if (!isset($this->assertions['moodleversion'])) { 337 throw new coding_exception('Required Moodle version must be set before calling this'); 338 } 339 340 $fullpath = $this->extractdir.'/'.$this->rootdir.'/version.php'; 341 342 if (!file_exists($fullpath)) { 343 // This is tolerated for themes only. 344 if ($this->assertions['plugintype'] === 'theme') { 345 $this->add_message(self::DEBUG, 'missingversionphp'); 346 return true; 347 } else { 348 $this->add_message(self::ERROR, 'missingversionphp'); 349 return false; 350 } 351 } 352 353 $this->versionphp = array(); 354 $info = $this->parse_version_php($fullpath); 355 356 if (isset($info['module->version'])) { 357 $this->add_message(self::ERROR, 'versionphpsyntax', '$module'); 358 return false; 359 } 360 361 if (isset($info['plugin->version'])) { 362 $this->versionphp['version'] = $info['plugin->version']; 363 $this->add_message(self::INFO, 'pluginversion', $this->versionphp['version']); 364 } else { 365 $this->add_message(self::ERROR, 'missingversion'); 366 return false; 367 } 368 369 if (isset($info['plugin->requires'])) { 370 $this->versionphp['requires'] = $info['plugin->requires']; 371 if ($this->versionphp['requires'] > $this->assertions['moodleversion']) { 372 $this->add_message(self::ERROR, 'requiresmoodle', $this->versionphp['requires']); 373 return false; 374 } 375 $this->add_message(self::INFO, 'requiresmoodle', $this->versionphp['requires']); 376 } 377 378 if (!isset($info['plugin->component'])) { 379 $this->add_message(self::ERROR, 'missingcomponent'); 380 return false; 381 } 382 383 $this->versionphp['component'] = $info['plugin->component']; 384 list($reqtype, $reqname) = core_component::normalize_component($this->versionphp['component']); 385 if ($reqtype !== $this->assertions['plugintype']) { 386 $this->add_message(self::ERROR, 'componentmismatchtype', array( 387 'expected' => $this->assertions['plugintype'], 388 'found' => $reqtype)); 389 return false; 390 } 391 if ($reqname !== $this->rootdir) { 392 $this->add_message(self::ERROR, 'componentmismatchname', $reqname); 393 return false; 394 } 395 $this->add_message(self::INFO, 'componentmatch', $this->versionphp['component']); 396 397 // Ensure the version we are uploading is higher than the version currently installed. 398 $plugininfo = $this->get_plugin_manager()->get_plugin_info($this->versionphp['component']); 399 if (!is_null($plugininfo) && $this->versionphp['version'] < $plugininfo->versiondb) { 400 $this->add_message(self::ERROR, 'pluginversiontoolow', $plugininfo->versiondb); 401 return false; 402 } 403 404 if (isset($info['plugin->maturity'])) { 405 $this->versionphp['maturity'] = $info['plugin->maturity']; 406 if ($this->versionphp['maturity'] === 'MATURITY_STABLE') { 407 $this->add_message(self::INFO, 'maturity', $this->versionphp['maturity']); 408 } else { 409 $this->add_message(self::WARNING, 'maturity', $this->versionphp['maturity']); 410 } 411 } 412 413 if (isset($info['plugin->release'])) { 414 $this->versionphp['release'] = $info['plugin->release']; 415 $this->add_message(self::INFO, 'release', $this->versionphp['release']); 416 } 417 418 return true; 419 } 420 421 /** 422 * Returns false if the English language pack is not provided correctly. 423 * 424 * @return bool 425 */ 426 protected function validate_language_pack() { 427 428 if (!isset($this->assertions['plugintype'])) { 429 throw new coding_exception('Required plugin type must be set before calling this'); 430 } 431 432 if (!isset($this->extractfiles[$this->rootdir.'/lang/en/']) 433 or $this->extractfiles[$this->rootdir.'/lang/en/'] !== true 434 or !is_dir($this->extractdir.'/'.$this->rootdir.'/lang/en')) { 435 $this->add_message(self::ERROR, 'missinglangenfolder'); 436 return false; 437 } 438 439 $langfiles = array(); 440 foreach (array_keys($this->extractfiles) as $extractfile) { 441 $matches = array(); 442 if (preg_match('#^'.preg_quote($this->rootdir).'/lang/en/([^/]+).php?$#i', $extractfile, $matches)) { 443 $langfiles[] = $matches[1]; 444 } 445 } 446 447 if (empty($langfiles)) { 448 $this->add_message(self::ERROR, 'missinglangenfile'); 449 return false; 450 } else if (count($langfiles) > 1) { 451 $this->add_message(self::WARNING, 'multiplelangenfiles'); 452 } else { 453 $this->langfilename = $langfiles[0]; 454 $this->add_message(self::DEBUG, 'foundlangfile', $this->langfilename); 455 } 456 457 if ($this->assertions['plugintype'] === 'mod') { 458 $expected = $this->rootdir.'.php'; 459 } else { 460 $expected = $this->assertions['plugintype'].'_'.$this->rootdir.'.php'; 461 } 462 463 if (!isset($this->extractfiles[$this->rootdir.'/lang/en/'.$expected]) 464 or $this->extractfiles[$this->rootdir.'/lang/en/'.$expected] !== true 465 or !is_file($this->extractdir.'/'.$this->rootdir.'/lang/en/'.$expected)) { 466 $this->add_message(self::ERROR, 'missingexpectedlangenfile', $expected); 467 return false; 468 } 469 470 return true; 471 } 472 473 /** 474 * Returns false of the given add-on can't be installed into its location. 475 * 476 * @return bool 477 */ 478 public function validate_target_location() { 479 480 if (!isset($this->assertions['plugintype'])) { 481 throw new coding_exception('Required plugin type must be set before calling this'); 482 } 483 484 $plugintypepath = $this->get_plugintype_location($this->assertions['plugintype']); 485 486 if (is_null($plugintypepath)) { 487 $this->add_message(self::ERROR, 'unknowntype', $this->assertions['plugintype']); 488 return false; 489 } 490 491 if (!is_dir($plugintypepath)) { 492 throw new coding_exception('Plugin type location does not exist!'); 493 } 494 495 // Always check that the plugintype root is writable. 496 if (!is_writable($plugintypepath)) { 497 $this->add_message(self::ERROR, 'pathwritable', $plugintypepath); 498 return false; 499 } else { 500 $this->add_message(self::INFO, 'pathwritable', $plugintypepath); 501 } 502 503 // The target location itself may or may not exist. Even if installing an 504 // available update, the code could have been removed by accident (and 505 // be reported as missing) etc. So we just make sure that the code 506 // can be replaced if it already exists. 507 $target = $plugintypepath.'/'.$this->rootdir; 508 if (file_exists($target)) { 509 if (!is_dir($target)) { 510 $this->add_message(self::ERROR, 'targetnotdir', $target); 511 return false; 512 } 513 $this->add_message(self::WARNING, 'targetexists', $target); 514 if ($this->get_plugin_manager()->is_directory_removable($target)) { 515 $this->add_message(self::INFO, 'pathwritable', $target); 516 } else { 517 $this->add_message(self::ERROR, 'pathwritable', $target); 518 return false; 519 } 520 } 521 522 return true; 523 } 524 525 // Helper methods. 526 527 /** 528 * Get as much information from existing version.php as possible 529 * 530 * @param string $fullpath full path to the version.php file 531 * @return array of found meta-info declarations 532 */ 533 protected function parse_version_php($fullpath) { 534 535 $content = $this->get_stripped_file_contents($fullpath); 536 537 preg_match_all('#\$((plugin|module)\->(version|maturity|release|requires))=()(\d+(\.\d+)?);#m', $content, $matches1); 538 preg_match_all('#\$((plugin|module)\->(maturity))=()(MATURITY_\w+);#m', $content, $matches2); 539 preg_match_all('#\$((plugin|module)\->(release))=([\'"])(.*?)\4;#m', $content, $matches3); 540 preg_match_all('#\$((plugin|module)\->(component))=([\'"])(.+?_.+?)\4;#m', $content, $matches4); 541 542 if (count($matches1[1]) + count($matches2[1]) + count($matches3[1]) + count($matches4[1])) { 543 $info = array_combine( 544 array_merge($matches1[1], $matches2[1], $matches3[1], $matches4[1]), 545 array_merge($matches1[5], $matches2[5], $matches3[5], $matches4[5]) 546 ); 547 548 } else { 549 $info = array(); 550 } 551 552 return $info; 553 } 554 555 /** 556 * Append the given message to the messages log 557 * 558 * @param string $level e.g. self::ERROR 559 * @param string $msgcode may form a string 560 * @param string|array|object $a optional additional info suitable for {@link get_string()} 561 */ 562 protected function add_message($level, $msgcode, $a = null) { 563 $msg = (object)array( 564 'level' => $level, 565 'msgcode' => $msgcode, 566 'addinfo' => $a, 567 ); 568 $this->messages[] = $msg; 569 } 570 571 /** 572 * Returns bare PHP code from the given file 573 * 574 * Returns contents without PHP opening and closing tags, text outside php code, 575 * comments and extra whitespaces. 576 * 577 * @param string $fullpath full path to the file 578 * @return string 579 */ 580 protected function get_stripped_file_contents($fullpath) { 581 582 $source = file_get_contents($fullpath); 583 $tokens = token_get_all($source); 584 $output = ''; 585 $doprocess = false; 586 foreach ($tokens as $token) { 587 if (is_string($token)) { 588 // Simple one character token. 589 $id = -1; 590 $text = $token; 591 } else { 592 // Token array. 593 list($id, $text) = $token; 594 } 595 switch ($id) { 596 case T_WHITESPACE: 597 case T_COMMENT: 598 case T_ML_COMMENT: 599 case T_DOC_COMMENT: 600 // Ignore whitespaces, inline comments, multiline comments and docblocks. 601 break; 602 case T_OPEN_TAG: 603 // Start processing. 604 $doprocess = true; 605 break; 606 case T_CLOSE_TAG: 607 // Stop processing. 608 $doprocess = false; 609 break; 610 default: 611 // Anything else is within PHP tags, return it as is. 612 if ($doprocess) { 613 $output .= $text; 614 if ($text === 'function') { 615 // Explicitly keep the whitespace that would be ignored. 616 $output .= ' '; 617 } 618 } 619 break; 620 } 621 } 622 623 return $output; 624 } 625 626 /** 627 * Returns the full path to the root directory of the given plugin type. 628 * 629 * @param string $plugintype 630 * @return string|null 631 */ 632 public function get_plugintype_location($plugintype) { 633 return $this->get_plugin_manager()->get_plugintype_root($plugintype); 634 } 635 636 /** 637 * Returns plugin manager to use. 638 * 639 * @return core_plugin_manager 640 */ 641 protected function get_plugin_manager() { 642 return core_plugin_manager::instance(); 643 } 644 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body