MDL-39374 log only real folder edits
[moodle.git] / mdeploy.php
blob303d3d1a44248c1dcaa04e59c7c4929731532273
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.
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
21 * This script looks after deploying available updates to the local Moodle site.
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
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
33 if (defined('MOODLE_INTERNAL')) {
34 die('This is a standalone utility that should not be included by any other Moodle code.');
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.
56 * @copyright 2012 David Mudrak <david@moodle.com>
57 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
59 abstract class singleton_pattern {
61 /** @var array singleton_pattern instances */
62 protected static $singletoninstances = array();
64 /**
65 * Factory method returning the singleton instance.
67 * Subclasses may want to override the {@link self::initialize()} method that is
68 * called right after their instantiation.
70 * @return mixed the singleton instance
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();
78 return static::$singletoninstances[$class];
81 /**
82 * Optional post-instantiation code.
84 protected function initialize() {
85 // Do nothing in this base class.
88 /**
89 * Direct instantiation not allowed, use the factory method {@link instance()}
91 final protected function __construct() {
94 /**
95 * Sorry, this is singleton.
97 final protected function __clone() {
102 // User input handling /////////////////////////////////////////////////////////
105 * Provides access to the script options.
107 * Implements the delegate pattern by dispatching the calls to appropriate
108 * helper class (CLI or HTTP).
110 * @copyright 2012 David Mudrak <david@moodle.com>
111 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
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;
128 * Returns the value of an option passed to the script.
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.
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.
138 * The array options are not supported.
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
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);
156 if (func_num_args() == 1) {
157 return $this->get_required_option($name);
158 } else {
159 return $this->get_optional_option($name, $default);
164 * Returns the meta-information about the given option.
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
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'),
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;
198 return $all;
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;
210 } else {
211 // Search by the long option name
212 if ($optioninfo[1] === $name) {
213 $found = $optioninfo;
214 break;
219 if (!$found) {
220 return false;
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;
233 * Casts the value to the given type.
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
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.');
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 = '';
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');
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.');
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.');
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');
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');
304 if (strpos($raw, '__') !== false) {
305 throw new invalid_option_exception('Invalid plugin name');
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');
313 return $raw;
315 default:
316 throw new invalid_coding_exception('Unknown option type.');
322 * Picks the appropriate helper class to delegate calls to.
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();
332 // End of external API
335 * Validates the parameter name.
337 * @param string $name
338 * @throws invalid_coding_exception
340 protected function validate_option_name($name) {
342 if (empty($name)) {
343 throw new invalid_coding_exception('Invalid empty option name.');
346 $meta = $this->get_option_info($name);
347 if (empty($meta)) {
348 throw new invalid_coding_exception('Invalid option name: '.$name);
353 * Returns cleaned option value or throws exception.
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
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);
368 * Returns cleaned option value or the default value
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
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;
386 * Base class for input providers.
388 * @copyright 2012 David Mudrak <david@moodle.com>
389 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
391 abstract class input_provider extends singleton_pattern {
393 /** @var array list of all passed valid options */
394 protected $options = array();
397 * Returns the casted value of the option.
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
403 public function get_option($name) {
405 if (!$this->has_option($name)) {
406 throw new invalid_coding_exception('Option not passed: '.$name);
409 return $this->options[$name];
413 * Was the given option passed?
415 * @param string $name optionname
416 * @return bool
418 public function has_option($name) {
419 return array_key_exists($name, $this->options);
423 * Initializes the input provider.
425 protected function initialize() {
426 $this->populate_options();
429 // End of external API
432 * Parses and validates all supported options passed to the script.
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.
445 $info = $input->get_option_info($k);
446 if (!$info) {
447 continue;
450 $casted = $input->cast_value($v, $info->type);
452 if (!empty($info->shortname)) {
453 $cooked[$info->shortname] = $casted;
456 if (!empty($info->longname)) {
457 $cooked[$info->longname] = $casted;
461 // Store the options.
462 $this->options = $cooked;
468 * Provides access to the script options passed via CLI.
470 * @copyright 2012 David Mudrak <david@moodle.com>
471 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
473 class input_cli_provider extends input_provider {
476 * Parses raw options passed to the script.
478 * @return array as returned by getopt()
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.'::';
500 return getopt($short, $long);
506 * Provides access to the script options passed via HTTP request.
508 * @copyright 2012 David Mudrak <david@moodle.com>
509 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
511 class input_http_provider extends input_provider {
514 * Parses raw options passed to the script.
516 * @return array of raw values passed via HTTP request
518 protected function parse_raw_options() {
519 return $_POST;
524 // Output handling /////////////////////////////////////////////////////////////
527 * Provides output operations.
529 * @copyright 2012 David Mudrak <david@moodle.com>
530 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
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;
538 * Magic method triggered when invoking an inaccessible method.
540 * @param string $name method name
541 * @param array $arguments method arguments
543 public function __call($name, array $arguments = array()) {
544 call_user_func_array(array($this->outputprovider, $name), $arguments);
548 * Picks the appropriate helper class to delegate calls to.
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();
561 * Base class for all output providers.
563 * @copyright 2012 David Mudrak <david@moodle.com>
564 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
566 abstract class output_provider extends singleton_pattern {
570 * Provides output to the command line.
572 * @copyright 2012 David Mudrak <david@moodle.com>
573 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
575 class output_cli_provider extends output_provider {
578 * Prints help information in CLI mode.
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;
592 if (!empty($info->longname)) {
593 $option[] = '--'.$info->longname;
595 $this->outln(sprintf('%-20s %s', implode(', ', $option), $info->desc));
599 // End of external API
602 * Writes a text to the STDOUT followed by a new line character.
604 * @param string $text text to print
606 protected function outln($text='') {
607 fputs(STDOUT, $text.PHP_EOL);
613 * Provides HTML output as a part of HTTP response.
615 * @copyright 2012 David Mudrak <david@moodle.com>
616 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
618 class output_http_provider extends output_provider {
621 * Prints help on the script usage.
623 public function help() {
624 // No help available via HTTP
628 * Display the information about uncaught exception
630 * @param Exception $e uncaught exception
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();
645 // End of external API
648 * Produce the HTML page header
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">';
667 * Produce the HTML page footer
669 protected function end_output() {
670 echo '</div></body></html>';
674 // The main class providing all the functionality //////////////////////////////
677 * The actual worker class implementing the main functionality of the script.
679 * @copyright 2012 David Mudrak <david@moodle.com>
680 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
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;
707 * Main - the one that actually does something
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);
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');
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');
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)');
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)');
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)');
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.
781 // Print help in CLI by default.
782 $this->output->help();
783 $this->done(self::EXIT_UNKNOWN_ACTION);
787 * Attempts to log a thrown exception
789 * @param Exception $e uncaught exception
791 public function log_exception(Exception $e) {
792 $this->log($e->__toString());
796 * Initialize the worker class.
798 protected function initialize() {
799 $this->input = input_manager::instance();
800 $this->output = output_manager::instance();
803 // End of external API
806 * Finish this script execution.
808 * @param int $exitcode
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);
823 * Authorize access to the script.
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.
829 * @throws unauthorized_access_exception
831 protected function authorize() {
833 if (PHP_SAPI === 'cli') {
834 $this->log('Successfully authorized using the CLI SAPI');
835 return;
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.');
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.');
857 if (count($stored) < 2) {
858 throw new unauthorized_access_exception('Invalid format of the passphrase file.');
861 if (time() - (int)$stored[1] > 30 * 60) {
862 throw new unauthorized_access_exception('Passphrase timeout.');
865 if (strlen($stored[0]) < 24) {
866 throw new unauthorized_access_exception('Session passphrase not long enough.');
869 if ($password !== $stored[0]) {
870 throw new unauthorized_access_exception('Session passphrase does not match the stored one.');
873 $this->log('Successfully authorized using the passphrase file');
877 * Returns the full path to the log file.
879 * @return string
881 protected function log_location() {
883 if (!is_null($this->logfile)) {
884 return $this->logfile;
887 $dataroot = $this->input->get_option('dataroot', '');
889 if (empty($dataroot)) {
890 $this->logfile = false;
891 return $this->logfile;
894 $myroot = $dataroot.'/mdeploy';
896 if (!is_dir($myroot)) {
897 mkdir($myroot, 02777, true);
900 $this->logfile = $myroot.'/mdeploy.log';
901 return $this->logfile;
905 * Choose the target location for the given ZIP's URL.
907 * @param string $source URL
908 * @return string
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);
919 $target = $pool.'/'.md5($source);
921 $suffix = 0;
922 while (file_exists($target.'.'.$suffix.'.zip')) {
923 $suffix++;
926 return $target.'.'.$suffix.'.zip';
930 * Choose the location of the current plugin folder backup
932 * @param string $path full path to the current folder
933 * @return string
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);
944 $target = $pool.'/'.basename($path).'_'.time();
946 $suffix = 0;
947 while (file_exists($target.'.'.$suffix)) {
948 $suffix++;
951 return $target.'.'.$suffix;
955 * Downloads the given file into the given destination.
957 * This is basically a simplified version of {@link download_file_content()} from
958 * Moodle itself, tuned for fetching files from moodle.org servers.
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
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.');
972 if (!$ch = curl_init($source)) {
973 $this->log('Unable to init cURL.');
974 return false;
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);
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);
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);
1015 $targetfile = fopen($target, 'w');
1017 if (!$targetfile) {
1018 throw new download_file_exception('Unable to create local file '.$target);
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);
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;
1044 return true;
1048 * Log a message
1050 * @param string $message
1052 protected function log($message) {
1054 $logpath = $this->log_location();
1056 if (empty($logpath)) {
1057 // no logging available
1058 return;
1061 $f = fopen($logpath, 'ab');
1063 if ($f === false) {
1064 throw new filesystem_exception('Unable to open the log file for appending');
1067 $message = $this->format_log_message($message);
1069 fwrite($f, $message);
1071 fclose($f);
1075 * Prepares the log message for writing into the file
1077 * @param string $msg
1078 * @return string
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;
1089 * Checks to see if the given source could be safely moved into a new location
1091 * @param string $source full path to the existing directory
1092 * @return bool
1094 protected function move_directory_source_precheck($source) {
1096 if (!is_writable($source)) {
1097 return false;
1100 if (is_dir($source)) {
1101 $handle = opendir($source);
1102 } else {
1103 return false;
1106 $result = true;
1108 while ($filename = readdir($handle)) {
1109 $sourcepath = $source.'/'.$filename;
1111 if ($filename === '.' or $filename === '..') {
1112 continue;
1115 if (is_dir($sourcepath)) {
1116 $result = $result && $this->move_directory_source_precheck($sourcepath);
1118 } else {
1119 $result = $result && is_writable($sourcepath);
1123 closedir($handle);
1125 return $result;
1129 * Checks to see if a source foldr could be safely moved into the given new location
1131 * @param string $destination full path to the new expected location of a folder
1132 * @return bool
1134 protected function move_directory_target_precheck($target) {
1136 if (file_exists($target)) {
1137 return false;
1140 $result = mkdir($target, 02777) && rmdir($target);
1142 return $result;
1146 * Moves the given source into a new location recursively
1148 * The target location can not exist.
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
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');
1161 return $this->move_directory_into($source, $target, $keepsourceroot);
1165 * Moves the given source into a new location recursively
1167 * If the target already exists, files are moved into it. The target is created otherwise.
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
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');
1182 if (is_dir($target)) {
1183 $result = true;
1184 } else {
1185 $result = mkdir($target, 02777);
1188 while ($filename = readdir($handle)) {
1189 $sourcepath = $source.'/'.$filename;
1190 $targetpath = $target.'/'.$filename;
1192 if ($filename === '.' or $filename === '..') {
1193 continue;
1196 if (is_dir($sourcepath)) {
1197 $result = $result && $this->move_directory($sourcepath, $targetpath, false);
1199 } else {
1200 $result = $result && rename($sourcepath, $targetpath);
1204 closedir($handle);
1206 if (!$keepsourceroot) {
1207 $result = $result && rmdir($source);
1210 clearstatcache();
1212 return $result;
1216 * Deletes the given directory recursively
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
1222 protected function remove_directory($path, $keeppathroot = false) {
1224 $result = true;
1226 if (!file_exists($path)) {
1227 return $result;
1230 if (is_dir($path)) {
1231 $handle = opendir($path);
1232 } else {
1233 throw new filesystem_exception('Given path is not a directory');
1236 while ($filename = readdir($handle)) {
1237 $filepath = $path.'/'.$filename;
1239 if ($filename === '.' or $filename === '..') {
1240 continue;
1243 if (is_dir($filepath)) {
1244 $result = $result && $this->remove_directory($filepath, false);
1246 } else {
1247 $result = $result && unlink($filepath);
1251 closedir($handle);
1253 if (!$keeppathroot) {
1254 $result = $result && rmdir($path);
1257 clearstatcache();
1259 return $result;
1263 * Unzip the file obtained from the Plugins directory to this site
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
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');
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');
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');
1299 $zip->close();
1300 unlink($ziplocation);
1304 * Redirect the browser
1306 * @todo check if there has been some output yet
1307 * @param string $url
1309 protected function redirect($url) {
1310 header('Location: '.$url);
1316 * Provides exception handlers for this script
1318 class exception_handlers {
1321 * Sets the exception handler
1324 * @param string $handler name
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;
1334 set_exception_handler('exception_handlers::'.$handler.'_exception_handler');
1338 * Returns the text describing the thrown exception
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.
1344 * @param Exception $e thrown exception
1345 * @return string
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;
1356 * Very basic exception handler
1358 * @param Exception $e uncaught exception
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>');
1369 * Default exception handler
1371 * When this handler is used, input_manager and output_manager singleton instances already
1372 * exist in the memory and can be used.
1374 * @param Exception $e uncaught exception
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);
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.