From 966bd7852ea70151c8b297e0158ed9c5841ada0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Thu, 13 Sep 2012 16:34:47 +0200 Subject: [PATCH 01/32] MDL-34099 Report available updates for plugins at admin/index.php The Notifications (admin/index.php) page has now information about available updates for core and eventually plugins, too. Note that the structure of the available updates array changed. This breaks backward compatibility for eventual 3rd renderers out there (not expected though). --- admin/index.php | 23 ++++++++++++++++++++++- admin/renderer.php | 30 ++++++++++++++++++++++++------ lang/en/admin.php | 1 + 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/admin/index.php b/admin/index.php index 410cbf6723b..d3ec395e574 100644 --- a/admin/index.php +++ b/admin/index.php @@ -416,9 +416,30 @@ $cronoverdue = ($lastcron < time() - 3600 * 24); $dbproblems = $DB->diagnose(); $maintenancemode = !empty($CFG->maintenance_enabled); +// Available updates for Moodle core $updateschecker = available_update_checker::instance(); -$availableupdates = $updateschecker->get_update_info('core', +$availableupdates = array(); +$availableupdates['core'] = $updateschecker->get_update_info('core', array('minmaturity' => $CFG->updateminmaturity, 'notifybuilds' => $CFG->updatenotifybuilds)); + +// Available updates for contributed plugins +$pluginman = plugin_manager::instance(); +foreach ($pluginman->get_plugins() as $plugintype => $plugintypeinstances) { + foreach ($plugintypeinstances as $pluginname => $plugininfo) { + if (!empty($plugininfo->availableupdates)) { + foreach ($plugininfo->availableupdates as $pluginavailableupdate) { + if ($pluginavailableupdate->version > $plugininfo->versiondisk) { + if (!isset($availableupdates[$plugintype.'_'.$pluginname])) { + $availableupdates[$plugintype.'_'.$pluginname] = array(); + } + $availableupdates[$plugintype.'_'.$pluginname][] = $pluginavailableupdate; + } + } + } + } +} + +// The timestamp of the most recent check for available updates $availableupdatesfetch = $updateschecker->get_last_timefetched(); $buggyiconvnomb = (!function_exists('mb_convert_encoding') and @iconv('UTF-8', 'UTF-8//IGNORE', '100'.chr(130).'€') !== '100€'); diff --git a/admin/renderer.php b/admin/renderer.php index 293bf52f225..bf45639be98 100644 --- a/admin/renderer.php +++ b/admin/renderer.php @@ -495,21 +495,39 @@ class core_admin_renderer extends plugin_renderer_base { } /** - * Displays the info about available Moodle updates + * Displays the info about available Moodle core and plugin updates * - * @param array|null $updates array of available_update_info objects or null + * The structure of the $updates param has changed since 2.4. It contains not only updates + * for the core itself, but also for all other installed plugins. + * + * @param array|null $updates array of (string)component => array of available_update_info objects or null * @param int|null $fetch timestamp of the most recent updates fetch or null (unknown) * @return string */ protected function available_updates($updates, $fetch) { $updateinfo = $this->box_start('generalbox adminwarning availableupdatesinfo'); + $someupdateavailable = false; if (is_array($updates)) { - $updateinfo .= $this->heading(get_string('updateavailable', 'core_admin'), 3); - foreach ($updates as $update) { - $updateinfo .= $this->moodle_available_update_info($update); + if (is_array($updates['core'])) { + $someupdateavailable = true; + $updateinfo .= $this->heading(get_string('updateavailable', 'core_admin'), 3); + foreach ($updates['core'] as $update) { + $updateinfo .= $this->moodle_available_update_info($update); + } } - } else { + unset($updates['core']); + // If something has left in the $updates array now, it is updates for plugins. + if (!empty($updates)) { + $someupdateavailable = true; + $updateinfo .= $this->heading(get_string('updateavailableforplugin', 'core_admin'), 3); + $pluginsoverviewurl = new moodle_url('/admin/plugins.php', array('updatesonly' => 1)); + $updateinfo .= $this->container(get_string('pluginsoverviewsee', 'core_admin', + array('url' => $pluginsoverviewurl->out()))); + } + } + + if (!$someupdateavailable) { $now = time(); if ($fetch and ($fetch <= $now) and ($now - $fetch < HOURSECS)) { $updateinfo .= $this->heading(get_string('updateavailablenot', 'core_admin'), 3); diff --git a/lang/en/admin.php b/lang/en/admin.php index c2ae229d477..a0c322603b6 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -774,6 +774,7 @@ $string['pluginscheck'] = 'Plugin dependencies check'; $string['pluginscheckfailed'] = 'Dependencies check failed for {$a->pluginslist}'; $string['pluginschecktodo'] = 'You must solve all the plugin requirements before proceeding to install this Moodle version!'; $string['pluginsoverview'] = 'Plugins overview'; +$string['pluginsoverviewsee'] = 'See plugins overview page for more details.'; $string['profilecategory'] = 'Category'; $string['profilecategoryname'] = 'Category name (must be unique)'; $string['profilecategorynamenotunique'] = 'This category name is already in use'; From 89af176511a8d62381bea07d04a890edbd6d8f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Fri, 14 Sep 2012 16:18:31 +0200 Subject: [PATCH 02/32] MDL-35238 Introduce a first version of the mdeploy.php script This is supposed to serve as a standalone script that accepts parameters via CLI or HTTP. As no Moodle library can be reliably included, we have to implement our own input handling, output handling and processing the actual update/install task. PHPUnit is used to test the functionality. Although (again), the testing must not be included as a part of standard Moodle PHPUnit environment but explicitly. --- mdeploy.php | 508 ++++++++++++++++++++++++++++++++++++++++++++++++ mdeploytest.php | 155 +++++++++++++++ 2 files changed, 663 insertions(+) create mode 100644 mdeploy.php create mode 100644 mdeploytest.php diff --git a/mdeploy.php b/mdeploy.php new file mode 100644 index 00000000000..5287e4c330e --- /dev/null +++ b/mdeploy.php @@ -0,0 +1,508 @@ +. + +/** + * Moodle deployment utility + * + * This script looks after deploying available updates to the local Moodle site. + * + * @package core + * @copyright 2012 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +if (defined('MOODLE_INTERNAL')) { + die('This is a standalone utility that should not be included by any other Moodle code.'); +} + + +// Exceptions ////////////////////////////////////////////////////////////////// + +class invalid_coding_exception extends Exception {} +class missing_option_exception extends Exception {} + + +// Various support classes ///////////////////////////////////////////////////// + +/** + * Base class implementing the singleton pattern using late static binding feature. + * + * @copyright 2012 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class singleton_pattern { + + /** @var array singleton_pattern instances */ + protected static $singletoninstances = array(); + + /** + * Factory method returning the singleton instance. + * + * Subclasses may want to override the {@link self::initialize()} method that is + * called right after their instantiation. + * + * @return mixed the singleton instance + */ + final public static function instance() { + $class = get_called_class(); + if (!isset(static::$singletoninstances[$class])) { + static::$singletoninstances[$class] = new static(); + static::$singletoninstances[$class]->initialize(); + } + return static::$singletoninstances[$class]; + } + + /** + * Optional post-instantiation code. + */ + protected function initialize() { + // Do nothing in this base class. + } + + /** + * Direct instantiation not allowed, use the factory method {@link instance()} + */ + final protected function __construct() { + } + + /** + * Sorry, this is singleton. + */ + final protected function __clone() { + } +} + + +// User input handling ///////////////////////////////////////////////////////// + +/** + * Provides access to the script options. + * + * Implements the delegate pattern by dispatching the calls to appropriate + * helper class (CLI or HTTP). + * + * @copyright 2012 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class input_manager extends singleton_pattern { + + const TYPE_FLAG = 'flag'; // No value, just a flag (switch) + const TYPE_INT = 'int'; // Integer + + /** @var input_cli_manager|input_http_manager the provider of the input */ + protected $inputprovider = null; + + /** + * Returns the value of an option passed to the script. + * + * If the caller passes just the $name, the requested argument is considered + * required. The caller may specify the second argument which then + * makes the argument optional with the given default value. + * + * If the type of the $name option is TYPE_FLAG (switch), this method returns + * true if the flag has been passed or false if it was not. Specifying the + * default value makes no sense in this case and leads to invalid coding exception. + * + * The array options are not supported. + * + * @example $filename = $input->get_option('f'); + * @example $filename = $input->get_option('filename'); + * @example if ($input->get_option('verbose')) { ... } + * @param string $name + * @return mixed + */ + public function get_option($name, $default = 'provide_default_value_explicitly') { + + $this->validate_option_name($name); + + $info = $this->get_option_info($name); + + if ($info->type === input_manager::TYPE_FLAG) { + return $this->inputprovider->has_option($name); + } + + if (func_num_args() == 1) { + return $this->get_required_option($name); + } else { + return $this->get_optional_option($name, $default); + } + } + + /** + * Returns the meta-information about the given option. + * + * @param string|null $name short or long option name, defaults to returning the list of all + * @return array|object|false array with all, object with the specific option meta-information or false of no such an option + */ + public function get_option_info($name=null) { + + $supportedoptions = array( + array('h', 'help', input_manager::TYPE_FLAG, 'Prints usage information'), + array('i', 'install', input_manager::TYPE_FLAG, 'Installation mode'), + array('u', 'upgrade', input_manager::TYPE_FLAG, 'Upgrade mode'), + ); + + if (is_null($name)) { + $all = array(); + foreach ($supportedoptions as $optioninfo) { + $info = new stdClass(); + $info->shortname = $optioninfo[0]; + $info->longname = $optioninfo[1]; + $info->type = $optioninfo[2]; + $info->desc = $optioninfo[3]; + $all[] = $info; + } + return $all; + } + + $found = false; + + foreach ($supportedoptions as $optioninfo) { + if (strlen($name) == 1) { + // Search by the short option name + if ($optioninfo[0] === $name) { + $found = $optioninfo; + break; + } + } else { + // Search by the long option name + if ($optioninfo[1] === $name) { + $found = $optioninfo; + break; + } + } + } + + if (!$found) { + return false; + } + + $info = new stdClass(); + $info->shortname = $found[0]; + $info->longname = $found[1]; + $info->type = $found[2]; + $info->desc = $found[3]; + + return $info; + } + + /** + * Casts the value to the given type. + * + * @param mixed $raw the raw value + * @param string $type the expected value type, e.g. {@link input_manager::TYPE_INT} + * @return mixed + */ + public function cast_value($raw, $type) { + + if (is_array($raw)) { + throw new invalid_coding_exception('Unsupported array option.'); + } else if (is_object($raw)) { + throw new invalid_coding_exception('Unsupported object option.'); + } + + switch ($type) { + + case input_manager::TYPE_FLAG: + return true; + + case input_manager::TYPE_INT: + return (int)$raw; + + default: + throw new invalid_coding_exception('Unknown option type.'); + + } + } + + /** + * Picks the appropriate helper class to delegate calls to. + */ + protected function initialize() { + if (PHP_SAPI === 'cli') { + $this->inputprovider = input_cli_provider::instance(); + } else { + $this->inputprovider = input_http_provider::instance(); + } + } + + // End of external API + + /** + * Validates the parameter name. + * + * @param string $name + * @throws invalid_coding_exception + */ + protected function validate_option_name($name) { + + if (empty($name)) { + throw new invalid_coding_exception('Invalid empty option name.'); + } + + $meta = $this->get_option_info($name); + if (empty($meta)) { + throw new invalid_coding_exception('Invalid option name: '.$name); + } + } + + /** + * Returns cleaned option value or throws exception. + * + * @param string $name the name of the parameter + * @param string $type the parameter type, e.g. {@link input_manager::TYPE_INT} + * @return mixed + */ + protected function get_required_option($name, $type) { + if ($this->inputprovider->has_option($name)) { + return $this->cast_value($this->inputprovider->get_raw_option($name), $type); + } else { + throw new missing_option_exception('Missing required option: '.$name); + } + } + + /** + * Returns cleaned option value or the default value + * + * @param string $name the name of the parameter + * @param string $type the parameter type, e.g. {@link input_manager::TYPE_INT} + * @param mixed $default the default value. + * @return mixed + */ + protected function get_optional_option($name, $type, $default) { + if ($this->inputprovider->has_option($name)) { + return $this->inputprovider->get_raw_option($name); + } else { + return $default; + } + } +} + + +/** + * Base class for input providers. + * + * @copyright 2012 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class input_provider extends singleton_pattern { + + /** @var array list of all passed valid options */ + protected $options = array(); + + /** + * Returns the casted value of the option. + * + * @param string $name option name + * @throws invalid_coding_exception if the option has not been passed + * @return mixed casted value of the option + */ + public function get_option($name) { + + if (!$this->has_option($name)) { + throw new invalid_coding_exception('Option not passed: '.$name); + } + + return $this->options[$name]; + } + + /** + * Was the given option passed? + * + * @param string $name optionname + * @return bool + */ + public function has_option($name) { + return array_key_exists($name, $this->options); + } + + /** + * Initializes the input provider. + */ + protected function initialize() { + $this->populate_options(); + } + + // End of external API + + /** + * Parses and validates all supported options passed to the script. + */ + protected function populate_options() { + + $input = input_manager::instance(); + $raw = $this->parse_raw_options(); + $cooked = array(); + + foreach ($raw as $k => $v) { + if (is_array($v) or is_object($v)) { + // Not supported. + } + + $info = $input->get_option_info($k); + if (!$info) { + continue; + } + + $casted = $input->cast_value($v, $info->type); + + if (!empty($info->shortname)) { + $cooked[$info->shortname] = $casted; + } + + if (!empty($info->longname)) { + $cooked[$info->longname] = $casted; + } + } + + // Store the options. + $this->options = $cooked; + } +} + + +/** + * Provides access to the script options passed via CLI. + * + * @copyright 2012 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class input_cli_provider extends input_provider { + + /** + * Parses raw options passed to the script. + * + * @return array as returned by getopt() + */ + protected function parse_raw_options() { + + $input = input_manager::instance(); + + // Signatures of some in-built PHP functions are just crazy, aren't they. + $short = ''; + $long = array(); + + foreach ($input->get_option_info() as $option) { + if ($option->type === input_manager::TYPE_FLAG) { + // No value expected for this option. + $short .= $option->shortname; + $long[] = $option->longname; + } else { + // A value expected for the option, all considered as optional. + $short .= empty($option->shortname) ? '' : $option->shortname.'::'; + $long[] = empty($option->longname) ? '' : $option->longname.'::'; + } + } + + return getopt($short, $long); + } +} + + +/** + * Provides access to the script options passed via HTTP request. + * + * @copyright 2012 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class input_http_provider extends input_provider { + + /** + * Parses raw options passed to the script. + * + * @return array of raw values passed via HTTP request + */ + protected function parse_raw_options() { + return $_GET; // TODO switch to $_POST + } +} + + +// Output handling ///////////////////////////////////////////////////////////// + +/** + * TODO: short description. + * + * TODO: long description. + * + * @copyright 2012 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class output_manager extends singleton_pattern { + +} + + +// The main class providing all the functionality ////////////////////////////// + +/** + * TODO: short description. + * + * TODO: long description. + * + * @copyright 2012 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class worker extends singleton_pattern { + + /** + * TODO: short description. + * + * @param input_manager $input + * @param output_manager $output + * @return TODO + */ + public function execute(input_manager $input, output_manager $output) { + + // Authorize access. None in CLI. Passphrase in HTTP. + + // Fetch the ZIP file into a temporary location. + + // If the target location exists, backup it. + + // Unzip the ZIP file into the target location. + + // Redirect to the given URL (in HTTP) or exit (in CLI). + } + +} + + +//////////////////////////////////////////////////////////////////////////////// + +// Check if the script is actually executed or if it was just included by someone +// else - typically by the PHPUnit. This is a PHP alternative to the Python's +// if __name__ == '__main__' + +if (!debug_backtrace()) { + // We are executed by the SAPI + + // Initialize the input options manager. + $input = input_manager::instance(); + + // Initialize the output (display) manager. + $output = output_manager::instance(); + + // Initialize the worker class to actually make the job. + $worker = worker::instance(); + + // Lights, Camera, Action! + $worker->execute($input, $output); + +} else { + // We are included - probably by some unit testing framework. Do nothing. +} diff --git a/mdeploytest.php b/mdeploytest.php new file mode 100644 index 00000000000..445993802e0 --- /dev/null +++ b/mdeploytest.php @@ -0,0 +1,155 @@ +. + +/** + * PHPUnit tests for the mdeploy.php utility + * + * Because the mdeploy.php can't be part of the Moodle code itself, this tests must be + * executed using something like: + * + * $ phpunit --no-configuration mdeploytest + * + * @package core + * @copyright 2012 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require(__DIR__.'/mdeploy.php'); + +/** + * Provides testable input options. + * + * @copyright 2012 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class input_fake_provider extends input_provider { + + /** @var array */ + protected $fakeoptions = array(); + + /** + * Sets fake raw options. + * + * @param array $options + */ + public function set_fake_options(array $options) { + $this->fakeoptions = $options; + $this->populate_options(); + } + + /** + * Returns the explicitly set fake options. + * + * @return array + */ + protected function parse_raw_options() { + return $this->fakeoptions; + } +} + +/** + * Testable subclass. + * + * @copyright 2012 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class testable_input_manager extends input_manager { + + /** + * Provides access to the protected method so we can test it explicitly. + */ + public function cast_value($raw, $type) { + return parent::cast_value($raw, $type); + } + + /** + * Sets the fake input provider. + */ + protected function initialize() { + $this->inputprovider = input_fake_provider::instance(); + } +} + + +/** + * Test cases for the mdeploy utility + * + * @copyright 2012 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mdeploytest extends PHPUnit_Framework_TestCase { + + public function test_same_singletons() { + $a = input_manager::instance(); + $b = input_manager::instance(); + $this->assertSame($a, $b); + } + + /** + * @dataProvider data_for_cast_value + */ + public function test_cast_value($raw, $type, $result) { + $input = testable_input_manager::instance(); + $this->assertSame($input->cast_value($raw, $type), $result); + } + + public function data_for_cast_value() { + return array( + array('3', input_manager::TYPE_INT, 3), + array(4, input_manager::TYPE_INT, 4), + array('', input_manager::TYPE_INT, 0), + + array(true, input_manager::TYPE_FLAG, true), + array(false, input_manager::TYPE_FLAG, true), + array(0, input_manager::TYPE_FLAG, true), + array('1', input_manager::TYPE_FLAG, true), + array('0', input_manager::TYPE_FLAG, true), + array('muhehe', input_manager::TYPE_FLAG, true), + ); + } + + /** + * @expectedException invalid_coding_exception + */ + public function test_cast_array_argument() { + $input = testable_input_manager::instance(); + $input->cast_value(array(1, 2, 3), input_manager::TYPE_INT); // must throw exception + } + + /** + * @expectedException invalid_coding_exception + */ + public function test_cast_object_argument() { + $input = testable_input_manager::instance(); + $o = new stdClass(); + $input->cast_value($o, input_manager::TYPE_INT); // must throw exception + } + + public function test_has_option() { + $provider = input_fake_provider::instance(); + + $provider->set_fake_options(array()); + $this->assertFalse($provider->has_option('foo')); // foo not passed + + $provider->set_fake_options(array('foo' => 1)); + $this->assertFalse($provider->has_option('foo')); // foo passed but not a known option + + $provider->set_fake_options(array('foo' => 1, 'help' => false)); + $this->assertTrue($provider->has_option('help')); // help passed and it is a flag (value ignored) + $this->assertTrue($provider->has_option('h')); // 'h' is a shortname for 'help' + } +} From c99910bb3a984a5072e4c3361be9c0b5082ed820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Mon, 17 Sep 2012 14:59:23 +0200 Subject: [PATCH 03/32] MDL-35238 Print help on the script usage in CLI mode --- mdeploy.php | 139 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 116 insertions(+), 23 deletions(-) diff --git a/mdeploy.php b/mdeploy.php index 5287e4c330e..ca93557a4ab 100644 --- a/mdeploy.php +++ b/mdeploy.php @@ -103,7 +103,7 @@ class input_manager extends singleton_pattern { const TYPE_FLAG = 'flag'; // No value, just a flag (switch) const TYPE_INT = 'int'; // Integer - /** @var input_cli_manager|input_http_manager the provider of the input */ + /** @var input_cli_provider|input_http_provider the provider of the input */ protected $inputprovider = null; /** @@ -435,38 +435,132 @@ class input_http_provider extends input_provider { // Output handling ///////////////////////////////////////////////////////////// /** - * TODO: short description. - * - * TODO: long description. + * Provides output operations. * * @copyright 2012 David Mudrak * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class output_manager extends singleton_pattern { + /** @var output_cli_provider|output_http_provider the provider of the output functionality */ + protected $outputprovider = null; + + /** + * Magic method triggered when invoking an inaccessible method. + * + * @param string $name method name + * @param array $arguments method arguments + */ + public function __call($name, array $arguments = array()) { + call_user_func_array(array($this->outputprovider, $name), $arguments); + } + + /** + * Picks the appropriate helper class to delegate calls to. + */ + protected function initialize() { + if (PHP_SAPI === 'cli') { + $this->outputprovider = output_cli_provider::instance(); + } else { + $this->outputprovider = output_http_provider::instance(); + } + } } +/** + * Base class for all output providers. + * + * @copyright 2012 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class output_provider extends singleton_pattern { +} + +/** + * Provides output to the command line. + * + * @copyright 2012 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class output_cli_provider extends output_provider { + + /** + * Prints help information in CLI mode. + */ + public function help() { + + $this->outln('mdeploy.php - Moodle (http://moodle.org) deployment utility'); + $this->outln(); + $this->outln('Usage: $ sudo -u apache php mdeploy.php [options]'); + $this->outln(); + $input = input_manager::instance(); + foreach($input->get_option_info() as $info) { + $option = array(); + if (!empty($info->shortname)) { + $option[] = '-'.$info->shortname; + } + if (!empty($info->longname)) { + $option[] = '--'.$info->longname; + } + $this->outln(sprintf('%-20s %s', implode(', ', $option), $info->desc)); + } + } + + // End of external API + + /** + * Writes a text to the STDOUT followed by a new line character. + * + * @param string $text text to print + */ + protected function outln($text='') { + fputs(STDOUT, $text.PHP_EOL); + } +} + + +/** + * Provides HTML output as a part of HTTP response. + * + * @copyright 2012 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class output_http_provider extends output_provider { + + /** + * Prints help on the script usage. + */ + public function help() { + // No help available via HTTP + } +} + // The main class providing all the functionality ////////////////////////////// /** - * TODO: short description. - * - * TODO: long description. + * The actual worker class implementing the main functionality of the script. * * @copyright 2012 David Mudrak * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class worker extends singleton_pattern { + /** @var input_manager */ + protected $input = null; + + /** @var output_manager */ + protected $output = null; + /** - * TODO: short description. - * - * @param input_manager $input - * @param output_manager $output - * @return TODO + * Main - the one that actually does something */ - public function execute(input_manager $input, output_manager $output) { + public function execute() { + + if ($this->input->get_option('help')) { + $this->output->help(); + exit(1); + } // Authorize access. None in CLI. Passphrase in HTTP. @@ -479,6 +573,13 @@ class worker extends singleton_pattern { // Redirect to the given URL (in HTTP) or exit (in CLI). } + /** + * Initialize the worker class. + */ + protected function initialize() { + $this->input = input_manager::instance(); + $this->output = output_manager::instance(); + } } @@ -487,21 +588,13 @@ class worker extends singleton_pattern { // Check if the script is actually executed or if it was just included by someone // else - typically by the PHPUnit. This is a PHP alternative to the Python's // if __name__ == '__main__' - if (!debug_backtrace()) { - // We are executed by the SAPI - - // Initialize the input options manager. - $input = input_manager::instance(); - - // Initialize the output (display) manager. - $output = output_manager::instance(); - + // We are executed by the SAPI. // Initialize the worker class to actually make the job. $worker = worker::instance(); // Lights, Camera, Action! - $worker->execute($input, $output); + $worker->execute(); } else { // We are included - probably by some unit testing framework. Do nothing. From 11c3c579a1456584495eb1a047921d9dba7c5add Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Mon, 17 Sep 2012 16:24:02 +0200 Subject: [PATCH 04/32] MDL-35238 Fix getting input option value --- mdeploy.php | 8 ++++---- mdeploytest.php | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/mdeploy.php b/mdeploy.php index ca93557a4ab..5d7eddbea0a 100644 --- a/mdeploy.php +++ b/mdeploy.php @@ -267,9 +267,9 @@ class input_manager extends singleton_pattern { * @param string $type the parameter type, e.g. {@link input_manager::TYPE_INT} * @return mixed */ - protected function get_required_option($name, $type) { + protected function get_required_option($name) { if ($this->inputprovider->has_option($name)) { - return $this->cast_value($this->inputprovider->get_raw_option($name), $type); + return $this->inputprovider->get_option($name); } else { throw new missing_option_exception('Missing required option: '.$name); } @@ -283,9 +283,9 @@ class input_manager extends singleton_pattern { * @param mixed $default the default value. * @return mixed */ - protected function get_optional_option($name, $type, $default) { + protected function get_optional_option($name, $default) { if ($this->inputprovider->has_option($name)) { - return $this->inputprovider->get_raw_option($name); + return $this->inputprovider->get_option($name); } else { return $default; } diff --git a/mdeploytest.php b/mdeploytest.php index 445993802e0..ed1b5a6e036 100644 --- a/mdeploytest.php +++ b/mdeploytest.php @@ -152,4 +152,20 @@ class mdeploytest extends PHPUnit_Framework_TestCase { $this->assertTrue($provider->has_option('help')); // help passed and it is a flag (value ignored) $this->assertTrue($provider->has_option('h')); // 'h' is a shortname for 'help' } + + public function test_get_option() { + $input = testable_input_manager::instance(); + $provider = input_fake_provider::instance(); + + $provider->set_fake_options(array('help' => false, 'passfile' => '_mdeploy.123456')); + $this->assertTrue($input->get_option('h')); + $this->assertEquals($input->get_option('passfile'), '_mdeploy.123456'); + $this->assertEquals($input->get_option('password', 'admin123'), 'admin123'); + try { + $this->assertEquals($input->get_option('password'), 'admin123'); // must throw exception (not passed but required) + $this->assertTrue(false); + } catch (missing_option_exception $e) { + $this->assertTrue(true); + } + } } From c57f18ad06558da0ebfb6c38fef05923701d9bbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Mon, 17 Sep 2012 18:52:49 +0200 Subject: [PATCH 05/32] MDL-35238 Implement HTTP authorization based on passphrase matching --- mdeploy.php | 61 +++++++++++++++++++++++++++++++++++++++++++++++++ mdeploytest.php | 8 +++++++ 2 files changed, 69 insertions(+) diff --git a/mdeploy.php b/mdeploy.php index 5d7eddbea0a..3b7c3f8b471 100644 --- a/mdeploy.php +++ b/mdeploy.php @@ -34,6 +34,7 @@ if (defined('MOODLE_INTERNAL')) { class invalid_coding_exception extends Exception {} class missing_option_exception extends Exception {} +class unauthorized_access_exception extends Exception {} // Various support classes ///////////////////////////////////////////////////// @@ -100,8 +101,11 @@ abstract class singleton_pattern { */ class input_manager extends singleton_pattern { + const TYPE_FILE = 'file'; // File name const TYPE_FLAG = 'flag'; // No value, just a flag (switch) const TYPE_INT = 'int'; // Integer + const TYPE_PATH = 'path'; // Full path to a file or a directory + const TYPE_RAW = 'raw'; // Raw value, keep as is /** @var input_cli_provider|input_http_provider the provider of the input */ protected $inputprovider = null; @@ -151,9 +155,12 @@ class input_manager extends singleton_pattern { public function get_option_info($name=null) { $supportedoptions = array( + array('d', 'dataroot', input_manager::TYPE_PATH, 'Full path to the dataroot (moodledata) directory'), array('h', 'help', input_manager::TYPE_FLAG, 'Prints usage information'), array('i', 'install', input_manager::TYPE_FLAG, 'Installation mode'), array('u', 'upgrade', input_manager::TYPE_FLAG, 'Upgrade mode'), + array('', 'passfile', input_manager::TYPE_FILE, 'File name of the passphrase file (HTTP access only)'), + array('', 'password', input_manager::TYPE_RAW, 'Session passphrase (HTTP access only)'), ); if (is_null($name)) { @@ -217,12 +224,31 @@ class input_manager extends singleton_pattern { switch ($type) { + case input_manager::TYPE_FILE: + $raw = preg_replace('~[[:cntrl:]]|[&<>"`\|\':\\\\/]~u', '', $raw); + $raw = preg_replace('~\.\.+~', '', $raw); + if ($raw === '.') { + $raw = ''; + } + return $raw; + case input_manager::TYPE_FLAG: return true; case input_manager::TYPE_INT: return (int)$raw; + case input_manager::TYPE_PATH: + $raw = str_replace('\\', '/', $raw); + $raw = preg_replace('~[[:cntrl:]]|[&<>"`\|\':]~u', '', $raw); + $raw = preg_replace('~\.\.+~', '', $raw); + $raw = preg_replace('~//+~', '/', $raw); + $raw = preg_replace('~/(\./)+~', '/', $raw); + return $raw; + + case input_manager::TYPE_RAW: + return $raw; + default: throw new invalid_coding_exception('Unknown option type.'); @@ -563,6 +589,7 @@ class worker extends singleton_pattern { } // Authorize access. None in CLI. Passphrase in HTTP. + $this->authorize(); // Fetch the ZIP file into a temporary location. @@ -580,6 +607,40 @@ class worker extends singleton_pattern { $this->input = input_manager::instance(); $this->output = output_manager::instance(); } + + // End of external API + + /** + * Authorize access to the script. + * + * In CLI mode, the access is automatically authorized. In HTTP mode, the + * passphrase submitted via the request params must match the contents of the + * file, the name of which is passed in another parameter. + * + * @throws unauthorized_access_exception + */ + protected function authorize() { + + if (PHP_SAPI === 'cli') { + return; + } + + $dataroot = $this->input->get_option('dataroot'); + $passfile = $this->input->get_option('passfile'); + $password = $this->input->get_option('password'); + + $passpath = $dataroot.'/temp/mdeploy/'.$passfile; + + if (!is_readable($passpath)) { + throw new unauthorized_access_exception('Unable to read passphrase file.'); + } + + $stored = file_get_contents($passpath); + + if ($password !== $stored) { + throw new unauthorized_access_exception('Session passphrase does not match the stored one.'); + } + } } diff --git a/mdeploytest.php b/mdeploytest.php index ed1b5a6e036..e61654db1a6 100644 --- a/mdeploytest.php +++ b/mdeploytest.php @@ -119,6 +119,14 @@ class mdeploytest extends PHPUnit_Framework_TestCase { array('1', input_manager::TYPE_FLAG, true), array('0', input_manager::TYPE_FLAG, true), array('muhehe', input_manager::TYPE_FLAG, true), + + array('C:\\WINDOWS\\user.dat', input_manager::TYPE_PATH, 'C/WINDOWS/user.dat'), + array('../../../etc/passwd', input_manager::TYPE_PATH, '/etc/passwd'), + array('///////.././public_html/test.php', input_manager::TYPE_PATH, '/public_html/test.php'), + + array("!@#$%|/etc/qwerty\n\n\t\n\r", input_manager::TYPE_RAW, "!@#$%|/etc/qwerty\n\n\t\n\r"), + + array("\nrock'n'roll.mp3\t.exe", input_manager::TYPE_FILE, 'rocknroll.mp3.exe'), ); } From 4df8bced232b7065b845c83753aa9454f7ec3f8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Tue, 18 Sep 2012 22:13:48 +0200 Subject: [PATCH 06/32] MDL-35238 Allow filtering at the Plugins overview page --- admin/plugins.php | 12 +++++- admin/renderer.php | 88 +++++++++++++++++++++++++++++++++++--- lang/en/plugin.php | 5 +++ theme/base/style/admin.css | 4 ++ 4 files changed, 100 insertions(+), 9 deletions(-) diff --git a/admin/plugins.php b/admin/plugins.php index 8da3fd7a5fe..7b59cb19f1e 100644 --- a/admin/plugins.php +++ b/admin/plugins.php @@ -32,15 +32,23 @@ require_capability('moodle/site:config', context_system::instance()); admin_externalpage_setup('pluginsoverview'); $fetchremote = optional_param('fetchremote', false, PARAM_BOOL); +$updatesonly = optional_param('updatesonly', false, PARAM_BOOL); +$contribonly = optional_param('contribonly', false, PARAM_BOOL); $pluginman = plugin_manager::instance(); $checker = available_update_checker::instance(); +// Filtering options. +$options = array( + 'updatesonly' => $updatesonly, + 'contribonly' => $contribonly, +); + if ($fetchremote) { require_sesskey(); $checker->fetch(); - redirect($PAGE->url); + redirect(new moodle_url($PAGE->url, $options)); } $output = $PAGE->get_renderer('core', 'admin'); -echo $output->plugin_management_page($pluginman, $checker); +echo $output->plugin_management_page($pluginman, $checker, $options); diff --git a/admin/renderer.php b/admin/renderer.php index bf45639be98..cc057fd9782 100644 --- a/admin/renderer.php +++ b/admin/renderer.php @@ -278,22 +278,30 @@ class core_admin_renderer extends plugin_renderer_base { /** * Display the plugin management page (admin/plugins.php). * + * The filtering options array may contain following items: + * bool contribonly - show only contributed extensions + * bool updatesonly - show only plugins with an available update + * * @param plugin_manager $pluginman * @param available_update_checker $checker + * @param array $options filtering options * @return string HTML to output. */ - public function plugin_management_page(plugin_manager $pluginman, available_update_checker $checker) { + public function plugin_management_page(plugin_manager $pluginman, available_update_checker $checker, array $options = array()) { global $CFG; $output = ''; $output .= $this->header(); $output .= $this->heading(get_string('pluginsoverview', 'core_admin')); - $output .= $this->plugins_overview_panel($pluginman); + $output .= $this->plugins_overview_panel($pluginman, $options); if (empty($CFG->disableupdatenotifications)) { $output .= $this->container_start('checkforupdates'); - $output .= $this->single_button(new moodle_url($this->page->url, array('fetchremote' => 1)), get_string('checkforupdates', 'core_plugin')); + $output .= $this->single_button( + new moodle_url($this->page->url, array_merge($options, array('fetchremote' => 1))), + get_string('checkforupdates', 'core_plugin') + ); if ($timefetched = $checker->get_last_timefetched()) { $output .= $this->container(get_string('checkforupdateslast', 'core_plugin', userdate($timefetched, get_string('strftimedatetime', 'core_langconfig')))); @@ -301,7 +309,7 @@ class core_admin_renderer extends plugin_renderer_base { $output .= $this->container_end(); } - $output .= $this->box($this->plugins_control_panel($pluginman), 'generalbox'); + $output .= $this->box($this->plugins_control_panel($pluginman, $options), 'generalbox'); $output .= $this->footer(); return $output; @@ -870,9 +878,10 @@ class core_admin_renderer extends plugin_renderer_base { * Prints an overview about the plugins - number of installed, number of extensions etc. * * @param plugin_manager $pluginman provides information about the plugins + * @param array $options filtering options * @return string as usually */ - public function plugins_overview_panel(plugin_manager $pluginman) { + public function plugins_overview_panel(plugin_manager $pluginman, array $options = array()) { global $CFG; $plugininfo = $pluginman->get_plugins(); @@ -898,14 +907,49 @@ class core_admin_renderer extends plugin_renderer_base { } $info = array(); + $filter = array(); + $somefilteractive = false; $info[] = html_writer::tag('span', get_string('numtotal', 'core_plugin', $numtotal), array('class' => 'info total')); $info[] = html_writer::tag('span', get_string('numdisabled', 'core_plugin', $numdisabled), array('class' => 'info disabled')); $info[] = html_writer::tag('span', get_string('numextension', 'core_plugin', $numextension), array('class' => 'info extension')); + if ($numextension > 0) { + if (empty($options['contribonly'])) { + $filter[] = html_writer::link( + new moodle_url($this->page->url, array('contribonly' => 1)), + get_string('filtercontribonly', 'core_plugin'), + array('class' => 'filter-item show-contribonly') + ); + } else { + $filter[] = html_writer::tag('span', get_string('filtercontribonlyactive', 'core_plugin'), + array('class' => 'filter-item active show-contribonly')); + $somefilteractive = true; + } + } if ($numupdatable > 0) { $info[] = html_writer::tag('span', get_string('numupdatable', 'core_plugin', $numupdatable), array('class' => 'info updatable')); + if (empty($options['updatesonly'])) { + $filter[] = html_writer::link( + new moodle_url($this->page->url, array('updatesonly' => 1)), + get_string('filterupdatesonly', 'core_plugin'), + array('class' => 'filter-item show-updatesonly') + ); + } else { + $filter[] = html_writer::tag('span', get_string('filterupdatesonlyactive', 'core_plugin'), + array('class' => 'filter-item active show-updatesonly')); + $somefilteractive = true; + } + } + if ($somefilteractive) { + $filter[] = html_writer::link($this->page->url, get_string('filterall', 'core_plugin'), array('class' => 'filter-item show-all')); } - return $this->output->box(implode(html_writer::tag('span', ' ', array('class' => 'separator')), $info), '', 'plugins-overview-panel'); + $output = $this->output->box(implode(html_writer::tag('span', ' ', array('class' => 'separator')), $info), '', 'plugins-overview-panel'); + + if (!empty($filter)) { + $output .= $this->output->box(implode(html_writer::tag('span', ' ', array('class' => 'separator')), $filter), '', 'plugins-overview-filter'); + } + + return $output; } /** @@ -914,13 +958,43 @@ class core_admin_renderer extends plugin_renderer_base { * This default implementation renders all plugins into one big table. * * @param plugin_manager $pluginman provides information about the plugins. + * @param array $options filtering options * @return string HTML code */ - public function plugins_control_panel(plugin_manager $pluginman) { + public function plugins_control_panel(plugin_manager $pluginman, array $options = array()) { global $CFG; $plugininfo = $pluginman->get_plugins(); + // Filter the list of plugins according the options. + if (!empty($options['updatesonly'])) { + $updateable = array(); + foreach ($plugininfo as $plugintype => $pluginnames) { + foreach ($pluginnames as $pluginname => $pluginfo) { + if (!empty($pluginfo->availableupdates)) { + foreach ($pluginfo->availableupdates as $pluginavailableupdate) { + if ($pluginavailableupdate->version > $pluginfo->versiondisk) { + $updateable[$plugintype][$pluginname] = $pluginfo; + } + } + } + } + } + $plugininfo = $updateable; + } + + if (!empty($options['contribonly'])) { + $contribs = array(); + foreach ($plugininfo as $plugintype => $pluginnames) { + foreach ($pluginnames as $pluginname => $pluginfo) { + if (!$pluginfo->is_standard()) { + $contribs[$plugintype][$pluginname] = $pluginfo; + } + } + } + $plugininfo = $contribs; + } + if (empty($plugininfo)) { return ''; } diff --git a/lang/en/plugin.php b/lang/en/plugin.php index 9fd873a16bf..cd529b4a18c 100644 --- a/lang/en/plugin.php +++ b/lang/en/plugin.php @@ -30,6 +30,11 @@ $string['availability'] = 'Availability'; $string['checkforupdates'] = 'Check for available updates'; $string['checkforupdateslast'] = 'Last check done on {$a}'; $string['displayname'] = 'Plugin name'; +$string['filterall'] = 'Show all'; +$string['filtercontribonly'] = 'Show contributions only'; +$string['filtercontribonlyactive'] = 'Showing contributions only'; +$string['filterupdatesonly'] = 'Show updateable only'; +$string['filterupdatesonlyactive'] = 'Showing updateable only'; $string['moodleversion'] = 'Moodle {$a}'; $string['nonehighlighted'] = 'No plugins require your attention now'; $string['nonehighlightedinfo'] = 'Display the list of all installed plugins anyway'; diff --git a/theme/base/style/admin.css b/theme/base/style/admin.css index 1d71a50a5d7..ef209d2c3f6 100644 --- a/theme/base/style/admin.css +++ b/theme/base/style/admin.css @@ -248,6 +248,10 @@ #page-admin-plugins #plugins-overview-panel .info {padding:5px 10px;} #page-admin-plugins #plugins-overview-panel .separator {border-left:1px dotted #999;} #page-admin-plugins #plugins-overview-panel .info.updatable {margin-left:10px;background-color:#d2ebff;font-weight:bold;-moz-border-radius: 10px;-webkit-border-radius: 10px;border-radius: 10px;} +#page-admin-plugins #plugins-overview-filter {margin:1em auto;text-align:center;} +#page-admin-plugins #plugins-overview-filter .filter-item {padding:5px 10px;} +#page-admin-plugins #plugins-overview-filter .filter-item.active {font-weight:bold;} +#page-admin-plugins #plugins-overview-filter .separator {border-left:1px dotted #999;} #page-admin-plugins #plugins-control-panel .notes .pluginupdateinfo {padding:5px 10px;margin:10px;background-color:#d2ebff;-moz-border-radius: 10px;-webkit-border-radius: 10px;border-radius: 10px;} #page-admin-plugins #plugins-control-panel .notes .pluginupdateinfo.maturity50 {background-color:#ffd3d9;} #page-admin-plugins #plugins-control-panel .notes .pluginupdateinfo.maturity100, From 7bc759bd448b9410222b0d2421b5abb786b0186c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Tue, 18 Sep 2012 22:26:22 +0200 Subject: [PATCH 07/32] MDL-35238 Remove the hide/show icon from the Plugins overview page This was reported in forums as an usability issue. This icon is used across Moodle as an active link to hide or show things - not as a status icon. People were trying to disable plugins by clicking the icon. Until there is a full support for doing this from the Plugins overview page, it is better to remove the icons completely. --- admin/renderer.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/admin/renderer.php b/admin/renderer.php index cc057fd9782..959366a26cc 100644 --- a/admin/renderer.php +++ b/admin/renderer.php @@ -1065,12 +1065,10 @@ class core_admin_renderer extends plugin_renderer_base { $availability = new html_table_cell(''); } else if ($isenabled) { $row->attributes['class'] .= ' enabled'; - $icon = $this->output->pix_icon('i/hide', get_string('pluginenabled', 'core_plugin')); - $availability = new html_table_cell($icon . ' ' . get_string('pluginenabled', 'core_plugin')); + $availability = new html_table_cell(get_string('pluginenabled', 'core_plugin')); } else { $row->attributes['class'] .= ' disabled'; - $icon = $this->output->pix_icon('i/show', get_string('plugindisabled', 'core_plugin')); - $availability = new html_table_cell($icon . ' ' . get_string('plugindisabled', 'core_plugin')); + $availability = new html_table_cell(get_string('plugindisabled', 'core_plugin')); } $actions = array(); From 9dcb52882ce4982058ad05342776a6957d3356fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Tue, 18 Sep 2012 22:29:09 +0200 Subject: [PATCH 08/32] MDL-35238 Distinguish the uninstall link from the settings link It has been reported (can't find the issue now, sorry) that having these two links next to each other represents an usability issue as folks found themselves clicking at the uninstall link instead of the settings link. Previously, these links were displayed in separate columns but then the whole table got too wide - see MDL-20438. --- theme/base/style/admin.css | 1 + 1 file changed, 1 insertion(+) diff --git a/theme/base/style/admin.css b/theme/base/style/admin.css index ef209d2c3f6..92539805609 100644 --- a/theme/base/style/admin.css +++ b/theme/base/style/admin.css @@ -252,6 +252,7 @@ #page-admin-plugins #plugins-overview-filter .filter-item {padding:5px 10px;} #page-admin-plugins #plugins-overview-filter .filter-item.active {font-weight:bold;} #page-admin-plugins #plugins-overview-filter .separator {border-left:1px dotted #999;} +#page-admin-plugins #plugins-control-panel .actions .uninstall {color:#900;} #page-admin-plugins #plugins-control-panel .notes .pluginupdateinfo {padding:5px 10px;margin:10px;background-color:#d2ebff;-moz-border-radius: 10px;-webkit-border-radius: 10px;border-radius: 10px;} #page-admin-plugins #plugins-control-panel .notes .pluginupdateinfo.maturity50 {background-color:#ffd3d9;} #page-admin-plugins #plugins-control-panel .notes .pluginupdateinfo.maturity100, From 02fd7f47a91175ef41a2cd4142de359ddb738276 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Wed, 12 Sep 2012 03:06:00 +0200 Subject: [PATCH 09/32] MDL-35238 Add a new admin setting to enable updates deployment --- admin/settings/server.php | 2 ++ lang/en/admin.php | 3 ++ lib/adminlib.php | 74 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/admin/settings/server.php b/admin/settings/server.php index 0c8746e9846..fe2a3dcccbe 100644 --- a/admin/settings/server.php +++ b/admin/settings/server.php @@ -228,6 +228,8 @@ if (empty($CFG->disableupdatenotifications)) { $temp = new admin_settingpage('updatenotifications', new lang_string('updatenotifications', 'core_admin')); $temp->add(new admin_setting_configcheckbox('updateautocheck', new lang_string('updateautocheck', 'core_admin'), new lang_string('updateautocheck_desc', 'core_admin'), 1)); + $temp->add(new admin_setting_updateautodeploy('updateautodeploy', new lang_string('updateautodeploy', 'core_admin'), + new lang_string('updateautodeploy_desc', 'core_admin'), 0)); $temp->add(new admin_setting_configselect('updateminmaturity', new lang_string('updateminmaturity', 'core_admin'), new lang_string('updateminmaturity_desc', 'core_admin'), MATURITY_STABLE, array( diff --git a/lang/en/admin.php b/lang/en/admin.php index a0c322603b6..64b01d6300d 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -1011,6 +1011,9 @@ $string['updatenotificationfooter'] = 'Your Moodle site {$a->siteurl} is configu $string['updatenotificationsubject'] = 'Moodle updates are available ({$a->siteurl})'; $string['updateautocheck'] = 'Automatically check for available updates'; $string['updateautocheck_desc'] = 'If enabled, your site will automatically check for available updates for both Moodle code and all additional plugins. If there is a new update available, a notification will be sent to site admins.'; +$string['updateautodeploy'] = 'Enable updates deployment'; +$string['updateautodeploy_desc'] = 'If enabled, you will be able to download and install available updates directly from Moodle administration pages. Note that your web server process has to have write access into folders with Moodle installation to make this work. That can be seen as a potential security risk.'; +$string['updateautodeploy_unablewriteplugins'] = 'Can\'t enable the feature - unable to write into plugin locations!'; $string['updateminmaturity'] = 'Required code maturity'; $string['updateminmaturity_desc'] = 'Notify about available updates only if the available code has the selected maturity level at least. Updates for plugins that do not declare their code maturity level are always reported regardless this setting.'; $string['updatenotifybuilds'] = 'Notify about new builds'; diff --git a/lib/adminlib.php b/lib/adminlib.php index 3813b4d46dd..ede57ab3206 100644 --- a/lib/adminlib.php +++ b/lib/adminlib.php @@ -8052,3 +8052,77 @@ class admin_setting_configmultiselect_modules extends admin_setting_configmultis return true; } } + + +/** + * Checkbox for the updateautodeploy setting + * + * This class implements the extra check to make sure that the web server + * process user has write access to the $CFG->dirroot. + * + * @copyright 2012 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class admin_setting_updateautodeploy extends admin_setting_configcheckbox { + + /** + * Sets the value for the setting + * + * When the feature is just about to be enabled, proceed some extra checks + * prior to setting the value. + * + * @param string $data the checkbox value + * @return string empty string or error + */ + public function write_setting($data) { + + // Are we just going to activate the feature? + $currentlyenabled = $this->config_read('updateautodeploy'); + if ((string)$data === $this->yes and empty($currentlyenabled)) { + if (!$this->plugin_locations_writeable()) { + return get_string('updateautodeploy_unablewriteplugins', 'core_admin'); + } + // TODO MDL-35239 check if the whole dirroot is writeable + } + + // Let the parent class actually save the value. + return parent::write_setting($data); + } + + /** + * Check if it is possible to write into plugin locations + * + * @return bool + */ + private function plugin_locations_writeable() { + global $CFG; + require_once($CFG->libdir.'/pluginlib.php'); + + // Check that the web server process is able to deploy new plugins of all types + $plugintypes = get_plugin_types(true); + foreach ($plugintypes as $plugintype => $plugintyperoot) { + if (!is_writeable($plugintyperoot)) { + debugging('Plugin type location not writeable: '.$plugintyperoot, DEBUG_ALL); + return false; + } + } + + // Check that the web server process is able to modify contributed plugins + $pluginman = plugin_manager::instance(); + $plugininfo = $pluginman->get_plugins(); + foreach ($plugininfo as $plugintype => $plugininstances) { + foreach ($plugininstances as $pluginname => $plugininfo) { + if ($plugininfo->is_standard()) { + // No need to check for these now until MDL-35239 is implemented + continue; + } + if (!is_writeable($plugininfo->rootdir)) { + debugging('Contributed plugin directory not writeable: '.$plugininfo->rootdir); + return false; + } + } + } + + return true; + } +} From f965e1653951b568651cea40e8205d9721c2b47a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Tue, 2 Oct 2012 01:11:40 +0200 Subject: [PATCH 10/32] MDL-35238 Make the auto-deploy feature lockable via config.php If $CFG->disableupdateautodeploy is set in config.php, no automatic deploy can happen. This is for sites that are managed by a provider but their clients have admin access. --- admin/settings/server.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/admin/settings/server.php b/admin/settings/server.php index fe2a3dcccbe..0fd6a74266d 100644 --- a/admin/settings/server.php +++ b/admin/settings/server.php @@ -228,8 +228,10 @@ if (empty($CFG->disableupdatenotifications)) { $temp = new admin_settingpage('updatenotifications', new lang_string('updatenotifications', 'core_admin')); $temp->add(new admin_setting_configcheckbox('updateautocheck', new lang_string('updateautocheck', 'core_admin'), new lang_string('updateautocheck_desc', 'core_admin'), 1)); - $temp->add(new admin_setting_updateautodeploy('updateautodeploy', new lang_string('updateautodeploy', 'core_admin'), - new lang_string('updateautodeploy_desc', 'core_admin'), 0)); + if (empty($CFG->disableupdateautodeploy)) { + $temp->add(new admin_setting_updateautodeploy('updateautodeploy', new lang_string('updateautodeploy', 'core_admin'), + new lang_string('updateautodeploy_desc', 'core_admin'), 0)); + } $temp->add(new admin_setting_configselect('updateminmaturity', new lang_string('updateminmaturity', 'core_admin'), new lang_string('updateminmaturity_desc', 'core_admin'), MATURITY_STABLE, array( From 7683e550ac50721ed3bea202d8ec0cbe88975d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Mon, 8 Oct 2012 01:48:15 +0200 Subject: [PATCH 11/32] MDL-35238 Introduce available_update_deployer class This class represents the communication bridge from Moodle UI to the (standalone) mdeploy.php utility. It consists of various helper methods useful when dealing with user interface, update confirmation etc. The class is implemented as a singleton. This allows us easily transfer required data from top level scripts (like /admin/index.php) into the rendering methods deep in the stack without the need to change the API of many methods on the way. --- lang/en/admin.php | 1 + lib/pluginlib.php | 328 +++++++++++++++++++++++++++++++++++ lib/tests/pluginlib_test.php | 14 ++ 3 files changed, 343 insertions(+) diff --git a/lang/en/admin.php b/lang/en/admin.php index 64b01d6300d..ac15bbc1a96 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -1005,6 +1005,7 @@ $string['updateavailableforplugin'] = 'There is a newer version for some of your $string['updateavailable_moreinfo'] = 'More info...'; $string['updateavailable_release'] = 'Moodle {$a}'; $string['updateavailable_version'] = 'Version {$a}'; +$string['updateavailableinstall'] = 'Install this update'; $string['updateavailablenot'] = 'Your Moodle code is up-to-date!'; $string['updatenotifications'] = 'Update notifications'; $string['updatenotificationfooter'] = 'Your Moodle site {$a->siteurl} is configured to automatically check for available updates. You are receiving this message as the administrator of the site. You can disable automatic checks for available updates in the Site administration section of the Settings block. You can customize the delivery of this message via your personal Messaging setting in the My profile settings section.'; diff --git a/lib/pluginlib.php b/lib/pluginlib.php index 58cffb15923..d2a66c74362 100644 --- a/lib/pluginlib.php +++ b/lib/pluginlib.php @@ -1428,6 +1428,334 @@ class available_update_info { } +/** + * Implements a communication bridge to the mdeploy.php utility + */ +class available_update_deployer { + + const HTTP_PARAM_PREFIX = 'updteautodpldata_'; // Hey, even Google has not heard of such a prefix! So it MUST be safe :-p + const HTTP_PARAM_CHECKER = 'datapackagesize'; // Name of the parameter that holds the number of items in the received data items + + /** @var available_update_deployer holds the singleton instance */ + protected static $singletoninstance; + /** @var moodle_url URL of a page that includes the deployer UI */ + protected $callerurl; + /** @var moodle_url URL to return after the deployment */ + protected $returnurl; + + /** + * Direct instantiation not allowed, use the factory method {@link self::instance()} + */ + protected function __construct() { + } + + /** + * Sorry, this is singleton + */ + protected function __clone() { + } + + /** + * Factory method for this class + * + * @return available_update_deployer the singleton instance + */ + public static function instance() { + if (is_null(self::$singletoninstance)) { + self::$singletoninstance = new self(); + } + return self::$singletoninstance; + } + + /** + * Is automatic deployment enabled? + * + * @return bool + */ + public function enabled() { + global $CFG; + + if (!empty($CFG->disableupdateautodeploy)) { + // The feature is prohibited via config.php + return false; + } + + return get_config('updateautodeploy'); + } + + /** + * Sets some base properties of the class to make it usable. + * + * @param moodle_url $callerurl the base URL of a script that will handle the class'es form data + * @param moodle_url $returnurl the final URL to return to when the deployment is finished + */ + public function initialize(moodle_url $callerurl, moodle_url $returnurl) { + + if (!$this->enabled()) { + throw new coding_exception('Unable to initialize the deployer, the feature is not enabled.'); + } + + $this->callerurl = $callerurl; + $this->returnurl = $returnurl; + } + + /** + * Has the deployer been initialized? + * + * Initialized deployer means that the following properties were set: + * callerurl, returnurl + * + * @return bool + */ + public function initialized() { + + if (!$this->enabled()) { + return false; + } + + if (empty($this->callerurl)) { + return false; + } + + if (empty($this->returnurl)) { + return false; + } + + return true; + } + + /** + * Check if the available update info contains all required data for deployment. + * + * All instances of {@link available_update_info} class always provide at least the + * component name and component version. Additionally, we also need the URL to download + * the ZIP package from. + * + * @param available_update_info $info + * @return bool + */ + public function can_deploy(available_update_info $info) { + + if (empty($info->download)) { + return false; + } + + return true; + } + + /** + * Prepares a renderable widget to confirm installation of an available update. + * + * @param available_update_info $info component version to deploy + * @return renderable + */ + public function make_confirm_widget(available_update_info $info) { + + if (!$this->initialized()) { + throw new coding_exception('Illegal method call - deployer not initialized.'); + } + + $params = $this->data_to_params(array( + 'updateinfo' => (array)$info, // see http://www.php.net/manual/en/language.types.array.php#language.types.array.casting + )); + + $widget = new single_button( + new moodle_url($this->callerurl, $params), + get_string('updateavailableinstall', 'core_admin'), + 'post' + ); + + return $widget; + } + + /** + * Prepares a renderable widget to execute installation of an available update. + * + * @param available_update_info $info component version to deploy + * @return renderable + */ + public function make_execution_widget(available_update_info $info) { + global $CFG; + + if (!$this->initialized()) { + throw new coding_exception('Illegal method call - deployer not initialized.'); + } + + $pluginrootpaths = get_plugin_types(true); + + list($plugintype, $pluginname) = normalize_component($info->component); + + if (empty($pluginrootpaths[$plugintype])) { + throw new coding_exception('Unknown plugin type root location', $plugintype); + } + + $params = array( + 'upgrade' => true, + 'type' => $plugintype, + 'name' => $pluginname, + 'typeroot' => $pluginrootpaths[$plugintype], + 'download' => $info->download, + 'dataroot' => $CFG->dataroot, + 'dirroot' => $CFG->dirroot, + 'passfile' => '', // TODO + 'password' => '', // TODO + ); + + $widget = new single_button( + new moodle_url('/mdeploy.php', $params), + get_string('updateavailableinstall', 'core_admin'), + 'post' + ); + + return $widget; + } + + /** + * Returns array of data objects passed to this tool. + * + * @return array + */ + public function submitted_data() { + + $data = $this->params_to_data($_POST); + + if (empty($data) or empty($data[self::HTTP_PARAM_CHECKER])) { + return false; + } + + if (!empty($data['updateinfo']) and is_object($data['updateinfo'])) { + $updateinfo = $data['updateinfo']; + if (!empty($updateinfo->component) and !empty($updateinfo->version)) { + $data['updateinfo'] = new available_update_info($updateinfo->component, (array)$updateinfo); + } + } + + if (!empty($data['callerurl'])) { + $data['callerurl'] = new moodle_url($data['callerurl']); + } + + if (!empty($data['returnurl'])) { + $data['returnurl'] = new moodle_url($data['returnurl']); + } + + return $data; + } + + /** + * Handles magic getters and setters for protected properties. + * + * @param string $name method name, e.g. set_returnurl() + * @param array $arguments arguments to be passed to the array + */ + public function __call($name, array $arguments = array()) { + + if (substr($name, 0, 4) === 'set_') { + $property = substr($name, 4); + if (empty($property)) { + throw new coding_exception('Invalid property name (empty)'); + } + if (empty($arguments)) { + $arguments = array(true); // Default value for flag-like properties. + } + // Make sure it is a protected property. + $isprotected = false; + $reflection = new ReflectionObject($this); + foreach ($reflection->getProperties(ReflectionProperty::IS_PROTECTED) as $reflectionproperty) { + if ($reflectionproperty->getName() === $property) { + $isprotected = true; + break; + } + } + if (!$isprotected) { + throw new coding_exception('Unable to set property - it does not exist or it is not protected'); + } + $value = reset($arguments); + $this->$property = $value; + return; + } + + if (substr($name, 0, 4) === 'get_') { + $property = substr($name, 4); + if (empty($property)) { + throw new coding_exception('Invalid property name (empty)'); + } + if (!empty($arguments)) { + throw new coding_exception('No parameter expected'); + } + // Make sure it is a protected property. + $isprotected = false; + $reflection = new ReflectionObject($this); + foreach ($reflection->getProperties(ReflectionProperty::IS_PROTECTED) as $reflectionproperty) { + if ($reflectionproperty->getName() === $property) { + $isprotected = true; + break; + } + } + if (!$isprotected) { + throw new coding_exception('Unable to get property - it does not exist or it is not protected'); + } + return $this->$property; + } + } + + // End of external API + + /** + * Prepares an array of HTTP parameters that can be passed to another page. + * + * @param array|object $data associative array or an object holding the data, data JSON-able + * @return array suitable as a param for moodle_url + */ + protected function data_to_params($data) { + + // Append some our own data + if (!empty($this->callerurl)) { + $data['callerurl'] = $this->callerurl->out(false); + } + if (!empty($this->callerurl)) { + $data['returnurl'] = $this->returnurl->out(false); + } + + // Finally append the count of items in the package. + $data[self::HTTP_PARAM_CHECKER] = count($data); + + // Generate params + $params = array(); + foreach ($data as $name => $value) { + $transname = self::HTTP_PARAM_PREFIX.$name; + $transvalue = json_encode($value); + $params[$transname] = $transvalue; + } + + return $params; + } + + /** + * Converts HTTP parameters passed to the script into native PHP data + * + * @param array $params such as $_REQUEST or $_POST + * @return array data passed for this class + */ + protected function params_to_data(array $params) { + + if (empty($params)) { + return array(); + } + + $data = array(); + foreach ($params as $name => $value) { + if (strpos($name, self::HTTP_PARAM_PREFIX) === 0) { + $realname = substr($name, strlen(self::HTTP_PARAM_PREFIX)); + $realvalue = json_decode($value); + $data[$realname] = $realvalue; + } + } + + return $data; + } +} + + /** * Factory class producing required subclasses of {@link plugininfo_base} */ diff --git a/lib/tests/pluginlib_test.php b/lib/tests/pluginlib_test.php index 2ec4d0dd2f8..62db9e44781 100644 --- a/lib/tests/pluginlib_test.php +++ b/lib/tests/pluginlib_test.php @@ -543,3 +543,17 @@ class testable_available_update_checker extends available_update_checker { class testable_available_update_checker_cron_executed extends Exception { } + + +/** + * Test cases for {@link available_update_deployer} class + */ +class available_update_deployer_test extends advanced_testcase { + + public function test_magic_setters() { + $deployer = available_update_deployer::instance(); + $value = new moodle_url('/'); + $deployer->set_returnurl($value); + $this->assertSame($deployer->get_returnurl(), $value); + } +} From fa3feafbbb88f03bb78621af2f9c2af42c1e85e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Mon, 8 Oct 2012 01:54:16 +0200 Subject: [PATCH 12/32] MDL-35238 Display a button to install an available update --- admin/renderer.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/admin/renderer.php b/admin/renderer.php index 959366a26cc..b00f92564fb 100644 --- a/admin/renderer.php +++ b/admin/renderer.php @@ -1148,6 +1148,13 @@ class core_admin_renderer extends plugin_renderer_base { $box = $this->output->box_start($boxclasses); $box .= html_writer::tag('div', get_string('updateavailable', 'core_plugin', $updateinfo->version), array('class' => 'version')); $box .= $this->output->box(implode(html_writer::tag('span', ' ', array('class' => 'separator')), $info), ''); + + $deployer = available_update_deployer::instance(); + if ($deployer->initialized() and $deployer->can_deploy($updateinfo)) { + $widget = $deployer->make_confirm_widget($updateinfo); + $box .= $this->output->render($widget); + } + $box .= $this->output->box_end(); return $box; From bcc8397a775a690703b5e72065703c38fde3f7c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Mon, 8 Oct 2012 01:56:10 +0200 Subject: [PATCH 13/32] MDL-35238 Handle the update installation request - display the confirmation page --- admin/index.php | 12 ++++++++++++ admin/plugins.php | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/admin/index.php b/admin/index.php index d3ec395e574..ed33c006014 100644 --- a/admin/index.php +++ b/admin/index.php @@ -261,6 +261,18 @@ if ($version > $CFG->version) { // upgrade } $output = $PAGE->get_renderer('core', 'admin'); + + $deployer = available_update_deployer::instance(); + if ($deployer->enabled()) { + $deployer->initialize($reloadurl, $reloadurl); + + $deploydata = $deployer->submitted_data(); + if (!empty($deploydata)) { + echo $output->upgrade_plugin_confirm_deploy_page($deployer, $deploydata); + die(); + } + } + echo $output->upgrade_plugin_check_page(plugin_manager::instance(), available_update_checker::instance(), $version, $showallplugins, $reloadurl, new moodle_url('/admin/index.php', array('confirmupgrade'=>1, 'confirmrelease'=>1, 'confirmplugincheck'=>1))); diff --git a/admin/plugins.php b/admin/plugins.php index 7b59cb19f1e..4a99a296e5a 100644 --- a/admin/plugins.php +++ b/admin/plugins.php @@ -51,4 +51,17 @@ if ($fetchremote) { } $output = $PAGE->get_renderer('core', 'admin'); + +$deployer = available_update_deployer::instance(); +if ($deployer->enabled()) { + $myurl = new moodle_url($PAGE->url, array('updatesonly' => $updatesonly, 'contribonly' => $contribonly)); + $deployer->initialize($myurl, $myurl); + + $deploydata = $deployer->submitted_data(); + if (!empty($deploydata)) { + echo $output->upgrade_plugin_confirm_deploy_page($deployer, $deploydata); + die(); + } +} + echo $output->plugin_management_page($pluginman, $checker, $options); From 6aa2e2881a12beb69b3f3249d4c4b71a171911a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Mon, 8 Oct 2012 01:58:58 +0200 Subject: [PATCH 14/32] MDL-35238 Admin renderer method to display the plugin update confirmation page --- admin/renderer.php | 54 ++++++++++++++++++++++++++++++++++++++++++++++ lang/en/plugin.php | 3 +++ 2 files changed, 57 insertions(+) diff --git a/admin/renderer.php b/admin/renderer.php index b00f92564fb..5509c53068b 100644 --- a/admin/renderer.php +++ b/admin/renderer.php @@ -234,6 +234,60 @@ class core_admin_renderer extends plugin_renderer_base { return $output; } + /** + * Prints a page with a summary of plugin deployment to be confirmed. + * + * @param available_update_deployer $deployer + * @param array $data deployer's data package as returned by {@link available_update_deployer::submitted_data()} + * @return string + */ + public function upgrade_plugin_confirm_deploy_page(available_update_deployer $deployer, array $data) { + + if (!$deployer->initialized()) { + throw new coding_exception('Unable to render a page for non-initialized deployer.'); + } + + if (empty($data['updateinfo'])) { + throw new coding_exception('Missing required data component.'); + } + + $updateinfo = $data['updateinfo']; + + $output = ''; + $output .= $this->header(); + $output .= $this->container_start('generalbox', 'notice'); + + $a = new stdClass(); + if (get_string_manager()->string_exists('pluginname', $updateinfo->component)) { + $a->name = get_string('pluginname', $updateinfo->component); + } else { + $a->name = $updateinfo->component; + } + + if (isset($updateinfo->release)) { + $a->version = $updateinfo->release . ' (' . $updateinfo->version . ')'; + } else { + $a->version = $updateinfo->version; + } + $a->url = $updateinfo->download; + + $output .= $this->output->heading(get_string('updatepluginconfirm', 'core_plugin')); + $output .= $this->output->container(format_text( + get_string('updatepluginconfirminfo', 'core_plugin', $a) . PHP_EOL . PHP_EOL . + get_string('updatepluginconfirmwarning', 'core_plugin') + )); + + $widget = $deployer->make_execution_widget($data['updateinfo']); + $output .= $this->output->render($widget); + + $output .= $this->output->single_button($data['returnurl'], get_string('cancel', 'core'), 'get'); + + $output .= $this->container_end(); + $output .= $this->footer(); + + return $output; + } + /** * Display the admin notifications page. * @param int $maturity diff --git a/lang/en/plugin.php b/lang/en/plugin.php index cd529b4a18c..f9670a05042 100644 --- a/lang/en/plugin.php +++ b/lang/en/plugin.php @@ -131,6 +131,9 @@ $string['type_webservice_plural'] = 'Webservice protocols'; $string['updateavailable'] = 'There is a new version {$a} available!'; $string['updateavailable_moreinfo'] = 'More info...'; $string['updateavailable_release'] = 'Release {$a}'; +$string['updatepluginconfirm'] = 'Plugin update confirmation'; +$string['updatepluginconfirminfo'] = 'You are about to install a new version of the plugin {$a->name}. A zip package with version {$a->version} of the plugin will be downloaded from {$a->url} and extracted to your Moodle installation so it can upgrade your installation.'; +$string['updatepluginconfirmwarning'] = 'Please note that Moodle will not automatically make a backup of your database before the upgrade. We strongly recommend that you make a full snapshot backup now, to cope with the rare case that the new code has bugs that make your site unavailable or even corrupts your database. Proceed at your own risk.'; $string['uninstall'] = 'Uninstall'; $string['version'] = 'Version'; $string['versiondb'] = 'Current version'; From 3daedb5c5a0adb4074bc5b6107f4759899145630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Wed, 10 Oct 2012 00:01:49 +0200 Subject: [PATCH 15/32] MDL-35238 Implement deployment authorization The caller of the mdeploy.php utility is expected to create a file in the data directory. The name of such file and the passphrase in it are then sent to mdeploy.php as a part of the request. The submitted and stored values are then compared. --- lib/pluginlib.php | 60 ++++++++++++++++++++++++++++++++++-- lib/tests/pluginlib_test.php | 24 ++++++++++++++- mdeploy.php | 29 ++++++++++++++--- 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/lib/pluginlib.php b/lib/pluginlib.php index d2a66c74362..46716ab1357 100644 --- a/lib/pluginlib.php +++ b/lib/pluginlib.php @@ -1589,6 +1589,8 @@ class available_update_deployer { throw new coding_exception('Unknown plugin type root location', $plugintype); } + list($passfile, $password) = $this->prepare_authorization(); + $params = array( 'upgrade' => true, 'type' => $plugintype, @@ -1597,8 +1599,8 @@ class available_update_deployer { 'download' => $info->download, 'dataroot' => $CFG->dataroot, 'dirroot' => $CFG->dirroot, - 'passfile' => '', // TODO - 'password' => '', // TODO + 'passfile' => $passfile, + 'password' => $password, ); $widget = new single_button( @@ -1698,6 +1700,42 @@ class available_update_deployer { } } + /** + * Generates a random token and stores it in a file in moodledata directory. + * + * @return array of the (string)filename and (string)password in this order + */ + public function prepare_authorization() { + global $CFG; + + make_upload_directory('mdeploy/auth/'); + + $attempts = 0; + $success = false; + + while (!$success and $attempts < 5) { + $attempts++; + + $passfile = $this->generate_passfile(); + $password = $this->generate_password(); + $now = time(); + + $filepath = $CFG->dataroot.'/mdeploy/auth/'.$passfile; + + if (!file_exists($filepath)) { + $success = file_put_contents($filepath, $password . PHP_EOL . $now . PHP_EOL, LOCK_EX); + } + } + + if ($success) { + return array($passfile, $password); + + } else { + throw new moodle_exception('unable_prepare_authorization', 'core_plugin'); + } + } + + // End of external API /** @@ -1753,6 +1791,24 @@ class available_update_deployer { return $data; } + + /** + * Returns a random string to be used as a filename of the password storage. + * + * @return string + */ + protected function generate_passfile() { + return clean_param(uniqid('mdeploy_', true), PARAM_FILE); + } + + /** + * Returns a random string to be used as the authorization token + * + * @return string + */ + protected function generate_password() { + return complex_random_string(); + } } diff --git a/lib/tests/pluginlib_test.php b/lib/tests/pluginlib_test.php index 62db9e44781..1552d9369c7 100644 --- a/lib/tests/pluginlib_test.php +++ b/lib/tests/pluginlib_test.php @@ -545,15 +545,37 @@ class testable_available_update_checker_cron_executed extends Exception { } +/** + * Modified {@link available_update_deployer} suitable for testing purposes + */ +class testable_available_update_deployer extends available_update_deployer { + +} + + /** * Test cases for {@link available_update_deployer} class */ class available_update_deployer_test extends advanced_testcase { public function test_magic_setters() { - $deployer = available_update_deployer::instance(); + $deployer = testable_available_update_deployer::instance(); $value = new moodle_url('/'); $deployer->set_returnurl($value); $this->assertSame($deployer->get_returnurl(), $value); } + + public function test_prepare_authorization() { + global $CFG; + + $deployer = testable_available_update_deployer::instance(); + list($passfile, $password) = $deployer->prepare_authorization(); + $filename = $CFG->phpunit_dataroot.'/mdeploy/auth/'.$passfile; + $this->assertFileExists($filename); + $stored = file($filename, FILE_IGNORE_NEW_LINES); + $this->assertEquals(count($stored), 2); + $this->assertGreaterThan(23, strlen($stored[0])); + $this->assertSame($stored[0], $password); + $this->assertTrue(time() - (int)$stored[1] < 60); + } } diff --git a/mdeploy.php b/mdeploy.php index 3b7c3f8b471..689f7267f9f 100644 --- a/mdeploy.php +++ b/mdeploy.php @@ -453,7 +453,7 @@ class input_http_provider extends input_provider { * @return array of raw values passed via HTTP request */ protected function parse_raw_options() { - return $_GET; // TODO switch to $_POST + return $_POST; } } @@ -629,15 +629,34 @@ class worker extends singleton_pattern { $passfile = $this->input->get_option('passfile'); $password = $this->input->get_option('password'); - $passpath = $dataroot.'/temp/mdeploy/'.$passfile; + $passpath = $dataroot.'/mdeploy/auth/'.$passfile; if (!is_readable($passpath)) { - throw new unauthorized_access_exception('Unable to read passphrase file.'); + throw new unauthorized_access_exception('Unable to read the passphrase file.'); } - $stored = file_get_contents($passpath); + $stored = file($passpath, FILE_IGNORE_NEW_LINES); - if ($password !== $stored) { + // "This message will self-destruct in five seconds." -- Mission Commander Swanbeck, Mission: Impossible II + unlink($passpath); + + if (is_readable($passpath)) { + throw new unauthorized_access_exception('Unable to remove the passphrase file.'); + } + + if (count($stored) < 2) { + throw new unauthorized_access_exception('Invalid format of the passphrase file.'); + } + + if (time() - (int)$stored[1] > 30 * 60) { + throw new unauthorized_access_exception('Passphrase timeout.'); + } + + if (strlen($stored[0]) < 24) { + throw new unauthorized_access_exception('Session passphrase not long enough.'); + } + + if ($password !== $stored[0]) { throw new unauthorized_access_exception('Session passphrase does not match the stored one.'); } } From af29dade97d98e2547ae66cf44927bc3d193d419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Wed, 10 Oct 2012 00:44:39 +0200 Subject: [PATCH 16/32] MDL-35238 Reorganise the worker::execute() flow --- mdeploy.php | 52 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/mdeploy.php b/mdeploy.php index 689f7267f9f..0b5fdaaf5b8 100644 --- a/mdeploy.php +++ b/mdeploy.php @@ -572,6 +572,10 @@ class output_http_provider extends output_provider { */ class worker extends singleton_pattern { + const EXIT_OK = 0; // Success exit code. + const EXIT_HELP = 1; // Explicit help required. + const EXIT_UNKNOWN_ACTION = 127; // Neither -i nor -u provided. + /** @var input_manager */ protected $input = null; @@ -583,21 +587,34 @@ class worker extends singleton_pattern { */ public function execute() { - if ($this->input->get_option('help')) { - $this->output->help(); - exit(1); - } - // Authorize access. None in CLI. Passphrase in HTTP. $this->authorize(); - // Fetch the ZIP file into a temporary location. + // Asking for help in the CLI mode. + if ($this->input->get_option('help')) { + $this->output->help(); + $this->done(self::EXIT_HELP); + } - // If the target location exists, backup it. + if ($this->input->get_option('upgrade')) { + // Fetch the ZIP file into a temporary location. - // Unzip the ZIP file into the target location. + // Compare MD5 checksum of the ZIP file. - // Redirect to the given URL (in HTTP) or exit (in CLI). + // If the target location exists, backup it. + + // Unzip the ZIP file into the target location. + + // Redirect to the given URL (in HTTP) or exit (in CLI). + $this->done(); + + } else if ($this->input->get_option('install')) { + // Installing a new plugin not implemented yet. + } + + // Print help in CLI by default. + $this->output->help(); + $this->done(self::EXIT_UNKNOWN_ACTION); } /** @@ -610,6 +627,23 @@ class worker extends singleton_pattern { // End of external API + /** + * Finish this script execution. + * + * @param int $exitcode + */ + protected function done($exitcode = self::EXIT_OK) { + + if (PHP_SAPI === 'cli') { + exit($exitcode); + + } else { + $returnurl = $this->input->get_option('returnurl'); + redirect($returnurl); + exit($exitcode); + } + } + /** * Authorize access to the script. * From 4c72f55516403fcdaa1329952b349b109c56c9ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Thu, 11 Oct 2012 07:29:44 +0200 Subject: [PATCH 17/32] MDL-35238 Fetch the package and store it in a temporary location --- lib/pluginlib.php | 2 +- mdeploy.php | 149 ++++++++++++++++++++++++++++++++++++++++++++-- mdeploytest.php | 8 +++ 3 files changed, 154 insertions(+), 5 deletions(-) diff --git a/lib/pluginlib.php b/lib/pluginlib.php index 46716ab1357..cc95e538f15 100644 --- a/lib/pluginlib.php +++ b/lib/pluginlib.php @@ -1596,7 +1596,7 @@ class available_update_deployer { 'type' => $plugintype, 'name' => $pluginname, 'typeroot' => $pluginrootpaths[$plugintype], - 'download' => $info->download, + 'package' => $info->download, 'dataroot' => $CFG->dataroot, 'dirroot' => $CFG->dirroot, 'passfile' => $passfile, diff --git a/mdeploy.php b/mdeploy.php index 0b5fdaaf5b8..77dd0688a4d 100644 --- a/mdeploy.php +++ b/mdeploy.php @@ -20,6 +20,11 @@ * * This script looks after deploying available updates to the local Moodle site. * + * CLI usage example: + * $ sudo -u apache php mdeploy.php --upgrade \ + * --package=https://moodle.org/plugins/download.php/...zip \ + * --dataroot=/home/mudrd8mz/moodledata/moodle24 + * * @package core * @copyright 2012 David Mudrak * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later @@ -34,7 +39,9 @@ if (defined('MOODLE_INTERNAL')) { class invalid_coding_exception extends Exception {} class missing_option_exception extends Exception {} +class invalid_option_exception extends Exception {} class unauthorized_access_exception extends Exception {} +class download_file_exception extends Exception {} // Various support classes ///////////////////////////////////////////////////// @@ -106,6 +113,7 @@ class input_manager extends singleton_pattern { const TYPE_INT = 'int'; // Integer const TYPE_PATH = 'path'; // Full path to a file or a directory const TYPE_RAW = 'raw'; // Raw value, keep as is + const TYPE_URL = 'url'; // URL to a file /** @var input_cli_provider|input_http_provider the provider of the input */ protected $inputprovider = null; @@ -155,12 +163,13 @@ class input_manager extends singleton_pattern { public function get_option_info($name=null) { $supportedoptions = array( + array('', 'passfile', input_manager::TYPE_FILE, 'File name of the passphrase file (HTTP access only)'), + array('', 'password', input_manager::TYPE_RAW, 'Session passphrase (HTTP access only)'), array('d', 'dataroot', input_manager::TYPE_PATH, 'Full path to the dataroot (moodledata) directory'), array('h', 'help', input_manager::TYPE_FLAG, 'Prints usage information'), array('i', 'install', input_manager::TYPE_FLAG, 'Installation mode'), + array('p', 'package', input_manager::TYPE_URL, 'URL to the ZIP package to deploy'), array('u', 'upgrade', input_manager::TYPE_FLAG, 'Upgrade mode'), - array('', 'passfile', input_manager::TYPE_FILE, 'File name of the passphrase file (HTTP access only)'), - array('', 'password', input_manager::TYPE_RAW, 'Session passphrase (HTTP access only)'), ); if (is_null($name)) { @@ -249,6 +258,20 @@ class input_manager extends singleton_pattern { case input_manager::TYPE_RAW: return $raw; + case input_manager::TYPE_URL: + $regex = '^(https?|ftp)\:\/\/'; // protocol + $regex .= '([a-z0-9+!*(),;?&=\$_.-]+(\:[a-z0-9+!*(),;?&=\$_.-]+)?@)?'; // optional user and password + $regex .= '[a-z0-9+\$_-]+(\.[a-z0-9+\$_-]+)*'; // hostname or IP (one word like http://localhost/ allowed) + $regex .= '(\:[0-9]{2,5})?'; // port (optional) + $regex .= '(\/([a-z0-9+\$_-]\.?)+)*\/?'; // path to the file + $regex .= '(\?[a-z+&\$_.-][a-z0-9;:@/&%=+\$_.-]*)?'; // HTTP params + + if (preg_match('#'.$regex.'#i', $raw)) { + return $raw; + } else { + return ''; + } + default: throw new invalid_coding_exception('Unknown option type.'); @@ -582,6 +605,15 @@ class worker extends singleton_pattern { /** @var output_manager */ protected $output = null; + /** @var int the most recent cURL error number, zero for no error */ + private $curlerrno = null; + + /** @var string the most recent cURL error message, empty string for no error */ + private $curlerror = null; + + /** @var array|false the most recent cURL request info, if it was successful */ + private $curlinfo = null; + /** * Main - the one that actually does something */ @@ -598,10 +630,22 @@ class worker extends singleton_pattern { if ($this->input->get_option('upgrade')) { // Fetch the ZIP file into a temporary location. + $source = $this->input->get_option('package'); + if (empty($source)) { + throw new invalid_option_exception('Not a valid package URL'); + } + $target = $this->target_location($source); - // Compare MD5 checksum of the ZIP file. + if ($this->download_file($source, $target)) { + $this->log('ZIP fetched into '.$target); + } else { + $this->log('cURL error ' . $this->curlerrno . ' ' . $this->curlerror); + $this->log('Unable to download the file'); + } - // If the target location exists, backup it. + // Compare MD5 checksum of the ZIP file - TODO + + // If the target location exists, backup it - TODO // Unzip the ZIP file into the target location. @@ -694,6 +738,103 @@ class worker extends singleton_pattern { throw new unauthorized_access_exception('Session passphrase does not match the stored one.'); } } + + /** + * Choose the target location for the given ZIP's URL. + * + * @param string $source URL + * @return string + */ + protected function target_location($source) { + + $dataroot = $this->input->get_option('dataroot'); + $pool = $dataroot.'/mdeploy/var'; + + if (!is_dir($pool)) { + mkdir($pool, 02777, true); + } + + $target = $pool.'/'.md5($source); + + $suffix = 0; + while (file_exists($target.'.'.$suffix.'.zip')) { + $suffix++; + } + + return $target.'.'.$suffix.'.zip'; + } + + /** + * Downloads the given file into the given destination. + * + * This is basically a simplified version of {@link download_file_content()} from + * Moodle itself, tuned for fetching files from moodle.org servers. + * + * @param string $source file url starting with http(s):// + * @param string $target store the downloaded content to this file (full path) + * @return bool true on success, false otherwise + * @throws download_file_exception + */ + protected function download_file($source, $target) { + + $newlines = array("\r", "\n"); + $source = str_replace($newlines, '', $source); + if (!preg_match('|^https?://|i', $source)) { + throw new download_file_exception('Unsupported transport protocol.'); + } + if (!$ch = curl_init($source)) { + // $this->log('Unable to init cURL.'); + return false; + } + + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); // verify the peer's certificate + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // check the existence of a common name and also verify that it matches the hostname provided + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // return the transfer as a string + curl_setopt($ch, CURLOPT_HEADER, false); // don't include the header in the output + curl_setopt($ch, CURLOPT_TIMEOUT, 3600); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 20); // nah, moodle.org is never unavailable! :-p + curl_setopt($ch, CURLOPT_URL, $source); + + $targetfile = fopen($target, 'w'); + + if (!$targetfile) { + throw new download_file_exception('Unable to create local file '.$target); + } + + curl_setopt($ch, CURLOPT_FILE, $targetfile); + + $result = curl_exec($ch); + + // try to detect encoding problems + if ((curl_errno($ch) == 23 or curl_errno($ch) == 61) and defined('CURLOPT_ENCODING')) { + curl_setopt($ch, CURLOPT_ENCODING, 'none'); + $result = curl_exec($ch); + } + + fclose($targetfile); + + $this->curlerrno = curl_errno($ch); + $this->curlerror = curl_error($ch); + $this->curlinfo = curl_getinfo($ch); + + if (!$result or $this->curlerrno) { + return false; + + } else if (is_array($this->curlinfo) and (empty($this->curlinfo['http_code']) or $this->curlinfo['http_code'] != 200)) { + return false; + } + + return true; + } + + /** + * Log a message + * + * @param string $message + */ + protected function log($message) { + // TODO + } } diff --git a/mdeploytest.php b/mdeploytest.php index e61654db1a6..f1835095809 100644 --- a/mdeploytest.php +++ b/mdeploytest.php @@ -127,6 +127,14 @@ class mdeploytest extends PHPUnit_Framework_TestCase { array("!@#$%|/etc/qwerty\n\n\t\n\r", input_manager::TYPE_RAW, "!@#$%|/etc/qwerty\n\n\t\n\r"), array("\nrock'n'roll.mp3\t.exe", input_manager::TYPE_FILE, 'rocknroll.mp3.exe'), + + array('http://localhost/moodle/dev/plugin.zip', input_manager::TYPE_URL, 'http://localhost/moodle/dev/plugin.zip'), + array( + 'https://moodle.org/plugins/download.php/1292/mod_stampcoll_moodle23_2012062201.zip', + input_manager::TYPE_URL, + 'https://moodle.org/plugins/download.php/1292/mod_stampcoll_moodle23_2012062201.zip' + ), + array('file:///etc/passwd', input_manager::TYPE_URL, ''), ); } From 4f71de4161db7df7da14cec3d95dd9bfe8475b9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Thu, 11 Oct 2012 09:36:12 +0200 Subject: [PATCH 18/32] MDL-35238 Backup existing plugin version before replacing it This is here to save eventual local contributions to the plugin before we replace the whole directory in the next step. --- mdeploy.php | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 152 insertions(+), 6 deletions(-) diff --git a/mdeploy.php b/mdeploy.php index 77dd0688a4d..8d8c535a7a6 100644 --- a/mdeploy.php +++ b/mdeploy.php @@ -42,6 +42,7 @@ class missing_option_exception extends Exception {} class invalid_option_exception extends Exception {} class unauthorized_access_exception extends Exception {} class download_file_exception extends Exception {} +class backup_folder_exception extends Exception{} // Various support classes ///////////////////////////////////////////////////// @@ -114,6 +115,7 @@ class input_manager extends singleton_pattern { const TYPE_PATH = 'path'; // Full path to a file or a directory const TYPE_RAW = 'raw'; // Raw value, keep as is const TYPE_URL = 'url'; // URL to a file + const TYPE_PLUGIN = 'plugin'; // Plugin name /** @var input_cli_provider|input_http_provider the provider of the input */ protected $inputprovider = null; @@ -165,10 +167,13 @@ class input_manager extends singleton_pattern { $supportedoptions = array( array('', 'passfile', input_manager::TYPE_FILE, 'File name of the passphrase file (HTTP access only)'), array('', 'password', input_manager::TYPE_RAW, 'Session passphrase (HTTP access only)'), + array('', 'returnurl', input_manager::TYPE_URL, 'Return URL (HTTP access only)'), array('d', 'dataroot', input_manager::TYPE_PATH, 'Full path to the dataroot (moodledata) directory'), array('h', 'help', input_manager::TYPE_FLAG, 'Prints usage information'), array('i', 'install', input_manager::TYPE_FLAG, 'Installation mode'), + array('n', 'name', input_manager::TYPE_PLUGIN, 'Plugin name (the name of its folder)'), array('p', 'package', input_manager::TYPE_URL, 'URL to the ZIP package to deploy'), + array('r', 'typeroot', input_manager::TYPE_PATH, 'Full path of the container for this plugin type'), array('u', 'upgrade', input_manager::TYPE_FLAG, 'Upgrade mode'), ); @@ -269,9 +274,18 @@ class input_manager extends singleton_pattern { if (preg_match('#'.$regex.'#i', $raw)) { return $raw; } else { - return ''; + throw new invalid_option_exception('Not a valid URL'); } + case input_manager::TYPE_PLUGIN: + if (!preg_match('/^[a-z][a-z0-9_]*[a-z0-9]$/', $raw)) { + throw new invalid_option_exception('Invalid plugin name'); + } + if (strpos($raw, '__') !== false) { + throw new invalid_option_exception('Invalid plugin name'); + } + return $raw; + default: throw new invalid_coding_exception('Unknown option type.'); @@ -631,9 +645,6 @@ class worker extends singleton_pattern { if ($this->input->get_option('upgrade')) { // Fetch the ZIP file into a temporary location. $source = $this->input->get_option('package'); - if (empty($source)) { - throw new invalid_option_exception('Not a valid package URL'); - } $target = $this->target_location($source); if ($this->download_file($source, $target)) { @@ -645,9 +656,26 @@ class worker extends singleton_pattern { // Compare MD5 checksum of the ZIP file - TODO - // If the target location exists, backup it - TODO + // Backup the current version of the plugin + $plugintyperoot = $this->input->get_option('typeroot'); + $pluginname = $this->input->get_option('name'); + $sourcelocation = $plugintyperoot.'/'.$pluginname; + $backuplocation = $this->backup_location($sourcelocation); - // Unzip the ZIP file into the target location. + // We don't want to touch files unless we are pretty sure it would be all ok. + if (!$this->move_directory_source_precheck($sourcelocation)) { + throw new backup_folder_exception('Unable to backup the current version of the plugin (source precheck failed)'); + } + if (!$this->move_directory_target_precheck($backuplocation)) { + throw new backup_folder_exception('Unable to backup the current version of the plugin (backup precheck failed)'); + } + + // Looking good, let's try it. + if (!$this->move_directory($sourcelocation, $backuplocation)) { + throw new backup_folder_exception('Unable to backup the current version of the plugin (moving failed)'); + } + + // Unzip the ZIP file into the target location - TODO // Redirect to the given URL (in HTTP) or exit (in CLI). $this->done(); @@ -764,6 +792,31 @@ class worker extends singleton_pattern { return $target.'.'.$suffix.'.zip'; } + /** + * Choose the location of the current plugin folder backup + * + * @param string $path full path to the current folder + * @return string + */ + protected function backup_location($path) { + + $dataroot = $this->input->get_option('dataroot'); + $pool = $dataroot.'/mdeploy/archive'; + + if (!is_dir($pool)) { + mkdir($pool, 02777, true); + } + + $target = $pool.'/'.basename($path).'_'.time(); + + $suffix = 0; + while (file_exists($target.'.'.$suffix)) { + $suffix++; + } + + return $target.'.'.$suffix; + } + /** * Downloads the given file into the given destination. * @@ -835,6 +888,99 @@ class worker extends singleton_pattern { protected function log($message) { // TODO } + + /** + * Checks to see if the given source could be safely moved into a new location + * + * @param string $source full path to the existing directory + * @return bool + */ + protected function move_directory_source_precheck($source) { + + if (is_dir($source)) { + $handle = opendir($source); + } else { + return false; + } + + $result = true; + + while ($filename = readdir($handle)) { + $sourcepath = $source.'/'.$filename; + + if ($filename === '.' or $filename === '..') { + continue; + } + + if (is_dir($sourcepath)) { + $result = $result && $this->move_directory_source_precheck($sourcepath); + + } else { + $result = $result && is_writable($sourcepath); + } + } + + closedir($handle); + return $result && is_writable($source); + } + + /** + * Checks to see if a source foldr could be safely moved into the given new location + * + * @param string $destination full path to the new expected location of a folder + * @return bool + */ + protected function move_directory_target_precheck($target) { + + if (file_exists($target)) { + return false; + } + + $result = mkdir($target, 02777) && rmdir($target); + + return $result; + } + + /** + * Moves the given source into a new location recursively + * + * @param string $source full path to the existing directory + * @param string $destination full path to the new location of the folder + * @return bool + */ + protected function move_directory($source, $target) { + + if (file_exists($target)) { + throw new backup_location('Unable to move the directory - target location already exists'); + } + + if (is_dir($source)) { + $handle = opendir($source); + } else { + throw new backup_location('Source location is not a directory'); + } + + mkdir($target, 02777); + + while ($filename = readdir($handle)) { + $sourcepath = $source.'/'.$filename; + $targetpath = $target.'/'.$filename; + + if ($filename === '.' or $filename === '..') { + continue; + } + + if (is_dir($sourcepath)) { + $this->move_directory($sourcepath, $targetpath); + + } else { + rename($sourcepath, $targetpath); + } + } + + closedir($handle); + return rmdir($source); + } } From 23137c4ac49517d203d0c50c3c913396ad52b284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Thu, 11 Oct 2012 12:37:51 +0200 Subject: [PATCH 19/32] MDL-35238 Unzip the downloaded package and redirect to the upgrade page --- lib/pluginlib.php | 3 ++ mdeploy.php | 99 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/lib/pluginlib.php b/lib/pluginlib.php index cc95e538f15..9198600b390 100644 --- a/lib/pluginlib.php +++ b/lib/pluginlib.php @@ -1591,6 +1591,8 @@ class available_update_deployer { list($passfile, $password) = $this->prepare_authorization(); + $upgradeurl = new moodle_url('/admin'); + $params = array( 'upgrade' => true, 'type' => $plugintype, @@ -1601,6 +1603,7 @@ class available_update_deployer { 'dirroot' => $CFG->dirroot, 'passfile' => $passfile, 'password' => $password, + 'returnurl' => $upgradeurl->out(true), ); $widget = new single_button( diff --git a/mdeploy.php b/mdeploy.php index 8d8c535a7a6..68a447f939a 100644 --- a/mdeploy.php +++ b/mdeploy.php @@ -42,7 +42,9 @@ class missing_option_exception extends Exception {} class invalid_option_exception extends Exception {} class unauthorized_access_exception extends Exception {} class download_file_exception extends Exception {} -class backup_folder_exception extends Exception{} +class backup_folder_exception extends Exception {} +class zip_exception extends Exception {} +class filesystem_exception extends Exception {} // Various support classes ///////////////////////////////////////////////////// @@ -675,7 +677,8 @@ class worker extends singleton_pattern { throw new backup_folder_exception('Unable to backup the current version of the plugin (moving failed)'); } - // Unzip the ZIP file into the target location - TODO + // Unzip the plugin package file into the target location. + $this->unzip_plugin($target, $plugintyperoot, $sourcelocation, $backuplocation); // Redirect to the given URL (in HTTP) or exit (in CLI). $this->done(); @@ -711,7 +714,7 @@ class worker extends singleton_pattern { } else { $returnurl = $this->input->get_option('returnurl'); - redirect($returnurl); + $this->redirect($returnurl); exit($exitcode); } } @@ -951,13 +954,13 @@ class worker extends singleton_pattern { protected function move_directory($source, $target) { if (file_exists($target)) { - throw new backup_location('Unable to move the directory - target location already exists'); + throw new filesystem_exception('Unable to move the directory - target location already exists'); } if (is_dir($source)) { $handle = opendir($source); } else { - throw new backup_location('Source location is not a directory'); + throw new filesystem_exception('Source location is not a directory'); } mkdir($target, 02777); @@ -981,6 +984,92 @@ class worker extends singleton_pattern { closedir($handle); return rmdir($source); } + + /** + * Deletes the given directory recursively + * + * @param string $path full path to the directory + */ + protected function remove_directory($path) { + + if (!file_exists($path)) { + return; + } + + if (is_dir($path)) { + $handle = opendir($path); + } else { + throw new filesystem_exception('Given path is not a directory'); + } + + while ($filename = readdir($handle)) { + $filepath = $path.'/'.$filename; + + if ($filename === '.' or $filename === '..') { + continue; + } + + if (is_dir($filepath)) { + $this->remove_directory($filepath); + + } else { + unlink($filepath); + } + } + + closedir($handle); + return rmdir($path); + } + + /** + * Unzip the file obtained from the Plugins directory to this site + * + * @param string $ziplocation full path to the ZIP file + * @param string $plugintyperoot full path to the plugin's type location + * @param string $expectedlocation expected full path to the plugin after it is extracted + * @param string $backuplocation location of the previous version of the plugin + */ + protected function unzip_plugin($ziplocation, $plugintyperoot, $expectedlocation, $backuplocation) { + + $zip = new ZipArchive(); + $result = $zip->open($ziplocation); + + if ($result !== true) { + $this->move_directory($backuplocation, $expectedlocation); + throw new zip_exception('Unable to open the zip package'); + } + + // Make sure that the ZIP has expected structure + $pluginname = basename($expectedlocation); + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + $filename = $stat['name']; + $filename = explode('/', $filename); + if ($filename[0] !== $pluginname) { + $zip->close(); + throw new zip_exception('Invalid structure of the zip package'); + } + } + + if (!$zip->extractTo($plugintyperoot)) { + $zip->close(); + $this->remove_directory($expectedlocation); // just in case something was created + $this->move_directory($backuplocation, $expectedlocation); + throw new zip_exception('Unable to extract the zip package'); + } + + $zip->close(); + } + + /** + * Redirect the browser + * + * @todo check if there has been some output yet + * @param string $url + */ + protected function redirect($url) { + header('Location: '.$url); + } } From 292dbeac9be88e857cd8f97d9b26ae303c62148b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Thu, 11 Oct 2012 12:39:11 +0200 Subject: [PATCH 20/32] MDL-35238 Support deployment from yet another plugins check page too --- admin/index.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/admin/index.php b/admin/index.php index ed33c006014..2f88126da1a 100644 --- a/admin/index.php +++ b/admin/index.php @@ -317,6 +317,17 @@ if (moodle_needs_upgrading()) { $output = $PAGE->get_renderer('core', 'admin'); + $deployer = available_update_deployer::instance(); + if ($deployer->enabled()) { + $deployer->initialize($PAGE->url, $PAGE->url); + + $deploydata = $deployer->submitted_data(); + if (!empty($deploydata)) { + echo $output->upgrade_plugin_confirm_deploy_page($deployer, $deploydata); + die(); + } + } + // check plugin dependencies first $failed = array(); if (!plugin_manager::instance()->all_plugins_ok($version, $failed)) { From 08c3bc006d6e9486547e96ad194bb6ad6ffad885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Fri, 12 Oct 2012 03:51:47 +0200 Subject: [PATCH 21/32] MDL-35238 Warn the admin if they are about to overwrite a SCM checkout --- admin/renderer.php | 12 +++++++----- lang/en/plugin.php | 1 + lib/pluginlib.php | 27 +++++++++++++++++++++++++++ theme/base/style/admin.css | 5 +++++ 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/admin/renderer.php b/admin/renderer.php index 5509c53068b..581cdd112c9 100644 --- a/admin/renderer.php +++ b/admin/renderer.php @@ -255,7 +255,7 @@ class core_admin_renderer extends plugin_renderer_base { $output = ''; $output .= $this->header(); - $output .= $this->container_start('generalbox', 'notice'); + $output .= $this->container_start('generalbox updateplugin', 'notice'); $a = new stdClass(); if (get_string_manager()->string_exists('pluginname', $updateinfo->component)) { @@ -272,10 +272,12 @@ class core_admin_renderer extends plugin_renderer_base { $a->url = $updateinfo->download; $output .= $this->output->heading(get_string('updatepluginconfirm', 'core_plugin')); - $output .= $this->output->container(format_text( - get_string('updatepluginconfirminfo', 'core_plugin', $a) . PHP_EOL . PHP_EOL . - get_string('updatepluginconfirmwarning', 'core_plugin') - )); + $output .= $this->output->container(format_text(get_string('updatepluginconfirminfo', 'core_plugin', $a)), 'updatepluginconfirminfo'); + $output .= $this->output->container(get_string('updatepluginconfirmwarning', 'core_plugin', 'updatepluginconfirmwarning')); + + if ($repotype = $deployer->plugin_external_source($data['updateinfo'])) { + $output .= $this->output->container(get_string('updatepluginconfirmexternal', 'core_plugin', $repotype), 'updatepluginconfirmexternal'); + } $widget = $deployer->make_execution_widget($data['updateinfo']); $output .= $this->output->render($widget); diff --git a/lang/en/plugin.php b/lang/en/plugin.php index f9670a05042..8b692959861 100644 --- a/lang/en/plugin.php +++ b/lang/en/plugin.php @@ -133,6 +133,7 @@ $string['updateavailable_moreinfo'] = 'More info...'; $string['updateavailable_release'] = 'Release {$a}'; $string['updatepluginconfirm'] = 'Plugin update confirmation'; $string['updatepluginconfirminfo'] = 'You are about to install a new version of the plugin {$a->name}. A zip package with version {$a->version} of the plugin will be downloaded from {$a->url} and extracted to your Moodle installation so it can upgrade your installation.'; +$string['updatepluginconfirmexternal'] = 'It appears that the current version of the plugin has been obtained via source code management system ({$a}) checkout. If you install this update, you will no longer be able to obtain plugin updates from the source code management system. Please ensure that you definitely want to update the plugin before continuing.'; $string['updatepluginconfirmwarning'] = 'Please note that Moodle will not automatically make a backup of your database before the upgrade. We strongly recommend that you make a full snapshot backup now, to cope with the rare case that the new code has bugs that make your site unavailable or even corrupts your database. Proceed at your own risk.'; $string['uninstall'] = 'Uninstall'; $string['version'] = 'Version'; diff --git a/lib/pluginlib.php b/lib/pluginlib.php index 9198600b390..e0c51365b03 100644 --- a/lib/pluginlib.php +++ b/lib/pluginlib.php @@ -1543,6 +1543,33 @@ class available_update_deployer { return true; } + /** + * Check to see if the current version of the plugin seems to be a checkout of an external repository. + * + * @param available_update_info $info + * @return false|string + */ + public function plugin_external_source(available_update_info $info) { + + $paths = get_plugin_types(true); + list($plugintype, $pluginname) = normalize_component($info->component); + $pluginroot = $paths[$plugintype].'/'.$pluginname; + + if (is_dir($pluginroot.'/.git')) { + return 'git'; + } + + if (is_dir($pluginroot.'/CVS')) { + return 'cvs'; + } + + if (is_dir($pluginroot.'/.svn')) { + return 'svn'; + } + + return false; + } + /** * Prepares a renderable widget to confirm installation of an available update. * diff --git a/theme/base/style/admin.css b/theme/base/style/admin.css index 92539805609..3abdbc3b10b 100644 --- a/theme/base/style/admin.css +++ b/theme/base/style/admin.css @@ -102,6 +102,11 @@ #page-admin-index .adminwarning.availableupdatesinfo .moodleupdateinfo a {padding-right:1em;} #page-admin-index .adminwarning.availableupdatesinfo .moodleupdateinfo .separator {border-left:1px dotted #333;} +#page-admin-index .updateplugin div, +#page-admin-plugins .updateplugin div {margin-bottom:0.5em;} +#page-admin-index .updateplugin .updatepluginconfirmexternal, +#page-admin-plugins .updateplugin .updatepluginconfirmexternal {padding:1em;background-color:#ffd3d9;border:1px solid #EEAAAA} + #page-admin-user-user_bulk #users .fgroup {white-space: nowrap;} #page-admin-report-stats-index .graph {text-align: center;margin-bottom: 1em;} #page-admin-report-courseoverview-index .graph {text-align: center;margin-bottom: 1em;} From 85d751631328277b40ce88f4ed9cc67bb4494e96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Wed, 7 Nov 2012 09:50:59 +0100 Subject: [PATCH 22/32] MDL-35238 Fetch available updates using the 1.1 version of the API If the stored response has still 1.0 version (which is expected during the upgrade to 2.4), a debugging message is displayed. Added a string to explain what's going on and how to recover from the state. --- lang/en/plugin.php | 1 + lib/pluginlib.php | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lang/en/plugin.php b/lang/en/plugin.php index 8b692959861..6812824c87f 100644 --- a/lang/en/plugin.php +++ b/lang/en/plugin.php @@ -30,6 +30,7 @@ $string['availability'] = 'Availability'; $string['checkforupdates'] = 'Check for available updates'; $string['checkforupdateslast'] = 'Last check done on {$a}'; $string['displayname'] = 'Plugin name'; +$string['err_response_format_version'] = 'Unexpected version of the response format. Please try to re-check for available updates.'; $string['filterall'] = 'Show all'; $string['filtercontribonly'] = 'Show contributions only'; $string['filtercontribonlyactive'] = 'Showing contributions only'; diff --git a/lib/pluginlib.php b/lib/pluginlib.php index e0c51365b03..e3fd1f3448b 100644 --- a/lib/pluginlib.php +++ b/lib/pluginlib.php @@ -852,7 +852,7 @@ class available_update_checker { throw new available_update_checker_exception('err_response_status', $response['status']); } - if (empty($response['apiver']) or $response['apiver'] !== '1.0') { + if (empty($response['apiver']) or $response['apiver'] !== '1.1') { throw new available_update_checker_exception('err_response_format_version', $response['apiver']); } @@ -990,7 +990,7 @@ class available_update_checker { if (!empty($CFG->alternativeupdateproviderurl)) { return $CFG->alternativeupdateproviderurl; } else { - return 'http://download.moodle.org/api/1.0/updates.php'; + return 'http://download.moodle.org/api/1.1/updates.php'; } } From 6b75106a75f03b797531275d31390e02303bb4d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Wed, 7 Nov 2012 16:29:07 +0100 Subject: [PATCH 23/32] MDL-35238 Compare the ZIP package content hash with the expected value The expected value is returned as a part of available update info (requires API version 1.1). --- lib/pluginlib.php | 9 ++++++++- mdeploy.php | 19 ++++++++++++++++++- mdeploytest.php | 11 +++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/lib/pluginlib.php b/lib/pluginlib.php index e3fd1f3448b..4d69ffe60ae 100644 --- a/lib/pluginlib.php +++ b/lib/pluginlib.php @@ -1407,6 +1407,8 @@ class available_update_info { public $url = null; /** @var string|null optional URL of a ZIP package that can be downloaded and installed */ public $download = null; + /** @var string|null of self::download is set, then this must be the MD5 hash of the ZIP */ + public $downloadmd5 = null; /** * Creates new instance of the class @@ -1529,7 +1531,7 @@ class available_update_deployer { * * All instances of {@link available_update_info} class always provide at least the * component name and component version. Additionally, we also need the URL to download - * the ZIP package from. + * the ZIP package from and MD5 hash of the ZIP's content. * * @param available_update_info $info * @return bool @@ -1540,6 +1542,10 @@ class available_update_deployer { return false; } + if (empty($info->downloadmd5)) { + return false; + } + return true; } @@ -1626,6 +1632,7 @@ class available_update_deployer { 'name' => $pluginname, 'typeroot' => $pluginrootpaths[$plugintype], 'package' => $info->download, + 'md5' => $info->downloadmd5, 'dataroot' => $CFG->dataroot, 'dirroot' => $CFG->dirroot, 'passfile' => $passfile, diff --git a/mdeploy.php b/mdeploy.php index 68a447f939a..69b7b06d215 100644 --- a/mdeploy.php +++ b/mdeploy.php @@ -45,6 +45,7 @@ class download_file_exception extends Exception {} class backup_folder_exception extends Exception {} class zip_exception extends Exception {} class filesystem_exception extends Exception {} +class checksum_exception extends Exception {} // Various support classes ///////////////////////////////////////////////////// @@ -118,6 +119,7 @@ class input_manager extends singleton_pattern { const TYPE_RAW = 'raw'; // Raw value, keep as is const TYPE_URL = 'url'; // URL to a file const TYPE_PLUGIN = 'plugin'; // Plugin name + const TYPE_MD5 = 'md5'; // MD5 hash /** @var input_cli_provider|input_http_provider the provider of the input */ protected $inputprovider = null; @@ -173,6 +175,7 @@ class input_manager extends singleton_pattern { array('d', 'dataroot', input_manager::TYPE_PATH, 'Full path to the dataroot (moodledata) directory'), array('h', 'help', input_manager::TYPE_FLAG, 'Prints usage information'), array('i', 'install', input_manager::TYPE_FLAG, 'Installation mode'), + array('m', 'md5', input_manager::TYPE_MD5, 'Expected MD5 hash of the ZIP package to deploy'), array('n', 'name', input_manager::TYPE_PLUGIN, 'Plugin name (the name of its folder)'), array('p', 'package', input_manager::TYPE_URL, 'URL to the ZIP package to deploy'), array('r', 'typeroot', input_manager::TYPE_PATH, 'Full path of the container for this plugin type'), @@ -288,6 +291,12 @@ class input_manager extends singleton_pattern { } return $raw; + case input_manager::TYPE_MD5: + if (!preg_match('/^[a-f0-9]{32}$/', $raw)) { + throw new invalid_option_exception('Invalid MD5 hash format'); + } + return $raw; + default: throw new invalid_coding_exception('Unknown option type.'); @@ -654,9 +663,17 @@ class worker extends singleton_pattern { } else { $this->log('cURL error ' . $this->curlerrno . ' ' . $this->curlerror); $this->log('Unable to download the file'); + throw new download_file_exception('Unable to download the ZIP package'); } - // Compare MD5 checksum of the ZIP file - TODO + // Compare MD5 checksum of the ZIP file + $md5remote = $this->input->get_option('md5'); + $md5local = md5_file($target); + + if ($md5local !== $md5remote) { + $this->log('MD5 checksum failed. Expected: '.$md5remote.' Got: '.$md5local); + throw new checksum_exception('MD5 checksum failed'); + } // Backup the current version of the plugin $plugintyperoot = $this->input->get_option('typeroot'); diff --git a/mdeploytest.php b/mdeploytest.php index f1835095809..1cbfb91abca 100644 --- a/mdeploytest.php +++ b/mdeploytest.php @@ -135,6 +135,8 @@ class mdeploytest extends PHPUnit_Framework_TestCase { 'https://moodle.org/plugins/download.php/1292/mod_stampcoll_moodle23_2012062201.zip' ), array('file:///etc/passwd', input_manager::TYPE_URL, ''), + + array('5e8d2ea4f50d154730100b1645fbad67', input_manager::TYPE_MD5, '5e8d2ea4f50d154730100b1645fbad67'), ); } @@ -155,6 +157,15 @@ class mdeploytest extends PHPUnit_Framework_TestCase { $input->cast_value($o, input_manager::TYPE_INT); // must throw exception } + /** + * @expectedException invalid_option_exception + */ + public function test_cast_invalid_md5_value() { + $input = testable_input_manager::instance(); + $invalid = 'this is not a valid md5 hash'; + $input->cast_value($invalid, input_manager::TYPE_MD5); // must throw exception + } + public function test_has_option() { $provider = input_fake_provider::instance(); From b10b1e728d8f95f565f2fa7fb126dc744baf46db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Wed, 7 Nov 2012 16:30:57 +0100 Subject: [PATCH 24/32] MDL-35238 Remove the ZIP file after successful decompression --- mdeploy.php | 1 + 1 file changed, 1 insertion(+) diff --git a/mdeploy.php b/mdeploy.php index 69b7b06d215..d8ffb9c2ab4 100644 --- a/mdeploy.php +++ b/mdeploy.php @@ -1076,6 +1076,7 @@ class worker extends singleton_pattern { } $zip->close(); + unlink($ziplocation); } /** From 5bd9b0ae623f408f7bc33d096c11871641616fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Wed, 7 Nov 2012 16:33:44 +0100 Subject: [PATCH 25/32] MDL-35238 Fix the unit test for invalid input_manager::TYPE_URL values --- mdeploytest.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/mdeploytest.php b/mdeploytest.php index 1cbfb91abca..eb3332f1adb 100644 --- a/mdeploytest.php +++ b/mdeploytest.php @@ -134,7 +134,6 @@ class mdeploytest extends PHPUnit_Framework_TestCase { input_manager::TYPE_URL, 'https://moodle.org/plugins/download.php/1292/mod_stampcoll_moodle23_2012062201.zip' ), - array('file:///etc/passwd', input_manager::TYPE_URL, ''), array('5e8d2ea4f50d154730100b1645fbad67', input_manager::TYPE_MD5, '5e8d2ea4f50d154730100b1645fbad67'), ); @@ -157,6 +156,15 @@ class mdeploytest extends PHPUnit_Framework_TestCase { $input->cast_value($o, input_manager::TYPE_INT); // must throw exception } + /** + * @expectedException invalid_option_exception + */ + public function test_cast_invalid_url_value() { + $input = testable_input_manager::instance(); + $invalid = 'file:///etc/passwd'; + $input->cast_value($invalid, input_manager::TYPE_URL); // must throw exception + } + /** * @expectedException invalid_option_exception */ From ec8e1cbce67d5976efae1cdf2de8f99848be6811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Wed, 7 Nov 2012 21:12:01 +0100 Subject: [PATCH 26/32] MDL-35238 Add simple logging of mdeploy.php execution --- mdeploy.php | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/mdeploy.php b/mdeploy.php index d8ffb9c2ab4..a724cb6fbdb 100644 --- a/mdeploy.php +++ b/mdeploy.php @@ -639,11 +639,16 @@ class worker extends singleton_pattern { /** @var array|false the most recent cURL request info, if it was successful */ private $curlinfo = null; + /** @var string the full path to the log file */ + private $logfile = null; + /** * Main - the one that actually does something */ public function execute() { + $this->log('=== MDEPLOY EXECUTION START ==='); + // Authorize access. None in CLI. Passphrase in HTTP. $this->authorize(); @@ -654,16 +659,19 @@ class worker extends singleton_pattern { } if ($this->input->get_option('upgrade')) { + $this->log('Plugin upgrade requested'); + // Fetch the ZIP file into a temporary location. $source = $this->input->get_option('package'); $target = $this->target_location($source); + $this->log('Downloading package '.$source); if ($this->download_file($source, $target)) { - $this->log('ZIP fetched into '.$target); + $this->log('Package downloaded into '.$target); } else { $this->log('cURL error ' . $this->curlerrno . ' ' . $this->curlerror); $this->log('Unable to download the file'); - throw new download_file_exception('Unable to download the ZIP package'); + throw new download_file_exception('Unable to download the package'); } // Compare MD5 checksum of the ZIP file @@ -674,6 +682,7 @@ class worker extends singleton_pattern { $this->log('MD5 checksum failed. Expected: '.$md5remote.' Got: '.$md5local); throw new checksum_exception('MD5 checksum failed'); } + $this->log('MD5 checksum ok'); // Backup the current version of the plugin $plugintyperoot = $this->input->get_option('typeroot'); @@ -681,6 +690,9 @@ class worker extends singleton_pattern { $sourcelocation = $plugintyperoot.'/'.$pluginname; $backuplocation = $this->backup_location($sourcelocation); + $this->log('Current plugin code location: '.$sourcelocation); + $this->log('Moving the current code into archive: '.$backuplocation); + // We don't want to touch files unless we are pretty sure it would be all ok. if (!$this->move_directory_source_precheck($sourcelocation)) { throw new backup_folder_exception('Unable to backup the current version of the plugin (source precheck failed)'); @@ -696,6 +708,7 @@ class worker extends singleton_pattern { // Unzip the plugin package file into the target location. $this->unzip_plugin($target, $plugintyperoot, $sourcelocation, $backuplocation); + $this->log('Package successfully extracted'); // Redirect to the given URL (in HTTP) or exit (in CLI). $this->done(); @@ -748,6 +761,7 @@ class worker extends singleton_pattern { protected function authorize() { if (PHP_SAPI === 'cli') { + $this->log('Successfully authorized using the CLI SAPI'); return; } @@ -785,6 +799,36 @@ class worker extends singleton_pattern { if ($password !== $stored[0]) { throw new unauthorized_access_exception('Session passphrase does not match the stored one.'); } + + $this->log('Successfully authorized using the passphrase file'); + } + + /** + * Returns the full path to the log file. + * + * @return string + */ + protected function log_location() { + + if (!is_null($this->logfile)) { + return $this->logfile; + } + + $dataroot = $this->input->get_option('dataroot', false); + + if ($dataroot === false) { + $this->logfile = false; + return $this->logfile; + } + + $myroot = $dataroot.'/mdeploy'; + + if (!is_dir($myroot)) { + mkdir($myroot, 02777, true); + } + + $this->logfile = $myroot.'/mdeploy.log'; + return $this->logfile; } /** @@ -856,7 +900,7 @@ class worker extends singleton_pattern { throw new download_file_exception('Unsupported transport protocol.'); } if (!$ch = curl_init($source)) { - // $this->log('Unable to init cURL.'); + $this->log('Unable to init cURL.'); return false; } @@ -906,7 +950,39 @@ class worker extends singleton_pattern { * @param string $message */ protected function log($message) { - // TODO + + $logpath = $this->log_location(); + + if (empty($logpath)) { + // no logging available + return; + } + + $f = fopen($logpath, 'ab'); + + if ($f === false) { + throw new filesystem_exception('Unable to open the log file for appending'); + } + + $message = $this->format_log_message($message); + + fwrite($f, $message); + + fclose($f); + } + + /** + * Prepares the log message for writing into the file + * + * @param string $msg + * @return string + */ + protected function format_log_message($msg) { + + $msg = trim($msg); + $timestamp = date("Y-m-d H:i:s"); + + return $timestamp . ' '. $msg . PHP_EOL; } /** From 8ffa8d7e480f8ae0f1aec282feea5a20aa2df4b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Wed, 7 Nov 2012 21:54:22 +0100 Subject: [PATCH 27/32] MDL-35238 Be more verbose if the tilde character is used in TYPE_PATH script options The tilde character is not generally supported in Moodle. Previously it was just removed silently and it was difficult to realize what was going on. --- mdeploy.php | 3 +++ mdeploytest.php | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/mdeploy.php b/mdeploy.php index a724cb6fbdb..27f50262fa0 100644 --- a/mdeploy.php +++ b/mdeploy.php @@ -258,6 +258,9 @@ class input_manager extends singleton_pattern { return (int)$raw; case input_manager::TYPE_PATH: + if (strpos($raw, '~') !== false) { + throw new invalid_option_exception('Using the tilde (~) character in paths is not supported'); + } $raw = str_replace('\\', '/', $raw); $raw = preg_replace('~[[:cntrl:]]|[&<>"`\|\':]~u', '', $raw); $raw = preg_replace('~\.\.+~', '', $raw); diff --git a/mdeploytest.php b/mdeploytest.php index eb3332f1adb..471a9d33eae 100644 --- a/mdeploytest.php +++ b/mdeploytest.php @@ -174,6 +174,15 @@ class mdeploytest extends PHPUnit_Framework_TestCase { $input->cast_value($invalid, input_manager::TYPE_MD5); // must throw exception } + /** + * @expectedException invalid_option_exception + */ + public function test_cast_tilde_in_path() { + $input = testable_input_manager::instance(); + $invalid = '~/public_html/moodle_dev'; + $input->cast_value($invalid, input_manager::TYPE_PATH); // must throw exception + } + public function test_has_option() { $provider = input_fake_provider::instance(); From 80e9ba96c744f3825637d2f2e9d857a5f24fad2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Wed, 7 Nov 2012 23:20:54 +0100 Subject: [PATCH 28/32] MDL-35238 Improve exceptions handling --- mdeploy.php | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 2 deletions(-) diff --git a/mdeploy.php b/mdeploy.php index 27f50262fa0..8e5bfec9da2 100644 --- a/mdeploy.php +++ b/mdeploy.php @@ -611,6 +611,49 @@ class output_http_provider extends output_provider { public function help() { // No help available via HTTP } + + /** + * Display the information about uncaught exception + * + * @param Exception $e uncaught exception + */ + public function exception(Exception $e) { + $this->start_output(); + echo('

