MDL-49329 admin: Get rid of mdeploy and \core\update\deployer class
[moodle.git] / admin / tool / installaddon / classes / installer.php
1 <?php
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/>.
18 /**
19  * Provides tool_installaddon_installer related classes
20  *
21  * @package     tool_installaddon
22  * @subpackage  classes
23  * @copyright   2013 David Mudrak <david@moodle.com>
24  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25  */
27 defined('MOODLE_INTERNAL') || die();
29 /**
30  * Implements main plugin features.
31  *
32  * @copyright 2013 David Mudrak <david@moodle.com>
33  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
34  */
35 class tool_installaddon_installer {
37     /** @var tool_installaddon_installfromzip_form */
38     protected $installfromzipform = null;
40     /**
41      * Factory method returning an instance of this class.
42      *
43      * @return tool_installaddon_installer
44      */
45     public static function instance() {
46         return new static();
47     }
49     /**
50      * Returns the URL to the main page of this admin tool
51      *
52      * @param array optional parameters
53      * @return moodle_url
54      */
55     public function index_url(array $params = null) {
56         return new moodle_url('/admin/tool/installaddon/index.php', $params);
57     }
59     /**
60      * Returns URL to the repository that addons can be searched in and installed from
61      *
62      * @return moodle_url
63      */
64     public function get_addons_repository_url() {
65         global $CFG;
67         if (!empty($CFG->config_php_settings['alternativeaddonsrepositoryurl'])) {
68             $url = $CFG->config_php_settings['alternativeaddonsrepositoryurl'];
69         } else {
70             $url = 'https://moodle.org/plugins/get.php';
71         }
73         if (!$this->should_send_site_info()) {
74             return new moodle_url($url);
75         }
77         // Append the basic information about our site.
78         $site = array(
79             'fullname' => $this->get_site_fullname(),
80             'url' => $this->get_site_url(),
81             'majorversion' => $this->get_site_major_version(),
82         );
84         $site = $this->encode_site_information($site);
86         return new moodle_url($url, array('site' => $site));
87     }
89     /**
90      * @return tool_installaddon_installfromzip_form
91      */
92     public function get_installfromzip_form() {
93         if (!is_null($this->installfromzipform)) {
94             return $this->installfromzipform;
95         }
97         $action = $this->index_url();
98         $customdata = array('installer' => $this);
100         $this->installfromzipform = new tool_installaddon_installfromzip_form($action, $customdata);
102         return $this->installfromzipform;
103     }
105     /**
106      * Saves the ZIP file from the {@link tool_installaddon_installfromzip_form} form
107      *
108      * The file is saved into the given temporary location for inspection and eventual
109      * deployment. The form is expected to be submitted and validated.
110      *
111      * @param tool_installaddon_installfromzip_form $form
112      * @param string $targetdir full path to the directory where the ZIP should be stored to
113      * @return string filename of the saved file relative to the given target
114      */
115     public function save_installfromzip_file(tool_installaddon_installfromzip_form $form, $targetdir) {
117         $filename = clean_param($form->get_new_filename('zipfile'), PARAM_FILE);
118         $form->save_file('zipfile', $targetdir.'/'.$filename);
120         return $filename;
121     }
123     /**
124      * Extracts the saved file previously saved by {self::save_installfromzip_file()}
125      *
126      * The list of files found in the ZIP is returned via $zipcontentfiles parameter
127      * by reference. The format of that list is array of (string)filerelpath => (bool|string)
128      * where the array value is either true or a string describing the problematic file.
129      *
130      * @see zip_packer::extract_to_pathname()
131      * @param string $zipfilepath full path to the saved ZIP file
132      * @param string $targetdir full path to the directory to extract the ZIP file to
133      * @param string $rootdir explicitly rename the root directory of the ZIP into this non-empty value
134      * @param array list of extracted files as returned by {@link zip_packer::extract_to_pathname()}
135      */
136     public function extract_installfromzip_file($zipfilepath, $targetdir, $rootdir = '') {
137         global $CFG;
138         require_once($CFG->libdir.'/filelib.php');
140         $fp = get_file_packer('application/zip');
141         $files = $fp->extract_to_pathname($zipfilepath, $targetdir);
143         if (!$files) {
144             return array();
145         }
147         if (!empty($rootdir)) {
148             $files = $this->rename_extracted_rootdir($targetdir, $rootdir, $files);
149         }
151         // Sometimes zip may not contain all parent directories, add them to make it consistent.
152         foreach ($files as $path => $status) {
153             if ($status !== true) {
154                 continue;
155             }
156             $parts = explode('/', trim($path, '/'));
157             while (array_pop($parts)) {
158                 if (empty($parts)) {
159                     break;
160                 }
161                 $dir = implode('/', $parts).'/';
162                 if (!isset($files[$dir])) {
163                     $files[$dir] = true;
164                 }
165             }
166         }
168         return $files;
169     }
171     /**
172      * Returns localised list of available plugin types
173      *
174      * @return array (string)plugintype => (string)plugin name
175      */
176     public function get_plugin_types_menu() {
177         global $CFG;
179         $pluginman = core_plugin_manager::instance();
181         $menu = array('' => get_string('choosedots'));
182         foreach (array_keys($pluginman->get_plugin_types()) as $plugintype) {
183             $menu[$plugintype] = $pluginman->plugintype_name($plugintype).' ('.$plugintype.')';
184         }
186         return $menu;
187     }
189     /**
190      * Returns the full path of the root of the given plugin type
191      *
192      * Null is returned if the plugin type is not known. False is returned if the plugin type
193      * root is expected but not found. Otherwise, string is returned.
194      *
195      * @param string $plugintype
196      * @return string|bool|null
197      */
198     public function get_plugintype_root($plugintype) {
200         $plugintypepath = null;
201         foreach (core_component::get_plugin_types() as $type => $fullpath) {
202             if ($type === $plugintype) {
203                 $plugintypepath = $fullpath;
204                 break;
205             }
206         }
207         if (is_null($plugintypepath)) {
208             return null;
209         }
211         if (!is_dir($plugintypepath)) {
212             return false;
213         }
215         return $plugintypepath;
216     }
218     /**
219      * Is it possible to create a new plugin directory for the given plugin type?
220      *
221      * @throws coding_exception for invalid plugin types or non-existing plugin type locations
222      * @param string $plugintype
223      * @return boolean
224      */
225     public function is_plugintype_writable($plugintype) {
227         $plugintypepath = $this->get_plugintype_root($plugintype);
229         if (is_null($plugintypepath)) {
230             throw new coding_exception('Unknown plugin type!');
231         }
233         if ($plugintypepath === false) {
234             throw new coding_exception('Plugin type location does not exist!');
235         }
237         return is_writable($plugintypepath);
238     }
240     /**
241      * Hook method to handle the remote request to install an add-on
242      *
243      * This is used as a callback when the admin picks a plugin version in the
244      * Moodle Plugins directory and is redirected back to their site to install
245      * it.
246      *
247      * This hook is called early from admin/tool/installaddon/index.php page so that
248      * it has opportunity to take over the UI.
249      *
250      * @param tool_installaddon_renderer $output
251      * @param string|null $request
252      * @param bool $confirmed
253      */
254     public function handle_remote_request(tool_installaddon_renderer $output, $request, $confirmed = false) {
256         if (is_null($request)) {
257             return;
258         }
260         $data = $this->decode_remote_request($request);
262         if ($data === false) {
263             echo $output->remote_request_invalid_page($this->index_url());
264             exit();
265         }
267         list($plugintype, $pluginname) = core_component::normalize_component($data->component);
269         $plugintypepath = $this->get_plugintype_root($plugintype);
271         if (file_exists($plugintypepath.'/'.$pluginname)) {
272             echo $output->remote_request_alreadyinstalled_page($data, $this->index_url());
273             exit();
274         }
276         if (!$this->is_plugintype_writable($plugintype)) {
277             $continueurl = $this->index_url(array('installaddonrequest' => $request));
278             echo $output->remote_request_permcheck_page($data, $plugintypepath, $continueurl, $this->index_url());
279             exit();
280         }
282         $continueurl = $this->index_url(array(
283             'installaddonrequest' => $request,
284             'confirm' => 1,
285             'sesskey' => sesskey()));
287         if (!$confirmed) {
288             echo $output->remote_request_confirm_page($data, $continueurl, $this->index_url());
289             exit();
290         }
292         // The admin has confirmed their intention to install the add-on.
293         require_sesskey();
295         // Fetch the plugin info. The essential information is the URL to download the ZIP
296         // and the MD5 hash of the ZIP, obtained via HTTPS.
297         $client = \core\update\api::client();
298         $pluginfo = $client->get_plugin_info($data->component, $data->version);
300         if (empty($pluginfo) or empty($pluginfo->version)) {
301             echo $output->remote_request_pluginfo_failure($data, $this->index_url());
302             exit();
303         }
305         // Fetch the ZIP with the plugin version
306         $jobid = md5(rand().uniqid('', true));
307         $sourcedir = make_temp_directory('tool_installaddon/'.$jobid.'/source');
308         $zipfilename = 'downloaded.zip';
310         try {
311             $this->download_file($pluginfo->version->downloadurl, $sourcedir.'/'.$zipfilename);
313         } catch (tool_installaddon_installer_exception $e) {
314             if (debugging()) {
315                 throw $e;
316             } else {
317                 echo $output->installer_exception($e, $this->index_url());
318                 exit();
319             }
320         }
322         // Check the MD5 checksum
323         $md5expected = $pluginfo->version->downloadmd5;
324         $md5actual = md5_file($sourcedir.'/'.$zipfilename);
325         if ($md5expected !== $md5actual) {
326             $e = new tool_installaddon_installer_exception('err_zip_md5', array('expected' => $md5expected, 'actual' => $md5actual));
327             if (debugging()) {
328                 throw $e;
329             } else {
330                 echo $output->installer_exception($e, $this->index_url());
331                 exit();
332             }
333         }
335         // Redirect to the validation page.
336         $nexturl = new moodle_url('/admin/tool/installaddon/validate.php', array(
337             'sesskey' => sesskey(),
338             'jobid' => $jobid,
339             'zip' => $zipfilename,
340             'type' => $plugintype));
341         redirect($nexturl);
342     }
344     /**
345      * Download the given file into the given destination.
346      *
347      * This is basically a simplified version of {@link download_file_content()} from
348      * Moodle itself, tuned for fetching files from moodle.org servers. Same code is used
349      * in mdeploy.php for fetching available updates.
350      *
351      * TODO This all will be rewritten to use new plugin manager features.
352      *
353      * @param string $source file url starting with http(s)://
354      * @param string $target store the downloaded content to this file (full path)
355      * @throws tool_installaddon_installer_exception
356      */
357     public function download_file($source, $target) {
358         global $CFG;
359         require_once($CFG->libdir.'/filelib.php');
361         $targetfile = fopen($target, 'w');
363         if (!$targetfile) {
364             throw new tool_installaddon_installer_exception('err_download_write_file', $target);
365         }
367         $options = array(
368             'file' => $targetfile,
369             'timeout' => 300,
370             'followlocation' => true,
371             'maxredirs' => 3,
372             'ssl_verifypeer' => true,
373             'ssl_verifyhost' => 2,
374         );
376         $curl = new curl(array('proxy' => true));
378         $result = $curl->download_one($source, null, $options);
380         $curlinfo = $curl->get_info();
382         fclose($targetfile);
384         if ($result !== true) {
385             throw new tool_installaddon_installer_exception('err_curl_exec', array(
386                 'url' => $source, 'errorno' => $curl->get_errno(), 'error' => $result));
388         } else if (empty($curlinfo['http_code']) or $curlinfo['http_code'] != 200) {
389             throw new tool_installaddon_installer_exception('err_curl_http_code', array(
390                 'url' => $source, 'http_code' => $curlinfo['http_code']));
392         } else if (isset($curlinfo['ssl_verify_result']) and $curlinfo['ssl_verify_result'] != 0) {
393             throw new tool_installaddon_installer_exception('err_curl_ssl_verify', array(
394                 'url' => $source, 'ssl_verify_result' => $curlinfo['ssl_verify_result']));
395         }
396     }
398     /**
399      * Moves the given source into a new location recursively
400      *
401      * This is cross-device safe implementation to be used instead of the native rename() function.
402      * See https://bugs.php.net/bug.php?id=54097 for more details.
403      *
404      * @param string $source full path to the existing directory
405      * @param string $target full path to the new location of the directory
406      * @param int $dirpermissions
407      * @param int $filepermissions
408      */
409     public function move_directory($source, $target, $dirpermissions, $filepermissions) {
411         if (file_exists($target)) {
412             throw new tool_installaddon_installer_exception('err_folder_already_exists', array('path' => $target));
413         }
415         if (is_dir($source)) {
416             $handle = opendir($source);
417         } else {
418             throw new tool_installaddon_installer_exception('err_no_such_folder', array('path' => $source));
419         }
421         if (!file_exists($target)) {
422             // Do not use make_writable_directory() here - it is intended for dataroot only.
423             mkdir($target, true);
424             @chmod($target, $dirpermissions);
425         }
427         if (!is_writable($target)) {
428             closedir($handle);
429             throw new tool_installaddon_installer_exception('err_folder_not_writable', array('path' => $target));
430         }
432         while ($filename = readdir($handle)) {
433             $sourcepath = $source.'/'.$filename;
434             $targetpath = $target.'/'.$filename;
436             if ($filename === '.' or $filename === '..') {
437                 continue;
438             }
440             if (is_dir($sourcepath)) {
441                 $this->move_directory($sourcepath, $targetpath, $dirpermissions, $filepermissions);
443             } else {
444                 rename($sourcepath, $targetpath);
445                 @chmod($targetpath, $filepermissions);
446             }
447         }
449         closedir($handle);
451         rmdir($source);
453         clearstatcache();
454     }
456     /**
457      * Detect the given plugin's component name
458      *
459      * Only plugins that declare valid $plugin->component value in the version.php
460      * are supported.
461      *
462      * @param string $zipfilepath full path to the saved ZIP file
463      * @param string $workdir full path to the directory we can use for extracting required bits from the archive
464      * @return string|bool declared component name or false if unable to detect
465      */
466     public function detect_plugin_component($zipfilepath, $workdir) {
468         $versionphp = $this->extract_versionphp_file($zipfilepath, $workdir);
470         if (empty($versionphp)) {
471             return false;
472         }
474         return $this->detect_plugin_component_from_versionphp(file_get_contents($workdir.'/'.$versionphp));
475     }
477     //// End of external API ///////////////////////////////////////////////////
479     /**
480      * @see self::instance()
481      */
482     protected function __construct() {
483     }
485     /**
486      * @return string this site full name
487      */
488     protected function get_site_fullname() {
489         global $SITE;
491         return strip_tags($SITE->fullname);
492     }
494     /**
495      * @return string this site URL
496      */
497     protected function get_site_url() {
498         global $CFG;
500         return $CFG->wwwroot;
501     }
503     /**
504      * @return string major version like 2.5, 2.6 etc.
505      */
506     protected function get_site_major_version() {
507         return moodle_major_version();
508     }
510     /**
511      * Encodes the given array in a way that can be safely appended as HTTP GET param
512      *
513      * Be ware! The recipient may rely on the exact way how the site information is encoded.
514      * Do not change anything here unless you know what you are doing and understand all
515      * consequences! (Don't you love warnings like that, too? :-p)
516      *
517      * @param array $info
518      * @return string
519      */
520     protected function encode_site_information(array $info) {
521         return base64_encode(json_encode($info));
522     }
524     /**
525      * Decide if the encoded site information should be sent to the add-ons repository site
526      *
527      * For now, we just return true. In the future, we may want to implement some
528      * privacy aware logic (based on site/user preferences for example).
529      *
530      * @return bool
531      */
532     protected function should_send_site_info() {
533         return true;
534     }
536     /**
537      * Renames the root directory of the extracted ZIP package.
538      *
539      * This method does not validate the presence of the single root directory
540      * (the validator does it later). It just searches for the first directory
541      * under the given location and renames it.
542      *
543      * The method will not rename the root if the requested location already
544      * exists.
545      *
546      * @param string $dirname the location of the extracted ZIP package
547      * @param string $rootdir the requested name of the root directory
548      * @param array $files list of extracted files
549      * @return array eventually amended list of extracted files
550      */
551     protected function rename_extracted_rootdir($dirname, $rootdir, array $files) {
553         if (!is_dir($dirname)) {
554             debugging('Unable to rename rootdir of non-existing content', DEBUG_DEVELOPER);
555             return $files;
556         }
558         if (file_exists($dirname.'/'.$rootdir)) {
559             debugging('Unable to rename rootdir to already existing folder', DEBUG_DEVELOPER);
560             return $files;
561         }
563         $found = null; // The name of the first subdirectory under the $dirname.
564         foreach (scandir($dirname) as $item) {
565             if (substr($item, 0, 1) === '.') {
566                 continue;
567             }
568             if (is_dir($dirname.'/'.$item)) {
569                 $found = $item;
570                 break;
571             }
572         }
574         if (!is_null($found)) {
575             if (rename($dirname.'/'.$found, $dirname.'/'.$rootdir)) {
576                 $newfiles = array();
577                 foreach ($files as $filepath => $status) {
578                     $newpath = preg_replace('~^'.preg_quote($found.'/').'~', preg_quote($rootdir.'/'), $filepath);
579                     $newfiles[$newpath] = $status;
580                 }
581                 return $newfiles;
582             }
583         }
585         return $files;
586     }
588     /**
589      * Decode the request from the Moodle Plugins directory
590      *
591      * @param string $request submitted via 'installaddonrequest' HTTP parameter
592      * @return stdClass|bool false on error, object otherwise
593      */
594     protected function decode_remote_request($request) {
596         $data = base64_decode($request, true);
598         if ($data === false) {
599             return false;
600         }
602         $data = json_decode($data);
604         if (is_null($data)) {
605             return false;
606         }
608         if (!isset($data->name) or !isset($data->component) or !isset($data->version)) {
609             return false;
610         }
612         $data->name = s(strip_tags($data->name));
614         if ($data->component !== clean_param($data->component, PARAM_COMPONENT)) {
615             return false;
616         }
618         list($plugintype, $pluginname) = core_component::normalize_component($data->component);
620         if ($plugintype === 'core') {
621             return false;
622         }
624         if ($data->component !== $plugintype.'_'.$pluginname) {
625             return false;
626         }
628         if (!core_component::is_valid_plugin_name($plugintype, $pluginname)) {
629             return false;
630         }
632         $plugintypes = core_component::get_plugin_types();
633         if (!isset($plugintypes[$plugintype])) {
634             return false;
635         }
637         // Keep this regex in sync with the one used by the download.moodle.org/api/x.y/pluginfo.php
638         if (!preg_match('/^[0-9]+$/', $data->version)) {
639             return false;
640         }
642         return $data;
643     }
645     /**
646      * Extracts the version.php from the given plugin ZIP file into the target directory
647      *
648      * @param string $zipfilepath full path to the saved ZIP file
649      * @param string $targetdir full path to extract the file to
650      * @return string|bool path to the version.php within the $targetpath; false on error (e.g. not found)
651      */
652     protected function extract_versionphp_file($zipfilepath, $targetdir) {
653         global $CFG;
654         require_once($CFG->libdir.'/filelib.php');
656         $fp = get_file_packer('application/zip');
657         $files = $fp->list_files($zipfilepath);
659         if (empty($files)) {
660             return false;
661         }
663         $rootdirname = null;
664         $found = null;
666         foreach ($files as $file) {
667             // Valid plugin ZIP package has just one root directory with all
668             // files in it.
669             $pathnameitems = explode('/', $file->pathname);
671             if (empty($pathnameitems)) {
672                 return false;
673             }
675             // Set the expected name of the root directory in the first
676             // iteration of the loop.
677             if ($rootdirname === null) {
678                 $rootdirname = $pathnameitems[0];
679             }
681             // Require the same root directory for all files in the ZIP
682             // package.
683             if ($rootdirname !== $pathnameitems[0]) {
684                 return false;
685             }
687             // If we reached the valid version.php file, remember it.
688             if ($pathnameitems[1] === 'version.php' and !$file->is_directory and $file->size > 0) {
689                 $found = $file->pathname;
690             }
691         }
693         if (empty($found)) {
694             return false;
695         }
697         $extracted = $fp->extract_to_pathname($zipfilepath, $targetdir, array($found));
699         if (empty($extracted)) {
700             return false;
701         }
703         // The following syntax uses function array dereferencing, added in PHP 5.4.0.
704         return array_keys($extracted)[0];
705     }
707     /**
708      * Return the plugin component declared in its version.php file
709      *
710      * @param string $code the contents of the version.php file
711      * @return string|bool declared plugin component or false if unable to detect
712      */
713     protected function detect_plugin_component_from_versionphp($code) {
715         $result = preg_match_all('#^\s*\$plugin\->component\s*=\s*([\'"])(.+?_.+?)\1\s*;#m', $code, $matches);
717         // Return if and only if the single match was detected.
718         if ($result === 1 and !empty($matches[2][0])) {
719             return $matches[2][0];
720         }
722         return false;
723     }
727 /**
728  * General exception thrown by {@link tool_installaddon_installer} class
729  *
730  * @copyright 2013 David Mudrak <david@moodle.com>
731  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
732  */
733 class tool_installaddon_installer_exception extends moodle_exception {
735     /**
736      * @param string $errorcode exception description identifier
737      * @param mixed $debuginfo debugging data to display
738      */
739     public function __construct($errorcode, $a=null, $debuginfo=null) {
740         parent::__construct($errorcode, 'tool_installaddon', '', $a, print_r($debuginfo, true));
741     }