MDL-41254 backup Raise time and memory limit in precheck.
[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 from ' . $source . ' into ' . $target);
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);
1056         curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); // Allow redirection, we trust in ssl.
1057         curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
1059         if ($cacertfile = $this->get_cacert()) {
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         } else {
1065             $this->log('Using operating system CA certificates.');
1066         }
1068         $proxy = $this->input->get_option('proxy', false);
1069         if (!empty($proxy)) {
1070             curl_setopt($ch, CURLOPT_PROXY, $proxy);
1072             $proxytype = $this->input->get_option('proxytype', false);
1073             if (strtoupper($proxytype) === 'SOCKS5') {
1074                 $this->log('Using SOCKS5 proxy');
1075                 curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5);
1076             } else if (!empty($proxytype)) {
1077                 $this->log('Using HTTP proxy');
1078                 curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_HTTP);
1079                 curl_setopt($ch, CURLOPT_HTTPPROXYTUNNEL, false);
1080             }
1082             $proxyuserpwd = $this->input->get_option('proxyuserpwd', false);
1083             if (!empty($proxyuserpwd)) {
1084                 curl_setopt($ch, CURLOPT_PROXYUSERPWD, $proxyuserpwd);
1085                 curl_setopt($ch, CURLOPT_PROXYAUTH, CURLAUTH_BASIC | CURLAUTH_NTLM);
1086             }
1087         }
1089         $targetfile = fopen($target, 'w');
1091         if (!$targetfile) {
1092             throw new download_file_exception('Unable to create local file '.$target);
1093         }
1095         curl_setopt($ch, CURLOPT_FILE, $targetfile);
1097         $result = curl_exec($ch);
1099         // try to detect encoding problems
1100         if ((curl_errno($ch) == 23 or curl_errno($ch) == 61) and defined('CURLOPT_ENCODING')) {
1101             curl_setopt($ch, CURLOPT_ENCODING, 'none');
1102             $result = curl_exec($ch);
1103         }
1105         fclose($targetfile);
1107         $this->curlerrno = curl_errno($ch);
1108         $this->curlerror = curl_error($ch);
1109         $this->curlinfo = curl_getinfo($ch);
1111         if (!$result or $this->curlerrno) {
1112             $this->log('Curl Error.');
1113             return false;
1115         } else if (is_array($this->curlinfo) and (empty($this->curlinfo['http_code']) or ($this->curlinfo['http_code'] != 200))) {
1116             $this->log('Curl remote error.');
1117             $this->log(print_r($this->curlinfo,true));
1118             return false;
1119         }
1121         return true;
1122     }
1124     /**
1125      * Get the location of ca certificates.
1126      * @return string absolute file path or empty if default used
1127      */
1128     protected function get_cacert() {
1129         $dataroot = $this->input->get_option('dataroot');
1131         // Bundle in dataroot always wins.
1132         if (is_readable($dataroot.'/moodleorgca.crt')) {
1133             return realpath($dataroot.'/moodleorgca.crt');
1134         }
1136         // Next comes the default from php.ini
1137         $cacert = ini_get('curl.cainfo');
1138         if (!empty($cacert) and is_readable($cacert)) {
1139             return realpath($cacert);
1140         }
1142         // Windows PHP does not have any certs, we need to use something.
1143         if (stristr(PHP_OS, 'win') && !stristr(PHP_OS, 'darwin')) {
1144             if (is_readable(__DIR__.'/lib/cacert.pem')) {
1145                 return realpath(__DIR__.'/lib/cacert.pem');
1146             }
1147         }
1149         // Use default, this should work fine on all properly configured *nix systems.
1150         return null;
1151     }
1153     /**
1154      * Log a message
1155      *
1156      * @param string $message
1157      */
1158     protected function log($message) {
1160         $logpath = $this->log_location();
1162         if (empty($logpath)) {
1163             // no logging available
1164             return;
1165         }
1167         $f = fopen($logpath, 'ab');
1169         if ($f === false) {
1170             throw new filesystem_exception('Unable to open the log file for appending');
1171         }
1173         $message = $this->format_log_message($message);
1175         fwrite($f, $message);
1177         fclose($f);
1178     }
1180     /**
1181      * Prepares the log message for writing into the file
1182      *
1183      * @param string $msg
1184      * @return string
1185      */
1186     protected function format_log_message($msg) {
1188         $msg = trim($msg);
1189         $timestamp = date("Y-m-d H:i:s");
1191         return $timestamp . ' '. $msg . PHP_EOL;
1192     }
1194     /**
1195      * Checks to see if the given source could be safely moved into a new location
1196      *
1197      * @param string $source full path to the existing directory
1198      * @return bool
1199      */
1200     protected function move_directory_source_precheck($source) {
1202         if (!is_writable($source)) {
1203             return false;
1204         }
1206         if (is_dir($source)) {
1207             $handle = opendir($source);
1208         } else {
1209             return false;
1210         }
1212         $result = true;
1214         while ($filename = readdir($handle)) {
1215             $sourcepath = $source.'/'.$filename;
1217             if ($filename === '.' or $filename === '..') {
1218                 continue;
1219             }
1221             if (is_dir($sourcepath)) {
1222                 $result = $result && $this->move_directory_source_precheck($sourcepath);
1224             } else {
1225                 $result = $result && is_writable($sourcepath);
1226             }
1227         }
1229         closedir($handle);
1231         return $result;
1232     }
1234     /**
1235      * Checks to see if a source folder could be safely moved into the given new location
1236      *
1237      * @param string $destination full path to the new expected location of a folder
1238      * @return bool
1239      */
1240     protected function move_directory_target_precheck($target) {
1242         // Check if the target folder does not exist yet, can be created
1243         // and removed again.
1244         $result = $this->create_directory_precheck($target);
1246         // At the moment, it seems to be enough to check. We may want to add
1247         // more steps in the future.
1249         return $result;
1250     }
1252     /**
1253      * Make sure the given directory can be created (and removed)
1254      *
1255      * @param string $path full path to the folder
1256      * @return bool
1257      */
1258     protected function create_directory_precheck($path) {
1260         if (file_exists($path)) {
1261             return false;
1262         }
1264         $result = mkdir($path, 02777) && rmdir($path);
1266         return $result;
1267     }
1269     /**
1270      * Moves the given source into a new location recursively
1271      *
1272      * The target location can not exist.
1273      *
1274      * @param string $source full path to the existing directory
1275      * @param string $destination full path to the new location of the folder
1276      * @param bool $keepsourceroot should the root of the $source be kept or removed at the end
1277      * @return bool
1278      */
1279     protected function move_directory($source, $target, $keepsourceroot = false) {
1281         if (file_exists($target)) {
1282             throw new filesystem_exception('Unable to move the directory - target location already exists');
1283         }
1285         return $this->move_directory_into($source, $target, $keepsourceroot);
1286     }
1288     /**
1289      * Moves the given source into a new location recursively
1290      *
1291      * If the target already exists, files are moved into it. The target is created otherwise.
1292      *
1293      * @param string $source full path to the existing directory
1294      * @param string $destination full path to the new location of the folder
1295      * @param bool $keepsourceroot should the root of the $source be kept or removed at the end
1296      * @return bool
1297      */
1298     protected function move_directory_into($source, $target, $keepsourceroot = false) {
1300         if (is_dir($source)) {
1301             $handle = opendir($source);
1302         } else {
1303             throw new filesystem_exception('Source location is not a directory');
1304         }
1306         if (is_dir($target)) {
1307             $result = true;
1308         } else {
1309             $result = mkdir($target, 02777);
1310         }
1312         while ($filename = readdir($handle)) {
1313             $sourcepath = $source.'/'.$filename;
1314             $targetpath = $target.'/'.$filename;
1316             if ($filename === '.' or $filename === '..') {
1317                 continue;
1318             }
1320             if (is_dir($sourcepath)) {
1321                 $result = $result && $this->move_directory($sourcepath, $targetpath, false);
1323             } else {
1324                 $result = $result && rename($sourcepath, $targetpath);
1325             }
1326         }
1328         closedir($handle);
1330         if (!$keepsourceroot) {
1331             $result = $result && rmdir($source);
1332         }
1334         clearstatcache();
1336         return $result;
1337     }
1339     /**
1340      * Deletes the given directory recursively
1341      *
1342      * @param string $path full path to the directory
1343      * @param bool $keeppathroot should the root of the $path be kept (i.e. remove the content only) or removed too
1344      * @return bool
1345      */
1346     protected function remove_directory($path, $keeppathroot = false) {
1348         $result = true;
1350         if (!file_exists($path)) {
1351             return $result;
1352         }
1354         if (is_dir($path)) {
1355             $handle = opendir($path);
1356         } else {
1357             throw new filesystem_exception('Given path is not a directory');
1358         }
1360         while ($filename = readdir($handle)) {
1361             $filepath = $path.'/'.$filename;
1363             if ($filename === '.' or $filename === '..') {
1364                 continue;
1365             }
1367             if (is_dir($filepath)) {
1368                 $result = $result && $this->remove_directory($filepath, false);
1370             } else {
1371                 $result = $result && unlink($filepath);
1372             }
1373         }
1375         closedir($handle);
1377         if (!$keeppathroot) {
1378             $result = $result && rmdir($path);
1379         }
1381         clearstatcache();
1383         return $result;
1384     }
1386     /**
1387      * Unzip the file obtained from the Plugins directory to this site
1388      *
1389      * @param string $ziplocation full path to the ZIP file
1390      * @param string $plugintyperoot full path to the plugin's type location
1391      * @param string $expectedlocation expected full path to the plugin after it is extracted
1392      * @param string|bool $backuplocation location of the previous version of the plugin or false for no backup
1393      */
1394     protected function unzip_plugin($ziplocation, $plugintyperoot, $expectedlocation, $backuplocation) {
1396         $zip = new ZipArchive();
1397         $result = $zip->open($ziplocation);
1399         if ($result !== true) {
1400             if ($backuplocation !== false) {
1401                 $this->move_directory($backuplocation, $expectedlocation);
1402             }
1403             throw new zip_exception('Unable to open the zip package');
1404         }
1406         // Make sure that the ZIP has expected structure
1407         $pluginname = basename($expectedlocation);
1408         for ($i = 0; $i < $zip->numFiles; $i++) {
1409             $stat = $zip->statIndex($i);
1410             $filename = $stat['name'];
1411             $filename = explode('/', $filename);
1412             if ($filename[0] !== $pluginname) {
1413                 $zip->close();
1414                 throw new zip_exception('Invalid structure of the zip package');
1415             }
1416         }
1418         if (!$zip->extractTo($plugintyperoot)) {
1419             $zip->close();
1420             $this->remove_directory($expectedlocation, true); // just in case something was created
1421             if ($backuplocation !== false) {
1422                 $this->move_directory_into($backuplocation, $expectedlocation);
1423             }
1424             throw new zip_exception('Unable to extract the zip package');
1425         }
1427         $zip->close();
1428         unlink($ziplocation);
1429     }
1431     /**
1432      * Redirect the browser
1433      *
1434      * @todo check if there has been some output yet
1435      * @param string $url
1436      */
1437     protected function redirect($url) {
1438         header('Location: '.$url);
1439     }
1443 /**
1444  * Provides exception handlers for this script
1445  */
1446 class exception_handlers {
1448     /**
1449      * Sets the exception handler
1450      *
1451      *
1452      * @param string $handler name
1453      */
1454     public static function set_handler($handler) {
1456         if (PHP_SAPI === 'cli') {
1457             // No custom handler available for CLI mode.
1458             set_exception_handler(null);
1459             return;
1460         }
1462         set_exception_handler('exception_handlers::'.$handler.'_exception_handler');
1463     }
1465     /**
1466      * Returns the text describing the thrown exception
1467      *
1468      * By default, PHP displays full path to scripts when the exception is thrown. In order to prevent
1469      * sensitive information leak (and yes, the path to scripts at a web server _is_ sensitive information)
1470      * the path to scripts is removed from the message.
1471      *
1472      * @param Exception $e thrown exception
1473      * @return string
1474      */
1475     public static function format_exception_info(Exception $e) {
1477         $mydir = dirname(__FILE__).'/';
1478         $text = $e->__toString();
1479         $text = str_replace($mydir, '', $text);
1480         return $text;
1481     }
1483     /**
1484      * Very basic exception handler
1485      *
1486      * @param Exception $e uncaught exception
1487      */
1488     public static function bootstrap_exception_handler(Exception $e) {
1489         echo('<h1>Oops! It did it again</h1>');
1490         echo('<p><strong>Moodle deployment utility had a trouble with your request. See the debugging information for more details.</strong></p>');
1491         echo('<pre>');
1492         echo self::format_exception_info($e);
1493         echo('</pre>');
1494     }
1496     /**
1497      * Default exception handler
1498      *
1499      * When this handler is used, input_manager and output_manager singleton instances already
1500      * exist in the memory and can be used.
1501      *
1502      * @param Exception $e uncaught exception
1503      */
1504     public static function default_exception_handler(Exception $e) {
1506         $worker = worker::instance();
1507         $worker->log_exception($e);
1509         $output = output_manager::instance();
1510         $output->exception($e);
1511     }
1514 ////////////////////////////////////////////////////////////////////////////////
1516 // Check if the script is actually executed or if it was just included by someone
1517 // else - typically by the PHPUnit. This is a PHP alternative to the Python's
1518 // if __name__ == '__main__'
1519 if (!debug_backtrace()) {
1520     // We are executed by the SAPI.
1521     exception_handlers::set_handler('bootstrap');
1522     // Initialize the worker class to actually make the job.
1523     $worker = worker::instance();
1524     exception_handlers::set_handler('default');
1526     // Lights, Camera, Action!
1527     $worker->execute();
1529 } else {
1530     // We are included - probably by some unit testing framework. Do nothing.