MDL-55020 admin: Fix renaming of the plugin package root folder
[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
a2e1e0d0 27use core_component;
0e442ee7 28use coding_exception;
2785fd19 29use moodle_exception;
a2e1e0d0
DM
30use SplFileInfo;
31use RecursiveDirectoryIterator;
32use RecursiveIteratorIterator;
0e442ee7
DM
33
34defined('MOODLE_INTERNAL') || die();
35
36require_once($CFG->libdir.'/filelib.php');
37
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 */
50class code_manager {
51
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;
56
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;
65
66 if (empty($dirroot)) {
67 $dirroot = $CFG->dirroot;
68 }
69
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 }
81
82 $this->dirroot = $dirroot;
83 $this->temproot = $temproot;
84
85 $this->init_temp_directories();
86 }
87
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) {
100
101 // Sanitize and validate the URL.
102 $url = str_replace(array("\r", "\n"), '', $url);
103
104 if (!preg_match('|^https?://|i', $url)) {
105 $this->debug('Error fetching plugin ZIP: unsupported transport protocol: '.$url);
106 return false;
107 }
108
109 // The cache location for the file.
110 $distfile = $this->temproot.'/distfiles/'.$md5.'.zip';
111
fd2d1462 112 if (is_readable($distfile) and md5_file($distfile) === $md5) {
0e442ee7 113 return $distfile;
fd2d1462
DM
114 } else {
115 @unlink($distfile);
0e442ee7
DM
116 }
117
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);
122
123 if (!$result) {
124 return false;
125 }
126
127 $actualmd5 = md5_file($tempfile);
128
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 }
134
135 // If the file is empty, something went wrong.
136 if ($actualmd5 === 'd41d8cd98f00b204e9800998ecf8427e') {
137 return false;
138 }
139
140 // Store the file in our cache.
141 if (!rename($tempfile, $distfile)) {
142 return false;
143 }
144
145 return $distfile;
146 }
147
0e442ee7
DM
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
30c26421 159 * @return array list of extracted files as returned by {@link zip_packer::extract_to_pathname()}
0e442ee7
DM
160 */
161 public function unzip_plugin_file($zipfilepath, $targetdir, $rootdir = '') {
162
2785fd19 163 // Extract the package into a temporary location.
0e442ee7 164 $fp = get_file_packer('application/zip');
2785fd19
DM
165 $tempdir = make_request_directory();
166 $files = $fp->extract_to_pathname($zipfilepath, $tempdir);
0e442ee7
DM
167
168 if (!$files) {
169 return array();
170 }
171
2785fd19 172 // If requested, rename the root directory of the plugin.
0e442ee7 173 if (!empty($rootdir)) {
2785fd19 174 $files = $this->rename_extracted_rootdir($tempdir, $rootdir, $files);
0e442ee7
DM
175 }
176
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 }
193
2785fd19
DM
194 // Move the extracted files into the target location.
195 $this->move_extracted_plugin_files($tempdir, $targetdir, $files);
196
62ea3c43
DM
197 // Set the permissions of extracted subdirs and files.
198 $this->set_plugin_files_permissions($targetdir, $files);
199
0e442ee7
DM
200 return $files;
201 }
202
a2e1e0d0
DM
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) {
211
212 if (file_exists($targetzip)) {
213 throw new coding_exception('Attempting to create already existing ZIP file', $targetzip);
214 }
215
216 if (!is_writable(dirname($targetzip))) {
217 throw new coding_exception('Target ZIP location not writable', dirname($targetzip));
218 }
219
220 if (!is_dir($folderpath)) {
221 throw new coding_exception('Attempting to ZIP non-existing source directory', $folderpath);
222 }
223
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 }
228
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) {
239
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 }
244
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 }
249
250 if (empty($component) or empty($version)) {
251 return false;
252 }
253
254 if (!is_dir($folderpath)) {
255 return false;
256 }
257
258 $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
259
260 if (file_exists($archzip) and !$overwrite) {
261 return true;
262 }
263
264 $tmpzip = make_request_directory().'/'.$version.'.zip';
265 $zipped = $this->zip_plugin_folder($folderpath, $tmpzip);
266
267 if (!$zipped) {
268 return false;
269 }
270
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 }
278
279 make_writable_directory(dirname($archzip));
280 return rename($tmpzip, $archzip);
281 }
282
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) {
291
292 if (empty($component) or empty($version)) {
293 return false;
294 }
295
296 $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
297
298 if (file_exists($archzip)) {
299 return $archzip;
300 }
301
302 return false;
303 }
304
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) {
321
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 }
c8a6d162 338 $files[str_replace(DIRECTORY_SEPARATOR, '/', $key)] = str_replace(DIRECTORY_SEPARATOR, '/', $fileinfo->getRealPath());
a2e1e0d0
DM
339 }
340 return $files;
341 }
342
2d00be61
DM
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) {
354
355 $fp = get_file_packer('application/zip');
356 $files = $fp->list_files($zipfilepath);
357
358 if (empty($files)) {
359 return false;
360 }
361
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 }
379
380 return $rootdirname;
381 }
382
0e442ee7
DM
383 // This is the end, my only friend, the end ... of external public API.
384
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');
a2e1e0d0 390 make_writable_directory($this->temproot.'/archive');
0e442ee7
DM
391 }
392
393 /**
394 * Raise developer debugging level message.
395 *
396 * @param string $msg
397 */
398 protected function debug($msg) {
399 debugging($msg, DEBUG_DEVELOPER);
400 }
401
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) {
410
411 if (file_exists($tofile)) {
412 $this->debug('Error fetching plugin ZIP: target location exists.');
413 return false;
414 }
415
416 $status = $this->download_file_content($url, $tofile);
417
418 if (!$status) {
419 $this->debug('Error fetching plugin ZIP.');
420 @unlink($tofile);
421 return false;
422 }
423
424 return true;
425 }
426
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) {
435
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;
445
446 return download_file_content($url, $headers, $postdata, $fullresponse, $timeout,
447 $connecttimeout, $skipcertverify, $tofile, $calctimeout);
448 }
449
0e442ee7
DM
450 /**
451 * Renames the root directory of the extracted ZIP package.
452 *
2785fd19
DM
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.
0e442ee7
DM
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) {
464
465 if (!is_dir($dirname)) {
466 $this->debug('Unable to rename rootdir of non-existing content');
467 return $files;
468 }
469
470 if (file_exists($dirname.'/'.$rootdir)) {
471 // This typically means the real root dir already has the $rootdir name.
472 return $files;
473 }
474
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)) {
2785fd19
DM
481 if ($found !== null and $found !== $item) {
482 // Multiple directories found.
483 throw new moodle_exception('unexpected_archive_structure', 'core_plugin');
484 }
0e442ee7 485 $found = $item;
0e442ee7
DM
486 }
487 }
488
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 }
499
500 return $files;
501 }
502
62ea3c43
DM
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) {
518
519 $dirpermissions = fileperms($targetdir);
520 $filepermissions = ($dirpermissions & 0666);
521
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 }
2785fd19
DM
531
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;
541
542 foreach ($files as $file => $status) {
543 if ($status !== true) {
544 throw new moodle_exception('corrupted_archive_structure', 'core_plugin', '', $file, $status);
545 }
546
547 $source = $sourcedir.'/'.$file;
548 $target = $targetdir.'/'.$file;
549
550 if (is_dir($source)) {
551 continue;
552
553 } else {
554 if (!is_dir(dirname($target))) {
555 mkdir(dirname($target), $CFG->directorypermissions, true);
556 }
557 rename($source, $target);
558 }
559 }
560 }
0e442ee7 561}