MDL-49329 admin: Require confirmation before cancelling plugin install
[moodle.git] / lib / classes / update / code_manager.php
CommitLineData
0e442ee7
DM
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/>.
16
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 */
24
25namespace core\update;
26
27use coding_exception;
28
29defined('MOODLE_INTERNAL') || die();
30
31require_once($CFG->libdir.'/filelib.php');
32
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 */
45class code_manager {
46
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;
51
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;
60
61 if (empty($dirroot)) {
62 $dirroot = $CFG->dirroot;
63 }
64
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 }
76
77 $this->dirroot = $dirroot;
78 $this->temproot = $temproot;
79
80 $this->init_temp_directories();
81 }
82
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) {
95
96 // Sanitize and validate the URL.
97 $url = str_replace(array("\r", "\n"), '', $url);
98
99 if (!preg_match('|^https?://|i', $url)) {
100 $this->debug('Error fetching plugin ZIP: unsupported transport protocol: '.$url);
101 return false;
102 }
103
104 // The cache location for the file.
105 $distfile = $this->temproot.'/distfiles/'.$md5.'.zip';
106
fd2d1462 107 if (is_readable($distfile) and md5_file($distfile) === $md5) {
0e442ee7 108 return $distfile;
fd2d1462
DM
109 } else {
110 @unlink($distfile);
0e442ee7
DM
111 }
112
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);
117
118 if (!$result) {
119 return false;
120 }
121
122 $actualmd5 = md5_file($tempfile);
123
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 }
129
130 // If the file is empty, something went wrong.
131 if ($actualmd5 === 'd41d8cd98f00b204e9800998ecf8427e') {
132 return false;
133 }
134
135 // Store the file in our cache.
136 if (!rename($tempfile, $distfile)) {
137 return false;
138 }
139
140 return $distfile;
141 }
142
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) {
157
158 $targetparent = dirname($target);
159
160 if ($targetparent === '.') {
161 // No directory separator in $target..
162 throw new coding_exception('Invalid target path', $target);
163 }
164
165 if (!is_writable($targetparent)) {
166 throw new coding_exception('Attempting to move into non-writable parent directory', $targetparent);
167 }
168
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);
173
174 $this->move_directory($source, $target, $dirpermissions, $filepermissions);
175 }
176
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 = '') {
191
192 $fp = get_file_packer('application/zip');
193 $files = $fp->extract_to_pathname($zipfilepath, $targetdir);
194
195 if (!$files) {
196 return array();
197 }
198
199 if (!empty($rootdir)) {
200 $files = $this->rename_extracted_rootdir($targetdir, $rootdir, $files);
201 }
202
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 }
219
220 return $files;
221 }
222
2d00be61
DM
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) {
234
235 $fp = get_file_packer('application/zip');
236 $files = $fp->list_files($zipfilepath);
237
238 if (empty($files)) {
239 return false;
240 }
241
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 }
259
260 return $rootdirname;
261 }
262
0e442ee7
DM
263 // This is the end, my only friend, the end ... of external public API.
264
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 }
271
272 /**
273 * Raise developer debugging level message.
274 *
275 * @param string $msg
276 */
277 protected function debug($msg) {
278 debugging($msg, DEBUG_DEVELOPER);
279 }
280
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) {
289
290 if (file_exists($tofile)) {
291 $this->debug('Error fetching plugin ZIP: target location exists.');
292 return false;
293 }
294
295 $status = $this->download_file_content($url, $tofile);
296
297 if (!$status) {
298 $this->debug('Error fetching plugin ZIP.');
299 @unlink($tofile);
300 return false;
301 }
302
303 return true;
304 }
305
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) {
314
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;
324
325 return download_file_content($url, $headers, $postdata, $fullresponse, $timeout,
326 $connecttimeout, $skipcertverify, $tofile, $calctimeout);
327 }
328
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) {
342
343 if (file_exists($target)) {
344 throw new coding_exception('Attempting to overwrite existing directory', $target);
345 }
346
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 }
352
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 }
358
359 if (!is_writable($target)) {
360 closedir($handle);
361 throw new coding_exception('Created folder not writable', $target);
362 }
363
364 while ($filename = readdir($handle)) {
365 $sourcepath = $source.'/'.$filename;
366 $targetpath = $target.'/'.$filename;
367
368 if ($filename === '.' or $filename === '..') {
369 continue;
370 }
371
372 if (is_dir($sourcepath)) {
373 $this->move_directory($sourcepath, $targetpath, $dirpermissions, $filepermissions);
374
375 } else {
376 rename($sourcepath, $targetpath);
377 @chmod($targetpath, $filepermissions);
378 }
379 }
380
381 closedir($handle);
382 rmdir($source);
383 clearstatcache();
384 }
385
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) {
402
403 if (!is_dir($dirname)) {
404 $this->debug('Unable to rename rootdir of non-existing content');
405 return $files;
406 }
407
408 if (file_exists($dirname.'/'.$rootdir)) {
409 // This typically means the real root dir already has the $rootdir name.
410 return $files;
411 }
412
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 }
423
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 }
434
435 return $files;
436 }
437
438}