12719cbf446f1aeda26efa699c886e061ef61d8c
[moodle.git] / lib / classes / update / code_manager.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Provides core\update\code_manager class.
19  *
20  * @package     core_plugin
21  * @copyright   2012, 2013, 2015 David Mudrak <david@moodle.com>
22  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace core\update;
27 use core_component;
28 use coding_exception;
29 use SplFileInfo;
30 use RecursiveDirectoryIterator;
31 use RecursiveIteratorIterator;
33 defined('MOODLE_INTERNAL') || die();
35 require_once($CFG->libdir.'/filelib.php');
37 /**
38  * General purpose class managing the plugins source code files deployment
39  *
40  * The class is able and supposed to
41  * - fetch and cache ZIP files distributed via the Moodle Plugins directory
42  * - unpack the ZIP files in a temporary storage
43  * - archive existing version of the plugin source code
44  * - move (deploy) the plugin source code into the $CFG->dirroot
45  *
46  * @copyright 2015 David Mudrak <david@moodle.com>
47  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
48  */
49 class code_manager {
51     /** @var string full path to the Moodle app directory root */
52     protected $dirroot;
53     /** @var string full path to the temp directory root */
54     protected $temproot;
56     /**
57      * Instantiate the class instance
58      *
59      * @param string $dirroot full path to the moodle app directory root
60      * @param string $temproot full path to our temp directory
61      */
62     public function __construct($dirroot=null, $temproot=null) {
63         global $CFG;
65         if (empty($dirroot)) {
66             $dirroot = $CFG->dirroot;
67         }
69         if (empty($temproot)) {
70             // Note we are using core_plugin here as that is the valid core
71             // subsystem we are part of. The namespace of this class (core\update)
72             // does not match it for legacy reasons.  The data stored in the
73             // temp directory are expected to survive multiple requests and
74             // purging caches during the upgrade, so we make use of
75             // make_temp_directory(). The contents of it can be removed if needed,
76             // given the site is in the maintenance mode (so that cron is not
77             // executed) and the site is not being upgraded.
78             $temproot = make_temp_directory('core_plugin/code_manager');
79         }
81         $this->dirroot = $dirroot;
82         $this->temproot = $temproot;
84         $this->init_temp_directories();
85     }
87     /**
88      * Obtain the plugin ZIP file from the given URL
89      *
90      * The caller is supposed to know both downloads URL and the MD5 hash of
91      * the ZIP contents in advance, typically by using the API requests against
92      * the plugins directory.
93      *
94      * @param string $url
95      * @param string $md5
96      * @return string|bool full path to the file, false on error
97      */
98     public function get_remote_plugin_zip($url, $md5) {
100         // Sanitize and validate the URL.
101         $url = str_replace(array("\r", "\n"), '', $url);
103         if (!preg_match('|^https?://|i', $url)) {
104             $this->debug('Error fetching plugin ZIP: unsupported transport protocol: '.$url);
105             return false;
106         }
108         // The cache location for the file.
109         $distfile = $this->temproot.'/distfiles/'.$md5.'.zip';
111         if (is_readable($distfile) and md5_file($distfile) === $md5) {
112             return $distfile;
113         } else {
114             @unlink($distfile);
115         }
117         // Download the file into a temporary location.
118         $tempdir = make_request_directory();
119         $tempfile = $tempdir.'/plugin.zip';
120         $result = $this->download_plugin_zip_file($url, $tempfile);
122         if (!$result) {
123             return false;
124         }
126         $actualmd5 = md5_file($tempfile);
128         // Make sure the actual md5 hash matches the expected one.
129         if ($actualmd5 !== $md5) {
130             $this->debug('Error fetching plugin ZIP: md5 mismatch.');
131             return false;
132         }
134         // If the file is empty, something went wrong.
135         if ($actualmd5 === 'd41d8cd98f00b204e9800998ecf8427e') {
136             return false;
137         }
139         // Store the file in our cache.
140         if (!rename($tempfile, $distfile)) {
141             return false;
142         }
144         return $distfile;
145     }
147     /**
148      * Extracts the saved plugin ZIP file.
149      *
150      * Returns the list of files found in the ZIP. The format of that list is
151      * array of (string)filerelpath => (bool|string) where the array value is
152      * either true or a string describing the problematic file.
153      *
154      * @see zip_packer::extract_to_pathname()
155      * @param string $zipfilepath full path to the saved ZIP file
156      * @param string $targetdir full path to the directory to extract the ZIP file to
157      * @param string $rootdir explicitly rename the root directory of the ZIP into this non-empty value
158      * @return array list of extracted files as returned by {@link zip_packer::extract_to_pathname()}
159      */
160     public function unzip_plugin_file($zipfilepath, $targetdir, $rootdir = '') {
162         $fp = get_file_packer('application/zip');
163         $files = $fp->extract_to_pathname($zipfilepath, $targetdir);
165         if (!$files) {
166             return array();
167         }
169         if (!empty($rootdir)) {
170             $files = $this->rename_extracted_rootdir($targetdir, $rootdir, $files);
171         }
173         // Sometimes zip may not contain all parent directories, add them to make it consistent.
174         foreach ($files as $path => $status) {
175             if ($status !== true) {
176                 continue;
177             }
178             $parts = explode('/', trim($path, '/'));
179             while (array_pop($parts)) {
180                 if (empty($parts)) {
181                     break;
182                 }
183                 $dir = implode('/', $parts).'/';
184                 if (!isset($files[$dir])) {
185                     $files[$dir] = true;
186                 }
187             }
188         }
190         // Set the permissions of extracted subdirs and files.
191         $this->set_plugin_files_permissions($targetdir, $files);
193         return $files;
194     }
196     /**
197      * Make an archive backup of the existing plugin folder.
198      *
199      * @param string $folderpath full path to the plugin folder
200      * @param string $targetzip full path to the zip file to be created
201      * @return bool true if file created, false if not
202      */
203     public function zip_plugin_folder($folderpath, $targetzip) {
205         if (file_exists($targetzip)) {
206             throw new coding_exception('Attempting to create already existing ZIP file', $targetzip);
207         }
209         if (!is_writable(dirname($targetzip))) {
210             throw new coding_exception('Target ZIP location not writable', dirname($targetzip));
211         }
213         if (!is_dir($folderpath)) {
214             throw new coding_exception('Attempting to ZIP non-existing source directory', $folderpath);
215         }
217         $files = $this->list_plugin_folder_files($folderpath);
218         $fp = get_file_packer('application/zip');
219         return $fp->archive_to_pathname($files, $targetzip, false);
220     }
222     /**
223      * Archive the current plugin on-disk version.
224      *
225      * @param string $folderpath full path to the plugin folder
226      * @param string $component
227      * @param int $version
228      * @param bool $overwrite overwrite existing archive if found
229      * @return bool
230      */
231     public function archive_plugin_version($folderpath, $component, $version, $overwrite=false) {
233         if ($component !== clean_param($component, PARAM_SAFEDIR)) {
234             // This should never happen, but just in case.
235             throw new moodle_exception('unexpected_plugin_component_format', 'core_plugin', '', null, $component);
236         }
238         if ((string)$version !== clean_param((string)$version, PARAM_FILE)) {
239             // Prevent some nasty injections via $plugin->version tricks.
240             throw new moodle_exception('unexpected_plugin_version_format', 'core_plugin', '', null, $version);
241         }
243         if (empty($component) or empty($version)) {
244             return false;
245         }
247         if (!is_dir($folderpath)) {
248             return false;
249         }
251         $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
253         if (file_exists($archzip) and !$overwrite) {
254             return true;
255         }
257         $tmpzip = make_request_directory().'/'.$version.'.zip';
258         $zipped = $this->zip_plugin_folder($folderpath, $tmpzip);
260         if (!$zipped) {
261             return false;
262         }
264         // Assert that the file looks like a valid one.
265         list($expectedtype, $expectedname) = core_component::normalize_component($component);
266         $actualname = $this->get_plugin_zip_root_dir($tmpzip);
267         if ($actualname !== $expectedname) {
268             // This should not happen.
269             throw new moodle_exception('unexpected_archive_structure', 'core_plugin');
270         }
272         make_writable_directory(dirname($archzip));
273         return rename($tmpzip, $archzip);
274     }
276     /**
277      * Return the path to the ZIP file with the archive of the given plugin version.
278      *
279      * @param string $component
280      * @param int $version
281      * @return string|bool false if not found, full path otherwise
282      */
283     public function get_archived_plugin_version($component, $version) {
285         if (empty($component) or empty($version)) {
286             return false;
287         }
289         $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
291         if (file_exists($archzip)) {
292             return $archzip;
293         }
295         return false;
296     }
298     /**
299      * Returns list of all files in the given directory.
300      *
301      * Given a path like /full/path/to/mod/workshop, it returns array like
302      *
303      *  [workshop/] => /full/path/to/mod/workshop
304      *  [workshop/lang/] => /full/path/to/mod/workshop/lang
305      *  [workshop/lang/workshop.php] => /full/path/to/mod/workshop/lang/workshop.php
306      *  ...
307      *
308      * Which mathes the format used by Moodle file packers.
309      *
310      * @param string $folderpath full path to the plugin directory
311      * @return array (string)relpath => (string)fullpath
312      */
313     public function list_plugin_folder_files($folderpath) {
315         $folder = new RecursiveDirectoryIterator($folderpath);
316         $iterator = new RecursiveIteratorIterator($folder);
317         $folderpathinfo = new SplFileInfo($folderpath);
318         $strip = strlen($folderpathinfo->getPathInfo()->getRealPath()) + 1;
319         $files = array();
320         foreach ($iterator as $fileinfo) {
321             if ($fileinfo->getFilename() === '..') {
322                 continue;
323             }
324             if (strpos($fileinfo->getRealPath(), $folderpathinfo->getRealPath() !== 0)) {
325                 throw new moodle_exception('unexpected_filepath_mismatch', 'core_plugin');
326             }
327             $key = substr($fileinfo->getRealPath(), $strip);
328             if ($fileinfo->isDir() and substr($key, -1) !== '/') {
329                 $key .= '/';
330             }
331             $files[str_replace(DIRECTORY_SEPARATOR, '/', $key)] = str_replace(DIRECTORY_SEPARATOR, '/', $fileinfo->getRealPath());
332         }
333         return $files;
334     }
336     /**
337      * Detects the plugin's name from its ZIP file.
338      *
339      * Plugin ZIP packages are expected to contain a single directory and the
340      * directory name would become the plugin name once extracted to the Moodle
341      * dirroot.
342      *
343      * @param string $zipfilepath full path to the ZIP files
344      * @return string|bool false on error
345      */
346     public function get_plugin_zip_root_dir($zipfilepath) {
348         $fp = get_file_packer('application/zip');
349         $files = $fp->list_files($zipfilepath);
351         if (empty($files)) {
352             return false;
353         }
355         $rootdirname = null;
356         foreach ($files as $file) {
357             $pathnameitems = explode('/', $file->pathname);
358             if (empty($pathnameitems)) {
359                 return false;
360             }
361             // Set the expected name of the root directory in the first
362             // iteration of the loop.
363             if ($rootdirname === null) {
364                 $rootdirname = $pathnameitems[0];
365             }
366             // Require the same root directory for all files in the ZIP
367             // package.
368             if ($rootdirname !== $pathnameitems[0]) {
369                 return false;
370             }
371         }
373         return $rootdirname;
374     }
376     // This is the end, my only friend, the end ... of external public API.
378     /**
379      * Makes sure all temp directories exist and are writable.
380      */
381     protected function init_temp_directories() {
382         make_writable_directory($this->temproot.'/distfiles');
383         make_writable_directory($this->temproot.'/archive');
384     }
386     /**
387      * Raise developer debugging level message.
388      *
389      * @param string $msg
390      */
391     protected function debug($msg) {
392         debugging($msg, DEBUG_DEVELOPER);
393     }
395     /**
396      * Download the ZIP file with the plugin package from the given location
397      *
398      * @param string $url URL to the file
399      * @param string $tofile full path to where to store the downloaded file
400      * @return bool false on error
401      */
402     protected function download_plugin_zip_file($url, $tofile) {
404         if (file_exists($tofile)) {
405             $this->debug('Error fetching plugin ZIP: target location exists.');
406             return false;
407         }
409         $status = $this->download_file_content($url, $tofile);
411         if (!$status) {
412             $this->debug('Error fetching plugin ZIP.');
413             @unlink($tofile);
414             return false;
415         }
417         return true;
418     }
420     /**
421      * Thin wrapper for the core's download_file_content() function.
422      *
423      * @param string $url URL to the file
424      * @param string $tofile full path to where to store the downloaded file
425      * @return bool
426      */
427     protected function download_file_content($url, $tofile) {
429         // Prepare the parameters for the download_file_content() function.
430         $headers = null;
431         $postdata = null;
432         $fullresponse = false;
433         $timeout = 300;
434         $connecttimeout = 20;
435         $skipcertverify = false;
436         $tofile = $tofile;
437         $calctimeout = false;
439         return download_file_content($url, $headers, $postdata, $fullresponse, $timeout,
440             $connecttimeout, $skipcertverify, $tofile, $calctimeout);
441     }
443     /**
444      * Renames the root directory of the extracted ZIP package.
445      *
446      * This method does not validate the presence of the single root directory
447      * (it is the validator's duty). It just searches for the first directory
448      * under the given location and renames it.
449      *
450      * The method will not rename the root if the requested location already
451      * exists.
452      *
453      * @param string $dirname fullpath location of the extracted ZIP package
454      * @param string $rootdir the requested name of the root directory
455      * @param array $files list of extracted files
456      * @return array eventually amended list of extracted files
457      */
458     protected function rename_extracted_rootdir($dirname, $rootdir, array $files) {
460         if (!is_dir($dirname)) {
461             $this->debug('Unable to rename rootdir of non-existing content');
462             return $files;
463         }
465         if (file_exists($dirname.'/'.$rootdir)) {
466             // This typically means the real root dir already has the $rootdir name.
467             return $files;
468         }
470         $found = null; // The name of the first subdirectory under the $dirname.
471         foreach (scandir($dirname) as $item) {
472             if (substr($item, 0, 1) === '.') {
473                 continue;
474             }
475             if (is_dir($dirname.'/'.$item)) {
476                 $found = $item;
477                 break;
478             }
479         }
481         if (!is_null($found)) {
482             if (rename($dirname.'/'.$found, $dirname.'/'.$rootdir)) {
483                 $newfiles = array();
484                 foreach ($files as $filepath => $status) {
485                     $newpath = preg_replace('~^'.preg_quote($found.'/').'~', preg_quote($rootdir.'/'), $filepath);
486                     $newfiles[$newpath] = $status;
487                 }
488                 return $newfiles;
489             }
490         }
492         return $files;
493     }
495     /**
496      * Sets the permissions of extracted subdirs and files
497      *
498      * As a result of unzipping, the subdirs and files are created with
499      * permissions set to $CFG->directorypermissions and $CFG->filepermissions.
500      * These are too benevolent by default (777 and 666 respectively) for PHP
501      * scripts and may lead to HTTP 500 errors in some environments.
502      *
503      * To fix this behaviour, we inherit the permissions of the plugin root
504      * directory itself.
505      *
506      * @param string $targetdir full path to the directory the ZIP file was extracted to
507      * @param array $files list of extracted files
508      */
509     protected function set_plugin_files_permissions($targetdir, array $files) {
511         $dirpermissions = fileperms($targetdir);
512         $filepermissions = ($dirpermissions & 0666);
514         foreach ($files as $subpath => $notusedhere) {
515             $path = $targetdir.'/'.$subpath;
516             if (is_dir($path)) {
517                 @chmod($path, $dirpermissions);
518             } else {
519                 @chmod($path, $filepermissions);
520             }
521         }
522     }