source: moodle/trunk/fuentes/admin/tool/installaddon/classes/installer.php @ 136

Last change on this file since 136 was 136, checked in by mabarracus, 4 years ago

Ported code to xenial

File size: 21.0 KB
Line 
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 * 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 */
26
27defined('MOODLE_INTERNAL') || die();
28
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 */
35class tool_installaddon_installer {
36
37    /** @var tool_installaddon_installfromzip_form */
38    protected $installfromzipform = null;
39
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    }
48
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    }
58
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;
66
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        }
72
73        if (!$this->should_send_site_info()) {
74            return new moodle_url($url);
75        }
76
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        );
83
84        $site = $this->encode_site_information($site);
85
86        return new moodle_url($url, array('site' => $site));
87    }
88
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        }
96
97        $action = $this->index_url();
98        $customdata = array('installer' => $this);
99
100        $this->installfromzipform = new tool_installaddon_installfromzip_form($action, $customdata);
101
102        return $this->installfromzipform;
103    }
104
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) {
116
117        $filename = clean_param($form->get_new_filename('zipfile'), PARAM_FILE);
118        $form->save_file('zipfile', $targetdir.'/'.$filename);
119
120        return $filename;
121    }
122
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');
139
140        $fp = get_file_packer('application/zip');
141        $files = $fp->extract_to_pathname($zipfilepath, $targetdir);
142
143        if (!$files) {
144            return array();
145        }
146
147        if (!empty($rootdir)) {
148            $files = $this->rename_extracted_rootdir($targetdir, $rootdir, $files);
149        }
150
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        }
167
168        return $files;
169    }
170
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;
178
179        $pluginman = core_plugin_manager::instance();
180
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        }
185
186        return $menu;
187    }
188
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) {
199
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        }
210
211        if (!is_dir($plugintypepath)) {
212            return false;
213        }
214
215        return $plugintypepath;
216    }
217
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) {
226
227        $plugintypepath = $this->get_plugintype_root($plugintype);
228
229        if (is_null($plugintypepath)) {
230            throw new coding_exception('Unknown plugin type!');
231        }
232
233        if ($plugintypepath === false) {
234            throw new coding_exception('Plugin type location does not exist!');
235        }
236
237        return is_writable($plugintypepath);
238    }
239
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) {
255        global $CFG;
256        require_once(dirname(__FILE__).'/pluginfo_client.php');
257
258        if (is_null($request)) {
259            return;
260        }
261
262        $data = $this->decode_remote_request($request);
263
264        if ($data === false) {
265            echo $output->remote_request_invalid_page($this->index_url());
266            exit();
267        }
268
269        list($plugintype, $pluginname) = core_component::normalize_component($data->component);
270
271        $plugintypepath = $this->get_plugintype_root($plugintype);
272
273        if (file_exists($plugintypepath.'/'.$pluginname)) {
274            echo $output->remote_request_alreadyinstalled_page($data, $this->index_url());
275            exit();
276        }
277
278        if (!$this->is_plugintype_writable($plugintype)) {
279            $continueurl = $this->index_url(array('installaddonrequest' => $request));
280            echo $output->remote_request_permcheck_page($data, $plugintypepath, $continueurl, $this->index_url());
281            exit();
282        }
283
284        $continueurl = $this->index_url(array(
285            'installaddonrequest' => $request,
286            'confirm' => 1,
287            'sesskey' => sesskey()));
288
289        if (!$confirmed) {
290            echo $output->remote_request_confirm_page($data, $continueurl, $this->index_url());
291            exit();
292        }
293
294        // The admin has confirmed their intention to install the add-on.
295        require_sesskey();
296
297        // Fetch the plugin info. The essential information is the URL to download the ZIP
298        // and the MD5 hash of the ZIP, obtained via HTTPS.
299        $client = tool_installaddon_pluginfo_client::instance();
300
301        try {
302            $pluginfo = $client->get_pluginfo($data->component, $data->version);
303
304        } catch (tool_installaddon_pluginfo_exception $e) {
305            if (debugging()) {
306                throw $e;
307            } else {
308                echo $output->remote_request_pluginfo_exception($data, $e, $this->index_url());
309                exit();
310            }
311        }
312
313        // Fetch the ZIP with the plugin version
314        $jobid = md5(rand().uniqid('', true));
315        $sourcedir = make_temp_directory('tool_installaddon/'.$jobid.'/source');
316        $zipfilename = 'downloaded.zip';
317
318        try {
319            $this->download_file($pluginfo->downloadurl, $sourcedir.'/'.$zipfilename);
320
321        } catch (tool_installaddon_installer_exception $e) {
322            if (debugging()) {
323                throw $e;
324            } else {
325                echo $output->installer_exception($e, $this->index_url());
326                exit();
327            }
328        }
329
330        // Check the MD5 checksum
331        $md5expected = $pluginfo->downloadmd5;
332        $md5actual = md5_file($sourcedir.'/'.$zipfilename);
333        if ($md5expected !== $md5actual) {
334            $e = new tool_installaddon_installer_exception('err_zip_md5', array('expected' => $md5expected, 'actual' => $md5actual));
335            if (debugging()) {
336                throw $e;
337            } else {
338                echo $output->installer_exception($e, $this->index_url());
339                exit();
340            }
341        }
342
343        // Redirect to the validation page.
344        $nexturl = new moodle_url('/admin/tool/installaddon/validate.php', array(
345            'sesskey' => sesskey(),
346            'jobid' => $jobid,
347            'zip' => $zipfilename,
348            'type' => $plugintype));
349        redirect($nexturl);
350    }
351
352    /**
353     * Download the given file into the given destination.
354     *
355     * This is basically a simplified version of {@link download_file_content()} from
356     * Moodle itself, tuned for fetching files from moodle.org servers. Same code is used
357     * in mdeploy.php for fetching available updates.
358     *
359     * @param string $source file url starting with http(s)://
360     * @param string $target store the downloaded content to this file (full path)
361     * @throws tool_installaddon_installer_exception
362     */
363    public function download_file($source, $target) {
364        global $CFG;
365        require_once($CFG->libdir.'/filelib.php');
366
367        $targetfile = fopen($target, 'w');
368
369        if (!$targetfile) {
370            throw new tool_installaddon_installer_exception('err_download_write_file', $target);
371        }
372
373        $options = array(
374            'file' => $targetfile,
375            'timeout' => 300,
376            'followlocation' => true,
377            'maxredirs' => 3,
378            'ssl_verifypeer' => true,
379            'ssl_verifyhost' => 2,
380        );
381
382        $curl = new curl(array('proxy' => true));
383
384        $result = $curl->download_one($source, null, $options);
385
386        $curlinfo = $curl->get_info();
387
388        fclose($targetfile);
389
390        if ($result !== true) {
391            throw new tool_installaddon_installer_exception('err_curl_exec', array(
392                'url' => $source, 'errorno' => $curl->get_errno(), 'error' => $result));
393
394        } else if (empty($curlinfo['http_code']) or $curlinfo['http_code'] != 200) {
395            throw new tool_installaddon_installer_exception('err_curl_http_code', array(
396                'url' => $source, 'http_code' => $curlinfo['http_code']));
397
398        } else if (isset($curlinfo['ssl_verify_result']) and $curlinfo['ssl_verify_result'] != 0) {
399            throw new tool_installaddon_installer_exception('err_curl_ssl_verify', array(
400                'url' => $source, 'ssl_verify_result' => $curlinfo['ssl_verify_result']));
401        }
402    }
403
404    /**
405     * Moves the given source into a new location recursively
406     *
407     * This is cross-device safe implementation to be used instead of the native rename() function.
408     * See https://bugs.php.net/bug.php?id=54097 for more details.
409     *
410     * @param string $source full path to the existing directory
411     * @param string $target full path to the new location of the directory
412     * @param int $dirpermissions
413     * @param int $filepermissions
414     */
415    public function move_directory($source, $target, $dirpermissions, $filepermissions) {
416
417        if (file_exists($target)) {
418            throw new tool_installaddon_installer_exception('err_folder_already_exists', array('path' => $target));
419        }
420
421        if (is_dir($source)) {
422            $handle = opendir($source);
423        } else {
424            throw new tool_installaddon_installer_exception('err_no_such_folder', array('path' => $source));
425        }
426
427        if (!file_exists($target)) {
428            // Do not use make_writable_directory() here - it is intended for dataroot only.
429            mkdir($target, true);
430            @chmod($target, $dirpermissions);
431        }
432
433        if (!is_writable($target)) {
434            closedir($handle);
435            throw new tool_installaddon_installer_exception('err_folder_not_writable', array('path' => $target));
436        }
437
438        while ($filename = readdir($handle)) {
439            $sourcepath = $source.'/'.$filename;
440            $targetpath = $target.'/'.$filename;
441
442            if ($filename === '.' or $filename === '..') {
443                continue;
444            }
445
446            if (is_dir($sourcepath)) {
447                $this->move_directory($sourcepath, $targetpath, $dirpermissions, $filepermissions);
448
449            } else {
450                rename($sourcepath, $targetpath);
451                @chmod($targetpath, $filepermissions);
452            }
453        }
454
455        closedir($handle);
456
457        rmdir($source);
458
459        clearstatcache();
460    }
461
462    //// End of external API ///////////////////////////////////////////////////
463
464    /**
465     * @see self::instance()
466     */
467    protected function __construct() {
468    }
469
470    /**
471     * @return string this site full name
472     */
473    protected function get_site_fullname() {
474        global $SITE;
475
476        return strip_tags($SITE->fullname);
477    }
478
479    /**
480     * @return string this site URL
481     */
482    protected function get_site_url() {
483        global $CFG;
484
485        return $CFG->wwwroot;
486    }
487
488    /**
489     * @return string major version like 2.5, 2.6 etc.
490     */
491    protected function get_site_major_version() {
492        return moodle_major_version();
493    }
494
495    /**
496     * Encodes the given array in a way that can be safely appended as HTTP GET param
497     *
498     * Be ware! The recipient may rely on the exact way how the site information is encoded.
499     * Do not change anything here unless you know what you are doing and understand all
500     * consequences! (Don't you love warnings like that, too? :-p)
501     *
502     * @param array $info
503     * @return string
504     */
505    protected function encode_site_information(array $info) {
506        return base64_encode(json_encode($info));
507    }
508
509    /**
510     * Decide if the encoded site information should be sent to the add-ons repository site
511     *
512     * For now, we just return true. In the future, we may want to implement some
513     * privacy aware logic (based on site/user preferences for example).
514     *
515     * @return bool
516     */
517    protected function should_send_site_info() {
518        return true;
519    }
520
521    /**
522     * Renames the root directory of the extracted ZIP package.
523     *
524     * This method does not validate the presence of the single root directory
525     * (the validator does it later). It just searches for the first directory
526     * under the given location and renames it.
527     *
528     * The method will not rename the root if the requested location already
529     * exists.
530     *
531     * @param string $dirname the location of the extracted ZIP package
532     * @param string $rootdir the requested name of the root directory
533     * @param array $files list of extracted files
534     * @return array eventually amended list of extracted files
535     */
536    protected function rename_extracted_rootdir($dirname, $rootdir, array $files) {
537
538        if (!is_dir($dirname)) {
539            debugging('Unable to rename rootdir of non-existing content', DEBUG_DEVELOPER);
540            return $files;
541        }
542
543        if (file_exists($dirname.'/'.$rootdir)) {
544            debugging('Unable to rename rootdir to already existing folder', DEBUG_DEVELOPER);
545            return $files;
546        }
547
548        $found = null; // The name of the first subdirectory under the $dirname.
549        foreach (scandir($dirname) as $item) {
550            if (substr($item, 0, 1) === '.') {
551                continue;
552            }
553            if (is_dir($dirname.'/'.$item)) {
554                $found = $item;
555                break;
556            }
557        }
558
559        if (!is_null($found)) {
560            if (rename($dirname.'/'.$found, $dirname.'/'.$rootdir)) {
561                $newfiles = array();
562                foreach ($files as $filepath => $status) {
563                    $newpath = preg_replace('~^'.preg_quote($found.'/').'~', preg_quote($rootdir.'/'), $filepath);
564                    $newfiles[$newpath] = $status;
565                }
566                return $newfiles;
567            }
568        }
569
570        return $files;
571    }
572
573    /**
574     * Decode the request from the Moodle Plugins directory
575     *
576     * @param string $request submitted via 'installaddonrequest' HTTP parameter
577     * @return stdClass|bool false on error, object otherwise
578     */
579    protected function decode_remote_request($request) {
580
581        $data = base64_decode($request, true);
582
583        if ($data === false) {
584            return false;
585        }
586
587        $data = json_decode($data);
588
589        if (is_null($data)) {
590            return false;
591        }
592
593        if (!isset($data->name) or !isset($data->component) or !isset($data->version)) {
594            return false;
595        }
596
597        $data->name = s(strip_tags($data->name));
598
599        if ($data->component !== clean_param($data->component, PARAM_COMPONENT)) {
600            return false;
601        }
602
603        list($plugintype, $pluginname) = core_component::normalize_component($data->component);
604
605        if ($plugintype === 'core') {
606            return false;
607        }
608
609        if ($data->component !== $plugintype.'_'.$pluginname) {
610            return false;
611        }
612
613        if (!core_component::is_valid_plugin_name($plugintype, $pluginname)) {
614            return false;
615        }
616
617        $plugintypes = core_component::get_plugin_types();
618        if (!isset($plugintypes[$plugintype])) {
619            return false;
620        }
621
622        // Keep this regex in sync with the one used by the download.moodle.org/api/x.y/pluginfo.php
623        if (!preg_match('/^[0-9]+$/', $data->version)) {
624            return false;
625        }
626
627        return $data;
628    }
629}
630
631
632/**
633 * General exception thrown by {@link tool_installaddon_installer} class
634 *
635 * @copyright 2013 David Mudrak <david@moodle.com>
636 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
637 */
638class tool_installaddon_installer_exception extends moodle_exception {
639
640    /**
641     * @param string $errorcode exception description identifier
642     * @param mixed $debuginfo debugging data to display
643     */
644    public function __construct($errorcode, $a=null, $debuginfo=null) {
645        parent::__construct($errorcode, 'tool_installaddon', '', $a, print_r($debuginfo, true));
646    }
647}
Note: See TracBrowser for help on using the repository browser.