383890bbfc7320b7cd1719c43923377ab23a4b3e
[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 coding_exception;
29 defined('MOODLE_INTERNAL') || die();
31 require_once($CFG->libdir.'/filelib.php');
33 /**
34  * General purpose class managing the plugins source code files deployment
35  *
36  * The class is able and supposed to
37  * - fetch and cache ZIP files distributed via the Moodle Plugins directory
38  * - unpack the ZIP files in a temporary storage
39  * - archive existing version of the plugin source code
40  * - move (deploy) the plugin source code into the $CFG->dirroot
41  *
42  * @copyright 2015 David Mudrak <david@moodle.com>
43  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
44  */
45 class code_manager {
47     /** @var string full path to the Moodle app directory root */
48     protected $dirroot;
49     /** @var string full path to the temp directory root */
50     protected $temproot;
52     /**
53      * Instantiate the class instance
54      *
55      * @param string $dirroot full path to the moodle app directory root
56      * @param string $temproot full path to our temp directory
57      */
58     public function __construct($dirroot=null, $temproot=null) {
59         global $CFG;
61         if (empty($dirroot)) {
62             $dirroot = $CFG->dirroot;
63         }
65         if (empty($temproot)) {
66             // Note we are using core_plugin here as that is the valid core
67             // subsystem we are part of. The namespace of this class (core\update)
68             // does not match it for legacy reasons.  The data stored in the
69             // temp directory are expected to survive multiple requests and
70             // purging caches during the upgrade, so we make use of
71             // make_temp_directory(). The contents of it can be removed if needed,
72             // given the site is in the maintenance mode (so that cron is not
73             // executed) and the site is not being upgraded.
74             $temproot = make_temp_directory('core_plugin/code_manager');
75         }
77         $this->dirroot = $dirroot;
78         $this->temproot = $temproot;
80         $this->init_temp_directories();
81     }
83     /**
84      * Obtain the plugin ZIP file from the given URL
85      *
86      * The caller is supposed to know both downloads URL and the MD5 hash of
87      * the ZIP contents in advance, typically by using the API requests against
88      * the plugins directory.
89      *
90      * @param string $url
91      * @param string $md5
92      * @return string|bool full path to the file, false on error
93      */
94     public function get_remote_plugin_zip($url, $md5) {
96         // Sanitize and validate the URL.
97         $url = str_replace(array("\r", "\n"), '', $url);
99         if (!preg_match('|^https?://|i', $url)) {
100             $this->debug('Error fetching plugin ZIP: unsupported transport protocol: '.$url);
101             return false;
102         }
104         // The cache location for the file.
105         $distfile = $this->temproot.'/distfiles/'.$md5.'.zip';
107         if (is_readable($distfile) and md5_file($distfile) === $md5) {
108             return $distfile;
109         } else {
110             @unlink($distfile);
111         }
113         // Download the file into a temporary location.
114         $tempdir = make_request_directory();
115         $tempfile = $tempdir.'/plugin.zip';
116         $result = $this->download_plugin_zip_file($url, $tempfile);
118         if (!$result) {
119             return false;
120         }
122         $actualmd5 = md5_file($tempfile);
124         // Make sure the actual md5 hash matches the expected one.
125         if ($actualmd5 !== $md5) {
126             $this->debug('Error fetching plugin ZIP: md5 mismatch.');
127             return false;
128         }
130         // If the file is empty, something went wrong.
131         if ($actualmd5 === 'd41d8cd98f00b204e9800998ecf8427e') {
132             return false;
133         }
135         // Store the file in our cache.
136         if (!rename($tempfile, $distfile)) {
137             return false;
138         }
140         return $distfile;
141     }
143     /**
144      * Move a folder with the plugin code from the source to the target location
145      *
146      * This can be used to move plugin folders to and from the dirroot/dataroot
147      * as needed. It is assumed that the caller checked that both locations are
148      * writable.
149      *
150      * Permissions in the target location are set to the same values that the
151      * parent directory has (see MDL-42110 for details).
152      *
153      * @param string $source full path to the current plugin code folder
154      * @param string $target full path to the new plugin code folder
155      */
156     public function move_plugin_directory($source, $target) {
158         $targetparent = dirname($target);
160         if ($targetparent === '.') {
161             // No directory separator in $target..
162             throw new coding_exception('Invalid target path', $target);
163         }
165         if (!is_writable($targetparent)) {
166             throw new coding_exception('Attempting to move into non-writable parent directory', $targetparent);
167         }
169         // Use parent directory's permissions for us, too.
170         $dirpermissions = fileperms($targetparent);
171         // Strip execute flags and use that for files.
172         $filepermissions = ($dirpermissions & 0666);
174         $this->move_directory($source, $target, $dirpermissions, $filepermissions);
175     }
177     /**
178      * Extracts the saved plugin ZIP file.
179      *
180      * Returns the list of files found in the ZIP. The format of that list is
181      * array of (string)filerelpath => (bool|string) where the array value is
182      * either true or a string describing the problematic file.
183      *
184      * @see zip_packer::extract_to_pathname()
185      * @param string $zipfilepath full path to the saved ZIP file
186      * @param string $targetdir full path to the directory to extract the ZIP file to
187      * @param string $rootdir explicitly rename the root directory of the ZIP into this non-empty value
188      * @param array list of extracted files as returned by {@link zip_packer::extract_to_pathname()}
189      */
190     public function unzip_plugin_file($zipfilepath, $targetdir, $rootdir = '') {
192         $fp = get_file_packer('application/zip');
193         $files = $fp->extract_to_pathname($zipfilepath, $targetdir);
195         if (!$files) {
196             return array();
197         }
199         if (!empty($rootdir)) {
200             $files = $this->rename_extracted_rootdir($targetdir, $rootdir, $files);
201         }
203         // Sometimes zip may not contain all parent directories, add them to make it consistent.
204         foreach ($files as $path => $status) {
205             if ($status !== true) {
206                 continue;
207             }
208             $parts = explode('/', trim($path, '/'));
209             while (array_pop($parts)) {
210                 if (empty($parts)) {
211                     break;
212                 }
213                 $dir = implode('/', $parts).'/';
214                 if (!isset($files[$dir])) {
215                     $files[$dir] = true;
216                 }
217             }
218         }
220         return $files;
221     }
223     /**
224      * Detects the plugin's name from its ZIP file.
225      *
226      * Plugin ZIP packages are expected to contain a single directory and the
227      * directory name would become the plugin name once extracted to the Moodle
228      * dirroot.
229      *
230      * @param string $zipfilepath full path to the ZIP files
231      * @return string|bool false on error
232      */
233     public function get_plugin_zip_root_dir($zipfilepath) {
235         $fp = get_file_packer('application/zip');
236         $files = $fp->list_files($zipfilepath);
238         if (empty($files)) {
239             return false;
240         }
242         $rootdirname = null;
243         foreach ($files as $file) {
244             $pathnameitems = explode('/', $file->pathname);
245             if (empty($pathnameitems)) {
246                 return false;
247             }
248             // Set the expected name of the root directory in the first
249             // iteration of the loop.
250             if ($rootdirname === null) {
251                 $rootdirname = $pathnameitems[0];
252             }
253             // Require the same root directory for all files in the ZIP
254             // package.
255             if ($rootdirname !== $pathnameitems[0]) {
256                 return false;
257             }
258         }
260         return $rootdirname;
261     }
263     // This is the end, my only friend, the end ... of external public API.
265     /**
266      * Makes sure all temp directories exist and are writable.
267      */
268     protected function init_temp_directories() {
269         make_writable_directory($this->temproot.'/distfiles');
270     }
272     /**
273      * Raise developer debugging level message.
274      *
275      * @param string $msg
276      */
277     protected function debug($msg) {
278         debugging($msg, DEBUG_DEVELOPER);
279     }
281     /**
282      * Download the ZIP file with the plugin package from the given location
283      *
284      * @param string $url URL to the file
285      * @param string $tofile full path to where to store the downloaded file
286      * @return bool false on error
287      */
288     protected function download_plugin_zip_file($url, $tofile) {
290         if (file_exists($tofile)) {
291             $this->debug('Error fetching plugin ZIP: target location exists.');
292             return false;
293         }
295         $status = $this->download_file_content($url, $tofile);
297         if (!$status) {
298             $this->debug('Error fetching plugin ZIP.');
299             @unlink($tofile);
300             return false;
301         }
303         return true;
304     }
306     /**
307      * Thin wrapper for the core's download_file_content() function.
308      *
309      * @param string $url URL to the file
310      * @param string $tofile full path to where to store the downloaded file
311      * @return bool
312      */
313     protected function download_file_content($url, $tofile) {
315         // Prepare the parameters for the download_file_content() function.
316         $headers = null;
317         $postdata = null;
318         $fullresponse = false;
319         $timeout = 300;
320         $connecttimeout = 20;
321         $skipcertverify = false;
322         $tofile = $tofile;
323         $calctimeout = false;
325         return download_file_content($url, $headers, $postdata, $fullresponse, $timeout,
326             $connecttimeout, $skipcertverify, $tofile, $calctimeout);
327     }
329     /**
330      * Internal helper method supposed to be called by self::move_plugin_directory() only.
331      *
332      * Moves the given source into a new location recursively.
333      * This is cross-device safe implementation to be used instead of the native rename() function.
334      * See https://bugs.php.net/bug.php?id=54097 for more details.
335      *
336      * @param string $source full path to the existing directory
337      * @param string $target full path to the new location of the directory
338      * @param int $dirpermissions
339      * @param int $filepermissions
340      */
341     protected function move_directory($source, $target, $dirpermissions, $filepermissions) {
343         if (file_exists($target)) {
344             throw new coding_exception('Attempting to overwrite existing directory', $target);
345         }
347         if (is_dir($source)) {
348             $handle = opendir($source);
349         } else {
350             throw new coding_exception('Attempting to move non-existing source directory', $source);
351         }
353         if (!file_exists($target)) {
354             // Do not use make_writable_directory() here - it is intended for dataroot only.
355             mkdir($target, true);
356             @chmod($target, $dirpermissions);
357         }
359         if (!is_writable($target)) {
360             closedir($handle);
361             throw new coding_exception('Created folder not writable', $target);
362         }
364         while ($filename = readdir($handle)) {
365             $sourcepath = $source.'/'.$filename;
366             $targetpath = $target.'/'.$filename;
368             if ($filename === '.' or $filename === '..') {
369                 continue;
370             }
372             if (is_dir($sourcepath)) {
373                 $this->move_directory($sourcepath, $targetpath, $dirpermissions, $filepermissions);
375             } else {
376                 rename($sourcepath, $targetpath);
377                 @chmod($targetpath, $filepermissions);
378             }
379         }
381         closedir($handle);
382         rmdir($source);
383         clearstatcache();
384     }
386     /**
387      * Renames the root directory of the extracted ZIP package.
388      *
389      * This method does not validate the presence of the single root directory
390      * (it is the validator's duty). It just searches for the first directory
391      * under the given location and renames it.
392      *
393      * The method will not rename the root if the requested location already
394      * exists.
395      *
396      * @param string $dirname fullpath location of the extracted ZIP package
397      * @param string $rootdir the requested name of the root directory
398      * @param array $files list of extracted files
399      * @return array eventually amended list of extracted files
400      */
401     protected function rename_extracted_rootdir($dirname, $rootdir, array $files) {
403         if (!is_dir($dirname)) {
404             $this->debug('Unable to rename rootdir of non-existing content');
405             return $files;
406         }
408         if (file_exists($dirname.'/'.$rootdir)) {
409             // This typically means the real root dir already has the $rootdir name.
410             return $files;
411         }
413         $found = null; // The name of the first subdirectory under the $dirname.
414         foreach (scandir($dirname) as $item) {
415             if (substr($item, 0, 1) === '.') {
416                 continue;
417             }
418             if (is_dir($dirname.'/'.$item)) {
419                 $found = $item;
420                 break;
421             }
422         }
424         if (!is_null($found)) {
425             if (rename($dirname.'/'.$found, $dirname.'/'.$rootdir)) {
426                 $newfiles = array();
427                 foreach ($files as $filepath => $status) {
428                     $newpath = preg_replace('~^'.preg_quote($found.'/').'~', preg_quote($rootdir.'/'), $filepath);
429                     $newfiles[$newpath] = $status;
430                 }
431                 return $newfiles;
432             }
433         }
435         return $files;
436     }