25ba16830816bc4831bafeb0cfa1ad2454bb669b
[moodle.git] / lib / classes / update / deployer.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Defines classes used for updates.
19  *
20  * @package    core
21  * @copyright  2011 David Mudrak <david@moodle.com>
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
24 namespace core\update;
26 use coding_exception, core_component, moodle_url;
28 defined('MOODLE_INTERNAL') || die();
30 /**
31  * Implements a communication bridge to the mdeploy.php utility
32  */
33 class deployer {
35     /** @var \core\update\deployer holds the singleton instance */
36     protected static $singletoninstance;
37     /** @var moodle_url URL of a page that includes the deployer UI */
38     protected $callerurl;
39     /** @var moodle_url URL to return after the deployment */
40     protected $returnurl;
42     /**
43      * Direct instantiation not allowed, use the factory method {@link self::instance()}
44      */
45     protected function __construct() {
46     }
48     /**
49      * Sorry, this is singleton
50      */
51     protected function __clone() {
52     }
54     /**
55      * Factory method for this class
56      *
57      * @return \core\update\deployer the singleton instance
58      */
59     public static function instance() {
60         if (is_null(self::$singletoninstance)) {
61             self::$singletoninstance = new self();
62         }
63         return self::$singletoninstance;
64     }
66     /**
67      * Reset caches used by this script
68      *
69      * @param bool $phpunitreset is this called as a part of PHPUnit reset?
70      */
71     public static function reset_caches($phpunitreset = false) {
72         if ($phpunitreset) {
73             self::$singletoninstance = null;
74         }
75     }
77     /**
78      * Is automatic deployment enabled?
79      *
80      * @return bool
81      */
82     public function enabled() {
83         global $CFG;
85         if (!empty($CFG->disableupdateautodeploy)) {
86             // The feature is prohibited via config.php.
87             return false;
88         }
90         return get_config('updateautodeploy');
91     }
93     /**
94      * Sets some base properties of the class to make it usable.
95      *
96      * @param moodle_url $callerurl the base URL of a script that will handle the class'es form data
97      * @param moodle_url $returnurl the final URL to return to when the deployment is finished
98      */
99     public function initialize(moodle_url $callerurl, moodle_url $returnurl) {
101         if (!$this->enabled()) {
102             throw new coding_exception('Unable to initialize the deployer, the feature is not enabled.');
103         }
105         $this->callerurl = $callerurl;
106         $this->returnurl = $returnurl;
107     }
109     /**
110      * Has the deployer been initialized?
111      *
112      * Initialized deployer means that the following properties were set:
113      * callerurl, returnurl
114      *
115      * @return bool
116      */
117     public function initialized() {
119         if (!$this->enabled()) {
120             return false;
121         }
123         if (empty($this->callerurl)) {
124             return false;
125         }
127         if (empty($this->returnurl)) {
128             return false;
129         }
131         return true;
132     }
134     /**
135      * Returns a list of reasons why the deployment can not happen
136      *
137      * If the returned array is empty, the deployment seems to be possible. The returned
138      * structure is an associative array with keys representing individual impediments.
139      * Possible keys are: missingdownloadurl, missingdownloadmd5, notwritable.
140      *
141      * @param \core\update\info $info
142      * @return array
143      */
144     public function deployment_impediments(info $info) {
146         $impediments = array();
148         if (empty($info->download)) {
149             $impediments['missingdownloadurl'] = true;
150         }
152         if (empty($info->downloadmd5)) {
153             $impediments['missingdownloadmd5'] = true;
154         }
156         if (!empty($info->download) and !$this->update_downloadable($info->download)) {
157             $impediments['notdownloadable'] = true;
158         }
160         if (!$this->component_writable($info->component)) {
161             $impediments['notwritable'] = true;
162         }
164         return $impediments;
165     }
167     /**
168      * Check to see if the current version of the plugin seems to be a checkout of an external repository.
169      *
170      * @see core_plugin_manager::plugin_external_source()
171      * @param \core\update\info $info
172      * @return false|string
173      */
174     public function plugin_external_source(info $info) {
176         $paths = core_component::get_plugin_types();
177         list($plugintype, $pluginname) = core_component::normalize_component($info->component);
178         $pluginroot = $paths[$plugintype].'/'.$pluginname;
180         if (is_dir($pluginroot.'/.git')) {
181             return 'git';
182         }
184         if (is_file($pluginroot.'/.git')) {
185             return 'git-submodule';
186         }
188         if (is_dir($pluginroot.'/CVS')) {
189             return 'cvs';
190         }
192         if (is_dir($pluginroot.'/.svn')) {
193             return 'svn';
194         }
196         if (is_dir($pluginroot.'/.hg')) {
197             return 'mercurial';
198         }
200         return false;
201     }
203     /**
204      * Prepares a renderable widget to confirm installation of an available update.
205      *
206      * @param \core\update\info $info component version to deploy
207      * @return \renderable
208      */
209     public function make_confirm_widget(info $info) {
211         if (!$this->initialized()) {
212             throw new coding_exception('Illegal method call - deployer not initialized.');
213         }
215         $params = array(
216             'updateaddon' => $info->component,
217             'version' =>$info->version,
218             'sesskey' => sesskey(),
219         );
221         // Append some our own data.
222         if (!empty($this->callerurl)) {
223             $params['callerurl'] = $this->callerurl->out(false);
224         }
225         if (!empty($this->returnurl)) {
226             $params['returnurl'] = $this->returnurl->out(false);
227         }
229         $widget = new \single_button(
230             new moodle_url($this->callerurl, $params),
231             get_string('updateavailableinstall', 'core_admin'),
232             'post'
233         );
235         return $widget;
236     }
238     /**
239      * Prepares a renderable widget to execute installation of an available update.
240      *
241      * @param \core\update\info $info component version to deploy
242      * @param moodle_url $returnurl URL to return after the installation execution
243      * @return \renderable
244      */
245     public function make_execution_widget(info $info, moodle_url $returnurl = null) {
246         global $CFG;
248         if (!$this->initialized()) {
249             throw new coding_exception('Illegal method call - deployer not initialized.');
250         }
252         $pluginrootpaths = core_component::get_plugin_types();
254         list($plugintype, $pluginname) = core_component::normalize_component($info->component);
256         if (empty($pluginrootpaths[$plugintype])) {
257             throw new coding_exception('Unknown plugin type root location', $plugintype);
258         }
260         list($passfile, $password) = $this->prepare_authorization();
262         if (is_null($returnurl)) {
263             $returnurl = new moodle_url('/admin');
264         } else {
265             $returnurl = $returnurl;
266         }
268         $params = array(
269             'upgrade' => true,
270             'type' => $plugintype,
271             'name' => $pluginname,
272             'typeroot' => $pluginrootpaths[$plugintype],
273             'package' => $info->download,
274             'md5' => $info->downloadmd5,
275             'dataroot' => $CFG->dataroot,
276             'dirroot' => $CFG->dirroot,
277             'passfile' => $passfile,
278             'password' => $password,
279             'returnurl' => $returnurl->out(false),
280         );
282         if (!empty($CFG->proxyhost)) {
283             // MDL-36973 - Beware - we should call just !is_proxybypass() here. But currently, our
284             // cURL wrapper class does not do it. So, to have consistent behaviour, we pass proxy
285             // setting regardless the $CFG->proxybypass setting. Once the {@link curl} class is
286             // fixed, the condition should be amended.
287             if (true or !is_proxybypass($info->download)) {
288                 if (empty($CFG->proxyport)) {
289                     $params['proxy'] = $CFG->proxyhost;
290                 } else {
291                     $params['proxy'] = $CFG->proxyhost.':'.$CFG->proxyport;
292                 }
294                 if (!empty($CFG->proxyuser) and !empty($CFG->proxypassword)) {
295                     $params['proxyuserpwd'] = $CFG->proxyuser.':'.$CFG->proxypassword;
296                 }
298                 if (!empty($CFG->proxytype)) {
299                     $params['proxytype'] = $CFG->proxytype;
300                 }
301             }
302         }
304         $widget = new \single_button(
305             new moodle_url('/mdeploy.php', $params),
306             get_string('updateavailableinstall', 'core_admin'),
307             'post'
308         );
310         return $widget;
311     }
313     /**
314      * Returns array of data objects passed to this tool.
315      *
316      * @return array
317      */
318     public function submitted_data() {
319         $component = optional_param('updateaddon', '', PARAM_COMPONENT);
320         $version = optional_param('version', '', PARAM_RAW);
321         if (!$component or !$version) {
322             return false;
323         }
325         $plugininfo = \core_plugin_manager::instance()->get_plugin_info($component);
326         if (!$plugininfo) {
327             return false;
328         }
330         if ($plugininfo->is_standard()) {
331             return false;
332         }
334         if (!$updates = $plugininfo->available_updates()) {
335             return false;
336         }
338         $info = null;
339         foreach ($updates as $update) {
340             if ($update->version == $version) {
341                 $info = $update;
342                 break;
343             }
344         }
345         if (!$info) {
346             return false;
347         }
349         $data = array(
350             'updateaddon' => $component,
351             'updateinfo'  => $info,
352             'callerurl'   => optional_param('callerurl', null, PARAM_URL),
353             'returnurl'   => optional_param('returnurl', null, PARAM_URL),
354         );
355         if ($data['callerurl']) {
356             $data['callerurl'] = new moodle_url($data['callerurl']);
357         }
358         if ($data['callerurl']) {
359             $data['returnurl'] = new moodle_url($data['returnurl']);
360         }
362         return $data;
363     }
365     /**
366      * Handles magic getters and setters for protected properties.
367      *
368      * @param string $name method name, e.g. set_returnurl()
369      * @param array $arguments arguments to be passed to the array
370      */
371     public function __call($name, array $arguments = array()) {
373         if (substr($name, 0, 4) === 'set_') {
374             $property = substr($name, 4);
375             if (empty($property)) {
376                 throw new coding_exception('Invalid property name (empty)');
377             }
378             if (empty($arguments)) {
379                 $arguments = array(true); // Default value for flag-like properties.
380             }
381             // Make sure it is a protected property.
382             $isprotected = false;
383             $reflection = new \ReflectionObject($this);
384             foreach ($reflection->getProperties(\ReflectionProperty::IS_PROTECTED) as $reflectionproperty) {
385                 if ($reflectionproperty->getName() === $property) {
386                     $isprotected = true;
387                     break;
388                 }
389             }
390             if (!$isprotected) {
391                 throw new coding_exception('Unable to set property - it does not exist or it is not protected');
392             }
393             $value = reset($arguments);
394             $this->$property = $value;
395             return;
396         }
398         if (substr($name, 0, 4) === 'get_') {
399             $property = substr($name, 4);
400             if (empty($property)) {
401                 throw new coding_exception('Invalid property name (empty)');
402             }
403             if (!empty($arguments)) {
404                 throw new coding_exception('No parameter expected');
405             }
406             // Make sure it is a protected property.
407             $isprotected = false;
408             $reflection = new \ReflectionObject($this);
409             foreach ($reflection->getProperties(\ReflectionProperty::IS_PROTECTED) as $reflectionproperty) {
410                 if ($reflectionproperty->getName() === $property) {
411                     $isprotected = true;
412                     break;
413                 }
414             }
415             if (!$isprotected) {
416                 throw new coding_exception('Unable to get property - it does not exist or it is not protected');
417             }
418             return $this->$property;
419         }
420     }
422     /**
423      * Generates a random token and stores it in a file in moodledata directory.
424      *
425      * @return array of the (string)filename and (string)password in this order
426      */
427     public function prepare_authorization() {
428         global $CFG;
430         make_upload_directory('mdeploy/auth/');
432         $attempts = 0;
433         $success = false;
435         while (!$success and $attempts < 5) {
436             $attempts++;
438             $passfile = $this->generate_passfile();
439             $password = $this->generate_password();
440             $now = time();
442             $filepath = $CFG->dataroot.'/mdeploy/auth/'.$passfile;
444             if (!file_exists($filepath)) {
445                 $success = file_put_contents($filepath, $password . PHP_EOL . $now . PHP_EOL, LOCK_EX);
446                 chmod($filepath, $CFG->filepermissions);
447             }
448         }
450         if ($success) {
451             return array($passfile, $password);
453         } else {
454             throw new \moodle_exception('unable_prepare_authorization', 'core_plugin');
455         }
456     }
458     /* === End of external API === */
460     /**
461      * Returns a random string to be used as a filename of the password storage.
462      *
463      * @return string
464      */
465     protected function generate_passfile() {
466         return clean_param(uniqid('mdeploy_', true), PARAM_FILE);
467     }
469     /**
470      * Returns a random string to be used as the authorization token
471      *
472      * @return string
473      */
474     protected function generate_password() {
475         return complex_random_string();
476     }
478     /**
479      * Checks if the given component's directory is writable
480      *
481      * For the purpose of the deployment, the web server process has to have
482      * write access to all files in the component's directory (recursively) and for the
483      * directory itself.
484      *
485      * @see worker::move_directory_source_precheck()
486      * @param string $component normalized component name
487      * @return boolean
488      */
489     protected function component_writable($component) {
491         list($plugintype, $pluginname) = core_component::normalize_component($component);
493         $directory = core_component::get_plugin_directory($plugintype, $pluginname);
495         if (is_null($directory)) {
496             // Plugin unknown, most probably deleted or missing during upgrade,
497             // look at the parent directory instead because they might want to install it.
498             $plugintypes = core_component::get_plugin_types();
499             if (!isset($plugintypes[$plugintype])) {
500                 throw new coding_exception('Unknown component location', $component);
501             }
502             $directory = $plugintypes[$plugintype];
503         }
505         return $this->directory_writable($directory);
506     }
508     /**
509      * Checks if the mdeploy.php will be able to fetch the ZIP from the given URL
510      *
511      * This is mainly supposed to check if the transmission over HTTPS would
512      * work. That is, if the CA certificates are present at the server.
513      *
514      * @param string $downloadurl the URL of the ZIP package to download
515      * @return bool
516      */
517     protected function update_downloadable($downloadurl) {
518         global $CFG;
520         $curloptions = array(
521             'CURLOPT_SSL_VERIFYHOST' => 2,      // This is the default in {@link curl} class but just in case.
522             'CURLOPT_SSL_VERIFYPEER' => true,
523         );
525         $curl = new \curl(array('proxy' => true));
526         $result = $curl->head($downloadurl, $curloptions);
527         $errno = $curl->get_errno();
528         if (empty($errno)) {
529             return true;
530         } else {
531             return false;
532         }
533     }
535     /**
536      * Checks if the directory and all its contents (recursively) is writable
537      *
538      * @param string $path full path to a directory
539      * @return boolean
540      */
541     private function directory_writable($path) {
543         if (!is_writable($path)) {
544             return false;
545         }
547         if (is_dir($path)) {
548             $handle = opendir($path);
549         } else {
550             return false;
551         }
553         $result = true;
555         while ($filename = readdir($handle)) {
556             $filepath = $path.'/'.$filename;
558             if ($filename === '.' or $filename === '..') {
559                 continue;
560             }
562             if (is_dir($filepath)) {
563                 $result = $result && $this->directory_writable($filepath);
565             } else {
566                 $result = $result && is_writable($filepath);
567             }
568         }
570         closedir($handle);
572         return $result;
573     }