7059010b3384d2b830370efb35c4d11acca0352e
[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      * @param string $source file url starting with http(s)://
352      * @param string $target store the downloaded content to this file (full path)
353      * @throws tool_installaddon_installer_exception
354      */
355     public function download_file($source, $target) {
356         global $CFG;
357         require_once($CFG->libdir.'/filelib.php');
359         $targetfile = fopen($target, 'w');
361         if (!$targetfile) {
362             throw new tool_installaddon_installer_exception('err_download_write_file', $target);
363         }
365         $options = array(
366             'file' => $targetfile,
367             'timeout' => 300,
368             'followlocation' => true,
369             'maxredirs' => 3,
370             'ssl_verifypeer' => true,
371             'ssl_verifyhost' => 2,
372         );
374         $curl = new curl(array('proxy' => true));
376         $result = $curl->download_one($source, null, $options);
378         $curlinfo = $curl->get_info();
380         fclose($targetfile);
382         if ($result !== true) {
383             throw new tool_installaddon_installer_exception('err_curl_exec', array(
384                 'url' => $source, 'errorno' => $curl->get_errno(), 'error' => $result));
386         } else if (empty($curlinfo['http_code']) or $curlinfo['http_code'] != 200) {
387             throw new tool_installaddon_installer_exception('err_curl_http_code', array(
388                 'url' => $source, 'http_code' => $curlinfo['http_code']));
390         } else if (isset($curlinfo['ssl_verify_result']) and $curlinfo['ssl_verify_result'] != 0) {
391             throw new tool_installaddon_installer_exception('err_curl_ssl_verify', array(
392                 'url' => $source, 'ssl_verify_result' => $curlinfo['ssl_verify_result']));
393         }
394     }
396     /**
397      * Moves the given source into a new location recursively
398      *
399      * This is cross-device safe implementation to be used instead of the native rename() function.
400      * See https://bugs.php.net/bug.php?id=54097 for more details.
401      *
402      * @param string $source full path to the existing directory
403      * @param string $target full path to the new location of the directory
404      * @param int $dirpermissions
405      * @param int $filepermissions
406      */
407     public function move_directory($source, $target, $dirpermissions, $filepermissions) {
409         if (file_exists($target)) {
410             throw new tool_installaddon_installer_exception('err_folder_already_exists', array('path' => $target));
411         }
413         if (is_dir($source)) {
414             $handle = opendir($source);
415         } else {
416             throw new tool_installaddon_installer_exception('err_no_such_folder', array('path' => $source));
417         }
419         if (!file_exists($target)) {
420             // Do not use make_writable_directory() here - it is intended for dataroot only.
421             mkdir($target, true);
422             @chmod($target, $dirpermissions);
423         }
425         if (!is_writable($target)) {
426             closedir($handle);
427             throw new tool_installaddon_installer_exception('err_folder_not_writable', array('path' => $target));
428         }
430         while ($filename = readdir($handle)) {
431             $sourcepath = $source.'/'.$filename;
432             $targetpath = $target.'/'.$filename;
434             if ($filename === '.' or $filename === '..') {
435                 continue;
436             }
438             if (is_dir($sourcepath)) {
439                 $this->move_directory($sourcepath, $targetpath, $dirpermissions, $filepermissions);
441             } else {
442                 rename($sourcepath, $targetpath);
443                 @chmod($targetpath, $filepermissions);
444             }
445         }
447         closedir($handle);
449         rmdir($source);
451         clearstatcache();
452     }
454     /**
455      * Detect the given plugin's component name
456      *
457      * Only plugins that declare valid $plugin->component value in the version.php
458      * are supported.
459      *
460      * @param string $zipfilepath full path to the saved ZIP file
461      * @param string $workdir full path to the directory we can use for extracting required bits from the archive
462      * @return string|bool declared component name or false if unable to detect
463      */
464     public function detect_plugin_component($zipfilepath, $workdir) {
466         $versionphp = $this->extract_versionphp_file($zipfilepath, $workdir);
468         if (empty($versionphp)) {
469             return false;
470         }
472         return $this->detect_plugin_component_from_versionphp(file_get_contents($workdir.'/'.$versionphp));
473     }
475     //// End of external API ///////////////////////////////////////////////////
477     /**
478      * @see self::instance()
479      */
480     protected function __construct() {
481     }
483     /**
484      * @return string this site full name
485      */
486     protected function get_site_fullname() {
487         global $SITE;
489         return strip_tags($SITE->fullname);
490     }
492     /**
493      * @return string this site URL
494      */
495     protected function get_site_url() {
496         global $CFG;
498         return $CFG->wwwroot;
499     }
501     /**
502      * @return string major version like 2.5, 2.6 etc.
503      */
504     protected function get_site_major_version() {
505         return moodle_major_version();
506     }
508     /**
509      * Encodes the given array in a way that can be safely appended as HTTP GET param
510      *
511      * Be ware! The recipient may rely on the exact way how the site information is encoded.
512      * Do not change anything here unless you know what you are doing and understand all
513      * consequences! (Don't you love warnings like that, too? :-p)
514      *
515      * @param array $info
516      * @return string
517      */
518     protected function encode_site_information(array $info) {
519         return base64_encode(json_encode($info));
520     }
522     /**
523      * Decide if the encoded site information should be sent to the add-ons repository site
524      *
525      * For now, we just return true. In the future, we may want to implement some
526      * privacy aware logic (based on site/user preferences for example).
527      *
528      * @return bool
529      */
530     protected function should_send_site_info() {
531         return true;
532     }
534     /**
535      * Renames the root directory of the extracted ZIP package.
536      *
537      * This method does not validate the presence of the single root directory
538      * (the validator does it later). It just searches for the first directory
539      * under the given location and renames it.
540      *
541      * The method will not rename the root if the requested location already
542      * exists.
543      *
544      * @param string $dirname the location of the extracted ZIP package
545      * @param string $rootdir the requested name of the root directory
546      * @param array $files list of extracted files
547      * @return array eventually amended list of extracted files
548      */
549     protected function rename_extracted_rootdir($dirname, $rootdir, array $files) {
551         if (!is_dir($dirname)) {
552             debugging('Unable to rename rootdir of non-existing content', DEBUG_DEVELOPER);
553             return $files;
554         }
556         if (file_exists($dirname.'/'.$rootdir)) {
557             debugging('Unable to rename rootdir to already existing folder', DEBUG_DEVELOPER);
558             return $files;
559         }
561         $found = null; // The name of the first subdirectory under the $dirname.
562         foreach (scandir($dirname) as $item) {
563             if (substr($item, 0, 1) === '.') {
564                 continue;
565             }
566             if (is_dir($dirname.'/'.$item)) {
567                 $found = $item;
568                 break;
569             }
570         }
572         if (!is_null($found)) {
573             if (rename($dirname.'/'.$found, $dirname.'/'.$rootdir)) {
574                 $newfiles = array();
575                 foreach ($files as $filepath => $status) {
576                     $newpath = preg_replace('~^'.preg_quote($found.'/').'~', preg_quote($rootdir.'/'), $filepath);
577                     $newfiles[$newpath] = $status;
578                 }
579                 return $newfiles;
580             }
581         }
583         return $files;
584     }
586     /**
587      * Decode the request from the Moodle Plugins directory
588      *
589      * @param string $request submitted via 'installaddonrequest' HTTP parameter
590      * @return stdClass|bool false on error, object otherwise
591      */
592     protected function decode_remote_request($request) {
594         $data = base64_decode($request, true);
596         if ($data === false) {
597             return false;
598         }
600         $data = json_decode($data);
602         if (is_null($data)) {
603             return false;
604         }
606         if (!isset($data->name) or !isset($data->component) or !isset($data->version)) {
607             return false;
608         }
610         $data->name = s(strip_tags($data->name));
612         if ($data->component !== clean_param($data->component, PARAM_COMPONENT)) {
613             return false;
614         }
616         list($plugintype, $pluginname) = core_component::normalize_component($data->component);
618         if ($plugintype === 'core') {
619             return false;
620         }
622         if ($data->component !== $plugintype.'_'.$pluginname) {
623             return false;
624         }
626         if (!core_component::is_valid_plugin_name($plugintype, $pluginname)) {
627             return false;
628         }
630         $plugintypes = core_component::get_plugin_types();
631         if (!isset($plugintypes[$plugintype])) {
632             return false;
633         }
635         // Keep this regex in sync with the one used by the download.moodle.org/api/x.y/pluginfo.php
636         if (!preg_match('/^[0-9]+$/', $data->version)) {
637             return false;
638         }
640         return $data;
641     }
643     /**
644      * Extracts the version.php from the given plugin ZIP file into the target directory
645      *
646      * @param string $zipfilepath full path to the saved ZIP file
647      * @param string $targetdir full path to extract the file to
648      * @return string|bool path to the version.php within the $targetpath; false on error (e.g. not found)
649      */
650     protected function extract_versionphp_file($zipfilepath, $targetdir) {
651         global $CFG;
652         require_once($CFG->libdir.'/filelib.php');
654         $fp = get_file_packer('application/zip');
655         $files = $fp->list_files($zipfilepath);
657         if (empty($files)) {
658             return false;
659         }
661         $rootdirname = null;
662         $found = null;
664         foreach ($files as $file) {
665             // Valid plugin ZIP package has just one root directory with all
666             // files in it.
667             $pathnameitems = explode('/', $file->pathname);
669             if (empty($pathnameitems)) {
670                 return false;
671             }
673             // Set the expected name of the root directory in the first
674             // iteration of the loop.
675             if ($rootdirname === null) {
676                 $rootdirname = $pathnameitems[0];
677             }
679             // Require the same root directory for all files in the ZIP
680             // package.
681             if ($rootdirname !== $pathnameitems[0]) {
682                 return false;
683             }
685             // If we reached the valid version.php file, remember it.
686             if ($pathnameitems[1] === 'version.php' and !$file->is_directory and $file->size > 0) {
687                 $found = $file->pathname;
688             }
689         }
691         if (empty($found)) {
692             return false;
693         }
695         $extracted = $fp->extract_to_pathname($zipfilepath, $targetdir, array($found));
697         if (empty($extracted)) {
698             return false;
699         }
701         // The following syntax uses function array dereferencing, added in PHP 5.4.0.
702         return array_keys($extracted)[0];
703     }
705     /**
706      * Return the plugin component declared in its version.php file
707      *
708      * @param string $code the contents of the version.php file
709      * @return string|bool declared plugin component or false if unable to detect
710      */
711     protected function detect_plugin_component_from_versionphp($code) {
713         $result = preg_match_all('#^\s*\$plugin\->component\s*=\s*([\'"])(.+?_.+?)\1\s*;#m', $code, $matches);
715         // Return if and only if the single match was detected.
716         if ($result === 1 and !empty($matches[2][0])) {
717             return $matches[2][0];
718         }
720         return false;
721     }
725 /**
726  * General exception thrown by {@link tool_installaddon_installer} class
727  *
728  * @copyright 2013 David Mudrak <david@moodle.com>
729  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
730  */
731 class tool_installaddon_installer_exception extends moodle_exception {
733     /**
734      * @param string $errorcode exception description identifier
735      * @param mixed $debuginfo debugging data to display
736      */
737     public function __construct($errorcode, $a=null, $debuginfo=null) {
738         parent::__construct($errorcode, 'tool_installaddon', '', $a, print_r($debuginfo, true));
739     }