. /** * Moodle deployment utility * * This script looks after deploying new add-ons and available updates for them * to the local Moodle site. It can operate via both HTTP and CLI mode. * Moodle itself calls this utility via the HTTP mode when the admin is about to * install or update an add-on. You can use the CLI mode in your custom deployment * shell scripts. * * CLI usage example: * * $ sudo -u apache php mdeploy.php --install \ * --package=https://moodle.org/plugins/download.php/...zip \ * --typeroot=/var/www/moodle/htdocs/blocks * --name=loancalc * --md5=... * --dataroot=/var/www/moodle/data * * $ sudo -u apache php mdeploy.php --upgrade \ * --package=https://moodle.org/plugins/download.php/...zip \ * --typeroot=/var/www/moodle/htdocs/blocks * --name=loancalc * --md5=... * --dataroot=/var/www/moodle/data * * When called via HTTP, additional parameters returnurl, passfile and password must be * provided. Optional proxy configuration can be passed using parameters proxy, proxytype * and proxyuserpwd. * * Changes * * 1.1 - Added support to install a new plugin from the Moodle Plugins directory. * 1.0 - Initial version used in Moodle 2.4 to deploy available updates. * * @package core * @subpackage mdeploy * @version 1.1 * @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 {} 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 zip_exception extends Exception {} class filesystem_exception extends Exception {} class checksum_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_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 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; /** * 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('', '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('', 'proxy', input_manager::TYPE_RAW, 'HTTP proxy host and port (e.g. \'our.proxy.edu:8888\')'), array('', 'proxytype', input_manager::TYPE_RAW, 'Proxy type (HTTP or SOCKS5)'), array('', 'proxyuserpwd', input_manager::TYPE_RAW, 'Proxy username and password (e.g. \'username:password\')'), 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('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'), 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_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: if (strpos($raw, '~') !== false) { throw new invalid_option_exception('Using the tilde (~) character in paths is not supported'); } $colonpos = strpos($raw, ':'); if ($colonpos !== false) { if ($colonpos !== 1 or strrpos($raw, ':') !== 1) { throw new invalid_option_exception('Using the colon (:) character in paths is supported for Windows drive labels only.'); } if (preg_match('/^[a-zA-Z]:/', $raw) !== 1) { throw new invalid_option_exception('Using the colon (:) character in paths is supported for Windows drive labels only.'); } } $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; 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 { 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; 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.'); } } /** * 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) { if ($this->inputprovider->has_option($name)) { return $this->inputprovider->get_option($name); } 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, $default) { if ($this->inputprovider->has_option($name)) { return $this->inputprovider->get_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 $_POST; } } // Output handling ///////////////////////////////////////////////////////////// /** * 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 } /** * Display the information about uncaught exception * * @param Exception $e uncaught exception */ public function exception(Exception $e) { $docslink = 'http://docs.moodle.org/en/admin/mdeploy/'.get_class($e); $this->start_output(); echo('

Oops! It did it again

'); echo('

Moodle deployment utility had a trouble with your request. See the docs page and 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 ////////////////////////////// /** * 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 { 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; /** @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; /** @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(); // Asking for help in the CLI mode. if ($this->input->get_option('help')) { $this->output->help(); $this->done(self::EXIT_HELP); } 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('Package downloaded into '.$target); } else { $this->log('cURL error ' . $this->curlerrno . ' ' . $this->curlerror); $this->log('Unable to download the file from ' . $source . ' into ' . $target); throw new download_file_exception('Unable to download the package'); } // 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'); } $this->log('MD5 checksum ok'); // 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); $this->log('Current plugin code location: '.$sourcelocation); $this->log('Moving the current code into archive: '.$backuplocation); if (file_exists($sourcelocation)) { // 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, true)) { throw new backup_folder_exception('Unable to backup the current version of the plugin (moving failed)'); } } else { // Upgrading missing plugin - this happens often during upgrades. if (!$this->create_directory_precheck($sourcelocation)) { throw new filesystem_exception('Unable to prepare the plugin location (cannot create new directory)'); } } // 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(); } else if ($this->input->get_option('install')) { $this->log('Plugin installation requested'); $plugintyperoot = $this->input->get_option('typeroot'); $pluginname = $this->input->get_option('name'); $source = $this->input->get_option('package'); $md5remote = $this->input->get_option('md5'); // Check if the plugin location if available for us. $pluginlocation = $plugintyperoot.'/'.$pluginname; $this->log('New plugin code location: '.$pluginlocation); if (file_exists($pluginlocation)) { throw new filesystem_exception('Unable to prepare the plugin location (directory already exists)'); } if (!$this->create_directory_precheck($pluginlocation)) { throw new filesystem_exception('Unable to prepare the plugin location (cannot create new directory)'); } // Fetch the ZIP file into a temporary location. $target = $this->target_location($source); $this->log('Downloading package '.$source); if ($this->download_file($source, $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 package'); } // Compare MD5 checksum of the ZIP file $md5local = md5_file($target); if ($md5local !== $md5remote) { $this->log('MD5 checksum failed. Expected: '.$md5remote.' Got: '.$md5local); throw new checksum_exception('MD5 checksum failed'); } $this->log('MD5 checksum ok'); // Unzip the plugin package file into the plugin location. $this->unzip_plugin($target, $plugintyperoot, $pluginlocation, false); $this->log('Package successfully extracted'); // Redirect to the given URL (in HTTP) or exit (in CLI). $this->done(); } // Print help in CLI by default. $this->output->help(); $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. */ protected function initialize() { $this->input = input_manager::instance(); $this->output = output_manager::instance(); } // 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'); $this->redirect($returnurl); exit($exitcode); } } /** * 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') { $this->log('Successfully authorized using the CLI SAPI'); return; } $dataroot = $this->input->get_option('dataroot'); $passfile = $this->input->get_option('passfile'); $password = $this->input->get_option('password'); $passpath = $dataroot.'/mdeploy/auth/'.$passfile; if (!is_readable($passpath)) { throw new unauthorized_access_exception('Unable to read the passphrase file.'); } $stored = file($passpath, FILE_IGNORE_NEW_LINES); // "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.'); } $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', ''); if (empty($dataroot)) { $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; } /** * 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'; } /** * 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. * * 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); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); // Allow redirection, we trust in ssl. curl_setopt($ch, CURLOPT_MAXREDIRS, 5); if ($cacertfile = $this->get_cacert()) { // Do not use CA certs provided by the operating system. Instead, // use this CA cert to verify the ZIP provider. $this->log('Using custom CA certificate '.$cacertfile); curl_setopt($ch, CURLOPT_CAINFO, $cacertfile); } else { $this->log('Using operating system CA certificates.'); } $proxy = $this->input->get_option('proxy', false); if (!empty($proxy)) { curl_setopt($ch, CURLOPT_PROXY, $proxy); $proxytype = $this->input->get_option('proxytype', false); if (strtoupper($proxytype) === 'SOCKS5') { $this->log('Using SOCKS5 proxy'); curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5); } else if (!empty($proxytype)) { $this->log('Using HTTP proxy'); curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_HTTP); curl_setopt($ch, CURLOPT_HTTPPROXYTUNNEL, false); } $proxyuserpwd = $this->input->get_option('proxyuserpwd', false); if (!empty($proxyuserpwd)) { curl_setopt($ch, CURLOPT_PROXYUSERPWD, $proxyuserpwd); curl_setopt($ch, CURLOPT_PROXYAUTH, CURLAUTH_BASIC | CURLAUTH_NTLM); } } $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) { $this->log('Curl Error.'); return false; } else if (is_array($this->curlinfo) and (empty($this->curlinfo['http_code']) or ($this->curlinfo['http_code'] != 200))) { $this->log('Curl remote error.'); $this->log(print_r($this->curlinfo,true)); return false; } return true; } /** * Get the location of ca certificates. * @return string absolute file path or empty if default used */ protected function get_cacert() { $dataroot = $this->input->get_option('dataroot'); // Bundle in dataroot always wins. if (is_readable($dataroot.'/moodleorgca.crt')) { return realpath($dataroot.'/moodleorgca.crt'); } // Next comes the default from php.ini $cacert = ini_get('curl.cainfo'); if (!empty($cacert) and is_readable($cacert)) { return realpath($cacert); } // Windows PHP does not have any certs, we need to use something. if (stristr(PHP_OS, 'win') && !stristr(PHP_OS, 'darwin')) { if (is_readable(__DIR__.'/lib/cacert.pem')) { return realpath(__DIR__.'/lib/cacert.pem'); } } // Use default, this should work fine on all properly configured *nix systems. return null; } /** * Log a message * * @param string $message */ protected function log($message) { $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; } /** * 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_writable($source)) { return false; } 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; } /** * Checks to see if a source folder 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) { // Check if the target folder does not exist yet, can be created // and removed again. $result = $this->create_directory_precheck($target); // At the moment, it seems to be enough to check. We may want to add // more steps in the future. return $result; } /** * Make sure the given directory can be created (and removed) * * @param string $path full path to the folder * @return bool */ protected function create_directory_precheck($path) { if (file_exists($path)) { return false; } $result = mkdir($path, 02777) && rmdir($path); return $result; } /** * Moves the given source into a new location recursively * * The target location can not exist. * * @param string $source full path to the existing directory * @param string $destination full path to the new location of the folder * @param bool $keepsourceroot should the root of the $source be kept or removed at the end * @return bool */ protected function move_directory($source, $target, $keepsourceroot = false) { if (file_exists($target)) { throw new filesystem_exception('Unable to move the directory - target location already exists'); } return $this->move_directory_into($source, $target, $keepsourceroot); } /** * Moves the given source into a new location recursively * * If the target already exists, files are moved into it. The target is created otherwise. * * @param string $source full path to the existing directory * @param string $destination full path to the new location of the folder * @param bool $keepsourceroot should the root of the $source be kept or removed at the end * @return bool */ protected function move_directory_into($source, $target, $keepsourceroot = false) { if (is_dir($source)) { $handle = opendir($source); } else { throw new filesystem_exception('Source location is not a directory'); } if (is_dir($target)) { $result = true; } else { $result = mkdir($target, 02777); } while ($filename = readdir($handle)) { $sourcepath = $source.'/'.$filename; $targetpath = $target.'/'.$filename; if ($filename === '.' or $filename === '..') { continue; } if (is_dir($sourcepath)) { $result = $result && $this->move_directory($sourcepath, $targetpath, false); } else { $result = $result && rename($sourcepath, $targetpath); } } closedir($handle); if (!$keepsourceroot) { $result = $result && rmdir($source); } clearstatcache(); return $result; } /** * Deletes the given directory recursively * * @param string $path full path to the directory * @param bool $keeppathroot should the root of the $path be kept (i.e. remove the content only) or removed too * @return bool */ protected function remove_directory($path, $keeppathroot = false) { $result = true; if (!file_exists($path)) { return $result; } 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)) { $result = $result && $this->remove_directory($filepath, false); } else { $result = $result && unlink($filepath); } } closedir($handle); if (!$keeppathroot) { $result = $result && rmdir($path); } clearstatcache(); return $result; } /** * 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|bool $backuplocation location of the previous version of the plugin or false for no backup */ protected function unzip_plugin($ziplocation, $plugintyperoot, $expectedlocation, $backuplocation) { $zip = new ZipArchive(); $result = $zip->open($ziplocation); if ($result !== true) { if ($backuplocation !== false) { $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, true); // just in case something was created if ($backuplocation !== false) { $this->move_directory_into($backuplocation, $expectedlocation); } throw new zip_exception('Unable to extract the zip package'); } $zip->close(); unlink($ziplocation); } /** * Redirect the browser * * @todo check if there has been some output yet * @param string $url */ protected function redirect($url) { header('Location: '.$url); } } /** * 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 // 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. 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(); } else { // We are included - probably by some unit testing framework. Do nothing. }