MDL-55020 admin: Fix renaming of the plugin package root folder
[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 moodle_exception;
30 use SplFileInfo;
31 use RecursiveDirectoryIterator;
32 use RecursiveIteratorIterator;
34 defined('MOODLE_INTERNAL') || die();
36 require_once($CFG->libdir.'/filelib.php');
38 /**
39  * General purpose class managing the plugins source code files deployment
40  *
41  * The class is able and supposed to
42  * - fetch and cache ZIP files distributed via the Moodle Plugins directory
43  * - unpack the ZIP files in a temporary storage
44  * - archive existing version of the plugin source code
45  * - move (deploy) the plugin source code into the $CFG->dirroot
46  *
47  * @copyright 2015 David Mudrak <david@moodle.com>
48  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
49  */
50 class code_manager {
52     /** @var string full path to the Moodle app directory root */
53     protected $dirroot;
54     /** @var string full path to the temp directory root */
55     protected $temproot;
57     /**
58      * Instantiate the class instance
59      *
60      * @param string $dirroot full path to the moodle app directory root
61      * @param string $temproot full path to our temp directory
62      */
63     public function __construct($dirroot=null, $temproot=null) {
64         global $CFG;
66         if (empty($dirroot)) {
67             $dirroot = $CFG->dirroot;
68         }
70         if (empty($temproot)) {
71             // Note we are using core_plugin here as that is the valid core
72             // subsystem we are part of. The namespace of this class (core\update)
73             // does not match it for legacy reasons.  The data stored in the
74             // temp directory are expected to survive multiple requests and
75             // purging caches during the upgrade, so we make use of
76             // make_temp_directory(). The contents of it can be removed if needed,
77             // given the site is in the maintenance mode (so that cron is not
78             // executed) and the site is not being upgraded.
79             $temproot = make_temp_directory('core_plugin/code_manager');
80         }
82         $this->dirroot = $dirroot;
83         $this->temproot = $temproot;
85         $this->init_temp_directories();
86     }
88     /**
89      * Obtain the plugin ZIP file from the given URL
90      *
91      * The caller is supposed to know both downloads URL and the MD5 hash of
92      * the ZIP contents in advance, typically by using the API requests against
93      * the plugins directory.
94      *
95      * @param string $url
96      * @param string $md5
97      * @return string|bool full path to the file, false on error
98      */
99     public function get_remote_plugin_zip($url, $md5) {
101         // Sanitize and validate the URL.
102         $url = str_replace(array("\r", "\n"), '', $url);
104         if (!preg_match('|^https?://|i', $url)) {
105             $this->debug('Error fetching plugin ZIP: unsupported transport protocol: '.$url);
106             return false;
107         }
109         // The cache location for the file.
110         $distfile = $this->temproot.'/distfiles/'.$md5.'.zip';
112         if (is_readable($distfile) and md5_file($distfile) === $md5) {
113             return $distfile;
114         } else {
115             @unlink($distfile);
116         }
118         // Download the file into a temporary location.
119         $tempdir = make_request_directory();
120         $tempfile = $tempdir.'/plugin.zip';
121         $result = $this->download_plugin_zip_file($url, $tempfile);
123         if (!$result) {
124             return false;
125         }
127         $actualmd5 = md5_file($tempfile);
129         // Make sure the actual md5 hash matches the expected one.
130         if ($actualmd5 !== $md5) {
131             $this->debug('Error fetching plugin ZIP: md5 mismatch.');
132             return false;
133         }
135         // If the file is empty, something went wrong.
136         if ($actualmd5 === 'd41d8cd98f00b204e9800998ecf8427e') {
137             return false;
138         }
140         // Store the file in our cache.
141         if (!rename($tempfile, $distfile)) {
142             return false;
143         }
145         return $distfile;
146     }
148     /**
149      * Extracts the saved plugin ZIP file.
150      *
151      * Returns the list of files found in the ZIP. The format of that list is
152      * array of (string)filerelpath => (bool|string) where the array value is
153      * either true or a string describing the problematic file.
154      *
155      * @see zip_packer::extract_to_pathname()
156      * @param string $zipfilepath full path to the saved ZIP file
157      * @param string $targetdir full path to the directory to extract the ZIP file to
158      * @param string $rootdir explicitly rename the root directory of the ZIP into this non-empty value
159      * @return array list of extracted files as returned by {@link zip_packer::extract_to_pathname()}
160      */
161     public function unzip_plugin_file($zipfilepath, $targetdir, $rootdir = '') {
163         // Extract the package into a temporary location.
164         $fp = get_file_packer('application/zip');
165         $tempdir = make_request_directory();
166         $files = $fp->extract_to_pathname($zipfilepath, $tempdir);
168         if (!$files) {
169             return array();
170         }
172         // If requested, rename the root directory of the plugin.
173         if (!empty($rootdir)) {
174             $files = $this->rename_extracted_rootdir($tempdir, $rootdir, $files);
175         }
177         // Sometimes zip may not contain all parent directories, add them to make it consistent.
178         foreach ($files as $path => $status) {
179             if ($status !== true) {
180                 continue;
181             }
182             $parts = explode('/', trim($path, '/'));
183             while (array_pop($parts)) {
184                 if (empty($parts)) {
185                     break;
186                 }
187                 $dir = implode('/', $parts).'/';
188                 if (!isset($files[$dir])) {
189                     $files[$dir] = true;
190                 }
191             }
192         }
194         // Move the extracted files into the target location.
195         $this->move_extracted_plugin_files($tempdir, $targetdir, $files);
197         // Set the permissions of extracted subdirs and files.
198         $this->set_plugin_files_permissions($targetdir, $files);
200         return $files;
201     }
203     /**
204      * Make an archive backup of the existing plugin folder.
205      *
206      * @param string $folderpath full path to the plugin folder
207      * @param string $targetzip full path to the zip file to be created
208      * @return bool true if file created, false if not
209      */
210     public function zip_plugin_folder($folderpath, $targetzip) {
212         if (file_exists($targetzip)) {
213             throw new coding_exception('Attempting to create already existing ZIP file', $targetzip);
214         }
216         if (!is_writable(dirname($targetzip))) {
217             throw new coding_exception('Target ZIP location not writable', dirname($targetzip));
218         }
220         if (!is_dir($folderpath)) {
221             throw new coding_exception('Attempting to ZIP non-existing source directory', $folderpath);
222         }
224         $files = $this->list_plugin_folder_files($folderpath);
225         $fp = get_file_packer('application/zip');
226         return $fp->archive_to_pathname($files, $targetzip, false);
227     }
229     /**
230      * Archive the current plugin on-disk version.
231      *
232      * @param string $folderpath full path to the plugin folder
233      * @param string $component
234      * @param int $version
235      * @param bool $overwrite overwrite existing archive if found
236      * @return bool
237      */
238     public function archive_plugin_version($folderpath, $component, $version, $overwrite=false) {
240         if ($component !== clean_param($component, PARAM_SAFEDIR)) {
241             // This should never happen, but just in case.
242             throw new moodle_exception('unexpected_plugin_component_format', 'core_plugin', '', null, $component);
243         }
245         if ((string)$version !== clean_param((string)$version, PARAM_FILE)) {
246             // Prevent some nasty injections via $plugin->version tricks.
247             throw new moodle_exception('unexpected_plugin_version_format', 'core_plugin', '', null, $version);
248         }
250         if (empty($component) or empty($version)) {
251             return false;
252         }
254         if (!is_dir($folderpath)) {
255             return false;
256         }
258         $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
260         if (file_exists($archzip) and !$overwrite) {
261             return true;
262         }
264         $tmpzip = make_request_directory().'/'.$version.'.zip';
265         $zipped = $this->zip_plugin_folder($folderpath, $tmpzip);
267         if (!$zipped) {
268             return false;
269         }
271         // Assert that the file looks like a valid one.
272         list($expectedtype, $expectedname) = core_component::normalize_component($component);
273         $actualname = $this->get_plugin_zip_root_dir($tmpzip);
274         if ($actualname !== $expectedname) {
275             // This should not happen.
276             throw new moodle_exception('unexpected_archive_structure', 'core_plugin');
277         }
279         make_writable_directory(dirname($archzip));
280         return rename($tmpzip, $archzip);
281     }
283     /**
284      * Return the path to the ZIP file with the archive of the given plugin version.
285      *
286      * @param string $component
287      * @param int $version
288      * @return string|bool false if not found, full path otherwise
289      */
290     public function get_archived_plugin_version($component, $version) {
292         if (empty($component) or empty($version)) {
293             return false;
294         }
296         $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
298         if (file_exists($archzip)) {
299             return $archzip;
300         }
302         return false;
303     }
305     /**
306      * Returns list of all files in the given directory.
307      *
308      * Given a path like /full/path/to/mod/workshop, it returns array like
309      *
310      *  [workshop/] => /full/path/to/mod/workshop
311      *  [workshop/lang/] => /full/path/to/mod/workshop/lang
312      *  [workshop/lang/workshop.php] => /full/path/to/mod/workshop/lang/workshop.php
313      *  ...
314      *
315      * Which mathes the format used by Moodle file packers.
316      *
317      * @param string $folderpath full path to the plugin directory
318      * @return array (string)relpath => (string)fullpath
319      */
320     public function list_plugin_folder_files($folderpath) {
322         $folder = new RecursiveDirectoryIterator($folderpath);
323         $iterator = new RecursiveIteratorIterator($folder);
324         $folderpathinfo = new SplFileInfo($folderpath);
325         $strip = strlen($folderpathinfo->getPathInfo()->getRealPath()) + 1;
326         $files = array();
327         foreach ($iterator as $fileinfo) {
328             if ($fileinfo->getFilename() === '..') {
329                 continue;
330             }
331             if (strpos($fileinfo->getRealPath(), $folderpathinfo->getRealPath() !== 0)) {
332                 throw new moodle_exception('unexpected_filepath_mismatch', 'core_plugin');
333             }
334             $key = substr($fileinfo->getRealPath(), $strip);
335             if ($fileinfo->isDir() and substr($key, -1) !== '/') {
336                 $key .= '/';
337             }
338             $files[str_replace(DIRECTORY_SEPARATOR, '/', $key)] = str_replace(DIRECTORY_SEPARATOR, '/', $fileinfo->getRealPath());
339         }
340         return $files;
341     }
343     /**
344      * Detects the plugin's name from its ZIP file.
345      *
346      * Plugin ZIP packages are expected to contain a single directory and the
347      * directory name would become the plugin name once extracted to the Moodle
348      * dirroot.
349      *
350      * @param string $zipfilepath full path to the ZIP files
351      * @return string|bool false on error
352      */
353     public function get_plugin_zip_root_dir($zipfilepath) {
355         $fp = get_file_packer('application/zip');
356         $files = $fp->list_files($zipfilepath);
358         if (empty($files)) {
359             return false;
360         }
362         $rootdirname = null;
363         foreach ($files as $file) {
364             $pathnameitems = explode('/', $file->pathname);
365             if (empty($pathnameitems)) {
366                 return false;
367             }
368             // Set the expected name of the root directory in the first
369             // iteration of the loop.
370             if ($rootdirname === null) {
371                 $rootdirname = $pathnameitems[0];
372             }
373             // Require the same root directory for all files in the ZIP
374             // package.
375             if ($rootdirname !== $pathnameitems[0]) {
376                 return false;
377             }
378         }
380         return $rootdirname;
381     }
383     // This is the end, my only friend, the end ... of external public API.
385     /**
386      * Makes sure all temp directories exist and are writable.
387      */
388     protected function init_temp_directories() {
389         make_writable_directory($this->temproot.'/distfiles');
390         make_writable_directory($this->temproot.'/archive');
391     }
393     /**
394      * Raise developer debugging level message.
395      *
396      * @param string $msg
397      */
398     protected function debug($msg) {
399         debugging($msg, DEBUG_DEVELOPER);
400     }
402     /**
403      * Download the ZIP file with the plugin package from the given location
404      *
405      * @param string $url URL to the file
406      * @param string $tofile full path to where to store the downloaded file
407      * @return bool false on error
408      */
409     protected function download_plugin_zip_file($url, $tofile) {
411         if (file_exists($tofile)) {
412             $this->debug('Error fetching plugin ZIP: target location exists.');
413             return false;
414         }
416         $status = $this->download_file_content($url, $tofile);
418         if (!$status) {
419             $this->debug('Error fetching plugin ZIP.');
420             @unlink($tofile);
421             return false;
422         }
424         return true;
425     }
427     /**
428      * Thin wrapper for the core's download_file_content() function.
429      *
430      * @param string $url URL to the file
431      * @param string $tofile full path to where to store the downloaded file
432      * @return bool
433      */
434     protected function download_file_content($url, $tofile) {
436         // Prepare the parameters for the download_file_content() function.
437         $headers = null;
438         $postdata = null;
439         $fullresponse = false;
440         $timeout = 300;
441         $connecttimeout = 20;
442         $skipcertverify = false;
443         $tofile = $tofile;
444         $calctimeout = false;
446         return download_file_content($url, $headers, $postdata, $fullresponse, $timeout,
447             $connecttimeout, $skipcertverify, $tofile, $calctimeout);
448     }
450     /**
451      * Renames the root directory of the extracted ZIP package.
452      *
453      * This internal helper method assumes that the plugin ZIP package has been
454      * extracted into a temporary empty directory so the plugin folder is the
455      * only folder there. The ZIP package is supposed to be validated so that
456      * it contains just a single root folder.
457      *
458      * @param string $dirname fullpath location of the extracted ZIP package
459      * @param string $rootdir the requested name of the root directory
460      * @param array $files list of extracted files
461      * @return array eventually amended list of extracted files
462      */
463     protected function rename_extracted_rootdir($dirname, $rootdir, array $files) {
465         if (!is_dir($dirname)) {
466             $this->debug('Unable to rename rootdir of non-existing content');
467             return $files;
468         }
470         if (file_exists($dirname.'/'.$rootdir)) {
471             // This typically means the real root dir already has the $rootdir name.
472             return $files;
473         }
475         $found = null; // The name of the first subdirectory under the $dirname.
476         foreach (scandir($dirname) as $item) {
477             if (substr($item, 0, 1) === '.') {
478                 continue;
479             }
480             if (is_dir($dirname.'/'.$item)) {
481                 if ($found !== null and $found !== $item) {
482                     // Multiple directories found.
483                     throw new moodle_exception('unexpected_archive_structure', 'core_plugin');
484                 }
485                 $found = $item;
486             }
487         }
489         if (!is_null($found)) {
490             if (rename($dirname.'/'.$found, $dirname.'/'.$rootdir)) {
491                 $newfiles = array();
492                 foreach ($files as $filepath => $status) {
493                     $newpath = preg_replace('~^'.preg_quote($found.'/').'~', preg_quote($rootdir.'/'), $filepath);
494                     $newfiles[$newpath] = $status;
495                 }
496                 return $newfiles;
497             }
498         }
500         return $files;
501     }
503     /**
504      * Sets the permissions of extracted subdirs and files
505      *
506      * As a result of unzipping, the subdirs and files are created with
507      * permissions set to $CFG->directorypermissions and $CFG->filepermissions.
508      * These are too benevolent by default (777 and 666 respectively) for PHP
509      * scripts and may lead to HTTP 500 errors in some environments.
510      *
511      * To fix this behaviour, we inherit the permissions of the plugin root
512      * directory itself.
513      *
514      * @param string $targetdir full path to the directory the ZIP file was extracted to
515      * @param array $files list of extracted files
516      */
517     protected function set_plugin_files_permissions($targetdir, array $files) {
519         $dirpermissions = fileperms($targetdir);
520         $filepermissions = ($dirpermissions & 0666);
522         foreach ($files as $subpath => $notusedhere) {
523             $path = $targetdir.'/'.$subpath;
524             if (is_dir($path)) {
525                 @chmod($path, $dirpermissions);
526             } else {
527                 @chmod($path, $filepermissions);
528             }
529         }
530     }
532     /**
533      * Moves the extracted contents of the plugin ZIP into the target location.
534      *
535      * @param string $sourcedir full path to the directory the ZIP file was extracted to
536      * @param mixed $targetdir full path to the directory where the files should be moved to
537      * @param array $files list of extracted files
538      */
539     protected function move_extracted_plugin_files($sourcedir, $targetdir, array $files) {
540         global $CFG;
542         foreach ($files as $file => $status) {
543             if ($status !== true) {
544                 throw new moodle_exception('corrupted_archive_structure', 'core_plugin', '', $file, $status);
545             }
547             $source = $sourcedir.'/'.$file;
548             $target = $targetdir.'/'.$file;
550             if (is_dir($source)) {
551                 continue;
553             } else {
554                 if (!is_dir(dirname($target))) {
555                     mkdir(dirname($target), $CFG->directorypermissions, true);
556                 }
557                 rename($source, $target);
558             }
559         }
560     }