MDL-49329 admin: Fix plugins check page if no plugins require attention
[moodle.git] / lib / classes / update / deployer.php
CommitLineData
e87214bd
PS
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/>.
16
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 */
24namespace core\update;
25
26use coding_exception, core_component, moodle_url;
27
28defined('MOODLE_INTERNAL') || die();
29
30/**
31 * Implements a communication bridge to the mdeploy.php utility
32 */
33class deployer {
34
e87214bd
PS
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;
41
42 /**
43 * Direct instantiation not allowed, use the factory method {@link self::instance()}
44 */
45 protected function __construct() {
46 }
47
48 /**
49 * Sorry, this is singleton
50 */
51 protected function __clone() {
52 }
53
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 }
65
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 }
76
77 /**
78 * Is automatic deployment enabled?
79 *
80 * @return bool
81 */
82 public function enabled() {
83 global $CFG;
84
85 if (!empty($CFG->disableupdateautodeploy)) {
86 // The feature is prohibited via config.php.
87 return false;
88 }
89
90 return get_config('updateautodeploy');
91 }
92
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) {
100
101 if (!$this->enabled()) {
102 throw new coding_exception('Unable to initialize the deployer, the feature is not enabled.');
103 }
104
105 $this->callerurl = $callerurl;
106 $this->returnurl = $returnurl;
107 }
108
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() {
118
119 if (!$this->enabled()) {
120 return false;
121 }
122
123 if (empty($this->callerurl)) {
124 return false;
125 }
126
127 if (empty($this->returnurl)) {
128 return false;
129 }
130
131 return true;
132 }
133
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) {
145
146 $impediments = array();
147
148 if (empty($info->download)) {
149 $impediments['missingdownloadurl'] = true;
150 }
151
152 if (empty($info->downloadmd5)) {
153 $impediments['missingdownloadmd5'] = true;
154 }
155
156 if (!empty($info->download) and !$this->update_downloadable($info->download)) {
157 $impediments['notdownloadable'] = true;
158 }
159
160 if (!$this->component_writable($info->component)) {
161 $impediments['notwritable'] = true;
162 }
163
164 return $impediments;
165 }
166
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) {
175
176 $paths = core_component::get_plugin_types();
177 list($plugintype, $pluginname) = core_component::normalize_component($info->component);
178 $pluginroot = $paths[$plugintype].'/'.$pluginname;
179
180 if (is_dir($pluginroot.'/.git')) {
181 return 'git';
182 }
183
a5d08dce
DM
184 if (is_file($pluginroot.'/.git')) {
185 return 'git-submodule';
186 }
187
e87214bd
PS
188 if (is_dir($pluginroot.'/CVS')) {
189 return 'cvs';
190 }
191
192 if (is_dir($pluginroot.'/.svn')) {
193 return 'svn';
194 }
195
0b515736
OS
196 if (is_dir($pluginroot.'/.hg')) {
197 return 'mercurial';
198 }
199
e87214bd
PS
200 return false;
201 }
202
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) {
210
211 if (!$this->initialized()) {
212 throw new coding_exception('Illegal method call - deployer not initialized.');
213 }
214
fc281113
PS
215 $params = array(
216 'updateaddon' => $info->component,
217 'version' =>$info->version,
218 'sesskey' => sesskey(),
219 );
220
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 }
e87214bd
PS
228
229 $widget = new \single_button(
230 new moodle_url($this->callerurl, $params),
231 get_string('updateavailableinstall', 'core_admin'),
232 'post'
233 );
234
235 return $widget;
236 }
237
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;
247
248 if (!$this->initialized()) {
249 throw new coding_exception('Illegal method call - deployer not initialized.');
250 }
251
252 $pluginrootpaths = core_component::get_plugin_types();
253
254 list($plugintype, $pluginname) = core_component::normalize_component($info->component);
255
256 if (empty($pluginrootpaths[$plugintype])) {
257 throw new coding_exception('Unknown plugin type root location', $plugintype);
258 }
259
260 list($passfile, $password) = $this->prepare_authorization();
261
262 if (is_null($returnurl)) {
263 $returnurl = new moodle_url('/admin');
264 } else {
265 $returnurl = $returnurl;
266 }
267
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 );
281
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 }
293
294 if (!empty($CFG->proxyuser) and !empty($CFG->proxypassword)) {
295 $params['proxyuserpwd'] = $CFG->proxyuser.':'.$CFG->proxypassword;
296 }
297
298 if (!empty($CFG->proxytype)) {
299 $params['proxytype'] = $CFG->proxytype;
300 }
301 }
302 }
303
304 $widget = new \single_button(
305 new moodle_url('/mdeploy.php', $params),
306 get_string('updateavailableinstall', 'core_admin'),
307 'post'
308 );
309
310 return $widget;
311 }
312
313 /**
314 * Returns array of data objects passed to this tool.
315 *
316 * @return array
317 */
318 public function submitted_data() {
fc281113
PS
319 $component = optional_param('updateaddon', '', PARAM_COMPONENT);
320 $version = optional_param('version', '', PARAM_RAW);
321 if (!$component or !$version) {
322 return false;
323 }
324
325 $plugininfo = \core_plugin_manager::instance()->get_plugin_info($component);
326 if (!$plugininfo) {
327 return false;
328 }
e87214bd 329
fc281113
PS
330 if ($plugininfo->is_standard()) {
331 return false;
332 }
e87214bd 333
fc281113 334 if (!$updates = $plugininfo->available_updates()) {
e87214bd
PS
335 return false;
336 }
337
fc281113
PS
338 $info = null;
339 foreach ($updates as $update) {
340 if ($update->version == $version) {
341 $info = $update;
342 break;
e87214bd
PS
343 }
344 }
fc281113
PS
345 if (!$info) {
346 return false;
347 }
e87214bd 348
fc281113
PS
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']) {
e87214bd
PS
356 $data['callerurl'] = new moodle_url($data['callerurl']);
357 }
fc281113 358 if ($data['callerurl']) {
e87214bd
PS
359 $data['returnurl'] = new moodle_url($data['returnurl']);
360 }
361
362 return $data;
363 }
364
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()) {
372
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 }
397
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 }
421
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;
429
430 make_upload_directory('mdeploy/auth/');
431
432 $attempts = 0;
433 $success = false;
434
435 while (!$success and $attempts < 5) {
436 $attempts++;
437
438 $passfile = $this->generate_passfile();
439 $password = $this->generate_password();
440 $now = time();
441
442 $filepath = $CFG->dataroot.'/mdeploy/auth/'.$passfile;
443
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 }
449
450 if ($success) {
451 return array($passfile, $password);
452
453 } else {
454 throw new \moodle_exception('unable_prepare_authorization', 'core_plugin');
455 }
456 }
457
458 /* === End of external API === */
459
e87214bd
PS
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 }
468
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 }
477
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) {
490
491 list($plugintype, $pluginname) = core_component::normalize_component($component);
492
493 $directory = core_component::get_plugin_directory($plugintype, $pluginname);
494
495 if (is_null($directory)) {
fc281113
PS
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];
e87214bd
PS
503 }
504
505 return $this->directory_writable($directory);
506 }
507
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;
519
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 );
524
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 }
534
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) {
542
543 if (!is_writable($path)) {
544 return false;
545 }
546
547 if (is_dir($path)) {
548 $handle = opendir($path);
549 } else {
550 return false;
551 }
552
553 $result = true;
554
555 while ($filename = readdir($handle)) {
556 $filepath = $path.'/'.$filename;
557
558 if ($filename === '.' or $filename === '..') {
559 continue;
560 }
561
562 if (is_dir($filepath)) {
563 $result = $result && $this->directory_writable($filepath);
564
565 } else {
566 $result = $result && is_writable($filepath);
567 }
568 }
569
570 closedir($handle);
571
572 return $result;
573 }
574}