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