Oops! It did it again

'); + echo('

Moodle deployment utility had a trouble with your request. See the debugging information for more details.

'); + echo('
');
+        echo exception_handlers::format_exception_info($e);
+        echo('
'); + $this->end_output(); + } + + // End of external API + + /** + * Produce the HTML page header + */ + protected function start_output() { + echo ' + + + + + + +
'; + } + + /** + * Produce the HTML page footer + */ + protected function end_output() { + echo '
'; + } } // The main class providing all the functionality ////////////////////////////// @@ -725,6 +768,15 @@ class worker extends singleton_pattern { $this->done(self::EXIT_UNKNOWN_ACTION); } + /** + * Attempts to log a thrown exception + * + * @param Exception $e uncaught exception + */ + public function log_exception(Exception $e) { + $this->log($e->__toString()); + } + /** * Initialize the worker class. */ @@ -817,9 +869,9 @@ class worker extends singleton_pattern { return $this->logfile; } - $dataroot = $this->input->get_option('dataroot', false); + $dataroot = $this->input->get_option('dataroot', ''); - if ($dataroot === false) { + if (empty($dataroot)) { $this->logfile = false; return $this->logfile; } @@ -1170,6 +1222,77 @@ class worker extends singleton_pattern { } +/** + * Provides exception handlers for this script + */ +class exception_handlers { + + /** + * Sets the exception handler + * + * + * @param string $handler name + */ + public static function set_handler($handler) { + + if (PHP_SAPI === 'cli') { + // No custom handler available for CLI mode. + set_exception_handler(null); + return; + } + + set_exception_handler('exception_handlers::'.$handler.'_exception_handler'); + } + + /** + * Returns the text describing the thrown exception + * + * By default, PHP displays full path to scripts when the exception is thrown. In order to prevent + * sensitive information leak (and yes, the path to scripts at a web server _is_ sensitive information) + * the path to scripts is removed from the message. + * + * @param Exception $e thrown exception + * @return string + */ + public static function format_exception_info(Exception $e) { + + $mydir = dirname(__FILE__).'/'; + $text = $e->__toString(); + $text = str_replace($mydir, '', $text); + return $text; + } + + /** + * Very basic exception handler + * + * @param Exception $e uncaught exception + */ + public static function bootstrap_exception_handler(Exception $e) { + echo('

Oops! It did it again

'); + echo('

Moodle deployment utility had a trouble with your request. See the debugging information for more details.

'); + echo('
');
+        echo self::format_exception_info($e);
+        echo('
'); + } + + /** + * Default exception handler + * + * When this handler is used, input_manager and output_manager singleton instances already + * exist in the memory and can be used. + * + * @param Exception $e uncaught exception + */ + public static function default_exception_handler(Exception $e) { + + $worker = worker::instance(); + $worker->log_exception($e); + + $output = output_manager::instance(); + $output->exception($e); + } +} + //////////////////////////////////////////////////////////////////////////////// // Check if the script is actually executed or if it was just included by someone @@ -1177,8 +1300,10 @@ class worker extends singleton_pattern { // if __name__ == '__main__' if (!debug_backtrace()) { // We are executed by the SAPI. + exception_handlers::set_handler('bootstrap'); // Initialize the worker class to actually make the job. $worker = worker::instance(); + exception_handlers::set_handler('default'); // Lights, Camera, Action! $worker->execute(); From dc11af1903832f2852c7fc6f1f47b037d0163a2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Wed, 7 Nov 2012 23:50:15 +0100 Subject: [PATCH 29/32] MDL-35238 Add support for explicit singleton reset This may be needed during PHPUnit testing. --- lib/phpunit/classes/util.php | 3 +++ lib/pluginlib.php | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/lib/phpunit/classes/util.php b/lib/phpunit/classes/util.php index 1fa42d4a46c..4b10ce061f0 100644 --- a/lib/phpunit/classes/util.php +++ b/lib/phpunit/classes/util.php @@ -655,6 +655,9 @@ class phpunit_util { if (class_exists('available_update_checker')) { available_update_checker::reset_caches(true); } + if (class_exists('available_update_deployer')) { + available_update_deployer::reset_caches(true); + } // purge dataroot directory self::reset_dataroot(); diff --git a/lib/pluginlib.php b/lib/pluginlib.php index 4d69ffe60ae..e3878476635 100644 --- a/lib/pluginlib.php +++ b/lib/pluginlib.php @@ -1469,6 +1469,17 @@ class available_update_deployer { return self::$singletoninstance; } + /** + * Reset caches used by this script + * + * @param bool $phpunitreset is this called as a part of PHPUnit reset? + */ + public static function reset_caches($phpunitreset = false) { + if ($phpunitreset) { + self::$singletoninstance = null; + } + } + /** * Is automatic deployment enabled? * From 56c05088e593e1ed6ed8ce849ee1f88e7a5c258e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Thu, 8 Nov 2012 11:09:03 +0100 Subject: [PATCH 30/32] MDL-35238 Accept $CFG->alternativeupdateproviderurl from config.php file only There was a potential security risk that someone with access to the Moodle database could update mdl_config table and use it as a vector to install malicious code on the server. Credit goes to Dan Poltawski for raising this. --- lib/pluginlib.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pluginlib.php b/lib/pluginlib.php index e3878476635..bd7f6d47c44 100644 --- a/lib/pluginlib.php +++ b/lib/pluginlib.php @@ -987,8 +987,8 @@ class available_update_checker { protected function prepare_request_url() { global $CFG; - if (!empty($CFG->alternativeupdateproviderurl)) { - return $CFG->alternativeupdateproviderurl; + if (!empty($CFG->config_php_settings['alternativeupdateproviderurl'])) { + return $CFG->config_php_settings['alternativeupdateproviderurl']; } else { return 'http://download.moodle.org/api/1.1/updates.php'; } From d80f80f386473f6586a51da57823174b05b8ba58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Thu, 8 Nov 2012 22:43:37 +0100 Subject: [PATCH 31/32] MDL-35238 Do not check for write permissions when enabling the auto-deploy feature It seems to be better to check for required permission later, when the button to install the update is actually being displayed. Credit goes to Dan Poltawski for suggesting this. --- admin/settings/server.php | 2 +- lang/en/admin.php | 1 - lib/adminlib.php | 74 --------------------------------------- 3 files changed, 1 insertion(+), 76 deletions(-) diff --git a/admin/settings/server.php b/admin/settings/server.php index 0fd6a74266d..4e773cd704c 100644 --- a/admin/settings/server.php +++ b/admin/settings/server.php @@ -229,7 +229,7 @@ if (empty($CFG->disableupdatenotifications)) { $temp->add(new admin_setting_configcheckbox('updateautocheck', new lang_string('updateautocheck', 'core_admin'), new lang_string('updateautocheck_desc', 'core_admin'), 1)); if (empty($CFG->disableupdateautodeploy)) { - $temp->add(new admin_setting_updateautodeploy('updateautodeploy', new lang_string('updateautodeploy', 'core_admin'), + $temp->add(new admin_setting_configcheckbox('updateautodeploy', new lang_string('updateautodeploy', 'core_admin'), new lang_string('updateautodeploy_desc', 'core_admin'), 0)); } $temp->add(new admin_setting_configselect('updateminmaturity', new lang_string('updateminmaturity', 'core_admin'), diff --git a/lang/en/admin.php b/lang/en/admin.php index ac15bbc1a96..208f556deab 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -1014,7 +1014,6 @@ $string['updateautocheck'] = 'Automatically check for available updates'; $string['updateautocheck_desc'] = 'If enabled, your site will automatically check for available updates for both Moodle code and all additional plugins. If there is a new update available, a notification will be sent to site admins.'; $string['updateautodeploy'] = 'Enable updates deployment'; $string['updateautodeploy_desc'] = 'If enabled, you will be able to download and install available updates directly from Moodle administration pages. Note that your web server process has to have write access into folders with Moodle installation to make this work. That can be seen as a potential security risk.'; -$string['updateautodeploy_unablewriteplugins'] = 'Can\'t enable the feature - unable to write into plugin locations!'; $string['updateminmaturity'] = 'Required code maturity'; $string['updateminmaturity_desc'] = 'Notify about available updates only if the available code has the selected maturity level at least. Updates for plugins that do not declare their code maturity level are always reported regardless this setting.'; $string['updatenotifybuilds'] = 'Notify about new builds'; diff --git a/lib/adminlib.php b/lib/adminlib.php index ede57ab3206..3813b4d46dd 100644 --- a/lib/adminlib.php +++ b/lib/adminlib.php @@ -8052,77 +8052,3 @@ class admin_setting_configmultiselect_modules extends admin_setting_configmultis return true; } } - - -/** - * Checkbox for the updateautodeploy setting - * - * This class implements the extra check to make sure that the web server - * process user has write access to the $CFG->dirroot. - * - * @copyright 2012 David Mudrak - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class admin_setting_updateautodeploy extends admin_setting_configcheckbox { - - /** - * Sets the value for the setting - * - * When the feature is just about to be enabled, proceed some extra checks - * prior to setting the value. - * - * @param string $data the checkbox value - * @return string empty string or error - */ - public function write_setting($data) { - - // Are we just going to activate the feature? - $currentlyenabled = $this->config_read('updateautodeploy'); - if ((string)$data === $this->yes and empty($currentlyenabled)) { - if (!$this->plugin_locations_writeable()) { - return get_string('updateautodeploy_unablewriteplugins', 'core_admin'); - } - // TODO MDL-35239 check if the whole dirroot is writeable - } - - // Let the parent class actually save the value. - return parent::write_setting($data); - } - - /** - * Check if it is possible to write into plugin locations - * - * @return bool - */ - private function plugin_locations_writeable() { - global $CFG; - require_once($CFG->libdir.'/pluginlib.php'); - - // Check that the web server process is able to deploy new plugins of all types - $plugintypes = get_plugin_types(true); - foreach ($plugintypes as $plugintype => $plugintyperoot) { - if (!is_writeable($plugintyperoot)) { - debugging('Plugin type location not writeable: '.$plugintyperoot, DEBUG_ALL); - return false; - } - } - - // Check that the web server process is able to modify contributed plugins - $pluginman = plugin_manager::instance(); - $plugininfo = $pluginman->get_plugins(); - foreach ($plugininfo as $plugintype => $plugininstances) { - foreach ($plugininstances as $pluginname => $plugininfo) { - if ($plugininfo->is_standard()) { - // No need to check for these now until MDL-35239 is implemented - continue; - } - if (!is_writeable($plugininfo->rootdir)) { - debugging('Contributed plugin directory not writeable: '.$plugininfo->rootdir); - return false; - } - } - } - - return true; - } -} From 0daa642894c21179cbc96f810ffccbe2a2bbc733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Fri, 9 Nov 2012 00:20:18 +0100 Subject: [PATCH 32/32] MDL-35238 Inform the admin if the update can not be deployed due to write permissions --- admin/renderer.php | 11 ++++-- lang/en/plugin.php | 4 +++ lib/pluginlib.php | 89 ++++++++++++++++++++++++++++++++++++++++------ mdeploy.php | 7 +++- 4 files changed, 97 insertions(+), 14 deletions(-) diff --git a/admin/renderer.php b/admin/renderer.php index 581cdd112c9..7ca6ea24dbd 100644 --- a/admin/renderer.php +++ b/admin/renderer.php @@ -1206,9 +1206,14 @@ class core_admin_renderer extends plugin_renderer_base { $box .= $this->output->box(implode(html_writer::tag('span', ' ', array('class' => 'separator')), $info), ''); $deployer = available_update_deployer::instance(); - if ($deployer->initialized() and $deployer->can_deploy($updateinfo)) { - $widget = $deployer->make_confirm_widget($updateinfo); - $box .= $this->output->render($widget); + if ($deployer->initialized()) { + $impediments = $deployer->deployment_impediments($updateinfo); + if (empty($impediments)) { + $widget = $deployer->make_confirm_widget($updateinfo); + $box .= $this->output->render($widget); + } else if (isset($impediments['notwritable'])) { + $box .= $this->output->help_icon('notwritable', 'core_plugin', get_string('notwritable', 'core_plugin')); + } } $box .= $this->output->box_end(); diff --git a/lang/en/plugin.php b/lang/en/plugin.php index 6812824c87f..f21667ff020 100644 --- a/lang/en/plugin.php +++ b/lang/en/plugin.php @@ -41,6 +41,10 @@ $string['nonehighlighted'] = 'No plugins require your attention now'; $string['nonehighlightedinfo'] = 'Display the list of all installed plugins anyway'; $string['noneinstalled'] = 'No plugins of this type are installed'; $string['notes'] = 'Notes'; +$string['notwritable'] = 'Plugin files not writable'; +$string['notwritable_help'] = 'You have enabled automatic updates deployment and there is available update for this plugin. However, the plugin files are not writable by the web server so the update can not be installed at the moment. + +Make the plugin folder and all its contents writable to be able to install the available update automatically.'; $string['numtotal'] = 'Installed: {$a}'; $string['numdisabled'] = 'Disabled: {$a}'; $string['numextension'] = 'Contributions: {$a}'; diff --git a/lib/pluginlib.php b/lib/pluginlib.php index bd7f6d47c44..e8cb001ce7a 100644 --- a/lib/pluginlib.php +++ b/lib/pluginlib.php @@ -1538,26 +1538,32 @@ class available_update_deployer { } /** - * Check if the available update info contains all required data for deployment. + * Returns a list of reasons why the deployment can not happen * - * All instances of {@link available_update_info} class always provide at least the - * component name and component version. Additionally, we also need the URL to download - * the ZIP package from and MD5 hash of the ZIP's content. + * If the returned array is empty, the deployment seems to be possible. The returned + * structure is an associative array with keys representing individual impediments. + * Possible keys are: missingdownloadurl, missingdownloadmd5, notwritable. * * @param available_update_info $info - * @return bool + * @return array */ - public function can_deploy(available_update_info $info) { + public function deployment_impediments(available_update_info $info) { + + $impediments = array(); if (empty($info->download)) { - return false; + $impediments['missingdownloadurl'] = true; } if (empty($info->downloadmd5)) { - return false; + $impediments['missingdownloadmd5'] = true; } - return true; + if (!$this->component_writable($info->component)) { + $impediments['notwritable'] = true; + } + + return $impediments; } /** @@ -1783,7 +1789,6 @@ class available_update_deployer { } } - // End of external API /** @@ -1857,6 +1862,70 @@ class available_update_deployer { protected function generate_password() { return complex_random_string(); } + + /** + * Checks if the given component's directory is writable + * + * For the purpose of the deployment, the web server process has to have + * write access to all files in the component's directory (recursively) and for the + * directory itself. + * + * @see worker::move_directory_source_precheck() + * @param string $component normalized component name + * @return boolean + */ + protected function component_writable($component) { + + list($plugintype, $pluginname) = normalize_component($component); + + $directory = get_plugin_directory($plugintype, $pluginname); + + if (is_null($directory)) { + throw new coding_exception('Unknown component location', $component); + } + + return $this->directory_writable($directory); + } + + /** + * Checks if the directory and all its contents (recursively) is writable + * + * @param string $path full path to a directory + * @return boolean + */ + private function directory_writable($path) { + + if (!is_writable($path)) { + return false; + } + + if (is_dir($path)) { + $handle = opendir($path); + } else { + return false; + } + + $result = true; + + while ($filename = readdir($handle)) { + $filepath = $path.'/'.$filename; + + if ($filename === '.' or $filename === '..') { + continue; + } + + if (is_dir($filepath)) { + $result = $result && $this->directory_writable($filepath); + + } else { + $result = $result && is_writable($filepath); + } + } + + closedir($handle); + + return $result; + } } diff --git a/mdeploy.php b/mdeploy.php index 8e5bfec9da2..9fba3bad2b4 100644 --- a/mdeploy.php +++ b/mdeploy.php @@ -1048,6 +1048,10 @@ class worker extends singleton_pattern { */ protected function move_directory_source_precheck($source) { + if (!is_writable($source)) { + return false; + } + if (is_dir($source)) { $handle = opendir($source); } else { @@ -1072,7 +1076,8 @@ class worker extends singleton_pattern { } closedir($handle); - return $result && is_writable($source); + + return $result; } /**