Proyectos de Subversion Moodle

Rev

Ir a la última revisión | | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
 
25
namespace core\update;
26
 
27
use core_component;
28
use coding_exception;
29
use moodle_exception;
30
use SplFileInfo;
31
use RecursiveDirectoryIterator;
32
use RecursiveIteratorIterator;
33
 
34
defined('MOODLE_INTERNAL') || die();
35
 
36
require_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
 */
50
class 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
 
112
        if (is_readable($distfile) and md5_file($distfile) === $md5) {
113
            return $distfile;
114
        } else {
115
            @unlink($distfile);
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
 
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
159
     * @return array list of extracted files as returned by {@link zip_packer::extract_to_pathname()}
160
     */
161
    public function unzip_plugin_file($zipfilepath, $targetdir, $rootdir = '') {
162
 
163
        // Extract the package into a temporary location.
164
        $fp = get_file_packer('application/zip');
165
        $tempdir = make_request_directory();
166
        $files = $fp->extract_to_pathname($zipfilepath, $tempdir);
167
 
168
        if (!$files) {
169
            return array();
170
        }
171
 
172
        // If requested, rename the root directory of the plugin.
173
        if (!empty($rootdir)) {
174
            $files = $this->rename_extracted_rootdir($tempdir, $rootdir, $files);
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
 
194
        // Move the extracted files into the target location.
195
        $this->move_extracted_plugin_files($tempdir, $targetdir, $files);
196
 
197
        // Set the permissions of extracted subdirs and files.
198
        $this->set_plugin_files_permissions($targetdir, $files);
199
 
200
        return $files;
201
    }
202
 
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
            }
338
            $files[str_replace(DIRECTORY_SEPARATOR, '/', $key)] = str_replace(DIRECTORY_SEPARATOR, '/', $fileinfo->getRealPath());
339
        }
340
        return $files;
341
    }
342
 
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
 
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');
390
        make_writable_directory($this->temproot.'/archive');
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
 
450
    /**
451
     * Renames the root directory of the extracted ZIP package.
452
     *
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.
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)) {
481
                if ($found !== null and $found !== $item) {
482
                    // Multiple directories found.
483
                    throw new moodle_exception('unexpected_archive_structure', 'core_plugin');
484
                }
485
                $found = $item;
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
 
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
    }
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
    }
561
}