MDL-36963 Improve mdeploy worker::move_directory() method
[moodle.git] / mdeploy.php
CommitLineData
89af1765
DM
1<?php
2
3// This file is part of Moodle - http://moodle.org/
4//
5// Moodle is free software: you can redistribute it and/or modify
6// it under the terms of the GNU General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// Moodle is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU General Public License for more details.
14//
15// You should have received a copy of the GNU General Public License
16// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17
18/**
19 * Moodle deployment utility
20 *
21 * This script looks after deploying available updates to the local Moodle site.
22 *
4c72f555
DM
23 * CLI usage example:
24 * $ sudo -u apache php mdeploy.php --upgrade \
25 * --package=https://moodle.org/plugins/download.php/...zip \
26 * --dataroot=/home/mudrd8mz/moodledata/moodle24
27 *
89af1765
DM
28 * @package core
29 * @copyright 2012 David Mudrak <david@moodle.com>
30 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
31 */
32
33if (defined('MOODLE_INTERNAL')) {
34 die('This is a standalone utility that should not be included by any other Moodle code.');
35}
36
37
38// Exceptions //////////////////////////////////////////////////////////////////
39
40class invalid_coding_exception extends Exception {}
41class missing_option_exception extends Exception {}
4c72f555 42class invalid_option_exception extends Exception {}
c57f18ad 43class unauthorized_access_exception extends Exception {}
4c72f555 44class download_file_exception extends Exception {}
23137c4a
DM
45class backup_folder_exception extends Exception {}
46class zip_exception extends Exception {}
47class filesystem_exception extends Exception {}
6b75106a 48class checksum_exception extends Exception {}
89af1765
DM
49
50
51// Various support classes /////////////////////////////////////////////////////
52
53/**
54 * Base class implementing the singleton pattern using late static binding feature.
55 *
56 * @copyright 2012 David Mudrak <david@moodle.com>
57 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
58 */
59abstract class singleton_pattern {
60
61 /** @var array singleton_pattern instances */
62 protected static $singletoninstances = array();
63
64 /**
65 * Factory method returning the singleton instance.
66 *
67 * Subclasses may want to override the {@link self::initialize()} method that is
68 * called right after their instantiation.
69 *
70 * @return mixed the singleton instance
71 */
72 final public static function instance() {
73 $class = get_called_class();
74 if (!isset(static::$singletoninstances[$class])) {
75 static::$singletoninstances[$class] = new static();
76 static::$singletoninstances[$class]->initialize();
77 }
78 return static::$singletoninstances[$class];
79 }
80
81 /**
82 * Optional post-instantiation code.
83 */
84 protected function initialize() {
85 // Do nothing in this base class.
86 }
87
88 /**
89 * Direct instantiation not allowed, use the factory method {@link instance()}
90 */
91 final protected function __construct() {
92 }
93
94 /**
95 * Sorry, this is singleton.
96 */
97 final protected function __clone() {
98 }
99}
100
101
102// User input handling /////////////////////////////////////////////////////////
103
104/**
105 * Provides access to the script options.
106 *
107 * Implements the delegate pattern by dispatching the calls to appropriate
108 * helper class (CLI or HTTP).
109 *
110 * @copyright 2012 David Mudrak <david@moodle.com>
111 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
112 */
113class input_manager extends singleton_pattern {
114
c57f18ad 115 const TYPE_FILE = 'file'; // File name
89af1765
DM
116 const TYPE_FLAG = 'flag'; // No value, just a flag (switch)
117 const TYPE_INT = 'int'; // Integer
c57f18ad
DM
118 const TYPE_PATH = 'path'; // Full path to a file or a directory
119 const TYPE_RAW = 'raw'; // Raw value, keep as is
4c72f555 120 const TYPE_URL = 'url'; // URL to a file
4f71de41 121 const TYPE_PLUGIN = 'plugin'; // Plugin name
6b75106a 122 const TYPE_MD5 = 'md5'; // MD5 hash
89af1765 123
c99910bb 124 /** @var input_cli_provider|input_http_provider the provider of the input */
89af1765
DM
125 protected $inputprovider = null;
126
127 /**
128 * Returns the value of an option passed to the script.
129 *
130 * If the caller passes just the $name, the requested argument is considered
131 * required. The caller may specify the second argument which then
132 * makes the argument optional with the given default value.
133 *
134 * If the type of the $name option is TYPE_FLAG (switch), this method returns
135 * true if the flag has been passed or false if it was not. Specifying the
136 * default value makes no sense in this case and leads to invalid coding exception.
137 *
138 * The array options are not supported.
139 *
140 * @example $filename = $input->get_option('f');
141 * @example $filename = $input->get_option('filename');
142 * @example if ($input->get_option('verbose')) { ... }
143 * @param string $name
144 * @return mixed
145 */
146 public function get_option($name, $default = 'provide_default_value_explicitly') {
147
148 $this->validate_option_name($name);
149
150 $info = $this->get_option_info($name);
151
152 if ($info->type === input_manager::TYPE_FLAG) {
153 return $this->inputprovider->has_option($name);
154 }
155
156 if (func_num_args() == 1) {
157 return $this->get_required_option($name);
158 } else {
159 return $this->get_optional_option($name, $default);
160 }
161 }
162
163 /**
164 * Returns the meta-information about the given option.
165 *
166 * @param string|null $name short or long option name, defaults to returning the list of all
167 * @return array|object|false array with all, object with the specific option meta-information or false of no such an option
168 */
169 public function get_option_info($name=null) {
170
171 $supportedoptions = array(
4c72f555
DM
172 array('', 'passfile', input_manager::TYPE_FILE, 'File name of the passphrase file (HTTP access only)'),
173 array('', 'password', input_manager::TYPE_RAW, 'Session passphrase (HTTP access only)'),
42c6731c
DM
174 array('', 'proxy', input_manager::TYPE_RAW, 'HTTP proxy host and port (e.g. \'our.proxy.edu:8888\')'),
175 array('', 'proxytype', input_manager::TYPE_RAW, 'Proxy type (HTTP or SOCKS5)'),
176 array('', 'proxyuserpwd', input_manager::TYPE_RAW, 'Proxy username and password (e.g. \'username:password\')'),
4f71de41 177 array('', 'returnurl', input_manager::TYPE_URL, 'Return URL (HTTP access only)'),
c57f18ad 178 array('d', 'dataroot', input_manager::TYPE_PATH, 'Full path to the dataroot (moodledata) directory'),
89af1765
DM
179 array('h', 'help', input_manager::TYPE_FLAG, 'Prints usage information'),
180 array('i', 'install', input_manager::TYPE_FLAG, 'Installation mode'),
6b75106a 181 array('m', 'md5', input_manager::TYPE_MD5, 'Expected MD5 hash of the ZIP package to deploy'),
4f71de41 182 array('n', 'name', input_manager::TYPE_PLUGIN, 'Plugin name (the name of its folder)'),
4c72f555 183 array('p', 'package', input_manager::TYPE_URL, 'URL to the ZIP package to deploy'),
4f71de41 184 array('r', 'typeroot', input_manager::TYPE_PATH, 'Full path of the container for this plugin type'),
89af1765
DM
185 array('u', 'upgrade', input_manager::TYPE_FLAG, 'Upgrade mode'),
186 );
187
188 if (is_null($name)) {
189 $all = array();
190 foreach ($supportedoptions as $optioninfo) {
191 $info = new stdClass();
192 $info->shortname = $optioninfo[0];
193 $info->longname = $optioninfo[1];
194 $info->type = $optioninfo[2];
195 $info->desc = $optioninfo[3];
196 $all[] = $info;
197 }
198 return $all;
199 }
200
201 $found = false;
202
203 foreach ($supportedoptions as $optioninfo) {
204 if (strlen($name) == 1) {
205 // Search by the short option name
206 if ($optioninfo[0] === $name) {
207 $found = $optioninfo;
208 break;
209 }
210 } else {
211 // Search by the long option name
212 if ($optioninfo[1] === $name) {
213 $found = $optioninfo;
214 break;
215 }
216 }
217 }
218
219 if (!$found) {
220 return false;
221 }
222
223 $info = new stdClass();
224 $info->shortname = $found[0];
225 $info->longname = $found[1];
226 $info->type = $found[2];
227 $info->desc = $found[3];
228
229 return $info;
230 }
231
232 /**
233 * Casts the value to the given type.
234 *
235 * @param mixed $raw the raw value
236 * @param string $type the expected value type, e.g. {@link input_manager::TYPE_INT}
237 * @return mixed
238 */
239 public function cast_value($raw, $type) {
240
241 if (is_array($raw)) {
242 throw new invalid_coding_exception('Unsupported array option.');
243 } else if (is_object($raw)) {
244 throw new invalid_coding_exception('Unsupported object option.');
245 }
246
247 switch ($type) {
248
c57f18ad
DM
249 case input_manager::TYPE_FILE:
250 $raw = preg_replace('~[[:cntrl:]]|[&<>"`\|\':\\\\/]~u', '', $raw);
251 $raw = preg_replace('~\.\.+~', '', $raw);
252 if ($raw === '.') {
253 $raw = '';
254 }
255 return $raw;
256
89af1765
DM
257 case input_manager::TYPE_FLAG:
258 return true;
259
260 case input_manager::TYPE_INT:
261 return (int)$raw;
262
c57f18ad 263 case input_manager::TYPE_PATH:
8ffa8d7e
DM
264 if (strpos($raw, '~') !== false) {
265 throw new invalid_option_exception('Using the tilde (~) character in paths is not supported');
266 }
c57f18ad
DM
267 $raw = str_replace('\\', '/', $raw);
268 $raw = preg_replace('~[[:cntrl:]]|[&<>"`\|\':]~u', '', $raw);
269 $raw = preg_replace('~\.\.+~', '', $raw);
270 $raw = preg_replace('~//+~', '/', $raw);
271 $raw = preg_replace('~/(\./)+~', '/', $raw);
272 return $raw;
273
274 case input_manager::TYPE_RAW:
275 return $raw;
276
4c72f555
DM
277 case input_manager::TYPE_URL:
278 $regex = '^(https?|ftp)\:\/\/'; // protocol
279 $regex .= '([a-z0-9+!*(),;?&=\$_.-]+(\:[a-z0-9+!*(),;?&=\$_.-]+)?@)?'; // optional user and password
280 $regex .= '[a-z0-9+\$_-]+(\.[a-z0-9+\$_-]+)*'; // hostname or IP (one word like http://localhost/ allowed)
281 $regex .= '(\:[0-9]{2,5})?'; // port (optional)
282 $regex .= '(\/([a-z0-9+\$_-]\.?)+)*\/?'; // path to the file
283 $regex .= '(\?[a-z+&\$_.-][a-z0-9;:@/&%=+\$_.-]*)?'; // HTTP params
284
285 if (preg_match('#'.$regex.'#i', $raw)) {
286 return $raw;
287 } else {
4f71de41 288 throw new invalid_option_exception('Not a valid URL');
4c72f555
DM
289 }
290
4f71de41
DM
291 case input_manager::TYPE_PLUGIN:
292 if (!preg_match('/^[a-z][a-z0-9_]*[a-z0-9]$/', $raw)) {
293 throw new invalid_option_exception('Invalid plugin name');
294 }
295 if (strpos($raw, '__') !== false) {
296 throw new invalid_option_exception('Invalid plugin name');
297 }
298 return $raw;
299
6b75106a
DM
300 case input_manager::TYPE_MD5:
301 if (!preg_match('/^[a-f0-9]{32}$/', $raw)) {
302 throw new invalid_option_exception('Invalid MD5 hash format');
303 }
304 return $raw;
305
89af1765
DM
306 default:
307 throw new invalid_coding_exception('Unknown option type.');
308
309 }
310 }
311
312 /**
313 * Picks the appropriate helper class to delegate calls to.
314 */
315 protected function initialize() {
316 if (PHP_SAPI === 'cli') {
317 $this->inputprovider = input_cli_provider::instance();
318 } else {
319 $this->inputprovider = input_http_provider::instance();
320 }
321 }
322
323 // End of external API
324
325 /**
326 * Validates the parameter name.
327 *
328 * @param string $name
329 * @throws invalid_coding_exception
330 */
331 protected function validate_option_name($name) {
332
333 if (empty($name)) {
334 throw new invalid_coding_exception('Invalid empty option name.');
335 }
336
337 $meta = $this->get_option_info($name);
338 if (empty($meta)) {
339 throw new invalid_coding_exception('Invalid option name: '.$name);
340 }
341 }
342
343 /**
344 * Returns cleaned option value or throws exception.
345 *
346 * @param string $name the name of the parameter
347 * @param string $type the parameter type, e.g. {@link input_manager::TYPE_INT}
348 * @return mixed
349 */
11c3c579 350 protected function get_required_option($name) {
89af1765 351 if ($this->inputprovider->has_option($name)) {
11c3c579 352 return $this->inputprovider->get_option($name);
89af1765
DM
353 } else {
354 throw new missing_option_exception('Missing required option: '.$name);
355 }
356 }
357
358 /**
359 * Returns cleaned option value or the default value
360 *
361 * @param string $name the name of the parameter
362 * @param string $type the parameter type, e.g. {@link input_manager::TYPE_INT}
363 * @param mixed $default the default value.
364 * @return mixed
365 */
11c3c579 366 protected function get_optional_option($name, $default) {
89af1765 367 if ($this->inputprovider->has_option($name)) {
11c3c579 368 return $this->inputprovider->get_option($name);
89af1765
DM
369 } else {
370 return $default;
371 }
372 }
373}
374
375
376/**
377 * Base class for input providers.
378 *
379 * @copyright 2012 David Mudrak <david@moodle.com>
380 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
381 */
382abstract class input_provider extends singleton_pattern {
383
384 /** @var array list of all passed valid options */
385 protected $options = array();
386
387 /**
388 * Returns the casted value of the option.
389 *
390 * @param string $name option name
391 * @throws invalid_coding_exception if the option has not been passed
392 * @return mixed casted value of the option
393 */
394 public function get_option($name) {
395
396 if (!$this->has_option($name)) {
397 throw new invalid_coding_exception('Option not passed: '.$name);
398 }
399
400 return $this->options[$name];
401 }
402
403 /**
404 * Was the given option passed?
405 *
406 * @param string $name optionname
407 * @return bool
408 */
409 public function has_option($name) {
410 return array_key_exists($name, $this->options);
411 }
412
413 /**
414 * Initializes the input provider.
415 */
416 protected function initialize() {
417 $this->populate_options();
418 }
419
420 // End of external API
421
422 /**
423 * Parses and validates all supported options passed to the script.
424 */
425 protected function populate_options() {
426
427 $input = input_manager::instance();
428 $raw = $this->parse_raw_options();
429 $cooked = array();
430
431 foreach ($raw as $k => $v) {
432 if (is_array($v) or is_object($v)) {
433 // Not supported.
434 }
435
436 $info = $input->get_option_info($k);
437 if (!$info) {
438 continue;
439 }
440
441 $casted = $input->cast_value($v, $info->type);
442
443 if (!empty($info->shortname)) {
444 $cooked[$info->shortname] = $casted;
445 }
446
447 if (!empty($info->longname)) {
448 $cooked[$info->longname] = $casted;
449 }
450 }
451
452 // Store the options.
453 $this->options = $cooked;
454 }
455}
456
457
458/**
459 * Provides access to the script options passed via CLI.
460 *
461 * @copyright 2012 David Mudrak <david@moodle.com>
462 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
463 */
464class input_cli_provider extends input_provider {
465
466 /**
467 * Parses raw options passed to the script.
468 *
469 * @return array as returned by getopt()
470 */
471 protected function parse_raw_options() {
472
473 $input = input_manager::instance();
474
475 // Signatures of some in-built PHP functions are just crazy, aren't they.
476 $short = '';
477 $long = array();
478
479 foreach ($input->get_option_info() as $option) {
480 if ($option->type === input_manager::TYPE_FLAG) {
481 // No value expected for this option.
482 $short .= $option->shortname;
483 $long[] = $option->longname;
484 } else {
485 // A value expected for the option, all considered as optional.
486 $short .= empty($option->shortname) ? '' : $option->shortname.'::';
487 $long[] = empty($option->longname) ? '' : $option->longname.'::';
488 }
489 }
490
491 return getopt($short, $long);
492 }
493}
494
495
496/**
497 * Provides access to the script options passed via HTTP request.
498 *
499 * @copyright 2012 David Mudrak <david@moodle.com>
500 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
501 */
502class input_http_provider extends input_provider {
503
504 /**
505 * Parses raw options passed to the script.
506 *
507 * @return array of raw values passed via HTTP request
508 */
509 protected function parse_raw_options() {
3daedb5c 510 return $_POST;
89af1765
DM
511 }
512}
513
514
515// Output handling /////////////////////////////////////////////////////////////
516
517/**
c99910bb 518 * Provides output operations.
89af1765
DM
519 *
520 * @copyright 2012 David Mudrak <david@moodle.com>
521 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
522 */
523class output_manager extends singleton_pattern {
524
c99910bb
DM
525 /** @var output_cli_provider|output_http_provider the provider of the output functionality */
526 protected $outputprovider = null;
527
528 /**
529 * Magic method triggered when invoking an inaccessible method.
530 *
531 * @param string $name method name
532 * @param array $arguments method arguments
533 */
534 public function __call($name, array $arguments = array()) {
535 call_user_func_array(array($this->outputprovider, $name), $arguments);
536 }
537
538 /**
539 * Picks the appropriate helper class to delegate calls to.
540 */
541 protected function initialize() {
542 if (PHP_SAPI === 'cli') {
543 $this->outputprovider = output_cli_provider::instance();
544 } else {
545 $this->outputprovider = output_http_provider::instance();
546 }
547 }
89af1765
DM
548}
549
550
c99910bb
DM
551/**
552 * Base class for all output providers.
553 *
554 * @copyright 2012 David Mudrak <david@moodle.com>
555 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
556 */
557abstract class output_provider extends singleton_pattern {
558}
89af1765
DM
559
560/**
c99910bb 561 * Provides output to the command line.
89af1765 562 *
c99910bb
DM
563 * @copyright 2012 David Mudrak <david@moodle.com>
564 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
565 */
566class output_cli_provider extends output_provider {
567
568 /**
569 * Prints help information in CLI mode.
570 */
571 public function help() {
572
573 $this->outln('mdeploy.php - Moodle (http://moodle.org) deployment utility');
574 $this->outln();
575 $this->outln('Usage: $ sudo -u apache php mdeploy.php [options]');
576 $this->outln();
577 $input = input_manager::instance();
578 foreach($input->get_option_info() as $info) {
579 $option = array();
580 if (!empty($info->shortname)) {
581 $option[] = '-'.$info->shortname;
582 }
583 if (!empty($info->longname)) {
584 $option[] = '--'.$info->longname;
585 }
586 $this->outln(sprintf('%-20s %s', implode(', ', $option), $info->desc));
587 }
588 }
589
590 // End of external API
591
592 /**
593 * Writes a text to the STDOUT followed by a new line character.
594 *
595 * @param string $text text to print
596 */
597 protected function outln($text='') {
598 fputs(STDOUT, $text.PHP_EOL);
599 }
600}
601
602
603/**
604 * Provides HTML output as a part of HTTP response.
605 *
606 * @copyright 2012 David Mudrak <david@moodle.com>
607 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
608 */
609class output_http_provider extends output_provider {
610
611 /**
612 * Prints help on the script usage.
613 */
614 public function help() {
615 // No help available via HTTP
616 }
80e9ba96
DM
617
618 /**
619 * Display the information about uncaught exception
620 *
621 * @param Exception $e uncaught exception
622 */
623 public function exception(Exception $e) {
333048e0
DM
624
625 $docslink = 'http://docs.moodle.org/en/admin/mdeploy/'.get_class($e);
80e9ba96
DM
626 $this->start_output();
627 echo('<h1>Oops! It did it again</h1>');
333048e0
DM
628 echo('<p><strong>Moodle deployment utility had a trouble with your request.
629 See <a href="'.$docslink.'">the docs page</a> and the debugging information for more details.</strong></p>');
80e9ba96
DM
630 echo('<pre>');
631 echo exception_handlers::format_exception_info($e);
632 echo('</pre>');
633 $this->end_output();
634 }
635
636 // End of external API
637
638 /**
639 * Produce the HTML page header
640 */
641 protected function start_output() {
642 echo '<!doctype html>
643<html lang="en">
644<head>
645 <meta charset="utf-8">
646 <style type="text/css">
647 body {background-color:#666;font-family:"DejaVu Sans","Liberation Sans",Freesans,sans-serif;}
648 h1 {text-align:center;}
649 pre {white-space: pre-wrap;}
650 #page {background-color:#eee;width:1024px;margin:5em auto;border:3px solid #333;border-radius: 15px;padding:1em;}
651 </style>
652</head>
653<body>
654<div id="page">';
655 }
656
657 /**
658 * Produce the HTML page footer
659 */
660 protected function end_output() {
661 echo '</div></body></html>';
662 }
c99910bb
DM
663}
664
665// The main class providing all the functionality //////////////////////////////
666
667/**
668 * The actual worker class implementing the main functionality of the script.
89af1765
DM
669 *
670 * @copyright 2012 David Mudrak <david@moodle.com>
671 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
672 */
673class worker extends singleton_pattern {
674
af29dade
DM
675 const EXIT_OK = 0; // Success exit code.
676 const EXIT_HELP = 1; // Explicit help required.
677 const EXIT_UNKNOWN_ACTION = 127; // Neither -i nor -u provided.
678
c99910bb
DM
679 /** @var input_manager */
680 protected $input = null;
681
682 /** @var output_manager */
683 protected $output = null;
684
4c72f555
DM
685 /** @var int the most recent cURL error number, zero for no error */
686 private $curlerrno = null;
687
688 /** @var string the most recent cURL error message, empty string for no error */
689 private $curlerror = null;
690
691 /** @var array|false the most recent cURL request info, if it was successful */
692 private $curlinfo = null;
693
ec8e1cbc
DM
694 /** @var string the full path to the log file */
695 private $logfile = null;
696
89af1765 697 /**
c99910bb 698 * Main - the one that actually does something
89af1765 699 */
c99910bb
DM
700 public function execute() {
701
ec8e1cbc
DM
702 $this->log('=== MDEPLOY EXECUTION START ===');
703
af29dade
DM
704 // Authorize access. None in CLI. Passphrase in HTTP.
705 $this->authorize();
706
707 // Asking for help in the CLI mode.
c99910bb
DM
708 if ($this->input->get_option('help')) {
709 $this->output->help();
af29dade 710 $this->done(self::EXIT_HELP);
c99910bb 711 }
89af1765 712
af29dade 713 if ($this->input->get_option('upgrade')) {
ec8e1cbc
DM
714 $this->log('Plugin upgrade requested');
715
af29dade 716 // Fetch the ZIP file into a temporary location.
4c72f555 717 $source = $this->input->get_option('package');
4c72f555 718 $target = $this->target_location($source);
ec8e1cbc 719 $this->log('Downloading package '.$source);
af29dade 720
4c72f555 721 if ($this->download_file($source, $target)) {
ec8e1cbc 722 $this->log('Package downloaded into '.$target);
4c72f555
DM
723 } else {
724 $this->log('cURL error ' . $this->curlerrno . ' ' . $this->curlerror);
725 $this->log('Unable to download the file');
ec8e1cbc 726 throw new download_file_exception('Unable to download the package');
4c72f555
DM
727 }
728
6b75106a
DM
729 // Compare MD5 checksum of the ZIP file
730 $md5remote = $this->input->get_option('md5');
731 $md5local = md5_file($target);
732
733 if ($md5local !== $md5remote) {
734 $this->log('MD5 checksum failed. Expected: '.$md5remote.' Got: '.$md5local);
735 throw new checksum_exception('MD5 checksum failed');
736 }
ec8e1cbc 737 $this->log('MD5 checksum ok');
89af1765 738
4f71de41
DM
739 // Backup the current version of the plugin
740 $plugintyperoot = $this->input->get_option('typeroot');
741 $pluginname = $this->input->get_option('name');
742 $sourcelocation = $plugintyperoot.'/'.$pluginname;
743 $backuplocation = $this->backup_location($sourcelocation);
89af1765 744
ec8e1cbc
DM
745 $this->log('Current plugin code location: '.$sourcelocation);
746 $this->log('Moving the current code into archive: '.$backuplocation);
747
4f71de41
DM
748 // We don't want to touch files unless we are pretty sure it would be all ok.
749 if (!$this->move_directory_source_precheck($sourcelocation)) {
750 throw new backup_folder_exception('Unable to backup the current version of the plugin (source precheck failed)');
751 }
752 if (!$this->move_directory_target_precheck($backuplocation)) {
753 throw new backup_folder_exception('Unable to backup the current version of the plugin (backup precheck failed)');
754 }
755
756 // Looking good, let's try it.
757 if (!$this->move_directory($sourcelocation, $backuplocation)) {
758 throw new backup_folder_exception('Unable to backup the current version of the plugin (moving failed)');
759 }
760
23137c4a
DM
761 // Unzip the plugin package file into the target location.
762 $this->unzip_plugin($target, $plugintyperoot, $sourcelocation, $backuplocation);
ec8e1cbc 763 $this->log('Package successfully extracted');
89af1765 764
af29dade
DM
765 // Redirect to the given URL (in HTTP) or exit (in CLI).
766 $this->done();
89af1765 767
af29dade
DM
768 } else if ($this->input->get_option('install')) {
769 // Installing a new plugin not implemented yet.
770 }
771
772 // Print help in CLI by default.
773 $this->output->help();
774 $this->done(self::EXIT_UNKNOWN_ACTION);
89af1765
DM
775 }
776
80e9ba96
DM
777 /**
778 * Attempts to log a thrown exception
779 *
780 * @param Exception $e uncaught exception
781 */
782 public function log_exception(Exception $e) {
783 $this->log($e->__toString());
784 }
785
c99910bb
DM
786 /**
787 * Initialize the worker class.
788 */
789 protected function initialize() {
790 $this->input = input_manager::instance();
791 $this->output = output_manager::instance();
792 }
c57f18ad
DM
793
794 // End of external API
795
af29dade
DM
796 /**
797 * Finish this script execution.
798 *
799 * @param int $exitcode
800 */
801 protected function done($exitcode = self::EXIT_OK) {
802
803 if (PHP_SAPI === 'cli') {
804 exit($exitcode);
805
806 } else {
807 $returnurl = $this->input->get_option('returnurl');
23137c4a 808 $this->redirect($returnurl);
af29dade
DM
809 exit($exitcode);
810 }
811 }
812
c57f18ad
DM
813 /**
814 * Authorize access to the script.
815 *
816 * In CLI mode, the access is automatically authorized. In HTTP mode, the
817 * passphrase submitted via the request params must match the contents of the
818 * file, the name of which is passed in another parameter.
819 *
820 * @throws unauthorized_access_exception
821 */
822 protected function authorize() {
823
824 if (PHP_SAPI === 'cli') {
ec8e1cbc 825 $this->log('Successfully authorized using the CLI SAPI');
c57f18ad
DM
826 return;
827 }
828
829 $dataroot = $this->input->get_option('dataroot');
830 $passfile = $this->input->get_option('passfile');
831 $password = $this->input->get_option('password');
832
3daedb5c 833 $passpath = $dataroot.'/mdeploy/auth/'.$passfile;
c57f18ad
DM
834
835 if (!is_readable($passpath)) {
3daedb5c 836 throw new unauthorized_access_exception('Unable to read the passphrase file.');
c57f18ad
DM
837 }
838
3daedb5c 839 $stored = file($passpath, FILE_IGNORE_NEW_LINES);
c57f18ad 840
3daedb5c
DM
841 // "This message will self-destruct in five seconds." -- Mission Commander Swanbeck, Mission: Impossible II
842 unlink($passpath);
843
844 if (is_readable($passpath)) {
845 throw new unauthorized_access_exception('Unable to remove the passphrase file.');
846 }
847
848 if (count($stored) < 2) {
849 throw new unauthorized_access_exception('Invalid format of the passphrase file.');
850 }
851
852 if (time() - (int)$stored[1] > 30 * 60) {
853 throw new unauthorized_access_exception('Passphrase timeout.');
854 }
855
856 if (strlen($stored[0]) < 24) {
857 throw new unauthorized_access_exception('Session passphrase not long enough.');
858 }
859
860 if ($password !== $stored[0]) {
c57f18ad
DM
861 throw new unauthorized_access_exception('Session passphrase does not match the stored one.');
862 }
ec8e1cbc
DM
863
864 $this->log('Successfully authorized using the passphrase file');
865 }
866
867 /**
868 * Returns the full path to the log file.
869 *
870 * @return string
871 */
872 protected function log_location() {
873
874 if (!is_null($this->logfile)) {
875 return $this->logfile;
876 }
877
80e9ba96 878 $dataroot = $this->input->get_option('dataroot', '');
ec8e1cbc 879
80e9ba96 880 if (empty($dataroot)) {
ec8e1cbc
DM
881 $this->logfile = false;
882 return $this->logfile;
883 }
884
885 $myroot = $dataroot.'/mdeploy';
886
887 if (!is_dir($myroot)) {
888 mkdir($myroot, 02777, true);
889 }
890
891 $this->logfile = $myroot.'/mdeploy.log';
892 return $this->logfile;
c57f18ad 893 }
4c72f555
DM
894
895 /**
896 * Choose the target location for the given ZIP's URL.
897 *
898 * @param string $source URL
899 * @return string
900 */
901 protected function target_location($source) {
902
903 $dataroot = $this->input->get_option('dataroot');
904 $pool = $dataroot.'/mdeploy/var';
905
906 if (!is_dir($pool)) {
907 mkdir($pool, 02777, true);
908 }
909
910 $target = $pool.'/'.md5($source);
911
912 $suffix = 0;
913 while (file_exists($target.'.'.$suffix.'.zip')) {
914 $suffix++;
915 }
916
917 return $target.'.'.$suffix.'.zip';
918 }
919
4f71de41
DM
920 /**
921 * Choose the location of the current plugin folder backup
922 *
923 * @param string $path full path to the current folder
924 * @return string
925 */
926 protected function backup_location($path) {
927
928 $dataroot = $this->input->get_option('dataroot');
929 $pool = $dataroot.'/mdeploy/archive';
930
931 if (!is_dir($pool)) {
932 mkdir($pool, 02777, true);
933 }
934
935 $target = $pool.'/'.basename($path).'_'.time();
936
937 $suffix = 0;
938 while (file_exists($target.'.'.$suffix)) {
939 $suffix++;
940 }
941
942 return $target.'.'.$suffix;
943 }
944
4c72f555
DM
945 /**
946 * Downloads the given file into the given destination.
947 *
948 * This is basically a simplified version of {@link download_file_content()} from
949 * Moodle itself, tuned for fetching files from moodle.org servers.
950 *
951 * @param string $source file url starting with http(s)://
952 * @param string $target store the downloaded content to this file (full path)
953 * @return bool true on success, false otherwise
954 * @throws download_file_exception
955 */
956 protected function download_file($source, $target) {
957
958 $newlines = array("\r", "\n");
959 $source = str_replace($newlines, '', $source);
960 if (!preg_match('|^https?://|i', $source)) {
961 throw new download_file_exception('Unsupported transport protocol.');
962 }
963 if (!$ch = curl_init($source)) {
ec8e1cbc 964 $this->log('Unable to init cURL.');
4c72f555
DM
965 return false;
966 }
967
968 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); // verify the peer's certificate
969 curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // check the existence of a common name and also verify that it matches the hostname provided
970 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // return the transfer as a string
971 curl_setopt($ch, CURLOPT_HEADER, false); // don't include the header in the output
972 curl_setopt($ch, CURLOPT_TIMEOUT, 3600);
973 curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 20); // nah, moodle.org is never unavailable! :-p
974 curl_setopt($ch, CURLOPT_URL, $source);
975
e428b5e0
DM
976 $dataroot = $this->input->get_option('dataroot');
977 $cacertfile = $dataroot.'/moodleorgca.crt';
978 if (is_readable($cacertfile)) {
979 // Do not use CA certs provided by the operating system. Instead,
980 // use this CA cert to verify the ZIP provider.
981 $this->log('Using custom CA certificate '.$cacertfile);
982 curl_setopt($ch, CURLOPT_CAINFO, $cacertfile);
983 }
984
42c6731c
DM
985 $proxy = $this->input->get_option('proxy', false);
986 if (!empty($proxy)) {
987 curl_setopt($ch, CURLOPT_PROXY, $proxy);
988
989 $proxytype = $this->input->get_option('proxytype', false);
990 if (strtoupper($proxytype) === 'SOCKS5') {
991 $this->log('Using SOCKS5 proxy');
992 curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5);
993 } else if (!empty($proxytype)) {
994 $this->log('Using HTTP proxy');
995 curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_HTTP);
996 curl_setopt($ch, CURLOPT_HTTPPROXYTUNNEL, false);
997 }
998
999 $proxyuserpwd = $this->input->get_option('proxyuserpwd', false);
1000 if (!empty($proxyuserpwd)) {
1001 curl_setopt($ch, CURLOPT_PROXYUSERPWD, $proxyuserpwd);
1002 curl_setopt($ch, CURLOPT_PROXYAUTH, CURLAUTH_BASIC | CURLAUTH_NTLM);
1003 }
1004 }
1005
4c72f555
DM
1006 $targetfile = fopen($target, 'w');
1007
1008 if (!$targetfile) {
1009 throw new download_file_exception('Unable to create local file '.$target);
1010 }
1011
1012 curl_setopt($ch, CURLOPT_FILE, $targetfile);
1013
1014 $result = curl_exec($ch);
1015
1016 // try to detect encoding problems
1017 if ((curl_errno($ch) == 23 or curl_errno($ch) == 61) and defined('CURLOPT_ENCODING')) {
1018 curl_setopt($ch, CURLOPT_ENCODING, 'none');
1019 $result = curl_exec($ch);
1020 }
1021
1022 fclose($targetfile);
1023
1024 $this->curlerrno = curl_errno($ch);
1025 $this->curlerror = curl_error($ch);
1026 $this->curlinfo = curl_getinfo($ch);
1027
1028 if (!$result or $this->curlerrno) {
1029 return false;
1030
1031 } else if (is_array($this->curlinfo) and (empty($this->curlinfo['http_code']) or $this->curlinfo['http_code'] != 200)) {
1032 return false;
1033 }
1034
1035 return true;
1036 }
1037
1038 /**
1039 * Log a message
1040 *
1041 * @param string $message
1042 */
1043 protected function log($message) {
ec8e1cbc
DM
1044
1045 $logpath = $this->log_location();
1046
1047 if (empty($logpath)) {
1048 // no logging available
1049 return;
1050 }
1051
1052 $f = fopen($logpath, 'ab');
1053
1054 if ($f === false) {
1055 throw new filesystem_exception('Unable to open the log file for appending');
1056 }
1057
1058 $message = $this->format_log_message($message);
1059
1060 fwrite($f, $message);
1061
1062 fclose($f);
1063 }
1064
1065 /**
1066 * Prepares the log message for writing into the file
1067 *
1068 * @param string $msg
1069 * @return string
1070 */
1071 protected function format_log_message($msg) {
1072
1073 $msg = trim($msg);
1074 $timestamp = date("Y-m-d H:i:s");
1075
1076 return $timestamp . ' '. $msg . PHP_EOL;
4c72f555 1077 }
4f71de41
DM
1078
1079 /**
1080 * Checks to see if the given source could be safely moved into a new location
1081 *
1082 * @param string $source full path to the existing directory
1083 * @return bool
1084 */
1085 protected function move_directory_source_precheck($source) {
1086
0daa6428
DM
1087 if (!is_writable($source)) {
1088 return false;
1089 }
1090
4f71de41
DM
1091 if (is_dir($source)) {
1092 $handle = opendir($source);
1093 } else {
1094 return false;
1095 }
1096
1097 $result = true;
1098
1099 while ($filename = readdir($handle)) {
1100 $sourcepath = $source.'/'.$filename;
1101
1102 if ($filename === '.' or $filename === '..') {
1103 continue;
1104 }
1105
1106 if (is_dir($sourcepath)) {
1107 $result = $result && $this->move_directory_source_precheck($sourcepath);
1108
1109 } else {
1110 $result = $result && is_writable($sourcepath);
1111 }
1112 }
1113
1114 closedir($handle);
0daa6428
DM
1115
1116 return $result;
4f71de41
DM
1117 }
1118
1119 /**
1120 * Checks to see if a source foldr could be safely moved into the given new location
1121 *
1122 * @param string $destination full path to the new expected location of a folder
1123 * @return bool
1124 */
1125 protected function move_directory_target_precheck($target) {
1126
1127 if (file_exists($target)) {
1128 return false;
1129 }
1130
1131 $result = mkdir($target, 02777) && rmdir($target);
1132
1133 return $result;
1134 }
1135
1136 /**
1137 * Moves the given source into a new location recursively
1138 *
b68bbc5a
DM
1139 * The target location can not exist.
1140 *
4f71de41
DM
1141 * @param string $source full path to the existing directory
1142 * @param string $destination full path to the new location of the folder
b68bbc5a 1143 * @param bool $keepsourceroot should the root of the $source be kept or removed at the end
4f71de41
DM
1144 * @return bool
1145 */
b68bbc5a 1146 protected function move_directory($source, $target, $keepsourceroot = false) {
4f71de41
DM
1147
1148 if (file_exists($target)) {
23137c4a 1149 throw new filesystem_exception('Unable to move the directory - target location already exists');
4f71de41
DM
1150 }
1151
b68bbc5a
DM
1152 return $this->move_directory_into($source, $target, $keepsourceroot);
1153 }
1154
1155 /**
1156 * Moves the given source into a new location recursively
1157 *
1158 * If the target already exists, files are moved into it. The target is created otherwise.
1159 *
1160 * @param string $source full path to the existing directory
1161 * @param string $destination full path to the new location of the folder
1162 * @param bool $keepsourceroot should the root of the $source be kept or removed at the end
1163 * @return bool
1164 */
1165 protected function move_directory_into($source, $target, $keepsourceroot = false) {
1166
4f71de41
DM
1167 if (is_dir($source)) {
1168 $handle = opendir($source);
1169 } else {
23137c4a 1170 throw new filesystem_exception('Source location is not a directory');
4f71de41
DM
1171 }
1172
b68bbc5a
DM
1173 if (is_dir($target)) {
1174 $result = true;
1175 } else {
1176 $result = mkdir($target, 02777);
1177 }
4f71de41
DM
1178
1179 while ($filename = readdir($handle)) {
1180 $sourcepath = $source.'/'.$filename;
1181 $targetpath = $target.'/'.$filename;
1182
1183 if ($filename === '.' or $filename === '..') {
1184 continue;
1185 }
1186
1187 if (is_dir($sourcepath)) {
b68bbc5a 1188 $result = $result && $this->move_directory($sourcepath, $targetpath, false);
4f71de41
DM
1189
1190 } else {
b68bbc5a 1191 $result = $result && rename($sourcepath, $targetpath);
4f71de41
DM
1192 }
1193 }
1194
1195 closedir($handle);
b68bbc5a
DM
1196
1197 if (!$keepsourceroot) {
1198 $result = $result && rmdir($source);
1199 }
1200
1201 clearstatcache();
1202
1203 return $result;
4f71de41 1204 }
23137c4a
DM
1205
1206 /**
1207 * Deletes the given directory recursively
1208 *
1209 * @param string $path full path to the directory
1210 */
1211 protected function remove_directory($path) {
1212
1213 if (!file_exists($path)) {
1214 return;
1215 }
1216
1217 if (is_dir($path)) {
1218 $handle = opendir($path);
1219 } else {
1220 throw new filesystem_exception('Given path is not a directory');
1221 }
1222
1223 while ($filename = readdir($handle)) {
1224 $filepath = $path.'/'.$filename;
1225
1226 if ($filename === '.' or $filename === '..') {
1227 continue;
1228 }
1229
1230 if (is_dir($filepath)) {
1231 $this->remove_directory($filepath);
1232
1233 } else {
1234 unlink($filepath);
1235 }
1236 }
1237
1238 closedir($handle);
1239 return rmdir($path);
1240 }
1241
1242 /**
1243 * Unzip the file obtained from the Plugins directory to this site
1244 *
1245 * @param string $ziplocation full path to the ZIP file
1246 * @param string $plugintyperoot full path to the plugin's type location
1247 * @param string $expectedlocation expected full path to the plugin after it is extracted
1248 * @param string $backuplocation location of the previous version of the plugin
1249 */
1250 protected function unzip_plugin($ziplocation, $plugintyperoot, $expectedlocation, $backuplocation) {
1251
1252 $zip = new ZipArchive();
1253 $result = $zip->open($ziplocation);
1254
1255 if ($result !== true) {
1256 $this->move_directory($backuplocation, $expectedlocation);
1257 throw new zip_exception('Unable to open the zip package');
1258 }
1259
1260 // Make sure that the ZIP has expected structure
1261 $pluginname = basename($expectedlocation);
1262 for ($i = 0; $i < $zip->numFiles; $i++) {
1263 $stat = $zip->statIndex($i);
1264 $filename = $stat['name'];
1265 $filename = explode('/', $filename);
1266 if ($filename[0] !== $pluginname) {
1267 $zip->close();
1268 throw new zip_exception('Invalid structure of the zip package');
1269 }
1270 }
1271
1272 if (!$zip->extractTo($plugintyperoot)) {
1273 $zip->close();
1274 $this->remove_directory($expectedlocation); // just in case something was created
1275 $this->move_directory($backuplocation, $expectedlocation);
1276 throw new zip_exception('Unable to extract the zip package');
1277 }
1278
1279 $zip->close();
b10b1e72 1280 unlink($ziplocation);
23137c4a
DM
1281 }
1282
1283 /**
1284 * Redirect the browser
1285 *
1286 * @todo check if there has been some output yet
1287 * @param string $url
1288 */
1289 protected function redirect($url) {
1290 header('Location: '.$url);
1291 }
89af1765
DM
1292}
1293
1294
80e9ba96
DM
1295/**
1296 * Provides exception handlers for this script
1297 */
1298class exception_handlers {
1299
1300 /**
1301 * Sets the exception handler
1302 *
1303 *
1304 * @param string $handler name
1305 */
1306 public static function set_handler($handler) {
1307
1308 if (PHP_SAPI === 'cli') {
1309 // No custom handler available for CLI mode.
1310 set_exception_handler(null);
1311 return;
1312 }
1313
1314 set_exception_handler('exception_handlers::'.$handler.'_exception_handler');
1315 }
1316
1317 /**
1318 * Returns the text describing the thrown exception
1319 *
1320 * By default, PHP displays full path to scripts when the exception is thrown. In order to prevent
1321 * sensitive information leak (and yes, the path to scripts at a web server _is_ sensitive information)
1322 * the path to scripts is removed from the message.
1323 *
1324 * @param Exception $e thrown exception
1325 * @return string
1326 */
1327 public static function format_exception_info(Exception $e) {
1328
1329 $mydir = dirname(__FILE__).'/';
1330 $text = $e->__toString();
1331 $text = str_replace($mydir, '', $text);
1332 return $text;
1333 }
1334
1335 /**
1336 * Very basic exception handler
1337 *
1338 * @param Exception $e uncaught exception
1339 */
1340 public static function bootstrap_exception_handler(Exception $e) {
1341 echo('<h1>Oops! It did it again</h1>');
1342 echo('<p><strong>Moodle deployment utility had a trouble with your request. See the debugging information for more details.</strong></p>');
1343 echo('<pre>');
1344 echo self::format_exception_info($e);
1345 echo('</pre>');
1346 }
1347
1348 /**
1349 * Default exception handler
1350 *
1351 * When this handler is used, input_manager and output_manager singleton instances already
1352 * exist in the memory and can be used.
1353 *
1354 * @param Exception $e uncaught exception
1355 */
1356 public static function default_exception_handler(Exception $e) {
1357
1358 $worker = worker::instance();
1359 $worker->log_exception($e);
1360
1361 $output = output_manager::instance();
1362 $output->exception($e);
1363 }
1364}
1365
89af1765
DM
1366////////////////////////////////////////////////////////////////////////////////
1367
1368// Check if the script is actually executed or if it was just included by someone
1369// else - typically by the PHPUnit. This is a PHP alternative to the Python's
1370// if __name__ == '__main__'
89af1765 1371if (!debug_backtrace()) {
c99910bb 1372 // We are executed by the SAPI.
80e9ba96 1373 exception_handlers::set_handler('bootstrap');
89af1765
DM
1374 // Initialize the worker class to actually make the job.
1375 $worker = worker::instance();
80e9ba96 1376 exception_handlers::set_handler('default');
89af1765
DM
1377
1378 // Lights, Camera, Action!
c99910bb 1379 $worker->execute();
89af1765
DM
1380
1381} else {
1382 // We are included - probably by some unit testing framework. Do nothing.
1383}