MDL-38456 Allow colons in paths passed to mdeploy.php utility
[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 available updates to the local Moodle site.
22  *
23  * CLI usage example:
24  *  $ sudo -u apache php mdeploy.php --upgrade \
25  *                                   --package=https://moodle.org/plugins/download.php/...zip \
26  *                                   --dataroot=/home/mudrd8mz/moodledata/moodle24
27  *
28  * @package     core
29  * @copyright   2012 David Mudrak <david@moodle.com>
30  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
31  */
33 if (defined('MOODLE_INTERNAL')) {
34     die('This is a standalone utility that should not be included by any other Moodle code.');
35 }
38 // Exceptions //////////////////////////////////////////////////////////////////
40 class invalid_coding_exception extends Exception {}
41 class missing_option_exception extends Exception {}
42 class invalid_option_exception extends Exception {}
43 class unauthorized_access_exception extends Exception {}
44 class download_file_exception extends Exception {}
45 class backup_folder_exception extends Exception {}
46 class zip_exception extends Exception {}
47 class filesystem_exception extends Exception {}
48 class checksum_exception extends Exception {}
51 // Various support classes /////////////////////////////////////////////////////
53 /**
54  * Base class implementing the singleton pattern using late static binding feature.
55  *
56  * @copyright 2012 David Mudrak <david@moodle.com>
57  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
58  */
59 abstract class singleton_pattern {
61     /** @var array singleton_pattern instances */
62     protected static $singletoninstances = array();
64     /**
65      * Factory method returning the singleton instance.
66      *
67      * Subclasses may want to override the {@link self::initialize()} method that is
68      * called right after their instantiation.
69      *
70      * @return mixed the singleton instance
71      */
72     final public static function instance() {
73         $class = get_called_class();
74         if (!isset(static::$singletoninstances[$class])) {
75             static::$singletoninstances[$class] = new static();
76             static::$singletoninstances[$class]->initialize();
77         }
78         return static::$singletoninstances[$class];
79     }
81     /**
82      * Optional post-instantiation code.
83      */
84     protected function initialize() {
85         // Do nothing in this base class.
86     }
88     /**
89      * Direct instantiation not allowed, use the factory method {@link instance()}
90      */
91     final protected function __construct() {
92     }
94     /**
95      * Sorry, this is singleton.
96      */
97     final protected function __clone() {
98     }
99 }
102 // User input handling /////////////////////////////////////////////////////////
104 /**
105  * Provides access to the script options.
106  *
107  * Implements the delegate pattern by dispatching the calls to appropriate
108  * helper class (CLI or HTTP).
109  *
110  * @copyright 2012 David Mudrak <david@moodle.com>
111  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
112  */
113 class input_manager extends singleton_pattern {
115     const TYPE_FILE         = 'file';   // File name
116     const TYPE_FLAG         = 'flag';   // No value, just a flag (switch)
117     const TYPE_INT          = 'int';    // Integer
118     const TYPE_PATH         = 'path';   // Full path to a file or a directory
119     const TYPE_RAW          = 'raw';    // Raw value, keep as is
120     const TYPE_URL          = 'url';    // URL to a file
121     const TYPE_PLUGIN       = 'plugin'; // Plugin name
122     const TYPE_MD5          = 'md5';    // MD5 hash
124     /** @var input_cli_provider|input_http_provider the provider of the input */
125     protected $inputprovider = null;
127     /**
128      * Returns the value of an option passed to the script.
129      *
130      * If the caller passes just the $name, the requested argument is considered
131      * required. The caller may specify the second argument which then
132      * makes the argument optional with the given default value.
133      *
134      * If the type of the $name option is TYPE_FLAG (switch), this method returns
135      * true if the flag has been passed or false if it was not. Specifying the
136      * default value makes no sense in this case and leads to invalid coding exception.
137      *
138      * The array options are not supported.
139      *
140      * @example $filename = $input->get_option('f');
141      * @example $filename = $input->get_option('filename');
142      * @example if ($input->get_option('verbose')) { ... }
143      * @param string $name
144      * @return mixed
145      */
146     public function get_option($name, $default = 'provide_default_value_explicitly') {
148         $this->validate_option_name($name);
150         $info = $this->get_option_info($name);
152         if ($info->type === input_manager::TYPE_FLAG) {
153             return $this->inputprovider->has_option($name);
154         }
156         if (func_num_args() == 1) {
157             return $this->get_required_option($name);
158         } else {
159             return $this->get_optional_option($name, $default);
160         }
161     }
163     /**
164      * Returns the meta-information about the given option.
165      *
166      * @param string|null $name short or long option name, defaults to returning the list of all
167      * @return array|object|false array with all, object with the specific option meta-information or false of no such an option
168      */
169     public function get_option_info($name=null) {
171         $supportedoptions = array(
172             array('', 'passfile', input_manager::TYPE_FILE, 'File name of the passphrase file (HTTP access only)'),
173             array('', 'password', input_manager::TYPE_RAW, 'Session passphrase (HTTP access only)'),
174             array('', 'proxy', input_manager::TYPE_RAW, 'HTTP proxy host and port (e.g. \'our.proxy.edu:8888\')'),
175             array('', 'proxytype', input_manager::TYPE_RAW, 'Proxy type (HTTP or SOCKS5)'),
176             array('', 'proxyuserpwd', input_manager::TYPE_RAW, 'Proxy username and password (e.g. \'username:password\')'),
177             array('', 'returnurl', input_manager::TYPE_URL, 'Return URL (HTTP access only)'),
178             array('d', 'dataroot', input_manager::TYPE_PATH, 'Full path to the dataroot (moodledata) directory'),
179             array('h', 'help', input_manager::TYPE_FLAG, 'Prints usage information'),
180             array('i', 'install', input_manager::TYPE_FLAG, 'Installation mode'),
181             array('m', 'md5', input_manager::TYPE_MD5, 'Expected MD5 hash of the ZIP package to deploy'),
182             array('n', 'name', input_manager::TYPE_PLUGIN, 'Plugin name (the name of its folder)'),
183             array('p', 'package', input_manager::TYPE_URL, 'URL to the ZIP package to deploy'),
184             array('r', 'typeroot', input_manager::TYPE_PATH, 'Full path of the container for this plugin type'),
185             array('u', 'upgrade', input_manager::TYPE_FLAG, 'Upgrade mode'),
186         );
188         if (is_null($name)) {
189             $all = array();
190             foreach ($supportedoptions as $optioninfo) {
191                 $info = new stdClass();
192                 $info->shortname = $optioninfo[0];
193                 $info->longname = $optioninfo[1];
194                 $info->type = $optioninfo[2];
195                 $info->desc = $optioninfo[3];
196                 $all[] = $info;
197             }
198             return $all;
199         }
201         $found = false;
203         foreach ($supportedoptions as $optioninfo) {
204             if (strlen($name) == 1) {
205                 // Search by the short option name
206                 if ($optioninfo[0] === $name) {
207                     $found = $optioninfo;
208                     break;
209                 }
210             } else {
211                 // Search by the long option name
212                 if ($optioninfo[1] === $name) {
213                     $found = $optioninfo;
214                     break;
215                 }
216             }
217         }
219         if (!$found) {
220             return false;
221         }
223         $info = new stdClass();
224         $info->shortname = $found[0];
225         $info->longname = $found[1];
226         $info->type = $found[2];
227         $info->desc = $found[3];
229         return $info;
230     }
232     /**
233      * Casts the value to the given type.
234      *
235      * @param mixed $raw the raw value
236      * @param string $type the expected value type, e.g. {@link input_manager::TYPE_INT}
237      * @return mixed
238      */
239     public function cast_value($raw, $type) {
241         if (is_array($raw)) {
242             throw new invalid_coding_exception('Unsupported array option.');
243         } else if (is_object($raw)) {
244             throw new invalid_coding_exception('Unsupported object option.');
245         }
247         switch ($type) {
249             case input_manager::TYPE_FILE:
250                 $raw = preg_replace('~[[:cntrl:]]|[&<>"`\|\':\\\\/]~u', '', $raw);
251                 $raw = preg_replace('~\.\.+~', '', $raw);
252                 if ($raw === '.') {
253                     $raw = '';
254                 }
255                 return $raw;
257             case input_manager::TYPE_FLAG:
258                 return true;
260             case input_manager::TYPE_INT:
261                 return (int)$raw;
263             case input_manager::TYPE_PATH:
264                 if (strpos($raw, '~') !== false) {
265                     throw new invalid_option_exception('Using the tilde (~) character in paths is not supported');
266                 }
267                 $colonpos = strpos($raw, ':');
268                 if ($colonpos !== false) {
269                     if ($colonpos !== 1 or strrpos($raw, ':') !== 1) {
270                         throw new invalid_option_exception('Using the colon (:) character in paths is supported for Windows drive labels only.');
271                     }
272                     if (preg_match('/^[a-zA-Z]:/', $raw) !== 1) {
273                         throw new invalid_option_exception('Using the colon (:) character in paths is supported for Windows drive labels only.');
274                     }
275                 }
276                 $raw = str_replace('\\', '/', $raw);
277                 $raw = preg_replace('~[[:cntrl:]]|[&<>"`\|\']~u', '', $raw);
278                 $raw = preg_replace('~\.\.+~', '', $raw);
279                 $raw = preg_replace('~//+~', '/', $raw);
280                 $raw = preg_replace('~/(\./)+~', '/', $raw);
281                 return $raw;
283             case input_manager::TYPE_RAW:
284                 return $raw;
286             case input_manager::TYPE_URL:
287                 $regex  = '^(https?|ftp)\:\/\/'; // protocol
288                 $regex .= '([a-z0-9+!*(),;?&=\$_.-]+(\:[a-z0-9+!*(),;?&=\$_.-]+)?@)?'; // optional user and password
289                 $regex .= '[a-z0-9+\$_-]+(\.[a-z0-9+\$_-]+)*'; // hostname or IP (one word like http://localhost/ allowed)
290                 $regex .= '(\:[0-9]{2,5})?'; // port (optional)
291                 $regex .= '(\/([a-z0-9+\$_-]\.?)+)*\/?'; // path to the file
292                 $regex .= '(\?[a-z+&\$_.-][a-z0-9;:@/&%=+\$_.-]*)?'; // HTTP params
294                 if (preg_match('#'.$regex.'#i', $raw)) {
295                     return $raw;
296                 } else {
297                     throw new invalid_option_exception('Not a valid URL');
298                 }
300             case input_manager::TYPE_PLUGIN:
301                 if (!preg_match('/^[a-z][a-z0-9_]*[a-z0-9]$/', $raw)) {
302                     throw new invalid_option_exception('Invalid plugin name');
303                 }
304                 if (strpos($raw, '__') !== false) {
305                     throw new invalid_option_exception('Invalid plugin name');
306                 }
307                 return $raw;
309             case input_manager::TYPE_MD5:
310                 if (!preg_match('/^[a-f0-9]{32}$/', $raw)) {
311                     throw new invalid_option_exception('Invalid MD5 hash format');
312                 }
313                 return $raw;
315             default:
316                 throw new invalid_coding_exception('Unknown option type.');
318         }
319     }
321     /**
322      * Picks the appropriate helper class to delegate calls to.
323      */
324     protected function initialize() {
325         if (PHP_SAPI === 'cli') {
326             $this->inputprovider = input_cli_provider::instance();
327         } else {
328             $this->inputprovider = input_http_provider::instance();
329         }
330     }
332     // End of external API
334     /**
335      * Validates the parameter name.
336      *
337      * @param string $name
338      * @throws invalid_coding_exception
339      */
340     protected function validate_option_name($name) {
342         if (empty($name)) {
343             throw new invalid_coding_exception('Invalid empty option name.');
344         }
346         $meta = $this->get_option_info($name);
347         if (empty($meta)) {
348             throw new invalid_coding_exception('Invalid option name: '.$name);
349         }
350     }
352     /**
353      * Returns cleaned option value or throws exception.
354      *
355      * @param string $name the name of the parameter
356      * @param string $type the parameter type, e.g. {@link input_manager::TYPE_INT}
357      * @return mixed
358      */
359     protected function get_required_option($name) {
360         if ($this->inputprovider->has_option($name)) {
361             return $this->inputprovider->get_option($name);
362         } else {
363             throw new missing_option_exception('Missing required option: '.$name);
364         }
365     }
367     /**
368      * Returns cleaned option value or the default value
369      *
370      * @param string $name the name of the parameter
371      * @param string $type the parameter type, e.g. {@link input_manager::TYPE_INT}
372      * @param mixed $default the default value.
373      * @return mixed
374      */
375     protected function get_optional_option($name, $default) {
376         if ($this->inputprovider->has_option($name)) {
377             return $this->inputprovider->get_option($name);
378         } else {
379             return $default;
380         }
381     }
385 /**
386  * Base class for input providers.
387  *
388  * @copyright 2012 David Mudrak <david@moodle.com>
389  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
390  */
391 abstract class input_provider extends singleton_pattern {
393     /** @var array list of all passed valid options */
394     protected $options = array();
396     /**
397      * Returns the casted value of the option.
398      *
399      * @param string $name option name
400      * @throws invalid_coding_exception if the option has not been passed
401      * @return mixed casted value of the option
402      */
403     public function get_option($name) {
405         if (!$this->has_option($name)) {
406             throw new invalid_coding_exception('Option not passed: '.$name);
407         }
409         return $this->options[$name];
410     }
412     /**
413      * Was the given option passed?
414      *
415      * @param string $name optionname
416      * @return bool
417      */
418     public function has_option($name) {
419         return array_key_exists($name, $this->options);
420     }
422     /**
423      * Initializes the input provider.
424      */
425     protected function initialize() {
426         $this->populate_options();
427     }
429     // End of external API
431     /**
432      * Parses and validates all supported options passed to the script.
433      */
434     protected function populate_options() {
436         $input = input_manager::instance();
437         $raw = $this->parse_raw_options();
438         $cooked = array();
440         foreach ($raw as $k => $v) {
441             if (is_array($v) or is_object($v)) {
442                 // Not supported.
443             }
445             $info = $input->get_option_info($k);
446             if (!$info) {
447                 continue;
448             }
450             $casted = $input->cast_value($v, $info->type);
452             if (!empty($info->shortname)) {
453                 $cooked[$info->shortname] = $casted;
454             }
456             if (!empty($info->longname)) {
457                 $cooked[$info->longname] = $casted;
458             }
459         }
461         // Store the options.
462         $this->options = $cooked;
463     }
467 /**
468  * Provides access to the script options passed via CLI.
469  *
470  * @copyright 2012 David Mudrak <david@moodle.com>
471  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
472  */
473 class input_cli_provider extends input_provider {
475     /**
476      * Parses raw options passed to the script.
477      *
478      * @return array as returned by getopt()
479      */
480     protected function parse_raw_options() {
482         $input = input_manager::instance();
484         // Signatures of some in-built PHP functions are just crazy, aren't they.
485         $short = '';
486         $long = array();
488         foreach ($input->get_option_info() as $option) {
489             if ($option->type === input_manager::TYPE_FLAG) {
490                 // No value expected for this option.
491                 $short .= $option->shortname;
492                 $long[] = $option->longname;
493             } else {
494                 // A value expected for the option, all considered as optional.
495                 $short .= empty($option->shortname) ? '' : $option->shortname.'::';
496                 $long[] = empty($option->longname) ? '' : $option->longname.'::';
497             }
498         }
500         return getopt($short, $long);
501     }
505 /**
506  * Provides access to the script options passed via HTTP request.
507  *
508  * @copyright 2012 David Mudrak <david@moodle.com>
509  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
510  */
511 class input_http_provider extends input_provider {
513     /**
514      * Parses raw options passed to the script.
515      *
516      * @return array of raw values passed via HTTP request
517      */
518     protected function parse_raw_options() {
519         return $_POST;
520     }
524 // Output handling /////////////////////////////////////////////////////////////
526 /**
527  * Provides output operations.
528  *
529  * @copyright 2012 David Mudrak <david@moodle.com>
530  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
531  */
532 class output_manager extends singleton_pattern {
534     /** @var output_cli_provider|output_http_provider the provider of the output functionality */
535     protected $outputprovider = null;
537     /**
538      * Magic method triggered when invoking an inaccessible method.
539      *
540      * @param string $name method name
541      * @param array $arguments method arguments
542      */
543     public function __call($name, array $arguments = array()) {
544         call_user_func_array(array($this->outputprovider, $name), $arguments);
545     }
547     /**
548      * Picks the appropriate helper class to delegate calls to.
549      */
550     protected function initialize() {
551         if (PHP_SAPI === 'cli') {
552             $this->outputprovider = output_cli_provider::instance();
553         } else {
554             $this->outputprovider = output_http_provider::instance();
555         }
556     }
560 /**
561  * Base class for all output providers.
562  *
563  * @copyright 2012 David Mudrak <david@moodle.com>
564  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
565  */
566 abstract class output_provider extends singleton_pattern {
569 /**
570  * Provides output to the command line.
571  *
572  * @copyright 2012 David Mudrak <david@moodle.com>
573  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
574  */
575 class output_cli_provider extends output_provider {
577     /**
578      * Prints help information in CLI mode.
579      */
580     public function help() {
582         $this->outln('mdeploy.php - Moodle (http://moodle.org) deployment utility');
583         $this->outln();
584         $this->outln('Usage: $ sudo -u apache php mdeploy.php [options]');
585         $this->outln();
586         $input = input_manager::instance();
587         foreach($input->get_option_info() as $info) {
588             $option = array();
589             if (!empty($info->shortname)) {
590                 $option[] = '-'.$info->shortname;
591             }
592             if (!empty($info->longname)) {
593                 $option[] = '--'.$info->longname;
594             }
595             $this->outln(sprintf('%-20s %s', implode(', ', $option), $info->desc));
596         }
597     }
599     // End of external API
601     /**
602      * Writes a text to the STDOUT followed by a new line character.
603      *
604      * @param string $text text to print
605      */
606     protected function outln($text='') {
607         fputs(STDOUT, $text.PHP_EOL);
608     }
612 /**
613  * Provides HTML output as a part of HTTP response.
614  *
615  * @copyright 2012 David Mudrak <david@moodle.com>
616  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
617  */
618 class output_http_provider extends output_provider {
620     /**
621      * Prints help on the script usage.
622      */
623     public function help() {
624         // No help available via HTTP
625     }
627     /**
628      * Display the information about uncaught exception
629      *
630      * @param Exception $e uncaught exception
631      */
632     public function exception(Exception $e) {
634         $docslink = 'http://docs.moodle.org/en/admin/mdeploy/'.get_class($e);
635         $this->start_output();
636         echo('<h1>Oops! It did it again</h1>');
637         echo('<p><strong>Moodle deployment utility had a trouble with your request.
638             See <a href="'.$docslink.'">the docs page</a> and the debugging information for more details.</strong></p>');
639         echo('<pre>');
640         echo exception_handlers::format_exception_info($e);
641         echo('</pre>');
642         $this->end_output();
643     }
645     // End of external API
647     /**
648      * Produce the HTML page header
649      */
650     protected function start_output() {
651         echo '<!doctype html>
652 <html lang="en">
653 <head>
654   <meta charset="utf-8">
655   <style type="text/css">
656     body {background-color:#666;font-family:"DejaVu Sans","Liberation Sans",Freesans,sans-serif;}
657     h1 {text-align:center;}
658     pre {white-space: pre-wrap;}
659     #page {background-color:#eee;width:1024px;margin:5em auto;border:3px solid #333;border-radius: 15px;padding:1em;}
660   </style>
661 </head>
662 <body>
663 <div id="page">';
664     }
666     /**
667      * Produce the HTML page footer
668      */
669     protected function end_output() {
670         echo '</div></body></html>';
671     }
674 // The main class providing all the functionality //////////////////////////////
676 /**
677  * The actual worker class implementing the main functionality of the script.
678  *
679  * @copyright 2012 David Mudrak <david@moodle.com>
680  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
681  */
682 class worker extends singleton_pattern {
684     const EXIT_OK                       = 0;    // Success exit code.
685     const EXIT_HELP                     = 1;    // Explicit help required.
686     const EXIT_UNKNOWN_ACTION           = 127;  // Neither -i nor -u provided.
688     /** @var input_manager */
689     protected $input = null;
691     /** @var output_manager */
692     protected $output = null;
694     /** @var int the most recent cURL error number, zero for no error */
695     private $curlerrno = null;
697     /** @var string the most recent cURL error message, empty string for no error */
698     private $curlerror = null;
700     /** @var array|false the most recent cURL request info, if it was successful */
701     private $curlinfo = null;
703     /** @var string the full path to the log file */
704     private $logfile = null;
706     /**
707      * Main - the one that actually does something
708      */
709     public function execute() {
711         $this->log('=== MDEPLOY EXECUTION START ===');
713         // Authorize access. None in CLI. Passphrase in HTTP.
714         $this->authorize();
716         // Asking for help in the CLI mode.
717         if ($this->input->get_option('help')) {
718             $this->output->help();
719             $this->done(self::EXIT_HELP);
720         }
722         if ($this->input->get_option('upgrade')) {
723             $this->log('Plugin upgrade requested');
725             // Fetch the ZIP file into a temporary location.
726             $source = $this->input->get_option('package');
727             $target = $this->target_location($source);
728             $this->log('Downloading package '.$source);
730             if ($this->download_file($source, $target)) {
731                 $this->log('Package downloaded into '.$target);
732             } else {
733                 $this->log('cURL error ' . $this->curlerrno . ' ' . $this->curlerror);
734                 $this->log('Unable to download the file');
735                 throw new download_file_exception('Unable to download the package');
736             }
738             // Compare MD5 checksum of the ZIP file
739             $md5remote = $this->input->get_option('md5');
740             $md5local = md5_file($target);
742             if ($md5local !== $md5remote) {
743                 $this->log('MD5 checksum failed. Expected: '.$md5remote.' Got: '.$md5local);
744                 throw new checksum_exception('MD5 checksum failed');
745             }
746             $this->log('MD5 checksum ok');
748             // Backup the current version of the plugin
749             $plugintyperoot = $this->input->get_option('typeroot');
750             $pluginname = $this->input->get_option('name');
751             $sourcelocation = $plugintyperoot.'/'.$pluginname;
752             $backuplocation = $this->backup_location($sourcelocation);
754             $this->log('Current plugin code location: '.$sourcelocation);
755             $this->log('Moving the current code into archive: '.$backuplocation);
757             // We don't want to touch files unless we are pretty sure it would be all ok.
758             if (!$this->move_directory_source_precheck($sourcelocation)) {
759                 throw new backup_folder_exception('Unable to backup the current version of the plugin (source precheck failed)');
760             }
761             if (!$this->move_directory_target_precheck($backuplocation)) {
762                 throw new backup_folder_exception('Unable to backup the current version of the plugin (backup precheck failed)');
763             }
765             // Looking good, let's try it.
766             if (!$this->move_directory($sourcelocation, $backuplocation, true)) {
767                 throw new backup_folder_exception('Unable to backup the current version of the plugin (moving failed)');
768             }
770             // Unzip the plugin package file into the target location.
771             $this->unzip_plugin($target, $plugintyperoot, $sourcelocation, $backuplocation);
772             $this->log('Package successfully extracted');
774             // Redirect to the given URL (in HTTP) or exit (in CLI).
775             $this->done();
777         } else if ($this->input->get_option('install')) {
778             // Installing a new plugin not implemented yet.
779         }
781         // Print help in CLI by default.
782         $this->output->help();
783         $this->done(self::EXIT_UNKNOWN_ACTION);
784     }
786     /**
787      * Attempts to log a thrown exception
788      *
789      * @param Exception $e uncaught exception
790      */
791     public function log_exception(Exception $e) {
792         $this->log($e->__toString());
793     }
795     /**
796      * Initialize the worker class.
797      */
798     protected function initialize() {
799         $this->input = input_manager::instance();
800         $this->output = output_manager::instance();
801     }
803     // End of external API
805     /**
806      * Finish this script execution.
807      *
808      * @param int $exitcode
809      */
810     protected function done($exitcode = self::EXIT_OK) {
812         if (PHP_SAPI === 'cli') {
813             exit($exitcode);
815         } else {
816             $returnurl = $this->input->get_option('returnurl');
817             $this->redirect($returnurl);
818             exit($exitcode);
819         }
820     }
822     /**
823      * Authorize access to the script.
824      *
825      * In CLI mode, the access is automatically authorized. In HTTP mode, the
826      * passphrase submitted via the request params must match the contents of the
827      * file, the name of which is passed in another parameter.
828      *
829      * @throws unauthorized_access_exception
830      */
831     protected function authorize() {
833         if (PHP_SAPI === 'cli') {
834             $this->log('Successfully authorized using the CLI SAPI');
835             return;
836         }
838         $dataroot = $this->input->get_option('dataroot');
839         $passfile = $this->input->get_option('passfile');
840         $password = $this->input->get_option('password');
842         $passpath = $dataroot.'/mdeploy/auth/'.$passfile;
844         if (!is_readable($passpath)) {
845             throw new unauthorized_access_exception('Unable to read the passphrase file.');
846         }
848         $stored = file($passpath, FILE_IGNORE_NEW_LINES);
850         // "This message will self-destruct in five seconds." -- Mission Commander Swanbeck, Mission: Impossible II
851         unlink($passpath);
853         if (is_readable($passpath)) {
854             throw new unauthorized_access_exception('Unable to remove the passphrase file.');
855         }
857         if (count($stored) < 2) {
858             throw new unauthorized_access_exception('Invalid format of the passphrase file.');
859         }
861         if (time() - (int)$stored[1] > 30 * 60) {
862             throw new unauthorized_access_exception('Passphrase timeout.');
863         }
865         if (strlen($stored[0]) < 24) {
866             throw new unauthorized_access_exception('Session passphrase not long enough.');
867         }
869         if ($password !== $stored[0]) {
870             throw new unauthorized_access_exception('Session passphrase does not match the stored one.');
871         }
873         $this->log('Successfully authorized using the passphrase file');
874     }
876     /**
877      * Returns the full path to the log file.
878      *
879      * @return string
880      */
881     protected function log_location() {
883         if (!is_null($this->logfile)) {
884             return $this->logfile;
885         }
887         $dataroot = $this->input->get_option('dataroot', '');
889         if (empty($dataroot)) {
890             $this->logfile = false;
891             return $this->logfile;
892         }
894         $myroot = $dataroot.'/mdeploy';
896         if (!is_dir($myroot)) {
897             mkdir($myroot, 02777, true);
898         }
900         $this->logfile = $myroot.'/mdeploy.log';
901         return $this->logfile;
902     }
904     /**
905      * Choose the target location for the given ZIP's URL.
906      *
907      * @param string $source URL
908      * @return string
909      */
910     protected function target_location($source) {
912         $dataroot = $this->input->get_option('dataroot');
913         $pool = $dataroot.'/mdeploy/var';
915         if (!is_dir($pool)) {
916             mkdir($pool, 02777, true);
917         }
919         $target = $pool.'/'.md5($source);
921         $suffix = 0;
922         while (file_exists($target.'.'.$suffix.'.zip')) {
923             $suffix++;
924         }
926         return $target.'.'.$suffix.'.zip';
927     }
929     /**
930      * Choose the location of the current plugin folder backup
931      *
932      * @param string $path full path to the current folder
933      * @return string
934      */
935     protected function backup_location($path) {
937         $dataroot = $this->input->get_option('dataroot');
938         $pool = $dataroot.'/mdeploy/archive';
940         if (!is_dir($pool)) {
941             mkdir($pool, 02777, true);
942         }
944         $target = $pool.'/'.basename($path).'_'.time();
946         $suffix = 0;
947         while (file_exists($target.'.'.$suffix)) {
948             $suffix++;
949         }
951         return $target.'.'.$suffix;
952     }
954     /**
955      * Downloads the given file into the given destination.
956      *
957      * This is basically a simplified version of {@link download_file_content()} from
958      * Moodle itself, tuned for fetching files from moodle.org servers.
959      *
960      * @param string $source file url starting with http(s)://
961      * @param string $target store the downloaded content to this file (full path)
962      * @return bool true on success, false otherwise
963      * @throws download_file_exception
964      */
965     protected function download_file($source, $target) {
967         $newlines = array("\r", "\n");
968         $source = str_replace($newlines, '', $source);
969         if (!preg_match('|^https?://|i', $source)) {
970             throw new download_file_exception('Unsupported transport protocol.');
971         }
972         if (!$ch = curl_init($source)) {
973             $this->log('Unable to init cURL.');
974             return false;
975         }
977         curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); // verify the peer's certificate
978         curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // check the existence of a common name and also verify that it matches the hostname provided
979         curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // return the transfer as a string
980         curl_setopt($ch, CURLOPT_HEADER, false); // don't include the header in the output
981         curl_setopt($ch, CURLOPT_TIMEOUT, 3600);
982         curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 20); // nah, moodle.org is never unavailable! :-p
983         curl_setopt($ch, CURLOPT_URL, $source);
985         $dataroot = $this->input->get_option('dataroot');
986         $cacertfile = $dataroot.'/moodleorgca.crt';
987         if (is_readable($cacertfile)) {
988             // Do not use CA certs provided by the operating system. Instead,
989             // use this CA cert to verify the ZIP provider.
990             $this->log('Using custom CA certificate '.$cacertfile);
991             curl_setopt($ch, CURLOPT_CAINFO, $cacertfile);
992         }
994         $proxy = $this->input->get_option('proxy', false);
995         if (!empty($proxy)) {
996             curl_setopt($ch, CURLOPT_PROXY, $proxy);
998             $proxytype = $this->input->get_option('proxytype', false);
999             if (strtoupper($proxytype) === 'SOCKS5') {
1000                 $this->log('Using SOCKS5 proxy');
1001                 curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5);
1002             } else if (!empty($proxytype)) {
1003                 $this->log('Using HTTP proxy');
1004                 curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_HTTP);
1005                 curl_setopt($ch, CURLOPT_HTTPPROXYTUNNEL, false);
1006             }
1008             $proxyuserpwd = $this->input->get_option('proxyuserpwd', false);
1009             if (!empty($proxyuserpwd)) {
1010                 curl_setopt($ch, CURLOPT_PROXYUSERPWD, $proxyuserpwd);
1011                 curl_setopt($ch, CURLOPT_PROXYAUTH, CURLAUTH_BASIC | CURLAUTH_NTLM);
1012             }
1013         }
1015         $targetfile = fopen($target, 'w');
1017         if (!$targetfile) {
1018             throw new download_file_exception('Unable to create local file '.$target);
1019         }
1021         curl_setopt($ch, CURLOPT_FILE, $targetfile);
1023         $result = curl_exec($ch);
1025         // try to detect encoding problems
1026         if ((curl_errno($ch) == 23 or curl_errno($ch) == 61) and defined('CURLOPT_ENCODING')) {
1027             curl_setopt($ch, CURLOPT_ENCODING, 'none');
1028             $result = curl_exec($ch);
1029         }
1031         fclose($targetfile);
1033         $this->curlerrno = curl_errno($ch);
1034         $this->curlerror = curl_error($ch);
1035         $this->curlinfo = curl_getinfo($ch);
1037         if (!$result or $this->curlerrno) {
1038             return false;
1040         } else if (is_array($this->curlinfo) and (empty($this->curlinfo['http_code']) or $this->curlinfo['http_code'] != 200)) {
1041             return false;
1042         }
1044         return true;
1045     }
1047     /**
1048      * Log a message
1049      *
1050      * @param string $message
1051      */
1052     protected function log($message) {
1054         $logpath = $this->log_location();
1056         if (empty($logpath)) {
1057             // no logging available
1058             return;
1059         }
1061         $f = fopen($logpath, 'ab');
1063         if ($f === false) {
1064             throw new filesystem_exception('Unable to open the log file for appending');
1065         }
1067         $message = $this->format_log_message($message);
1069         fwrite($f, $message);
1071         fclose($f);
1072     }
1074     /**
1075      * Prepares the log message for writing into the file
1076      *
1077      * @param string $msg
1078      * @return string
1079      */
1080     protected function format_log_message($msg) {
1082         $msg = trim($msg);
1083         $timestamp = date("Y-m-d H:i:s");
1085         return $timestamp . ' '. $msg . PHP_EOL;
1086     }
1088     /**
1089      * Checks to see if the given source could be safely moved into a new location
1090      *
1091      * @param string $source full path to the existing directory
1092      * @return bool
1093      */
1094     protected function move_directory_source_precheck($source) {
1096         if (!is_writable($source)) {
1097             return false;
1098         }
1100         if (is_dir($source)) {
1101             $handle = opendir($source);
1102         } else {
1103             return false;
1104         }
1106         $result = true;
1108         while ($filename = readdir($handle)) {
1109             $sourcepath = $source.'/'.$filename;
1111             if ($filename === '.' or $filename === '..') {
1112                 continue;
1113             }
1115             if (is_dir($sourcepath)) {
1116                 $result = $result && $this->move_directory_source_precheck($sourcepath);
1118             } else {
1119                 $result = $result && is_writable($sourcepath);
1120             }
1121         }
1123         closedir($handle);
1125         return $result;
1126     }
1128     /**
1129      * Checks to see if a source foldr could be safely moved into the given new location
1130      *
1131      * @param string $destination full path to the new expected location of a folder
1132      * @return bool
1133      */
1134     protected function move_directory_target_precheck($target) {
1136         if (file_exists($target)) {
1137             return false;
1138         }
1140         $result = mkdir($target, 02777) && rmdir($target);
1142         return $result;
1143     }
1145     /**
1146      * Moves the given source into a new location recursively
1147      *
1148      * The target location can not exist.
1149      *
1150      * @param string $source full path to the existing directory
1151      * @param string $destination full path to the new location of the folder
1152      * @param bool $keepsourceroot should the root of the $source be kept or removed at the end
1153      * @return bool
1154      */
1155     protected function move_directory($source, $target, $keepsourceroot = false) {
1157         if (file_exists($target)) {
1158             throw new filesystem_exception('Unable to move the directory - target location already exists');
1159         }
1161         return $this->move_directory_into($source, $target, $keepsourceroot);
1162     }
1164     /**
1165      * Moves the given source into a new location recursively
1166      *
1167      * If the target already exists, files are moved into it. The target is created otherwise.
1168      *
1169      * @param string $source full path to the existing directory
1170      * @param string $destination full path to the new location of the folder
1171      * @param bool $keepsourceroot should the root of the $source be kept or removed at the end
1172      * @return bool
1173      */
1174     protected function move_directory_into($source, $target, $keepsourceroot = false) {
1176         if (is_dir($source)) {
1177             $handle = opendir($source);
1178         } else {
1179             throw new filesystem_exception('Source location is not a directory');
1180         }
1182         if (is_dir($target)) {
1183             $result = true;
1184         } else {
1185             $result = mkdir($target, 02777);
1186         }
1188         while ($filename = readdir($handle)) {
1189             $sourcepath = $source.'/'.$filename;
1190             $targetpath = $target.'/'.$filename;
1192             if ($filename === '.' or $filename === '..') {
1193                 continue;
1194             }
1196             if (is_dir($sourcepath)) {
1197                 $result = $result && $this->move_directory($sourcepath, $targetpath, false);
1199             } else {
1200                 $result = $result && rename($sourcepath, $targetpath);
1201             }
1202         }
1204         closedir($handle);
1206         if (!$keepsourceroot) {
1207             $result = $result && rmdir($source);
1208         }
1210         clearstatcache();
1212         return $result;
1213     }
1215     /**
1216      * Deletes the given directory recursively
1217      *
1218      * @param string $path full path to the directory
1219      * @param bool $keeppathroot should the root of the $path be kept (i.e. remove the content only) or removed too
1220      * @return bool
1221      */
1222     protected function remove_directory($path, $keeppathroot = false) {
1224         $result = true;
1226         if (!file_exists($path)) {
1227             return $result;
1228         }
1230         if (is_dir($path)) {
1231             $handle = opendir($path);
1232         } else {
1233             throw new filesystem_exception('Given path is not a directory');
1234         }
1236         while ($filename = readdir($handle)) {
1237             $filepath = $path.'/'.$filename;
1239             if ($filename === '.' or $filename === '..') {
1240                 continue;
1241             }
1243             if (is_dir($filepath)) {
1244                 $result = $result && $this->remove_directory($filepath, false);
1246             } else {
1247                 $result = $result && unlink($filepath);
1248             }
1249         }
1251         closedir($handle);
1253         if (!$keeppathroot) {
1254             $result = $result && rmdir($path);
1255         }
1257         clearstatcache();
1259         return $result;
1260     }
1262     /**
1263      * Unzip the file obtained from the Plugins directory to this site
1264      *
1265      * @param string $ziplocation full path to the ZIP file
1266      * @param string $plugintyperoot full path to the plugin's type location
1267      * @param string $expectedlocation expected full path to the plugin after it is extracted
1268      * @param string $backuplocation location of the previous version of the plugin
1269      */
1270     protected function unzip_plugin($ziplocation, $plugintyperoot, $expectedlocation, $backuplocation) {
1272         $zip = new ZipArchive();
1273         $result = $zip->open($ziplocation);
1275         if ($result !== true) {
1276             $this->move_directory($backuplocation, $expectedlocation);
1277             throw new zip_exception('Unable to open the zip package');
1278         }
1280         // Make sure that the ZIP has expected structure
1281         $pluginname = basename($expectedlocation);
1282         for ($i = 0; $i < $zip->numFiles; $i++) {
1283             $stat = $zip->statIndex($i);
1284             $filename = $stat['name'];
1285             $filename = explode('/', $filename);
1286             if ($filename[0] !== $pluginname) {
1287                 $zip->close();
1288                 throw new zip_exception('Invalid structure of the zip package');
1289             }
1290         }
1292         if (!$zip->extractTo($plugintyperoot)) {
1293             $zip->close();
1294             $this->remove_directory($expectedlocation, true); // just in case something was created
1295             $this->move_directory_into($backuplocation, $expectedlocation);
1296             throw new zip_exception('Unable to extract the zip package');
1297         }
1299         $zip->close();
1300         unlink($ziplocation);
1301     }
1303     /**
1304      * Redirect the browser
1305      *
1306      * @todo check if there has been some output yet
1307      * @param string $url
1308      */
1309     protected function redirect($url) {
1310         header('Location: '.$url);
1311     }
1315 /**
1316  * Provides exception handlers for this script
1317  */
1318 class exception_handlers {
1320     /**
1321      * Sets the exception handler
1322      *
1323      *
1324      * @param string $handler name
1325      */
1326     public static function set_handler($handler) {
1328         if (PHP_SAPI === 'cli') {
1329             // No custom handler available for CLI mode.
1330             set_exception_handler(null);
1331             return;
1332         }
1334         set_exception_handler('exception_handlers::'.$handler.'_exception_handler');
1335     }
1337     /**
1338      * Returns the text describing the thrown exception
1339      *
1340      * By default, PHP displays full path to scripts when the exception is thrown. In order to prevent
1341      * sensitive information leak (and yes, the path to scripts at a web server _is_ sensitive information)
1342      * the path to scripts is removed from the message.
1343      *
1344      * @param Exception $e thrown exception
1345      * @return string
1346      */
1347     public static function format_exception_info(Exception $e) {
1349         $mydir = dirname(__FILE__).'/';
1350         $text = $e->__toString();
1351         $text = str_replace($mydir, '', $text);
1352         return $text;
1353     }
1355     /**
1356      * Very basic exception handler
1357      *
1358      * @param Exception $e uncaught exception
1359      */
1360     public static function bootstrap_exception_handler(Exception $e) {
1361         echo('<h1>Oops! It did it again</h1>');
1362         echo('<p><strong>Moodle deployment utility had a trouble with your request. See the debugging information for more details.</strong></p>');
1363         echo('<pre>');
1364         echo self::format_exception_info($e);
1365         echo('</pre>');
1366     }
1368     /**
1369      * Default exception handler
1370      *
1371      * When this handler is used, input_manager and output_manager singleton instances already
1372      * exist in the memory and can be used.
1373      *
1374      * @param Exception $e uncaught exception
1375      */
1376     public static function default_exception_handler(Exception $e) {
1378         $worker = worker::instance();
1379         $worker->log_exception($e);
1381         $output = output_manager::instance();
1382         $output->exception($e);
1383     }
1386 ////////////////////////////////////////////////////////////////////////////////
1388 // Check if the script is actually executed or if it was just included by someone
1389 // else - typically by the PHPUnit. This is a PHP alternative to the Python's
1390 // if __name__ == '__main__'
1391 if (!debug_backtrace()) {
1392     // We are executed by the SAPI.
1393     exception_handlers::set_handler('bootstrap');
1394     // Initialize the worker class to actually make the job.
1395     $worker = worker::instance();
1396     exception_handlers::set_handler('default');
1398     // Lights, Camera, Action!
1399     $worker->execute();
1401 } else {
1402     // We are included - probably by some unit testing framework. Do nothing.