MDL-49329 admin: Archive plugin code before removing it from dirroot
[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      * Move a folder with the plugin code from the source to the target location
149      *
150      * This can be used to move plugin folders to and from the dirroot/dataroot
151      * as needed. It is assumed that the caller checked that both locations are
152      * writable.
153      *
154      * Permissions in the target location are set to the same values that the
155      * parent directory has (see MDL-42110 for details).
156      *
157      * @param string $source full path to the current plugin code folder
158      * @param string $target full path to the new plugin code folder
159      */
160     public function move_plugin_directory($source, $target) {
162         $targetparent = dirname($target);
164         if ($targetparent === '.') {
165             // No directory separator in $target..
166             throw new coding_exception('Invalid target path', $target);
167         }
169         if (!is_writable($targetparent)) {
170             throw new coding_exception('Attempting to move into non-writable parent directory', $targetparent);
171         }
173         // Use parent directory's permissions for us, too.
174         $dirpermissions = fileperms($targetparent);
175         // Strip execute flags and use that for files.
176         $filepermissions = ($dirpermissions & 0666);
178         $this->move_directory($source, $target, $dirpermissions, $filepermissions);
179     }
181     /**
182      * Extracts the saved plugin ZIP file.
183      *
184      * Returns the list of files found in the ZIP. The format of that list is
185      * array of (string)filerelpath => (bool|string) where the array value is
186      * either true or a string describing the problematic file.
187      *
188      * @see zip_packer::extract_to_pathname()
189      * @param string $zipfilepath full path to the saved ZIP file
190      * @param string $targetdir full path to the directory to extract the ZIP file to
191      * @param string $rootdir explicitly rename the root directory of the ZIP into this non-empty value
192      * @param array list of extracted files as returned by {@link zip_packer::extract_to_pathname()}
193      */
194     public function unzip_plugin_file($zipfilepath, $targetdir, $rootdir = '') {
196         $fp = get_file_packer('application/zip');
197         $files = $fp->extract_to_pathname($zipfilepath, $targetdir);
199         if (!$files) {
200             return array();
201         }
203         if (!empty($rootdir)) {
204             $files = $this->rename_extracted_rootdir($targetdir, $rootdir, $files);
205         }
207         // Sometimes zip may not contain all parent directories, add them to make it consistent.
208         foreach ($files as $path => $status) {
209             if ($status !== true) {
210                 continue;
211             }
212             $parts = explode('/', trim($path, '/'));
213             while (array_pop($parts)) {
214                 if (empty($parts)) {
215                     break;
216                 }
217                 $dir = implode('/', $parts).'/';
218                 if (!isset($files[$dir])) {
219                     $files[$dir] = true;
220                 }
221             }
222         }
224         return $files;
225     }
227     /**
228      * Make an archive backup of the existing plugin folder.
229      *
230      * @param string $folderpath full path to the plugin folder
231      * @param string $targetzip full path to the zip file to be created
232      * @return bool true if file created, false if not
233      */
234     public function zip_plugin_folder($folderpath, $targetzip) {
236         if (file_exists($targetzip)) {
237             throw new coding_exception('Attempting to create already existing ZIP file', $targetzip);
238         }
240         if (!is_writable(dirname($targetzip))) {
241             throw new coding_exception('Target ZIP location not writable', dirname($targetzip));
242         }
244         if (!is_dir($folderpath)) {
245             throw new coding_exception('Attempting to ZIP non-existing source directory', $folderpath);
246         }
248         $files = $this->list_plugin_folder_files($folderpath);
249         $fp = get_file_packer('application/zip');
250         return $fp->archive_to_pathname($files, $targetzip, false);
251     }
253     /**
254      * Archive the current plugin on-disk version.
255      *
256      * @param string $folderpath full path to the plugin folder
257      * @param string $component
258      * @param int $version
259      * @param bool $overwrite overwrite existing archive if found
260      * @return bool
261      */
262     public function archive_plugin_version($folderpath, $component, $version, $overwrite=false) {
264         if ($component !== clean_param($component, PARAM_SAFEDIR)) {
265             // This should never happen, but just in case.
266             throw new moodle_exception('unexpected_plugin_component_format', 'core_plugin', '', null, $component);
267         }
269         if ((string)$version !== clean_param((string)$version, PARAM_FILE)) {
270             // Prevent some nasty injections via $plugin->version tricks.
271             throw new moodle_exception('unexpected_plugin_version_format', 'core_plugin', '', null, $version);
272         }
274         if (empty($component) or empty($version)) {
275             return false;
276         }
278         if (!is_dir($folderpath)) {
279             return false;
280         }
282         $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
284         if (file_exists($archzip) and !$overwrite) {
285             return true;
286         }
288         $tmpzip = make_request_directory().'/'.$version.'.zip';
289         $zipped = $this->zip_plugin_folder($folderpath, $tmpzip);
291         if (!$zipped) {
292             return false;
293         }
295         // Assert that the file looks like a valid one.
296         list($expectedtype, $expectedname) = core_component::normalize_component($component);
297         $actualname = $this->get_plugin_zip_root_dir($tmpzip);
298         if ($actualname !== $expectedname) {
299             // This should not happen.
300             throw new moodle_exception('unexpected_archive_structure', 'core_plugin');
301         }
303         make_writable_directory(dirname($archzip));
304         return rename($tmpzip, $archzip);
305     }
307     /**
308      * Return the path to the ZIP file with the archive of the given plugin version.
309      *
310      * @param string $component
311      * @param int $version
312      * @return string|bool false if not found, full path otherwise
313      */
314     public function get_archived_plugin_version($component, $version) {
316         if (empty($component) or empty($version)) {
317             return false;
318         }
320         $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
322         if (file_exists($archzip)) {
323             return $archzip;
324         }
326         return false;
327     }
329     /**
330      * Returns list of all files in the given directory.
331      *
332      * Given a path like /full/path/to/mod/workshop, it returns array like
333      *
334      *  [workshop/] => /full/path/to/mod/workshop
335      *  [workshop/lang/] => /full/path/to/mod/workshop/lang
336      *  [workshop/lang/workshop.php] => /full/path/to/mod/workshop/lang/workshop.php
337      *  ...
338      *
339      * Which mathes the format used by Moodle file packers.
340      *
341      * @param string $folderpath full path to the plugin directory
342      * @return array (string)relpath => (string)fullpath
343      */
344     public function list_plugin_folder_files($folderpath) {
346         $folder = new RecursiveDirectoryIterator($folderpath);
347         $iterator = new RecursiveIteratorIterator($folder);
348         $folderpathinfo = new SplFileInfo($folderpath);
349         $strip = strlen($folderpathinfo->getPathInfo()->getRealPath()) + 1;
350         $files = array();
351         foreach ($iterator as $fileinfo) {
352             if ($fileinfo->getFilename() === '..') {
353                 continue;
354             }
355             if (strpos($fileinfo->getRealPath(), $folderpathinfo->getRealPath() !== 0)) {
356                 throw new moodle_exception('unexpected_filepath_mismatch', 'core_plugin');
357             }
358             $key = substr($fileinfo->getRealPath(), $strip);
359             if ($fileinfo->isDir() and substr($key, -1) !== '/') {
360                 $key .= '/';
361             }
362             $files[$key] = $fileinfo->getRealPath();
363         }
364         return $files;
365     }
367     /**
368      * Detects the plugin's name from its ZIP file.
369      *
370      * Plugin ZIP packages are expected to contain a single directory and the
371      * directory name would become the plugin name once extracted to the Moodle
372      * dirroot.
373      *
374      * @param string $zipfilepath full path to the ZIP files
375      * @return string|bool false on error
376      */
377     public function get_plugin_zip_root_dir($zipfilepath) {
379         $fp = get_file_packer('application/zip');
380         $files = $fp->list_files($zipfilepath);
382         if (empty($files)) {
383             return false;
384         }
386         $rootdirname = null;
387         foreach ($files as $file) {
388             $pathnameitems = explode('/', $file->pathname);
389             if (empty($pathnameitems)) {
390                 return false;
391             }
392             // Set the expected name of the root directory in the first
393             // iteration of the loop.
394             if ($rootdirname === null) {
395                 $rootdirname = $pathnameitems[0];
396             }
397             // Require the same root directory for all files in the ZIP
398             // package.
399             if ($rootdirname !== $pathnameitems[0]) {
400                 return false;
401             }
402         }
404         return $rootdirname;
405     }
407     // This is the end, my only friend, the end ... of external public API.
409     /**
410      * Makes sure all temp directories exist and are writable.
411      */
412     protected function init_temp_directories() {
413         make_writable_directory($this->temproot.'/distfiles');
414         make_writable_directory($this->temproot.'/archive');
415     }
417     /**
418      * Raise developer debugging level message.
419      *
420      * @param string $msg
421      */
422     protected function debug($msg) {
423         debugging($msg, DEBUG_DEVELOPER);
424     }
426     /**
427      * Download the ZIP file with the plugin package from the given location
428      *
429      * @param string $url URL to the file
430      * @param string $tofile full path to where to store the downloaded file
431      * @return bool false on error
432      */
433     protected function download_plugin_zip_file($url, $tofile) {
435         if (file_exists($tofile)) {
436             $this->debug('Error fetching plugin ZIP: target location exists.');
437             return false;
438         }
440         $status = $this->download_file_content($url, $tofile);
442         if (!$status) {
443             $this->debug('Error fetching plugin ZIP.');
444             @unlink($tofile);
445             return false;
446         }
448         return true;
449     }
451     /**
452      * Thin wrapper for the core's download_file_content() function.
453      *
454      * @param string $url URL to the file
455      * @param string $tofile full path to where to store the downloaded file
456      * @return bool
457      */
458     protected function download_file_content($url, $tofile) {
460         // Prepare the parameters for the download_file_content() function.
461         $headers = null;
462         $postdata = null;
463         $fullresponse = false;
464         $timeout = 300;
465         $connecttimeout = 20;
466         $skipcertverify = false;
467         $tofile = $tofile;
468         $calctimeout = false;
470         return download_file_content($url, $headers, $postdata, $fullresponse, $timeout,
471             $connecttimeout, $skipcertverify, $tofile, $calctimeout);
472     }
474     /**
475      * Internal helper method supposed to be called by self::move_plugin_directory() only.
476      *
477      * Moves the given source into a new location recursively.
478      * This is cross-device safe implementation to be used instead of the native rename() function.
479      * See https://bugs.php.net/bug.php?id=54097 for more details.
480      *
481      * @param string $source full path to the existing directory
482      * @param string $target full path to the new location of the directory
483      * @param int $dirpermissions
484      * @param int $filepermissions
485      */
486     protected function move_directory($source, $target, $dirpermissions, $filepermissions) {
488         if (file_exists($target)) {
489             throw new coding_exception('Attempting to overwrite existing directory', $target);
490         }
492         if (is_dir($source)) {
493             $handle = opendir($source);
494         } else {
495             throw new coding_exception('Attempting to move non-existing source directory', $source);
496         }
498         if (!file_exists($target)) {
499             // Do not use make_writable_directory() here - it is intended for dataroot only.
500             mkdir($target, true);
501             @chmod($target, $dirpermissions);
502         }
504         if (!is_writable($target)) {
505             closedir($handle);
506             throw new coding_exception('Created folder not writable', $target);
507         }
509         while ($filename = readdir($handle)) {
510             $sourcepath = $source.'/'.$filename;
511             $targetpath = $target.'/'.$filename;
513             if ($filename === '.' or $filename === '..') {
514                 continue;
515             }
517             if (is_dir($sourcepath)) {
518                 $this->move_directory($sourcepath, $targetpath, $dirpermissions, $filepermissions);
520             } else {
521                 rename($sourcepath, $targetpath);
522                 @chmod($targetpath, $filepermissions);
523             }
524         }
526         closedir($handle);
527         rmdir($source);
528         clearstatcache();
529     }
531     /**
532      * Renames the root directory of the extracted ZIP package.
533      *
534      * This method does not validate the presence of the single root directory
535      * (it is the validator's duty). It just searches for the first directory
536      * under the given location and renames it.
537      *
538      * The method will not rename the root if the requested location already
539      * exists.
540      *
541      * @param string $dirname fullpath location of the extracted ZIP package
542      * @param string $rootdir the requested name of the root directory
543      * @param array $files list of extracted files
544      * @return array eventually amended list of extracted files
545      */
546     protected function rename_extracted_rootdir($dirname, $rootdir, array $files) {
548         if (!is_dir($dirname)) {
549             $this->debug('Unable to rename rootdir of non-existing content');
550             return $files;
551         }
553         if (file_exists($dirname.'/'.$rootdir)) {
554             // This typically means the real root dir already has the $rootdir name.
555             return $files;
556         }
558         $found = null; // The name of the first subdirectory under the $dirname.
559         foreach (scandir($dirname) as $item) {
560             if (substr($item, 0, 1) === '.') {
561                 continue;
562             }
563             if (is_dir($dirname.'/'.$item)) {
564                 $found = $item;
565                 break;
566             }
567         }
569         if (!is_null($found)) {
570             if (rename($dirname.'/'.$found, $dirname.'/'.$rootdir)) {
571                 $newfiles = array();
572                 foreach ($files as $filepath => $status) {
573                     $newpath = preg_replace('~^'.preg_quote($found.'/').'~', preg_quote($rootdir.'/'), $filepath);
574                     $newfiles[$newpath] = $status;
575                 }
576                 return $newfiles;
577             }
578         }
580         return $files;
581     }