Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
}