MDL-49329 admin: Introduce new \core\update\core_manager tool
[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
107 if (is_readable($distfile)) {
108 return $distfile;
109 }
110
111 // Download the file into a temporary location.
112 $tempdir = make_request_directory();
113 $tempfile = $tempdir.'/plugin.zip';
114 $result = $this->download_plugin_zip_file($url, $tempfile);
115
116 if (!$result) {
117 return false;
118 }
119
120 $actualmd5 = md5_file($tempfile);
121
122 // Make sure the actual md5 hash matches the expected one.
123 if ($actualmd5 !== $md5) {
124 $this->debug('Error fetching plugin ZIP: md5 mismatch.');
125 return false;
126 }
127
128 // If the file is empty, something went wrong.
129 if ($actualmd5 === 'd41d8cd98f00b204e9800998ecf8427e') {
130 return false;
131 }
132
133 // Store the file in our cache.
134 if (!rename($tempfile, $distfile)) {
135 return false;
136 }
137
138 return $distfile;
139 }
140
141 /**
142 * Move a folder with the plugin code from the source to the target location
143 *
144 * This can be used to move plugin folders to and from the dirroot/dataroot
145 * as needed. It is assumed that the caller checked that both locations are
146 * writable.
147 *
148 * Permissions in the target location are set to the same values that the
149 * parent directory has (see MDL-42110 for details).
150 *
151 * @param string $source full path to the current plugin code folder
152 * @param string $target full path to the new plugin code folder
153 */
154 public function move_plugin_directory($source, $target) {
155
156 $targetparent = dirname($target);
157
158 if ($targetparent === '.') {
159 // No directory separator in $target..
160 throw new coding_exception('Invalid target path', $target);
161 }
162
163 if (!is_writable($targetparent)) {
164 throw new coding_exception('Attempting to move into non-writable parent directory', $targetparent);
165 }
166
167 // Use parent directory's permissions for us, too.
168 $dirpermissions = fileperms($targetparent);
169 // Strip execute flags and use that for files.
170 $filepermissions = ($dirpermissions & 0666);
171
172 $this->move_directory($source, $target, $dirpermissions, $filepermissions);
173 }
174
175 /**
176 * Extracts the saved plugin ZIP file.
177 *
178 * Returns the list of files found in the ZIP. The format of that list is
179 * array of (string)filerelpath => (bool|string) where the array value is
180 * either true or a string describing the problematic file.
181 *
182 * @see zip_packer::extract_to_pathname()
183 * @param string $zipfilepath full path to the saved ZIP file
184 * @param string $targetdir full path to the directory to extract the ZIP file to
185 * @param string $rootdir explicitly rename the root directory of the ZIP into this non-empty value
186 * @param array list of extracted files as returned by {@link zip_packer::extract_to_pathname()}
187 */
188 public function unzip_plugin_file($zipfilepath, $targetdir, $rootdir = '') {
189
190 $fp = get_file_packer('application/zip');
191 $files = $fp->extract_to_pathname($zipfilepath, $targetdir);
192
193 if (!$files) {
194 return array();
195 }
196
197 if (!empty($rootdir)) {
198 $files = $this->rename_extracted_rootdir($targetdir, $rootdir, $files);
199 }
200
201 // Sometimes zip may not contain all parent directories, add them to make it consistent.
202 foreach ($files as $path => $status) {
203 if ($status !== true) {
204 continue;
205 }
206 $parts = explode('/', trim($path, '/'));
207 while (array_pop($parts)) {
208 if (empty($parts)) {
209 break;
210 }
211 $dir = implode('/', $parts).'/';
212 if (!isset($files[$dir])) {
213 $files[$dir] = true;
214 }
215 }
216 }
217
218 return $files;
219 }
220
221 // This is the end, my only friend, the end ... of external public API.
222
223 /**
224 * Makes sure all temp directories exist and are writable.
225 */
226 protected function init_temp_directories() {
227 make_writable_directory($this->temproot.'/distfiles');
228 }
229
230 /**
231 * Raise developer debugging level message.
232 *
233 * @param string $msg
234 */
235 protected function debug($msg) {
236 debugging($msg, DEBUG_DEVELOPER);
237 }
238
239 /**
240 * Download the ZIP file with the plugin package from the given location
241 *
242 * @param string $url URL to the file
243 * @param string $tofile full path to where to store the downloaded file
244 * @return bool false on error
245 */
246 protected function download_plugin_zip_file($url, $tofile) {
247
248 if (file_exists($tofile)) {
249 $this->debug('Error fetching plugin ZIP: target location exists.');
250 return false;
251 }
252
253 $status = $this->download_file_content($url, $tofile);
254
255 if (!$status) {
256 $this->debug('Error fetching plugin ZIP.');
257 @unlink($tofile);
258 return false;
259 }
260
261 return true;
262 }
263
264 /**
265 * Thin wrapper for the core's download_file_content() function.
266 *
267 * @param string $url URL to the file
268 * @param string $tofile full path to where to store the downloaded file
269 * @return bool
270 */
271 protected function download_file_content($url, $tofile) {
272
273 // Prepare the parameters for the download_file_content() function.
274 $headers = null;
275 $postdata = null;
276 $fullresponse = false;
277 $timeout = 300;
278 $connecttimeout = 20;
279 $skipcertverify = false;
280 $tofile = $tofile;
281 $calctimeout = false;
282
283 return download_file_content($url, $headers, $postdata, $fullresponse, $timeout,
284 $connecttimeout, $skipcertverify, $tofile, $calctimeout);
285 }
286
287 /**
288 * Internal helper method supposed to be called by self::move_plugin_directory() only.
289 *
290 * Moves the given source into a new location recursively.
291 * This is cross-device safe implementation to be used instead of the native rename() function.
292 * See https://bugs.php.net/bug.php?id=54097 for more details.
293 *
294 * @param string $source full path to the existing directory
295 * @param string $target full path to the new location of the directory
296 * @param int $dirpermissions
297 * @param int $filepermissions
298 */
299 protected function move_directory($source, $target, $dirpermissions, $filepermissions) {
300
301 if (file_exists($target)) {
302 throw new coding_exception('Attempting to overwrite existing directory', $target);
303 }
304
305 if (is_dir($source)) {
306 $handle = opendir($source);
307 } else {
308 throw new coding_exception('Attempting to move non-existing source directory', $source);
309 }
310
311 if (!file_exists($target)) {
312 // Do not use make_writable_directory() here - it is intended for dataroot only.
313 mkdir($target, true);
314 @chmod($target, $dirpermissions);
315 }
316
317 if (!is_writable($target)) {
318 closedir($handle);
319 throw new coding_exception('Created folder not writable', $target);
320 }
321
322 while ($filename = readdir($handle)) {
323 $sourcepath = $source.'/'.$filename;
324 $targetpath = $target.'/'.$filename;
325
326 if ($filename === '.' or $filename === '..') {
327 continue;
328 }
329
330 if (is_dir($sourcepath)) {
331 $this->move_directory($sourcepath, $targetpath, $dirpermissions, $filepermissions);
332
333 } else {
334 rename($sourcepath, $targetpath);
335 @chmod($targetpath, $filepermissions);
336 }
337 }
338
339 closedir($handle);
340 rmdir($source);
341 clearstatcache();
342 }
343
344 /**
345 * Renames the root directory of the extracted ZIP package.
346 *
347 * This method does not validate the presence of the single root directory
348 * (it is the validator's duty). It just searches for the first directory
349 * under the given location and renames it.
350 *
351 * The method will not rename the root if the requested location already
352 * exists.
353 *
354 * @param string $dirname fullpath location of the extracted ZIP package
355 * @param string $rootdir the requested name of the root directory
356 * @param array $files list of extracted files
357 * @return array eventually amended list of extracted files
358 */
359 protected function rename_extracted_rootdir($dirname, $rootdir, array $files) {
360
361 if (!is_dir($dirname)) {
362 $this->debug('Unable to rename rootdir of non-existing content');
363 return $files;
364 }
365
366 if (file_exists($dirname.'/'.$rootdir)) {
367 // This typically means the real root dir already has the $rootdir name.
368 return $files;
369 }
370
371 $found = null; // The name of the first subdirectory under the $dirname.
372 foreach (scandir($dirname) as $item) {
373 if (substr($item, 0, 1) === '.') {
374 continue;
375 }
376 if (is_dir($dirname.'/'.$item)) {
377 $found = $item;
378 break;
379 }
380 }
381
382 if (!is_null($found)) {
383 if (rename($dirname.'/'.$found, $dirname.'/'.$rootdir)) {
384 $newfiles = array();
385 foreach ($files as $filepath => $status) {
386 $newpath = preg_replace('~^'.preg_quote($found.'/').'~', preg_quote($rootdir.'/'), $filepath);
387 $newfiles[$newpath] = $status;
388 }
389 return $newfiles;
390 }
391 }
392
393 return $files;
394 }
395
396}