MDL-49329 admin: Archive plugin code before removing it from dirroot
[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;
a2e1e0d0
DM
29use SplFileInfo;
30use RecursiveDirectoryIterator;
31use RecursiveIteratorIterator;
0e442ee7
DM
32
33defined('MOODLE_INTERNAL') || die();
34
35require_once($CFG->libdir.'/filelib.php');
36
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 */
49class code_manager {
50
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;
55
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;
64
65 if (empty($dirroot)) {
66 $dirroot = $CFG->dirroot;
67 }
68
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 }
80
81 $this->dirroot = $dirroot;
82 $this->temproot = $temproot;
83
84 $this->init_temp_directories();
85 }
86
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) {
99
100 // Sanitize and validate the URL.
101 $url = str_replace(array("\r", "\n"), '', $url);
102
103 if (!preg_match('|^https?://|i', $url)) {
104 $this->debug('Error fetching plugin ZIP: unsupported transport protocol: '.$url);
105 return false;
106 }
107
108 // The cache location for the file.
109 $distfile = $this->temproot.'/distfiles/'.$md5.'.zip';
110
fd2d1462 111 if (is_readable($distfile) and md5_file($distfile) === $md5) {
0e442ee7 112 return $distfile;
fd2d1462
DM
113 } else {
114 @unlink($distfile);
0e442ee7
DM
115 }
116
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);
121
122 if (!$result) {
123 return false;
124 }
125
126 $actualmd5 = md5_file($tempfile);
127
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 }
133
134 // If the file is empty, something went wrong.
135 if ($actualmd5 === 'd41d8cd98f00b204e9800998ecf8427e') {
136 return false;
137 }
138
139 // Store the file in our cache.
140 if (!rename($tempfile, $distfile)) {
141 return false;
142 }
143
144 return $distfile;
145 }
146
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) {
161
162 $targetparent = dirname($target);
163
164 if ($targetparent === '.') {
165 // No directory separator in $target..
166 throw new coding_exception('Invalid target path', $target);
167 }
168
169 if (!is_writable($targetparent)) {
170 throw new coding_exception('Attempting to move into non-writable parent directory', $targetparent);
171 }
172
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);
177
178 $this->move_directory($source, $target, $dirpermissions, $filepermissions);
179 }
180
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 = '') {
195
196 $fp = get_file_packer('application/zip');
197 $files = $fp->extract_to_pathname($zipfilepath, $targetdir);
198
199 if (!$files) {
200 return array();
201 }
202
203 if (!empty($rootdir)) {
204 $files = $this->rename_extracted_rootdir($targetdir, $rootdir, $files);
205 }
206
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 }
223
224 return $files;
225 }
226
a2e1e0d0
DM
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) {
235
236 if (file_exists($targetzip)) {
237 throw new coding_exception('Attempting to create already existing ZIP file', $targetzip);
238 }
239
240 if (!is_writable(dirname($targetzip))) {
241 throw new coding_exception('Target ZIP location not writable', dirname($targetzip));
242 }
243
244 if (!is_dir($folderpath)) {
245 throw new coding_exception('Attempting to ZIP non-existing source directory', $folderpath);
246 }
247
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 }
252
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) {
263
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 }
268
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 }
273
274 if (empty($component) or empty($version)) {
275 return false;
276 }
277
278 if (!is_dir($folderpath)) {
279 return false;
280 }
281
282 $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
283
284 if (file_exists($archzip) and !$overwrite) {
285 return true;
286 }
287
288 $tmpzip = make_request_directory().'/'.$version.'.zip';
289 $zipped = $this->zip_plugin_folder($folderpath, $tmpzip);
290
291 if (!$zipped) {
292 return false;
293 }
294
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 }
302
303 make_writable_directory(dirname($archzip));
304 return rename($tmpzip, $archzip);
305 }
306
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) {
315
316 if (empty($component) or empty($version)) {
317 return false;
318 }
319
320 $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
321
322 if (file_exists($archzip)) {
323 return $archzip;
324 }
325
326 return false;
327 }
328
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) {
345
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 }
366
2d00be61
DM
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) {
378
379 $fp = get_file_packer('application/zip');
380 $files = $fp->list_files($zipfilepath);
381
382 if (empty($files)) {
383 return false;
384 }
385
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 }
403
404 return $rootdirname;
405 }
406
0e442ee7
DM
407 // This is the end, my only friend, the end ... of external public API.
408
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');
a2e1e0d0 414 make_writable_directory($this->temproot.'/archive');
0e442ee7
DM
415 }
416
417 /**
418 * Raise developer debugging level message.
419 *
420 * @param string $msg
421 */
422 protected function debug($msg) {
423 debugging($msg, DEBUG_DEVELOPER);
424 }
425
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) {
434
435 if (file_exists($tofile)) {
436 $this->debug('Error fetching plugin ZIP: target location exists.');
437 return false;
438 }
439
440 $status = $this->download_file_content($url, $tofile);
441
442 if (!$status) {
443 $this->debug('Error fetching plugin ZIP.');
444 @unlink($tofile);
445 return false;
446 }
447
448 return true;
449 }
450
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) {
459
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;
469
470 return download_file_content($url, $headers, $postdata, $fullresponse, $timeout,
471 $connecttimeout, $skipcertverify, $tofile, $calctimeout);
472 }
473
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) {
487
488 if (file_exists($target)) {
489 throw new coding_exception('Attempting to overwrite existing directory', $target);
490 }
491
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 }
497
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 }
503
504 if (!is_writable($target)) {
505 closedir($handle);
506 throw new coding_exception('Created folder not writable', $target);
507 }
508
509 while ($filename = readdir($handle)) {
510 $sourcepath = $source.'/'.$filename;
511 $targetpath = $target.'/'.$filename;
512
513 if ($filename === '.' or $filename === '..') {
514 continue;
515 }
516
517 if (is_dir($sourcepath)) {
518 $this->move_directory($sourcepath, $targetpath, $dirpermissions, $filepermissions);
519
520 } else {
521 rename($sourcepath, $targetpath);
522 @chmod($targetpath, $filepermissions);
523 }
524 }
525
526 closedir($handle);
527 rmdir($source);
528 clearstatcache();
529 }
530
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) {
547
548 if (!is_dir($dirname)) {
549 $this->debug('Unable to rename rootdir of non-existing content');
550 return $files;
551 }
552
553 if (file_exists($dirname.'/'.$rootdir)) {
554 // This typically means the real root dir already has the $rootdir name.
555 return $files;
556 }
557
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 }
568
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 }
579
580 return $files;
581 }
582
583}