Merge branch 'MDL-39054' of git://github.com/jacks92/moodle
[moodle.git] / mdeploy.php
1 <?php
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/>.
18 /**
19  * Moodle deployment utility
20  *
21  * This script looks after deploying new add-ons and available updates for them
22  * to the local Moodle site. It can operate via both HTTP and CLI mode.
23  * Moodle itself calls this utility via the HTTP mode when the admin is about to
24  * install or update an add-on. You can use the CLI mode in your custom deployment
25  * shell scripts.
26  *
27  * CLI usage example:
28  *
29  *  $ sudo -u apache php mdeploy.php --install \
30  *                                   --package=https://moodle.org/plugins/download.php/...zip \
31  *                                   --typeroot=/var/www/moodle/htdocs/blocks
32  *                                   --name=loancalc
33  *                                   --md5=...
34  *                                   --dataroot=/var/www/moodle/data
35  *
36  *  $ sudo -u apache php mdeploy.php --upgrade \
37  *                                   --package=https://moodle.org/plugins/download.php/...zip \
38  *                                   --typeroot=/var/www/moodle/htdocs/blocks
39  *                                   --name=loancalc
40  *                                   --md5=...
41  *                                   --dataroot=/var/www/moodle/data
42  *
43  * When called via HTTP, additional parameters returnurl, passfile and password must be
44  * provided. Optional proxy configuration can be passed using parameters proxy, proxytype
45  * and proxyuserpwd.
46  *
47  * Changes
48  *
49  * 1.1 - Added support to install a new plugin from the Moodle Plugins directory.
50  * 1.0 - Initial version used in Moodle 2.4 to deploy available updates.
51  *
52  * @package     core
53  * @subpackage  mdeploy
54  * @version     1.1
55  * @copyright   2012 David Mudrak <david@moodle.com>
56  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
57  */
59 if (defined('MOODLE_INTERNAL')) {
60     die('This is a standalone utility that should not be included by any other Moodle code.');
61 }
64 // Exceptions //////////////////////////////////////////////////////////////////
66 class invalid_coding_exception extends Exception {}
67 class missing_option_exception extends Exception {}
68 class invalid_option_exception extends Exception {}
69 class unauthorized_access_exception extends Exception {}
70 class download_file_exception extends Exception {}
71 class backup_folder_exception extends Exception {}
72 class zip_exception extends Exception {}
73 class filesystem_exception extends Exception {}
74 class checksum_exception extends Exception {}
77 // Various support classes /////////////////////////////////////////////////////
79 /**
80  * Base class implementing the singleton pattern using late static binding feature.
81  *
82  * @copyright 2012 David Mudrak <david@moodle.com>
83  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
84  */
85 abstract class singleton_pattern {
87     /** @var array singleton_pattern instances */
88     protected static $singletoninstances = array();
90     /**
91      * Factory method returning the singleton instance.
92      *
93      * Subclasses may want to override the {@link self::initialize()} method that is
94      * called right after their instantiation.
95      *
96      * @return mixed the singleton instance
97      */
98     final public static function instance() {
99         $class = get_called_class();
100         if (!isset(static::$singletoninstances[$class])) {
101             static::$singletoninstances[$class] = new static();
102             static::$singletoninstances[$class]->initialize();
103         }
104         return static::$singletoninstances[$class];
105     }
107     /**
108      * Optional post-instantiation code.
109      */
110     protected function initialize() {
111         // Do nothing in this base class.
112     }
114     /**
115      * Direct instantiation not allowed, use the factory method {@link instance()}
116      */
117     final protected function __construct() {
118     }
120     /**
121      * Sorry, this is singleton.
122      */
123     final protected function __clone() {
124     }
128 // User input handling /////////////////////////////////////////////////////////
130 /**
131  * Provides access to the script options.
132  *
133  * Implements the delegate pattern by dispatching the calls to appropriate
134  * helper class (CLI or HTTP).
135  *
136  * @copyright 2012 David Mudrak <david@moodle.com>
137  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
138  */
139 class input_manager extends singleton_pattern {
141     const TYPE_FILE         = 'file';   // File name
142     const TYPE_FLAG         = 'flag';   // No value, just a flag (switch)
143     const TYPE_INT          = 'int';    // Integer
144     const TYPE_PATH         = 'path';   // Full path to a file or a directory
145     const TYPE_RAW          = 'raw';    // Raw value, keep as is
146     const TYPE_URL          = 'url';    // URL to a file
147     const TYPE_PLUGIN       = 'plugin'; // Plugin name
148     const TYPE_MD5          = 'md5';    // MD5 hash
150     /** @var input_cli_provider|input_http_provider the provider of the input */
151     protected $inputprovider = null;
153     /**
154      * Returns the value of an option passed to the script.
155      *
156      * If the caller passes just the $name, the requested argument is considered
157      * required. The caller may specify the second argument which then
158      * makes the argument optional with the given default value.
159      *
160      * If the type of the $name option is TYPE_FLAG (switch), this method returns
161      * true if the flag has been passed or false if it was not. Specifying the
162      * default value makes no sense in this case and leads to invalid coding exception.
163      *
164      * The array options are not supported.
165      *
166      * @example $filename = $input->get_option('f');
167      * @example $filename = $input->get_option('filename');
168      * @example if ($input->get_option('verbose')) { ... }
169      * @param string $name
170      * @return mixed
171      */
172     public function get_option($name, $default = 'provide_default_value_explicitly') {
174         $this->validate_option_name($name);
176         $info = $this->get_option_info($name);
178         if ($info->type === input_manager::TYPE_FLAG) {
179             return $this->inputprovider->has_option($name);
180         }
182         if (func_num_args() == 1) {
183             return $this->get_required_option($name);
184         } else {
185             return $this->get_optional_option($name, $default);
186         }
187     }
189     /**
190      * Returns the meta-information about the given option.
191      *
192      * @param string|null $name short or long option name, defaults to returning the list of all
193      * @return array|object|false array with all, object with the specific option meta-information or false of no such an option
194      */
195     public function get_option_info($name=null) {
197         $supportedoptions = array(
198             array('', 'passfile', input_manager::TYPE_FILE, 'File name of the passphrase file (HTTP access only)'),
199             array('', 'password', input_manager::TYPE_RAW, 'Session passphrase (HTTP access only)'),
200             array('', 'proxy', input_manager::TYPE_RAW, 'HTTP proxy host and port (e.g. \'our.proxy.edu:8888\')'),
201             array('', 'proxytype', input_manager::TYPE_RAW, 'Proxy type (HTTP or SOCKS5)'),
202             array('', 'proxyuserpwd', input_manager::TYPE_RAW, 'Proxy username and password (e.g. \'username:password\')'),
203             array('', 'returnurl', input_manager::TYPE_URL, 'Return URL (HTTP access only)'),
204             array('d', 'dataroot', input_manager::TYPE_PATH, 'Full path to the dataroot (moodledata) directory'),
205             array('h', 'help', input_manager::TYPE_FLAG, 'Prints usage information'),
206             array('i', 'install', input_manager::TYPE_FLAG, 'Installation mode'),
207             array('m', 'md5', input_manager::TYPE_MD5, 'Expected MD5 hash of the ZIP package to deploy'),
208             array('n', 'name', input_manager::TYPE_PLUGIN, 'Plugin name (the name of its folder)'),
209             array('p', 'package', input_manager::TYPE_URL, 'URL to the ZIP package to deploy'),
210             array('r', 'typeroot', input_manager::TYPE_PATH, 'Full path of the container for this plugin type'),
211             array('u', 'upgrade', input_manager::TYPE_FLAG, 'Upgrade mode'),
212         );
214         if (is_null($name)) {
215             $all = array();
216             foreach ($supportedoptions as $optioninfo) {
217                 $info = new stdClass();
218                 $info->shortname = $optioninfo[0];
219                 $info->longname = $optioninfo[1];
220                 $info->type = $optioninfo[2];
221                 $info->desc = $optioninfo[3];
222                 $all[] = $info;
223             }
224             return $all;
225         }
227         $found = false;
229         foreach ($supportedoptions as $optioninfo) {
230             if (strlen($name) == 1) {
231                 // Search by the short option name
232                 if ($optioninfo[0] === $name) {
233                     $found = $optioninfo;
234                     break;
235                 }
236             } else {
237                 // Search by the long option name
238                 if ($optioninfo[1] === $name) {
239                     $found = $optioninfo;
240                     break;
241                 }
242             }
243         }
245         if (!$found) {
246             return false;
247         }
249         $info = new stdClass();
250         $info->shortname = $found[0];
251         $info->longname = $found[1];
252         $info->type = $found[2];
253         $info->desc = $found[3];
255         return $info;
256     }
258     /**
259      * Casts the value to the given type.
260      *
261      * @param mixed $raw the raw value
262      * @param string $type the expected value type, e.g. {@link input_manager::TYPE_INT}
263      * @return mixed
264      */
265     public function cast_value($raw, $type) {
267         if (is_array($raw)) {
268             throw new invalid_coding_exception('Unsupported array option.');
269         } else if (is_object($raw)) {
270             throw new invalid_coding_exception('Unsupported object option.');
271         }
273         switch ($type) {
275             case input_manager::TYPE_FILE:
276                 $raw = preg_replace('~[[:cntrl:]]|[&<>"`\|\':\\\\/]~u', '', $raw);
277                 $raw = preg_replace('~\.\.+~', '', $raw);
278                 if ($raw === '.') {
279                     $raw = '';
280                 }
281                 return $raw;
283             case input_manager::TYPE_FLAG:
284                 return true;
286             case input_manager::TYPE_INT:
287                 return (int)$raw;
289             case input_manager::TYPE_PATH:
290                 if (strpos($raw, '~') !== false) {
291                     throw new invalid_option_exception('Using the tilde (~) character in paths is not supported');
292                 }
293                 $colonpos = strpos($raw, ':');
294                 if ($colonpos !== false) {
295                     if ($colonpos !== 1 or strrpos($raw, ':') !== 1) {
296                         throw new invalid_option_exception('Using the colon (:) character in paths is supported for Windows drive labels only.');
297                     }
298                     if (preg_match('/^[a-zA-Z]:/', $raw) !== 1) {
299                         throw new invalid_option_exception('Using the colon (:) character in paths is supported for Windows drive labels only.');
300                     }
301                 }
302                 $raw = str_replace('\\', '/', $raw);
303                 $raw = preg_replace('~[[:cntrl:]]|[&<>"`\|\']~u', '', $raw);
304                 $raw = preg_replace('~\.\.+~', '', $raw);
305                 $raw = preg_replace('~//+~', '/', $raw);
306                 $raw = preg_replace('~/(\./)+~', '/', $raw);
307                 return $raw;
309             case input_manager::TYPE_RAW:
310                 return $raw;
312             case input_manager::TYPE_URL:
313                 $regex  = '^(https?|ftp)\:\/\/'; // protocol
314                 $regex .= '([a-z0-9+!*(),;?&=\$_.-]+(\:[a-z0-9+!*(),;?&=\$_.-]+)?@)?'; // optional user and password
315                 $regex .= '[a-z0-9+\$_-]+(\.[a-z0-9+\$_-]+)*'; // hostname or IP (one word like http://localhost/ allowed)
316                 $regex .= '(\:[0-9]{2,5})?'; // port (optional)
317                 $regex .= '(\/([a-z0-9+\$_-]\.?)+)*\/?'; // path to the file
318                 $regex .= '(\?[a-z+&\$_.-][a-z0-9;:@/&%=+\$_.-]*)?'; // HTTP params
320                 if (preg_match('#'.$regex.'#i', $raw)) {
321                     return $raw;
322                 } else {
323                     throw new invalid_option_exception('Not a valid URL');
324                 }
326             case input_manager::TYPE_PLUGIN:
327                 if (!preg_match('/^[a-z][a-z0-9_]*[a-z0-9]$/', $raw)) {
328                     throw new invalid_option_exception('Invalid plugin name');
329                 }
330                 if (strpos($raw, '__') !== false) {
331                     throw new invalid_option_exception('Invalid plugin name');
332                 }
333                 return $raw;
335             case input_manager::TYPE_MD5:
336                 if (!preg_match('/^[a-f0-9]{32}$/', $raw)) {
337                     throw new invalid_option_exception('Invalid MD5 hash format');
338                 }
339                 return $raw;
341             default:
342                 throw new invalid_coding_exception('Unknown option type.');
344         }
345     }
347     /**
348      * Picks the appropriate helper class to delegate calls to.
349      */
350     protected function initialize() {
351         if (PHP_SAPI === 'cli') {
352             $this->inputprovider = input_cli_provider::instance();
353         } else {
354             $this->inputprovider = input_http_provider::instance();
355         }
356     }
358     // End of external API
360     /**
361      * Validates the parameter name.
362      *
363      * @param string $name
364      * @throws invalid_coding_exception
365      */
366     protected function validate_option_name($name) {
368         if (empty($name)) {
369             throw new invalid_coding_exception('Invalid empty option name.');
370         }
372         $meta = $this->get_option_info($name);
373         if (empty($meta)) {
374             throw new invalid_coding_exception('Invalid option name: '.$name);
375         }
376     }
378     /**
379      * Returns cleaned option value or throws exception.
380      *
381      * @param string $name the name of the parameter
382      * @param string $type the parameter type, e.g. {@link input_manager::TYPE_INT}
383      * @return mixed
384      */
385     protected function get_required_option($name) {
386         if ($this->inputprovider->has_option($name)) {
387             return $this->inputprovider->get_option($name);
388         } else {
389             throw new missing_option_exception('Missing required option: '.$name);
390         }
391     }
393     /**
394      * Returns cleaned option value or the default value
395      *
396      * @param string $name the name of the parameter
397      * @param string $type the parameter type, e.g. {@link input_manager::TYPE_INT}
398      * @param mixed $default the default value.
399      * @return mixed
400      */
401     protected function get_optional_option($name, $default) {
402         if ($this->inputprovider->has_option($name)) {
403             return $this->inputprovider->get_option($name);
404         } else {
405             return $default;
406         }
407     }
411 /**
412  * Base class for input providers.
413  *
414  * @copyright 2012 David Mudrak <david@moodle.com>
415  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
416  */
417 abstract class input_provider extends singleton_pattern {
419     /** @var array list of all passed valid options */
420     protected $options = array();
422     /**
423      * Returns the casted value of the option.
424      *
425      * @param string $name option name
426      * @throws invalid_coding_exception if the option has not been passed
427      * @return mixed casted value of the option
428      */
429     public function get_option($name) {
431         if (!$this->has_option($name)) {
432             throw new invalid_coding_exception('Option not passed: '.$name);
433         }
435         return $this->options[$name];
436     }
438     /**
439      * Was the given option passed?
440      *
441      * @param string $name optionname
442      * @return bool
443      */
444     public function has_option($name) {
445         return array_key_exists($name, $this->options);
446     }
448     /**
449      * Initializes the input provider.
450      */
451     protected function initialize() {
452         $this->populate_options();
453     }
455     // End of external API
457     /**
458      * Parses and validates all supported options passed to the script.
459      */
460     protected function populate_options() {
462         $input = input_manager::instance();
463         $raw = $this->parse_raw_options();
464         $cooked = array();
466         foreach ($raw as $k => $v) {
467             if (is_array($v) or is_object($v)) {
468                 // Not supported.
469             }
471             $info = $input->get_option_info($k);
472             if (!$info) {
473                 continue;
474             }
476             $casted = $input->cast_value($v, $info->type);
478             if (!empty($info->shortname)) {
479                 $cooked[$info->shortname] = $casted;
480             }
482             if (!empty($info->longname)) {
483                 $cooked[$info->longname] = $casted;
484             }
485         }
487         // Store the options.
488         $this->options = $cooked;
489     }
493 /**
494  * Provides access to the script options passed via CLI.
495  *
496  * @copyright 2012 David Mudrak <david@moodle.com>
497  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
498  */
499 class input_cli_provider extends input_provider {
501     /**
502      * Parses raw options passed to the script.
503      *
504      * @return array as returned by getopt()
505      */
506     protected function parse_raw_options() {
508         $input = input_manager::instance();
510         // Signatures of some in-built PHP functions are just crazy, aren't they.
511         $short = '';
512         $long = array();
514         foreach ($input->get_option_info() as $option) {
515             if ($option->type === input_manager::TYPE_FLAG) {
516                 // No value expected for this option.
517                 $short .= $option->shortname;
518                 $long[] = $option->longname;
519             } else {
520                 // A value expected for the option, all considered as optional.
521                 $short .= empty($option->shortname) ? '' : $option->shortname.'::';
522                 $long[] = empty($option->longname) ? '' : $option->longname.'::';
523             }
524         }
526         return getopt($short, $long);
527     }
531 /**
532  * Provides access to the script options passed via HTTP request.
533  *
534  * @copyright 2012 David Mudrak <david@moodle.com>
535  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
536  */
537 class input_http_provider extends input_provider {
539     /**
540      * Parses raw options passed to the script.
541      *
542      * @return array of raw values passed via HTTP request
543      */
544     protected function parse_raw_options() {
545         return $_POST;
546     }
550 // Output handling /////////////////////////////////////////////////////////////
552 /**
553  * Provides output operations.
554  *
555  * @copyright 2012 David Mudrak <david@moodle.com>
556  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
557  */
558 class output_manager extends singleton_pattern {
560     /** @var output_cli_provider|output_http_provider the provider of the output functionality */
561     protected $outputprovider = null;
563     /**
564      * Magic method triggered when invoking an inaccessible method.
565      *
566      * @param string $name method name
567      * @param array $arguments method arguments
568      */
569     public function __call($name, array $arguments = array()) {
570         call_user_func_array(array($this->outputprovider, $name), $arguments);
571     }
573     /**
574      * Picks the appropriate helper class to delegate calls to.
575      */
576     protected function initialize() {
577         if (PHP_SAPI === 'cli') {
578             $this->outputprovider = output_cli_provider::instance();
579         } else {
580             $this->outputprovider = output_http_provider::instance();
581         }
582     }
586 /**
587  * Base class for all output providers.
588  *
589  * @copyright 2012 David Mudrak <david@moodle.com>
590  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
591  */
592 abstract class output_provider extends singleton_pattern {
595 /**
596  * Provides output to the command line.
597  *
598  * @copyright 2012 David Mudrak <david@moodle.com>
599  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
600  */
601 class output_cli_provider extends output_provider {
603     /**
604      * Prints help information in CLI mode.
605      */
606     public function help() {
608         $this->outln('mdeploy.php - Moodle (http://moodle.org) deployment utility');
609         $this->outln();
610         $this->outln('Usage: $ sudo -u apache php mdeploy.php [options]');
611         $this->outln();
612         $input = input_manager::instance();
613         foreach($input->get_option_info() as $info) {
614             $option = array();
615             if (!empty($info->shortname)) {
616                 $option[] = '-'.$info->shortname;
617             }
618             if (!empty($info->longname)) {
619                 $option[] = '--'.$info->longname;
620             }
621             $this->outln(sprintf('%-20s %s', implode(', ', $option), $info->desc));
622         }
623     }
625     // End of external API
627     /**
628      * Writes a text to the STDOUT followed by a new line character.
629      *
630      * @param string $text text to print
631      */
632     protected function outln($text='') {
633         fputs(STDOUT, $text.PHP_EOL);
634     }
638 /**
639  * Provides HTML output as a part of HTTP response.
640  *
641  * @copyright 2012 David Mudrak <david@moodle.com>
642  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
643  */
644 class output_http_provider extends output_provider {
646     /**
647      * Prints help on the script usage.
648      */
649     public function help() {
650         // No help available via HTTP
651     }
653     /**
654      * Display the information about uncaught exception
655      *
656      * @param Exception $e uncaught exception
657      */
658     public function exception(Exception $e) {
660         $docslink = 'http://docs.moodle.org/en/admin/mdeploy/'.get_class($e);
661         $this->start_output();
662         echo('<h1>Oops! It did it again</h1>');
663         echo('<p><strong>Moodle deployment utility had a trouble with your request.
664             See <a href="'.$docslink.'">the docs page</a> and the debugging information for more details.</strong></p>');
665         echo('<pre>');
666         echo exception_handlers::format_exception_info($e);
667         echo('</pre>');
668         $this->end_output();
669     }
671     // End of external API
673     /**
674      * Produce the HTML page header
675      */
676     protected function start_output() {
677         echo '<!doctype html>
678 <html lang="en">
679 <head>
680   <meta charset="utf-8">
681   <style type="text/css">
682     body {background-color:#666;font-family:"DejaVu Sans","Liberation Sans",Freesans,sans-serif;}
683     h1 {text-align:center;}
684     pre {white-space: pre-wrap;}
685     #page {background-color:#eee;width:1024px;margin:5em auto;border:3px solid #333;border-radius: 15px;padding:1em;}
686   </style>
687 </head>
688 <body>
689 <div id="page">';
690     }
692     /**
693      * Produce the HTML page footer
694      */
695     protected function end_output() {
696         echo '</div></body></html>';
697     }
700 // The main class providing all the functionality //////////////////////////////
702 /**
703  * The actual worker class implementing the main functionality of the script.
704  *
705  * @copyright 2012 David Mudrak <david@moodle.com>
706  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
707  */
708 class worker extends singleton_pattern {
710     const EXIT_OK                       = 0;    // Success exit code.
711     const EXIT_HELP                     = 1;    // Explicit help required.
712     const EXIT_UNKNOWN_ACTION           = 127;  // Neither -i nor -u provided.
714     /** @var input_manager */
715     protected $input = null;
717     /** @var output_manager */
718     protected $output = null;
720     /** @var int the most recent cURL error number, zero for no error */
721     private $curlerrno = null;
723     /** @var string the most recent cURL error message, empty string for no error */
724     private $curlerror = null;
726     /** @var array|false the most recent cURL request info, if it was successful */
727     private $curlinfo = null;
729     /** @var string the full path to the log file */
730     private $logfile = null;
732     /**
733      * Main - the one that actually does something
734      */
735     public function execute() {
737         $this->log('=== MDEPLOY EXECUTION START ===');
739         // Authorize access. None in CLI. Passphrase in HTTP.
740         $this->authorize();
742         // Asking for help in the CLI mode.
743         if ($this->input->get_option('help')) {
744             $this->output->help();
745             $this->done(self::EXIT_HELP);
746         }
748         if ($this->input->get_option('upgrade')) {
749             $this->log('Plugin upgrade requested');
751             // Fetch the ZIP file into a temporary location.
752             $source = $this->input->get_option('package');
753             $target = $this->target_location($source);
754             $this->log('Downloading package '.$source);
756             if ($this->download_file($source, $target)) {
757                 $this->log('Package downloaded into '.$target);
758             } else {
759                 $this->log('cURL error ' . $this->curlerrno . ' ' . $this->curlerror);
760                 $this->log('Unable to download the file');
761                 throw new download_file_exception('Unable to download the package');
762             }
764             // Compare MD5 checksum of the ZIP file
765             $md5remote = $this->input->get_option('md5');
766             $md5local = md5_file($target);
768             if ($md5local !== $md5remote) {
769                 $this->log('MD5 checksum failed. Expected: '.$md5remote.' Got: '.$md5local);
770                 throw new checksum_exception('MD5 checksum failed');
771             }
772             $this->log('MD5 checksum ok');
774             // Backup the current version of the plugin
775             $plugintyperoot = $this->input->get_option('typeroot');
776             $pluginname = $this->input->get_option('name');
777             $sourcelocation = $plugintyperoot.'/'.$pluginname;
778             $backuplocation = $this->backup_location($sourcelocation);
780             $this->log('Current plugin code location: '.$sourcelocation);
781             $this->log('Moving the current code into archive: '.$backuplocation);
783             // We don't want to touch files unless we are pretty sure it would be all ok.
784             if (!$this->move_directory_source_precheck($sourcelocation)) {
785                 throw new backup_folder_exception('Unable to backup the current version of the plugin (source precheck failed)');
786             }
787             if (!$this->move_directory_target_precheck($backuplocation)) {
788                 throw new backup_folder_exception('Unable to backup the current version of the plugin (backup precheck failed)');
789             }
791             // Looking good, let's try it.
792             if (!$this->move_directory($sourcelocation, $backuplocation, true)) {
793                 throw new backup_folder_exception('Unable to backup the current version of the plugin (moving failed)');
794             }
796             // Unzip the plugin package file into the target location.
797             $this->unzip_plugin($target, $plugintyperoot, $sourcelocation, $backuplocation);
798             $this->log('Package successfully extracted');
800             // Redirect to the given URL (in HTTP) or exit (in CLI).
801             $this->done();
803         } else if ($this->input->get_option('install')) {
804             $this->log('Plugin installation requested');
806             $plugintyperoot = $this->input->get_option('typeroot');
807             $pluginname     = $this->input->get_option('name');
808             $source         = $this->input->get_option('package');
809             $md5remote      = $this->input->get_option('md5');
811             // Check if the plugin location if available for us.
812             $pluginlocation = $plugintyperoot.'/'.$pluginname;
814             $this->log('New plugin code location: '.$pluginlocation);
816             if (file_exists($pluginlocation)) {
817                 throw new filesystem_exception('Unable to prepare the plugin location (directory already exists)');
818             }
820             if (!$this->create_directory_precheck($pluginlocation)) {
821                 throw new filesystem_exception('Unable to prepare the plugin location (cannot create new directory)');
822             }
824             // Fetch the ZIP file into a temporary location.
825             $target = $this->target_location($source);
826             $this->log('Downloading package '.$source);
828             if ($this->download_file($source, $target)) {
829                 $this->log('Package downloaded into '.$target);
830             } else {
831                 $this->log('cURL error ' . $this->curlerrno . ' ' . $this->curlerror);
832                 $this->log('Unable to download the file');
833                 throw new download_file_exception('Unable to download the package');
834             }
836             // Compare MD5 checksum of the ZIP file
837             $md5local = md5_file($target);
839             if ($md5local !== $md5remote) {
840                 $this->log('MD5 checksum failed. Expected: '.$md5remote.' Got: '.$md5local);
841                 throw new checksum_exception('MD5 checksum failed');
842             }
843             $this->log('MD5 checksum ok');
845             // Unzip the plugin package file into the plugin location.
846             $this->unzip_plugin($target, $plugintyperoot, $pluginlocation, false);
847             $this->log('Package successfully extracted');
849             // Redirect to the given URL (in HTTP) or exit (in CLI).
850             $this->done();
851         }
853         // Print help in CLI by default.
854         $this->output->help();
855         $this->done(self::EXIT_UNKNOWN_ACTION);
856     }
858     /**
859      * Attempts to log a thrown exception
860      *
861      * @param Exception $e uncaught exception
862      */
863     public function log_exception(Exception $e) {
864         $this->log($e->__toString());
865     }
867     /**
868      * Initialize the worker class.
869      */
870     protected function initialize() {
871         $this->input = input_manager::instance();
872         $this->output = output_manager::instance();
873     }
875     // End of external API
877     /**
878      * Finish this script execution.
879      *
880      * @param int $exitcode
881      */
882     protected function done($exitcode = self::EXIT_OK) {
884         if (PHP_SAPI === 'cli') {
885             exit($exitcode);
887         } else {
888             $returnurl = $this->input->get_option('returnurl');
889             $this->redirect($returnurl);
890             exit($exitcode);
891         }
892     }
894     /**
895      * Authorize access to the script.
896      *
897      * In CLI mode, the access is automatically authorized. In HTTP mode, the
898      * passphrase submitted via the request params must match the contents of the
899      * file, the name of which is passed in another parameter.
900      *
901      * @throws unauthorized_access_exception
902      */
903     protected function authorize() {
905         if (PHP_SAPI === 'cli') {
906             $this->log('Successfully authorized using the CLI SAPI');
907             return;
908         }
910         $dataroot = $this->input->get_option('dataroot');
911         $passfile = $this->input->get_option('passfile');
912         $password = $this->input->get_option('password');
914         $passpath = $dataroot.'/mdeploy/auth/'.$passfile;
916         if (!is_readable($passpath)) {
917             throw new unauthorized_access_exception('Unable to read the passphrase file.');
918         }
920         $stored = file($passpath, FILE_IGNORE_NEW_LINES);
922         // "This message will self-destruct in five seconds." -- Mission Commander Swanbeck, Mission: Impossible II
923         unlink($passpath);
925         if (is_readable($passpath)) {
926             throw new unauthorized_access_exception('Unable to remove the passphrase file.');
927         }
929         if (count($stored) < 2) {
930             throw new unauthorized_access_exception('Invalid format of the passphrase file.');
931         }
933         if (time() - (int)$stored[1] > 30 * 60) {
934             throw new unauthorized_access_exception('Passphrase timeout.');
935         }
937         if (strlen($stored[0]) < 24) {
938             throw new unauthorized_access_exception('Session passphrase not long enough.');
939         }
941         if ($password !== $stored[0]) {
942             throw new unauthorized_access_exception('Session passphrase does not match the stored one.');
943         }
945         $this->log('Successfully authorized using the passphrase file');
946     }
948     /**
949      * Returns the full path to the log file.
950      *
951      * @return string
952      */
953     protected function log_location() {
955         if (!is_null($this->logfile)) {
956             return $this->logfile;
957         }
959         $dataroot = $this->input->get_option('dataroot', '');
961         if (empty($dataroot)) {
962             $this->logfile = false;
963             return $this->logfile;
964         }
966         $myroot = $dataroot.'/mdeploy';
968         if (!is_dir($myroot)) {
969             mkdir($myroot, 02777, true);
970         }
972         $this->logfile = $myroot.'/mdeploy.log';
973         return $this->logfile;
974     }
976     /**
977      * Choose the target location for the given ZIP's URL.
978      *
979      * @param string $source URL
980      * @return string
981      */
982     protected function target_location($source) {
984         $dataroot = $this->input->get_option('dataroot');
985         $pool = $dataroot.'/mdeploy/var';
987         if (!is_dir($pool)) {
988             mkdir($pool, 02777, true);
989         }
991         $target = $pool.'/'.md5($source);
993         $suffix = 0;
994         while (file_exists($target.'.'.$suffix.'.zip')) {
995             $suffix++;
996         }
998         return $target.'.'.$suffix.'.zip';
999     }
1001     /**
1002      * Choose the location of the current plugin folder backup
1003      *
1004      * @param string $path full path to the current folder
1005      * @return string
1006      */
1007     protected function backup_location($path) {
1009         $dataroot = $this->input->get_option('dataroot');
1010         $pool = $dataroot.'/mdeploy/archive';
1012         if (!is_dir($pool)) {
1013             mkdir($pool, 02777, true);
1014         }
1016         $target = $pool.'/'.basename($path).'_'.time();
1018         $suffix = 0;
1019         while (file_exists($target.'.'.$suffix)) {
1020             $suffix++;
1021         }
1023         return $target.'.'.$suffix;
1024     }
1026     /**
1027      * Downloads the given file into the given destination.
1028      *
1029      * This is basically a simplified version of {@link download_file_content()} from
1030      * Moodle itself, tuned for fetching files from moodle.org servers.
1031      *
1032      * @param string $source file url starting with http(s)://
1033      * @param string $target store the downloaded content to this file (full path)
1034      * @return bool true on success, false otherwise
1035      * @throws download_file_exception
1036      */
1037     protected function download_file($source, $target) {
1039         $newlines = array("\r", "\n");
1040         $source = str_replace($newlines, '', $source);
1041         if (!preg_match('|^https?://|i', $source)) {
1042             throw new download_file_exception('Unsupported transport protocol.');
1043         }
1044         if (!$ch = curl_init($source)) {
1045             $this->log('Unable to init cURL.');
1046             return false;
1047         }
1049         curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); // verify the peer's certificate
1050         curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // check the existence of a common name and also verify that it matches the hostname provided
1051         curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // return the transfer as a string
1052         curl_setopt($ch, CURLOPT_HEADER, false); // don't include the header in the output
1053         curl_setopt($ch, CURLOPT_TIMEOUT, 3600);
1054         curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 20); // nah, moodle.org is never unavailable! :-p
1055         curl_setopt($ch, CURLOPT_URL, $source);
1057         $dataroot = $this->input->get_option('dataroot');
1058         $cacertfile = $dataroot.'/moodleorgca.crt';
1059         if (is_readable($cacertfile)) {
1060             // Do not use CA certs provided by the operating system. Instead,
1061             // use this CA cert to verify the ZIP provider.
1062             $this->log('Using custom CA certificate '.$cacertfile);
1063             curl_setopt($ch, CURLOPT_CAINFO, $cacertfile);
1064         }
1066         $proxy = $this->input->get_option('proxy', false);
1067         if (!empty($proxy)) {
1068             curl_setopt($ch, CURLOPT_PROXY, $proxy);
1070             $proxytype = $this->input->get_option('proxytype', false);
1071             if (strtoupper($proxytype) === 'SOCKS5') {
1072                 $this->log('Using SOCKS5 proxy');
1073                 curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5);
1074             } else if (!empty($proxytype)) {
1075                 $this->log('Using HTTP proxy');
1076                 curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_HTTP);
1077                 curl_setopt($ch, CURLOPT_HTTPPROXYTUNNEL, false);
1078             }
1080             $proxyuserpwd = $this->input->get_option('proxyuserpwd', false);
1081             if (!empty($proxyuserpwd)) {
1082                 curl_setopt($ch, CURLOPT_PROXYUSERPWD, $proxyuserpwd);
1083                 curl_setopt($ch, CURLOPT_PROXYAUTH, CURLAUTH_BASIC | CURLAUTH_NTLM);
1084             }
1085         }
1087         $targetfile = fopen($target, 'w');
1089         if (!$targetfile) {
1090             throw new download_file_exception('Unable to create local file '.$target);
1091         }
1093         curl_setopt($ch, CURLOPT_FILE, $targetfile);
1095         $result = curl_exec($ch);
1097         // try to detect encoding problems
1098         if ((curl_errno($ch) == 23 or curl_errno($ch) == 61) and defined('CURLOPT_ENCODING')) {
1099             curl_setopt($ch, CURLOPT_ENCODING, 'none');
1100             $result = curl_exec($ch);
1101         }
1103         fclose($targetfile);
1105         $this->curlerrno = curl_errno($ch);
1106         $this->curlerror = curl_error($ch);
1107         $this->curlinfo = curl_getinfo($ch);
1109         if (!$result or $this->curlerrno) {
1110             return false;
1112         } else if (is_array($this->curlinfo) and (empty($this->curlinfo['http_code']) or $this->curlinfo['http_code'] != 200)) {
1113             return false;
1114         }
1116         return true;
1117     }
1119     /**
1120      * Log a message
1121      *
1122      * @param string $message
1123      */
1124     protected function log($message) {
1126         $logpath = $this->log_location();
1128         if (empty($logpath)) {
1129             // no logging available
1130             return;
1131         }
1133         $f = fopen($logpath, 'ab');
1135         if ($f === false) {
1136             throw new filesystem_exception('Unable to open the log file for appending');
1137         }
1139         $message = $this->format_log_message($message);
1141         fwrite($f, $message);
1143         fclose($f);
1144     }
1146     /**
1147      * Prepares the log message for writing into the file
1148      *
1149      * @param string $msg
1150      * @return string
1151      */
1152     protected function format_log_message($msg) {
1154         $msg = trim($msg);
1155         $timestamp = date("Y-m-d H:i:s");
1157         return $timestamp . ' '. $msg . PHP_EOL;
1158     }
1160     /**
1161      * Checks to see if the given source could be safely moved into a new location
1162      *
1163      * @param string $source full path to the existing directory
1164      * @return bool
1165      */
1166     protected function move_directory_source_precheck($source) {
1168         if (!is_writable($source)) {
1169             return false;
1170         }
1172         if (is_dir($source)) {
1173             $handle = opendir($source);
1174         } else {
1175             return false;
1176         }
1178         $result = true;
1180         while ($filename = readdir($handle)) {
1181             $sourcepath = $source.'/'.$filename;
1183             if ($filename === '.' or $filename === '..') {
1184                 continue;
1185             }
1187             if (is_dir($sourcepath)) {
1188                 $result = $result && $this->move_directory_source_precheck($sourcepath);
1190             } else {
1191                 $result = $result && is_writable($sourcepath);
1192             }
1193         }
1195         closedir($handle);
1197         return $result;
1198     }
1200     /**
1201      * Checks to see if a source folder could be safely moved into the given new location
1202      *
1203      * @param string $destination full path to the new expected location of a folder
1204      * @return bool
1205      */
1206     protected function move_directory_target_precheck($target) {
1208         // Check if the target folder does not exist yet, can be created
1209         // and removed again.
1210         $result = $this->create_directory_precheck($target);
1212         // At the moment, it seems to be enough to check. We may want to add
1213         // more steps in the future.
1215         return $result;
1216     }
1218     /**
1219      * Make sure the given directory can be created (and removed)
1220      *
1221      * @param string $path full path to the folder
1222      * @return bool
1223      */
1224     protected function create_directory_precheck($path) {
1226         if (file_exists($path)) {
1227             return false;
1228         }
1230         $result = mkdir($path, 02777) && rmdir($path);
1232         return $result;
1233     }
1235     /**
1236      * Moves the given source into a new location recursively
1237      *
1238      * The target location can not exist.
1239      *
1240      * @param string $source full path to the existing directory
1241      * @param string $destination full path to the new location of the folder
1242      * @param bool $keepsourceroot should the root of the $source be kept or removed at the end
1243      * @return bool
1244      */
1245     protected function move_directory($source, $target, $keepsourceroot = false) {
1247         if (file_exists($target)) {
1248             throw new filesystem_exception('Unable to move the directory - target location already exists');
1249         }
1251         return $this->move_directory_into($source, $target, $keepsourceroot);
1252     }
1254     /**
1255      * Moves the given source into a new location recursively
1256      *
1257      * If the target already exists, files are moved into it. The target is created otherwise.
1258      *
1259      * @param string $source full path to the existing directory
1260      * @param string $destination full path to the new location of the folder
1261      * @param bool $keepsourceroot should the root of the $source be kept or removed at the end
1262      * @return bool
1263      */
1264     protected function move_directory_into($source, $target, $keepsourceroot = false) {
1266         if (is_dir($source)) {
1267             $handle = opendir($source);
1268         } else {
1269             throw new filesystem_exception('Source location is not a directory');
1270         }
1272         if (is_dir($target)) {
1273             $result = true;
1274         } else {
1275             $result = mkdir($target, 02777);
1276         }
1278         while ($filename = readdir($handle)) {
1279             $sourcepath = $source.'/'.$filename;
1280             $targetpath = $target.'/'.$filename;
1282             if ($filename === '.' or $filename === '..') {
1283                 continue;
1284             }
1286             if (is_dir($sourcepath)) {
1287                 $result = $result && $this->move_directory($sourcepath, $targetpath, false);
1289             } else {
1290                 $result = $result && rename($sourcepath, $targetpath);
1291             }
1292         }
1294         closedir($handle);
1296         if (!$keepsourceroot) {
1297             $result = $result && rmdir($source);
1298         }
1300         clearstatcache();
1302         return $result;
1303     }
1305     /**
1306      * Deletes the given directory recursively
1307      *
1308      * @param string $path full path to the directory
1309      * @param bool $keeppathroot should the root of the $path be kept (i.e. remove the content only) or removed too
1310      * @return bool
1311      */
1312     protected function remove_directory($path, $keeppathroot = false) {
1314         $result = true;
1316         if (!file_exists($path)) {
1317             return $result;
1318         }
1320         if (is_dir($path)) {
1321             $handle = opendir($path);
1322         } else {
1323             throw new filesystem_exception('Given path is not a directory');
1324         }
1326         while ($filename = readdir($handle)) {
1327             $filepath = $path.'/'.$filename;
1329             if ($filename === '.' or $filename === '..') {
1330                 continue;
1331             }
1333             if (is_dir($filepath)) {
1334                 $result = $result && $this->remove_directory($filepath, false);
1336             } else {
1337                 $result = $result && unlink($filepath);
1338             }
1339         }
1341         closedir($handle);
1343         if (!$keeppathroot) {
1344             $result = $result && rmdir($path);
1345         }
1347         clearstatcache();
1349         return $result;
1350     }
1352     /**
1353      * Unzip the file obtained from the Plugins directory to this site
1354      *
1355      * @param string $ziplocation full path to the ZIP file
1356      * @param string $plugintyperoot full path to the plugin's type location
1357      * @param string $expectedlocation expected full path to the plugin after it is extracted
1358      * @param string|bool $backuplocation location of the previous version of the plugin or false for no backup
1359      */
1360     protected function unzip_plugin($ziplocation, $plugintyperoot, $expectedlocation, $backuplocation) {
1362         $zip = new ZipArchive();
1363         $result = $zip->open($ziplocation);
1365         if ($result !== true) {
1366             if ($backuplocation !== false) {
1367                 $this->move_directory($backuplocation, $expectedlocation);
1368             }
1369             throw new zip_exception('Unable to open the zip package');
1370         }
1372         // Make sure that the ZIP has expected structure
1373         $pluginname = basename($expectedlocation);
1374         for ($i = 0; $i < $zip->numFiles; $i++) {
1375             $stat = $zip->statIndex($i);
1376             $filename = $stat['name'];
1377             $filename = explode('/', $filename);
1378             if ($filename[0] !== $pluginname) {
1379                 $zip->close();
1380                 throw new zip_exception('Invalid structure of the zip package');
1381             }
1382         }
1384         if (!$zip->extractTo($plugintyperoot)) {
1385             $zip->close();
1386             $this->remove_directory($expectedlocation, true); // just in case something was created
1387             if ($backuplocation !== false) {
1388                 $this->move_directory_into($backuplocation, $expectedlocation);
1389             }
1390             throw new zip_exception('Unable to extract the zip package');
1391         }
1393         $zip->close();
1394         unlink($ziplocation);
1395     }
1397     /**
1398      * Redirect the browser
1399      *
1400      * @todo check if there has been some output yet
1401      * @param string $url
1402      */
1403     protected function redirect($url) {
1404         header('Location: '.$url);
1405     }
1409 /**
1410  * Provides exception handlers for this script
1411  */
1412 class exception_handlers {
1414     /**
1415      * Sets the exception handler
1416      *
1417      *
1418      * @param string $handler name
1419      */
1420     public static function set_handler($handler) {
1422         if (PHP_SAPI === 'cli') {
1423             // No custom handler available for CLI mode.
1424             set_exception_handler(null);
1425             return;
1426         }
1428         set_exception_handler('exception_handlers::'.$handler.'_exception_handler');
1429     }
1431     /**
1432      * Returns the text describing the thrown exception
1433      *
1434      * By default, PHP displays full path to scripts when the exception is thrown. In order to prevent
1435      * sensitive information leak (and yes, the path to scripts at a web server _is_ sensitive information)
1436      * the path to scripts is removed from the message.
1437      *
1438      * @param Exception $e thrown exception
1439      * @return string
1440      */
1441     public static function format_exception_info(Exception $e) {
1443         $mydir = dirname(__FILE__).'/';
1444         $text = $e->__toString();
1445         $text = str_replace($mydir, '', $text);
1446         return $text;
1447     }
1449     /**
1450      * Very basic exception handler
1451      *
1452      * @param Exception $e uncaught exception
1453      */
1454     public static function bootstrap_exception_handler(Exception $e) {
1455         echo('<h1>Oops! It did it again</h1>');
1456         echo('<p><strong>Moodle deployment utility had a trouble with your request. See the debugging information for more details.</strong></p>');
1457         echo('<pre>');
1458         echo self::format_exception_info($e);
1459         echo('</pre>');
1460     }
1462     /**
1463      * Default exception handler
1464      *
1465      * When this handler is used, input_manager and output_manager singleton instances already
1466      * exist in the memory and can be used.
1467      *
1468      * @param Exception $e uncaught exception
1469      */
1470     public static function default_exception_handler(Exception $e) {
1472         $worker = worker::instance();
1473         $worker->log_exception($e);
1475         $output = output_manager::instance();
1476         $output->exception($e);
1477     }
1480 ////////////////////////////////////////////////////////////////////////////////
1482 // Check if the script is actually executed or if it was just included by someone
1483 // else - typically by the PHPUnit. This is a PHP alternative to the Python's
1484 // if __name__ == '__main__'
1485 if (!debug_backtrace()) {
1486     // We are executed by the SAPI.
1487     exception_handlers::set_handler('bootstrap');
1488     // Initialize the worker class to actually make the job.
1489     $worker = worker::instance();
1490     exception_handlers::set_handler('default');
1492     // Lights, Camera, Action!
1493     $worker->execute();
1495 } else {
1496     // We are included - probably by some unit testing framework. Do nothing.