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 | ||
f5f5a60a DM |
303 | if (isset($info['module->version'])) { |
304 | $this->add_message(self::ERROR, 'versionphpsyntax', '$module'); | |
305 | return false; | |
a4fcf56f DM |
306 | } |
307 | ||
f5f5a60a DM |
308 | if (isset($info['plugin->version'])) { |
309 | $this->versionphp['version'] = $info['plugin->version']; | |
a4fcf56f | 310 | $this->add_message(self::INFO, 'pluginversion', $this->versionphp['version']); |
f5f5a60a DM |
311 | } else { |
312 | $this->add_message(self::ERROR, 'missingversion'); | |
313 | return false; | |
a4fcf56f DM |
314 | } |
315 | ||
f5f5a60a DM |
316 | if (isset($info['plugin->requires'])) { |
317 | $this->versionphp['requires'] = $info['plugin->requires']; | |
a4fcf56f DM |
318 | if ($this->versionphp['requires'] > $this->assertions['moodleversion']) { |
319 | $this->add_message(self::ERROR, 'requiresmoodle', $this->versionphp['requires']); | |
320 | return false; | |
321 | } | |
322 | $this->add_message(self::INFO, 'requiresmoodle', $this->versionphp['requires']); | |
323 | } | |
324 | ||
f5f5a60a DM |
325 | if (isset($info['plugin->component'])) { |
326 | $this->versionphp['component'] = $info['plugin->component']; | |
56da374e | 327 | list($reqtype, $reqname) = core_component::normalize_component($this->versionphp['component']); |
a4fcf56f DM |
328 | if ($reqtype !== $this->assertions['plugintype']) { |
329 | $this->add_message(self::ERROR, 'componentmismatchtype', array( | |
330 | 'expected' => $this->assertions['plugintype'], | |
331 | 'found' => $reqtype)); | |
332 | return false; | |
333 | } | |
334 | if ($reqname !== $this->rootdir) { | |
335 | $this->add_message(self::ERROR, 'componentmismatchname', $reqname); | |
336 | return false; | |
337 | } | |
338 | $this->add_message(self::INFO, 'componentmatch', $this->versionphp['component']); | |
339 | } | |
340 | ||
f5f5a60a DM |
341 | if (isset($info['plugin->maturity'])) { |
342 | $this->versionphp['maturity'] = $info['plugin->maturity']; | |
a4fcf56f DM |
343 | if ($this->versionphp['maturity'] === 'MATURITY_STABLE') { |
344 | $this->add_message(self::INFO, 'maturity', $this->versionphp['maturity']); | |
345 | } else { | |
346 | $this->add_message(self::WARNING, 'maturity', $this->versionphp['maturity']); | |
347 | } | |
348 | } | |
349 | ||
f5f5a60a DM |
350 | if (isset($info['plugin->release'])) { |
351 | $this->versionphp['release'] = $info['plugin->release']; | |
a4fcf56f DM |
352 | $this->add_message(self::INFO, 'release', $this->versionphp['release']); |
353 | } | |
354 | ||
355 | return true; | |
356 | } | |
357 | ||
358 | /** | |
359 | * @return bool false if the English language pack is not provided correctly | |
360 | */ | |
361 | protected function validate_language_pack() { | |
362 | ||
363 | if (!isset($this->assertions['plugintype'])) { | |
364 | throw new coding_exception('Required plugin type must be set before calling this'); | |
365 | } | |
366 | ||
367 | if (!isset($this->extractfiles[$this->rootdir.'/lang/en/']) | |
368 | or $this->extractfiles[$this->rootdir.'/lang/en/'] !== true | |
369 | or !is_dir($this->extractdir.'/'.$this->rootdir.'/lang/en')) { | |
370 | $this->add_message(self::ERROR, 'missinglangenfolder'); | |
371 | return false; | |
372 | } | |
373 | ||
374 | $langfiles = array(); | |
375 | foreach (array_keys($this->extractfiles) as $extractfile) { | |
376 | $matches = array(); | |
377 | if (preg_match('#^'.preg_quote($this->rootdir).'/lang/en/([^/]+).php?$#i', $extractfile, $matches)) { | |
378 | $langfiles[] = $matches[1]; | |
379 | } | |
380 | } | |
381 | ||
382 | if (empty($langfiles)) { | |
383 | $this->add_message(self::ERROR, 'missinglangenfile'); | |
384 | return false; | |
385 | } else if (count($langfiles) > 1) { | |
386 | $this->add_message(self::WARNING, 'multiplelangenfiles'); | |
387 | } else { | |
388 | $this->langfilename = $langfiles[0]; | |
389 | $this->add_message(self::DEBUG, 'foundlangfile', $this->langfilename); | |
390 | } | |
391 | ||
392 | if ($this->assertions['plugintype'] === 'mod') { | |
393 | $expected = $this->rootdir.'.php'; | |
394 | } else { | |
395 | $expected = $this->assertions['plugintype'].'_'.$this->rootdir.'.php'; | |
396 | } | |
397 | ||
398 | if (!isset($this->extractfiles[$this->rootdir.'/lang/en/'.$expected]) | |
399 | or $this->extractfiles[$this->rootdir.'/lang/en/'.$expected] !== true | |
400 | or !is_file($this->extractdir.'/'.$this->rootdir.'/lang/en/'.$expected)) { | |
401 | $this->add_message(self::ERROR, 'missingexpectedlangenfile', $expected); | |
402 | return false; | |
403 | } | |
404 | ||
405 | return true; | |
406 | } | |
407 | ||
408 | ||
409 | /** | |
410 | * @return bool false of the given add-on can't be installed into its location | |
411 | */ | |
412 | public function validate_target_location() { | |
413 | ||
414 | if (!isset($this->assertions['plugintype'])) { | |
415 | throw new coding_exception('Required plugin type must be set before calling this'); | |
416 | } | |
417 | ||
418 | $plugintypepath = $this->get_plugintype_location($this->assertions['plugintype']); | |
419 | ||
420 | if (is_null($plugintypepath)) { | |
421 | $this->add_message(self::ERROR, 'unknowntype', $this->assertions['plugintype']); | |
422 | return false; | |
423 | } | |
424 | ||
425 | if (!is_dir($plugintypepath)) { | |
426 | throw new coding_exception('Plugin type location does not exist!'); | |
427 | } | |
428 | ||
429 | $target = $plugintypepath.'/'.$this->rootdir; | |
430 | ||
431 | if (file_exists($target)) { | |
432 | $this->add_message(self::ERROR, 'targetexists', $target); | |
433 | return false; | |
434 | } | |
435 | ||
436 | if (is_writable($plugintypepath)) { | |
437 | $this->add_message(self::INFO, 'pathwritable', $plugintypepath); | |
438 | } else { | |
439 | $this->add_message(self::ERROR, 'pathwritable', $plugintypepath); | |
440 | return false; | |
441 | } | |
442 | ||
443 | return true; | |
444 | } | |
445 | ||
446 | // Helper methods ////////////////////////////////////////////////////////// | |
447 | ||
448 | /** | |
449 | * Get as much information from existing version.php as possible | |
450 | * | |
451 | * @param string full path to the version.php file | |
452 | * @return array of found meta-info declarations | |
453 | */ | |
454 | protected function parse_version_php($fullpath) { | |
455 | ||
456 | $content = $this->get_stripped_file_contents($fullpath); | |
457 | ||
458 | preg_match_all('#\$((plugin|module)\->(version|maturity|release|requires))=()(\d+(\.\d+)?);#m', $content, $matches1); | |
459 | preg_match_all('#\$((plugin|module)\->(maturity))=()(MATURITY_\w+);#m', $content, $matches2); | |
460 | preg_match_all('#\$((plugin|module)\->(release))=([\'"])(.*?)\4;#m', $content, $matches3); | |
461 | preg_match_all('#\$((plugin|module)\->(component))=([\'"])(.+?_.+?)\4;#m', $content, $matches4); | |
462 | ||
463 | if (count($matches1[1]) + count($matches2[1]) + count($matches3[1]) + count($matches4[1])) { | |
464 | $info = array_combine( | |
465 | array_merge($matches1[1], $matches2[1], $matches3[1], $matches4[1]), | |
466 | array_merge($matches1[5], $matches2[5], $matches3[5], $matches4[5]) | |
467 | ); | |
468 | ||
469 | } else { | |
470 | $info = array(); | |
471 | } | |
472 | ||
473 | return $info; | |
474 | } | |
475 | ||
476 | /** | |
477 | * Append the given message to the messages log | |
478 | * | |
479 | * @param string $level e.g. self::ERROR | |
480 | * @param string $msgcode may form a string | |
481 | * @param string|array|object $a optional additional info suitable for {@link get_string()} | |
482 | */ | |
483 | protected function add_message($level, $msgcode, $a = null) { | |
484 | $msg = (object)array( | |
485 | 'level' => $level, | |
486 | 'msgcode' => $msgcode, | |
487 | 'addinfo' => $a, | |
488 | ); | |
489 | $this->messages[] = $msg; | |
490 | } | |
491 | ||
492 | /** | |
493 | * Returns bare PHP code from the given file | |
494 | * | |
495 | * Returns contents without PHP opening and closing tags, text outside php code, | |
496 | * comments and extra whitespaces. | |
497 | * | |
498 | * @param string $fullpath full path to the file | |
499 | * @return string | |
500 | */ | |
501 | protected function get_stripped_file_contents($fullpath) { | |
502 | ||
503 | $source = file_get_contents($fullpath); | |
504 | $tokens = token_get_all($source); | |
505 | $output = ''; | |
506 | $doprocess = false; | |
507 | foreach ($tokens as $token) { | |
508 | if (is_string($token)) { | |
509 | // Simple one character token. | |
510 | $id = -1; | |
511 | $text = $token; | |
512 | } else { | |
513 | // Token array. | |
514 | list($id, $text) = $token; | |
515 | } | |
516 | switch ($id) { | |
517 | case T_WHITESPACE: | |
518 | case T_COMMENT: | |
519 | case T_ML_COMMENT: | |
520 | case T_DOC_COMMENT: | |
521 | // Ignore whitespaces, inline comments, multiline comments and docblocks. | |
522 | break; | |
523 | case T_OPEN_TAG: | |
524 | // Start processing. | |
525 | $doprocess = true; | |
526 | break; | |
527 | case T_CLOSE_TAG: | |
528 | // Stop processing. | |
529 | $doprocess = false; | |
530 | break; | |
531 | default: | |
532 | // Anything else is within PHP tags, return it as is. | |
533 | if ($doprocess) { | |
534 | $output .= $text; | |
535 | if ($text === 'function') { | |
536 | // Explicitly keep the whitespace that would be ignored. | |
537 | $output .= ' '; | |
538 | } | |
539 | } | |
540 | break; | |
541 | } | |
542 | } | |
543 | ||
544 | return $output; | |
545 | } | |
546 | ||
547 | ||
548 | /** | |
549 | * Returns the full path to the root directory of the given plugin type | |
550 | * | |
551 | * @param string $plugintype | |
552 | * @return string|null | |
553 | */ | |
554 | public function get_plugintype_location($plugintype) { | |
555 | ||
556 | $plugintypepath = null; | |
557 | ||
46f6f7f2 | 558 | foreach (core_component::get_plugin_types() as $type => $fullpath) { |
a4fcf56f DM |
559 | if ($type === $plugintype) { |
560 | $plugintypepath = $fullpath; | |
561 | break; | |
562 | } | |
563 | } | |
564 | ||
565 | return $plugintypepath; | |
566 | } | |
567 | } |