MDL-49329 admin: Clean up code manager methods
[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
0e442ee7
DM
147 /**
148 * Extracts the saved plugin ZIP file.
149 *
150 * Returns the list of files found in the ZIP. The format of that list is
151 * array of (string)filerelpath => (bool|string) where the array value is
152 * either true or a string describing the problematic file.
153 *
154 * @see zip_packer::extract_to_pathname()
155 * @param string $zipfilepath full path to the saved ZIP file
156 * @param string $targetdir full path to the directory to extract the ZIP file to
157 * @param string $rootdir explicitly rename the root directory of the ZIP into this non-empty value
158 * @param array list of extracted files as returned by {@link zip_packer::extract_to_pathname()}
159 */
160 public function unzip_plugin_file($zipfilepath, $targetdir, $rootdir = '') {
161
162 $fp = get_file_packer('application/zip');
163 $files = $fp->extract_to_pathname($zipfilepath, $targetdir);
164
165 if (!$files) {
166 return array();
167 }
168
169 if (!empty($rootdir)) {
170 $files = $this->rename_extracted_rootdir($targetdir, $rootdir, $files);
171 }
172
173 // Sometimes zip may not contain all parent directories, add them to make it consistent.
174 foreach ($files as $path => $status) {
175 if ($status !== true) {
176 continue;
177 }
178 $parts = explode('/', trim($path, '/'));
179 while (array_pop($parts)) {
180 if (empty($parts)) {
181 break;
182 }
183 $dir = implode('/', $parts).'/';
184 if (!isset($files[$dir])) {
185 $files[$dir] = true;
186 }
187 }
188 }
189
190 return $files;
191 }
192
a2e1e0d0
DM
193 /**
194 * Make an archive backup of the existing plugin folder.
195 *
196 * @param string $folderpath full path to the plugin folder
197 * @param string $targetzip full path to the zip file to be created
198 * @return bool true if file created, false if not
199 */
200 public function zip_plugin_folder($folderpath, $targetzip) {
201
202 if (file_exists($targetzip)) {
203 throw new coding_exception('Attempting to create already existing ZIP file', $targetzip);
204 }
205
206 if (!is_writable(dirname($targetzip))) {
207 throw new coding_exception('Target ZIP location not writable', dirname($targetzip));
208 }
209
210 if (!is_dir($folderpath)) {
211 throw new coding_exception('Attempting to ZIP non-existing source directory', $folderpath);
212 }
213
214 $files = $this->list_plugin_folder_files($folderpath);
215 $fp = get_file_packer('application/zip');
216 return $fp->archive_to_pathname($files, $targetzip, false);
217 }
218
219 /**
220 * Archive the current plugin on-disk version.
221 *
222 * @param string $folderpath full path to the plugin folder
223 * @param string $component
224 * @param int $version
225 * @param bool $overwrite overwrite existing archive if found
226 * @return bool
227 */
228 public function archive_plugin_version($folderpath, $component, $version, $overwrite=false) {
229
230 if ($component !== clean_param($component, PARAM_SAFEDIR)) {
231 // This should never happen, but just in case.
232 throw new moodle_exception('unexpected_plugin_component_format', 'core_plugin', '', null, $component);
233 }
234
235 if ((string)$version !== clean_param((string)$version, PARAM_FILE)) {
236 // Prevent some nasty injections via $plugin->version tricks.
237 throw new moodle_exception('unexpected_plugin_version_format', 'core_plugin', '', null, $version);
238 }
239
240 if (empty($component) or empty($version)) {
241 return false;
242 }
243
244 if (!is_dir($folderpath)) {
245 return false;
246 }
247
248 $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
249
250 if (file_exists($archzip) and !$overwrite) {
251 return true;
252 }
253
254 $tmpzip = make_request_directory().'/'.$version.'.zip';
255 $zipped = $this->zip_plugin_folder($folderpath, $tmpzip);
256
257 if (!$zipped) {
258 return false;
259 }
260
261 // Assert that the file looks like a valid one.
262 list($expectedtype, $expectedname) = core_component::normalize_component($component);
263 $actualname = $this->get_plugin_zip_root_dir($tmpzip);
264 if ($actualname !== $expectedname) {
265 // This should not happen.
266 throw new moodle_exception('unexpected_archive_structure', 'core_plugin');
267 }
268
269 make_writable_directory(dirname($archzip));
270 return rename($tmpzip, $archzip);
271 }
272
273 /**
274 * Return the path to the ZIP file with the archive of the given plugin version.
275 *
276 * @param string $component
277 * @param int $version
278 * @return string|bool false if not found, full path otherwise
279 */
280 public function get_archived_plugin_version($component, $version) {
281
282 if (empty($component) or empty($version)) {
283 return false;
284 }
285
286 $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
287
288 if (file_exists($archzip)) {
289 return $archzip;
290 }
291
292 return false;
293 }
294
295 /**
296 * Returns list of all files in the given directory.
297 *
298 * Given a path like /full/path/to/mod/workshop, it returns array like
299 *
300 * [workshop/] => /full/path/to/mod/workshop
301 * [workshop/lang/] => /full/path/to/mod/workshop/lang
302 * [workshop/lang/workshop.php] => /full/path/to/mod/workshop/lang/workshop.php
303 * ...
304 *
305 * Which mathes the format used by Moodle file packers.
306 *
307 * @param string $folderpath full path to the plugin directory
308 * @return array (string)relpath => (string)fullpath
309 */
310 public function list_plugin_folder_files($folderpath) {
311
312 $folder = new RecursiveDirectoryIterator($folderpath);
313 $iterator = new RecursiveIteratorIterator($folder);
314 $folderpathinfo = new SplFileInfo($folderpath);
315 $strip = strlen($folderpathinfo->getPathInfo()->getRealPath()) + 1;
316 $files = array();
317 foreach ($iterator as $fileinfo) {
318 if ($fileinfo->getFilename() === '..') {
319 continue;
320 }
321 if (strpos($fileinfo->getRealPath(), $folderpathinfo->getRealPath() !== 0)) {
322 throw new moodle_exception('unexpected_filepath_mismatch', 'core_plugin');
323 }
324 $key = substr($fileinfo->getRealPath(), $strip);
325 if ($fileinfo->isDir() and substr($key, -1) !== '/') {
326 $key .= '/';
327 }
328 $files[$key] = $fileinfo->getRealPath();
329 }
330 return $files;
331 }
332
2d00be61
DM
333 /**
334 * Detects the plugin's name from its ZIP file.
335 *
336 * Plugin ZIP packages are expected to contain a single directory and the
337 * directory name would become the plugin name once extracted to the Moodle
338 * dirroot.
339 *
340 * @param string $zipfilepath full path to the ZIP files
341 * @return string|bool false on error
342 */
343 public function get_plugin_zip_root_dir($zipfilepath) {
344
345 $fp = get_file_packer('application/zip');
346 $files = $fp->list_files($zipfilepath);
347
348 if (empty($files)) {
349 return false;
350 }
351
352 $rootdirname = null;
353 foreach ($files as $file) {
354 $pathnameitems = explode('/', $file->pathname);
355 if (empty($pathnameitems)) {
356 return false;
357 }
358 // Set the expected name of the root directory in the first
359 // iteration of the loop.
360 if ($rootdirname === null) {
361 $rootdirname = $pathnameitems[0];
362 }
363 // Require the same root directory for all files in the ZIP
364 // package.
365 if ($rootdirname !== $pathnameitems[0]) {
366 return false;
367 }
368 }
369
370 return $rootdirname;
371 }
372
0e442ee7
DM
373 // This is the end, my only friend, the end ... of external public API.
374
375 /**
376 * Makes sure all temp directories exist and are writable.
377 */
378 protected function init_temp_directories() {
379 make_writable_directory($this->temproot.'/distfiles');
a2e1e0d0 380 make_writable_directory($this->temproot.'/archive');
0e442ee7
DM
381 }
382
383 /**
384 * Raise developer debugging level message.
385 *
386 * @param string $msg
387 */
388 protected function debug($msg) {
389 debugging($msg, DEBUG_DEVELOPER);
390 }
391
392 /**
393 * Download the ZIP file with the plugin package from the given location
394 *
395 * @param string $url URL to the file
396 * @param string $tofile full path to where to store the downloaded file
397 * @return bool false on error
398 */
399 protected function download_plugin_zip_file($url, $tofile) {
400
401 if (file_exists($tofile)) {
402 $this->debug('Error fetching plugin ZIP: target location exists.');
403 return false;
404 }
405
406 $status = $this->download_file_content($url, $tofile);
407
408 if (!$status) {
409 $this->debug('Error fetching plugin ZIP.');
410 @unlink($tofile);
411 return false;
412 }
413
414 return true;
415 }
416
417 /**
418 * Thin wrapper for the core's download_file_content() function.
419 *
420 * @param string $url URL to the file
421 * @param string $tofile full path to where to store the downloaded file
422 * @return bool
423 */
424 protected function download_file_content($url, $tofile) {
425
426 // Prepare the parameters for the download_file_content() function.
427 $headers = null;
428 $postdata = null;
429 $fullresponse = false;
430 $timeout = 300;
431 $connecttimeout = 20;
432 $skipcertverify = false;
433 $tofile = $tofile;
434 $calctimeout = false;
435
436 return download_file_content($url, $headers, $postdata, $fullresponse, $timeout,
437 $connecttimeout, $skipcertverify, $tofile, $calctimeout);
438 }
439
0e442ee7
DM
440 /**
441 * Renames the root directory of the extracted ZIP package.
442 *
443 * This method does not validate the presence of the single root directory
444 * (it is the validator's duty). It just searches for the first directory
445 * under the given location and renames it.
446 *
447 * The method will not rename the root if the requested location already
448 * exists.
449 *
450 * @param string $dirname fullpath location of the extracted ZIP package
451 * @param string $rootdir the requested name of the root directory
452 * @param array $files list of extracted files
453 * @return array eventually amended list of extracted files
454 */
455 protected function rename_extracted_rootdir($dirname, $rootdir, array $files) {
456
457 if (!is_dir($dirname)) {
458 $this->debug('Unable to rename rootdir of non-existing content');
459 return $files;
460 }
461
462 if (file_exists($dirname.'/'.$rootdir)) {
463 // This typically means the real root dir already has the $rootdir name.
464 return $files;
465 }
466
467 $found = null; // The name of the first subdirectory under the $dirname.
468 foreach (scandir($dirname) as $item) {
469 if (substr($item, 0, 1) === '.') {
470 continue;
471 }
472 if (is_dir($dirname.'/'.$item)) {
473 $found = $item;
474 break;
475 }
476 }
477
478 if (!is_null($found)) {
479 if (rename($dirname.'/'.$found, $dirname.'/'.$rootdir)) {
480 $newfiles = array();
481 foreach ($files as $filepath => $status) {
482 $newpath = preg_replace('~^'.preg_quote($found.'/').'~', preg_quote($rootdir.'/'), $filepath);
483 $newfiles[$newpath] = $status;
484 }
485 return $newfiles;
486 }
487 }
488
489 return $files;
490 }
491
492}