Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | 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
namespace core\update;
18
 
11 efrain 19
use core_collator;
1 efrain 20
use core_component;
21
use coding_exception;
22
use moodle_exception;
23
use SplFileInfo;
24
use RecursiveDirectoryIterator;
25
use RecursiveIteratorIterator;
26
 
27
defined('MOODLE_INTERNAL') || die();
28
 
29
require_once($CFG->libdir.'/filelib.php');
30
 
31
/**
32
 * General purpose class managing the plugins source code files deployment
33
 *
34
 * The class is able and supposed to
35
 * - fetch and cache ZIP files distributed via the Moodle Plugins directory
36
 * - unpack the ZIP files in a temporary storage
37
 * - archive existing version of the plugin source code
38
 * - move (deploy) the plugin source code into the $CFG->dirroot
39
 *
11 efrain 40
 * @package   core
41
 * @copyright 2012 David Mudrak <david@moodle.com>
1 efrain 42
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
43
 */
44
class code_manager {
45
 
46
    /** @var string full path to the Moodle app directory root */
47
    protected $dirroot;
48
    /** @var string full path to the temp directory root */
49
    protected $temproot;
50
 
51
    /**
52
     * Instantiate the class instance
53
     *
54
     * @param string $dirroot full path to the moodle app directory root
55
     * @param string $temproot full path to our temp directory
56
     */
57
    public function __construct($dirroot=null, $temproot=null) {
58
        global $CFG;
59
 
60
        if (empty($dirroot)) {
61
            $dirroot = $CFG->dirroot;
62
        }
63
 
64
        if (empty($temproot)) {
65
            // Note we are using core_plugin here as that is the valid core
66
            // subsystem we are part of. The namespace of this class (core\update)
67
            // does not match it for legacy reasons.  The data stored in the
68
            // temp directory are expected to survive multiple requests and
69
            // purging caches during the upgrade, so we make use of
70
            // make_temp_directory(). The contents of it can be removed if needed,
71
            // given the site is in the maintenance mode (so that cron is not
72
            // executed) and the site is not being upgraded.
73
            $temproot = make_temp_directory('core_plugin/code_manager');
74
        }
75
 
76
        $this->dirroot = $dirroot;
77
        $this->temproot = $temproot;
78
 
79
        $this->init_temp_directories();
80
    }
81
 
82
    /**
83
     * Obtain the plugin ZIP file from the given URL
84
     *
85
     * The caller is supposed to know both downloads URL and the MD5 hash of
86
     * the ZIP contents in advance, typically by using the API requests against
87
     * the plugins directory.
88
     *
89
     * @param string $url
90
     * @param string $md5
91
     * @return string|bool full path to the file, false on error
92
     */
93
    public function get_remote_plugin_zip($url, $md5) {
94
 
95
        // Sanitize and validate the URL.
96
        $url = str_replace(array("\r", "\n"), '', $url);
97
 
98
        if (!preg_match('|^https?://|i', $url)) {
99
            $this->debug('Error fetching plugin ZIP: unsupported transport protocol: '.$url);
100
            return false;
101
        }
102
 
103
        // The cache location for the file.
104
        $distfile = $this->temproot.'/distfiles/'.$md5.'.zip';
105
 
106
        if (is_readable($distfile) and md5_file($distfile) === $md5) {
107
            return $distfile;
108
        } else {
109
            @unlink($distfile);
110
        }
111
 
112
        // Download the file into a temporary location.
113
        $tempdir = make_request_directory();
114
        $tempfile = $tempdir.'/plugin.zip';
115
        $result = $this->download_plugin_zip_file($url, $tempfile);
116
 
117
        if (!$result) {
118
            return false;
119
        }
120
 
121
        $actualmd5 = md5_file($tempfile);
122
 
123
        // Make sure the actual md5 hash matches the expected one.
124
        if ($actualmd5 !== $md5) {
125
            $this->debug('Error fetching plugin ZIP: md5 mismatch.');
126
            return false;
127
        }
128
 
129
        // If the file is empty, something went wrong.
130
        if ($actualmd5 === 'd41d8cd98f00b204e9800998ecf8427e') {
131
            return false;
132
        }
133
 
134
        // Store the file in our cache.
135
        if (!rename($tempfile, $distfile)) {
136
            return false;
137
        }
138
 
139
        return $distfile;
140
    }
141
 
142
    /**
143
     * Extracts the saved plugin ZIP file.
144
     *
145
     * Returns the list of files found in the ZIP. The format of that list is
146
     * array of (string)filerelpath => (bool|string) where the array value is
147
     * either true or a string describing the problematic file.
148
     *
149
     * @see zip_packer::extract_to_pathname()
150
     * @param string $zipfilepath full path to the saved ZIP file
151
     * @param string $targetdir full path to the directory to extract the ZIP file to
152
     * @param string $rootdir explicitly rename the root directory of the ZIP into this non-empty value
153
     * @return array list of extracted files as returned by {@link zip_packer::extract_to_pathname()}
154
     */
155
    public function unzip_plugin_file($zipfilepath, $targetdir, $rootdir = '') {
156
 
157
        // Extract the package into a temporary location.
158
        $fp = get_file_packer('application/zip');
159
        $tempdir = make_request_directory();
160
        $files = $fp->extract_to_pathname($zipfilepath, $tempdir);
161
 
162
        if (!$files) {
163
            return array();
164
        }
165
 
166
        // If requested, rename the root directory of the plugin.
167
        if (!empty($rootdir)) {
168
            $files = $this->rename_extracted_rootdir($tempdir, $rootdir, $files);
169
        }
170
 
171
        // Sometimes zip may not contain all parent directories, add them to make it consistent.
172
        foreach ($files as $path => $status) {
173
            if ($status !== true) {
174
                continue;
175
            }
176
            $parts = explode('/', trim($path, '/'));
177
            while (array_pop($parts)) {
178
                if (empty($parts)) {
179
                    break;
180
                }
181
                $dir = implode('/', $parts).'/';
182
                if (!isset($files[$dir])) {
183
                    $files[$dir] = true;
184
                }
185
            }
186
        }
187
 
188
        // Move the extracted files into the target location.
189
        $this->move_extracted_plugin_files($tempdir, $targetdir, $files);
190
 
191
        // Set the permissions of extracted subdirs and files.
192
        $this->set_plugin_files_permissions($targetdir, $files);
193
 
194
        return $files;
195
    }
196
 
197
    /**
198
     * Make an archive backup of the existing plugin folder.
199
     *
200
     * @param string $folderpath full path to the plugin folder
201
     * @param string $targetzip full path to the zip file to be created
202
     * @return bool true if file created, false if not
203
     */
204
    public function zip_plugin_folder($folderpath, $targetzip) {
205
 
206
        if (file_exists($targetzip)) {
207
            throw new coding_exception('Attempting to create already existing ZIP file', $targetzip);
208
        }
209
 
210
        if (!is_writable(dirname($targetzip))) {
211
            throw new coding_exception('Target ZIP location not writable', dirname($targetzip));
212
        }
213
 
214
        if (!is_dir($folderpath)) {
215
            throw new coding_exception('Attempting to ZIP non-existing source directory', $folderpath);
216
        }
217
 
218
        $files = $this->list_plugin_folder_files($folderpath);
219
        $fp = get_file_packer('application/zip');
220
        return $fp->archive_to_pathname($files, $targetzip, false);
221
    }
222
 
223
    /**
224
     * Archive the current plugin on-disk version.
225
     *
226
     * @param string $folderpath full path to the plugin folder
227
     * @param string $component
228
     * @param int $version
229
     * @param bool $overwrite overwrite existing archive if found
230
     * @return bool
231
     */
232
    public function archive_plugin_version($folderpath, $component, $version, $overwrite=false) {
233
 
234
        if ($component !== clean_param($component, PARAM_SAFEDIR)) {
235
            // This should never happen, but just in case.
236
            throw new moodle_exception('unexpected_plugin_component_format', 'core_plugin', '', null, $component);
237
        }
238
 
239
        if ((string)$version !== clean_param((string)$version, PARAM_FILE)) {
240
            // Prevent some nasty injections via $plugin->version tricks.
241
            throw new moodle_exception('unexpected_plugin_version_format', 'core_plugin', '', null, $version);
242
        }
243
 
244
        if (empty($component) or empty($version)) {
245
            return false;
246
        }
247
 
248
        if (!is_dir($folderpath)) {
249
            return false;
250
        }
251
 
252
        $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
253
 
254
        if (file_exists($archzip) and !$overwrite) {
255
            return true;
256
        }
257
 
258
        $tmpzip = make_request_directory().'/'.$version.'.zip';
259
        $zipped = $this->zip_plugin_folder($folderpath, $tmpzip);
260
 
261
        if (!$zipped) {
262
            return false;
263
        }
264
 
265
        // Assert that the file looks like a valid one.
266
        list($expectedtype, $expectedname) = core_component::normalize_component($component);
267
        $actualname = $this->get_plugin_zip_root_dir($tmpzip);
268
        if ($actualname !== $expectedname) {
269
            // This should not happen.
270
            throw new moodle_exception('unexpected_archive_structure', 'core_plugin');
271
        }
272
 
273
        make_writable_directory(dirname($archzip));
274
        return rename($tmpzip, $archzip);
275
    }
276
 
277
    /**
278
     * Return the path to the ZIP file with the archive of the given plugin version.
279
     *
280
     * @param string $component
281
     * @param int $version
282
     * @return string|bool false if not found, full path otherwise
283
     */
284
    public function get_archived_plugin_version($component, $version) {
285
 
286
        if (empty($component) or empty($version)) {
287
            return false;
288
        }
289
 
290
        $archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
291
 
292
        if (file_exists($archzip)) {
293
            return $archzip;
294
        }
295
 
296
        return false;
297
    }
298
 
299
    /**
300
     * Returns list of all files in the given directory.
301
     *
302
     * Given a path like /full/path/to/mod/workshop, it returns array like
303
     *
304
     *  [workshop/] => /full/path/to/mod/workshop
305
     *  [workshop/lang/] => /full/path/to/mod/workshop/lang
306
     *  [workshop/lang/workshop.php] => /full/path/to/mod/workshop/lang/workshop.php
307
     *  ...
308
     *
309
     * Which mathes the format used by Moodle file packers.
310
     *
311
     * @param string $folderpath full path to the plugin directory
312
     * @return array (string)relpath => (string)fullpath
313
     */
314
    public function list_plugin_folder_files($folderpath) {
315
 
316
        $folder = new RecursiveDirectoryIterator($folderpath);
317
        $iterator = new RecursiveIteratorIterator($folder);
318
        $folderpathinfo = new SplFileInfo($folderpath);
319
        $strip = strlen($folderpathinfo->getPathInfo()->getRealPath()) + 1;
320
        $files = array();
321
        foreach ($iterator as $fileinfo) {
322
            if ($fileinfo->getFilename() === '..') {
323
                continue;
324
            }
325
            if (strpos($fileinfo->getRealPath(), $folderpathinfo->getRealPath()) !== 0) {
326
                throw new moodle_exception('unexpected_filepath_mismatch', 'core_plugin');
327
            }
328
            $key = substr($fileinfo->getRealPath(), $strip);
329
            if ($fileinfo->isDir() and substr($key, -1) !== '/') {
330
                $key .= '/';
331
            }
332
            $files[str_replace(DIRECTORY_SEPARATOR, '/', $key)] = str_replace(DIRECTORY_SEPARATOR, '/', $fileinfo->getRealPath());
333
        }
334
        return $files;
335
    }
336
 
337
    /**
338
     * Detects the plugin's name from its ZIP file.
339
     *
340
     * Plugin ZIP packages are expected to contain a single directory and the
341
     * directory name would become the plugin name once extracted to the Moodle
342
     * dirroot.
343
     *
344
     * @param string $zipfilepath full path to the ZIP files
345
     * @return string|bool false on error
346
     */
347
    public function get_plugin_zip_root_dir($zipfilepath) {
348
 
349
        $fp = get_file_packer('application/zip');
350
        $files = $fp->list_files($zipfilepath);
351
 
352
        if (empty($files)) {
353
            return false;
354
        }
355
 
356
        $rootdirname = null;
357
        foreach ($files as $file) {
358
            $pathnameitems = explode('/', $file->pathname);
359
            if (empty($pathnameitems)) {
360
                return false;
361
            }
362
            // Set the expected name of the root directory in the first
363
            // iteration of the loop.
364
            if ($rootdirname === null) {
365
                $rootdirname = $pathnameitems[0];
366
            }
367
            // Require the same root directory for all files in the ZIP
368
            // package.
369
            if ($rootdirname !== $pathnameitems[0]) {
370
                return false;
371
            }
372
        }
373
 
374
        return $rootdirname;
375
    }
376
 
377
    // This is the end, my only friend, the end ... of external public API.
378
 
379
    /**
380
     * Makes sure all temp directories exist and are writable.
381
     */
382
    protected function init_temp_directories() {
383
        make_writable_directory($this->temproot.'/distfiles');
384
        make_writable_directory($this->temproot.'/archive');
385
    }
386
 
387
    /**
388
     * Raise developer debugging level message.
389
     *
390
     * @param string $msg
391
     */
392
    protected function debug($msg) {
393
        debugging($msg, DEBUG_DEVELOPER);
394
    }
395
 
396
    /**
397
     * Download the ZIP file with the plugin package from the given location
398
     *
399
     * @param string $url URL to the file
400
     * @param string $tofile full path to where to store the downloaded file
401
     * @return bool false on error
402
     */
403
    protected function download_plugin_zip_file($url, $tofile) {
404
 
405
        if (file_exists($tofile)) {
406
            $this->debug('Error fetching plugin ZIP: target location exists.');
407
            return false;
408
        }
409
 
410
        $status = $this->download_file_content($url, $tofile);
411
 
412
        if (!$status) {
413
            $this->debug('Error fetching plugin ZIP.');
414
            @unlink($tofile);
415
            return false;
416
        }
417
 
418
        return true;
419
    }
420
 
421
    /**
422
     * Thin wrapper for the core's download_file_content() function.
423
     *
424
     * @param string $url URL to the file
425
     * @param string $tofile full path to where to store the downloaded file
426
     * @return bool
427
     */
428
    protected function download_file_content($url, $tofile) {
429
 
430
        // Prepare the parameters for the download_file_content() function.
431
        $headers = null;
432
        $postdata = null;
433
        $fullresponse = false;
434
        $timeout = 300;
435
        $connecttimeout = 20;
436
        $skipcertverify = false;
437
        $tofile = $tofile;
438
        $calctimeout = false;
439
 
440
        return download_file_content($url, $headers, $postdata, $fullresponse, $timeout,
441
            $connecttimeout, $skipcertverify, $tofile, $calctimeout);
442
    }
443
 
444
    /**
445
     * Renames the root directory of the extracted ZIP package.
446
     *
447
     * This internal helper method assumes that the plugin ZIP package has been
448
     * extracted into a temporary empty directory so the plugin folder is the
449
     * only folder there. The ZIP package is supposed to be validated so that
450
     * it contains just a single root folder.
451
     *
452
     * @param string $dirname fullpath location of the extracted ZIP package
453
     * @param string $rootdir the requested name of the root directory
454
     * @param array $files list of extracted files
455
     * @return array eventually amended list of extracted files
456
     */
457
    protected function rename_extracted_rootdir($dirname, $rootdir, array $files) {
458
 
459
        if (!is_dir($dirname)) {
460
            $this->debug('Unable to rename rootdir of non-existing content');
461
            return $files;
462
        }
463
 
464
        if (file_exists($dirname.'/'.$rootdir)) {
465
            // This typically means the real root dir already has the $rootdir name.
466
            return $files;
467
        }
468
 
469
        $found = null; // The name of the first subdirectory under the $dirname.
470
        foreach (scandir($dirname) as $item) {
471
            if (substr($item, 0, 1) === '.') {
472
                continue;
473
            }
474
            if (is_dir($dirname.'/'.$item)) {
475
                if ($found !== null and $found !== $item) {
476
                    // Multiple directories found.
477
                    throw new moodle_exception('unexpected_archive_structure', 'core_plugin');
478
                }
479
                $found = $item;
480
            }
481
        }
482
 
483
        if (!is_null($found)) {
484
            if (rename($dirname.'/'.$found, $dirname.'/'.$rootdir)) {
485
                $newfiles = array();
486
                foreach ($files as $filepath => $status) {
487
                    $newpath = preg_replace('~^'.preg_quote($found.'/').'~', preg_quote($rootdir.'/'), $filepath);
488
                    $newfiles[$newpath] = $status;
489
                }
490
                return $newfiles;
491
            }
492
        }
493
 
494
        return $files;
495
    }
496
 
497
    /**
498
     * Sets the permissions of extracted subdirs and files
499
     *
500
     * As a result of unzipping, the subdirs and files are created with
501
     * permissions set to $CFG->directorypermissions and $CFG->filepermissions.
502
     * These are too benevolent by default (777 and 666 respectively) for PHP
503
     * scripts and may lead to HTTP 500 errors in some environments.
504
     *
505
     * To fix this behaviour, we inherit the permissions of the plugin root
506
     * directory itself.
507
     *
508
     * @param string $targetdir full path to the directory the ZIP file was extracted to
509
     * @param array $files list of extracted files
510
     */
511
    protected function set_plugin_files_permissions($targetdir, array $files) {
512
 
513
        $dirpermissions = fileperms($targetdir);
514
        $filepermissions = ($dirpermissions & 0666);
515
 
516
        foreach ($files as $subpath => $notusedhere) {
517
            $path = $targetdir.'/'.$subpath;
518
            if (is_dir($path)) {
519
                @chmod($path, $dirpermissions);
520
            } else {
521
                @chmod($path, $filepermissions);
522
            }
523
        }
524
    }
525
 
526
    /**
527
     * Moves the extracted contents of the plugin ZIP into the target location.
528
     *
529
     * @param string $sourcedir full path to the directory the ZIP file was extracted to
530
     * @param mixed $targetdir full path to the directory where the files should be moved to
531
     * @param array $files list of extracted files
532
     */
533
    protected function move_extracted_plugin_files($sourcedir, $targetdir, array $files) {
534
        global $CFG;
535
 
11 efrain 536
        // Iterate sorted file list (to ensure directories precede files within them).
537
        core_collator::ksort($files);
1 efrain 538
        foreach ($files as $file => $status) {
539
            if ($status !== true) {
540
                throw new moodle_exception('corrupted_archive_structure', 'core_plugin', '', $file, $status);
541
            }
542
 
543
            $source = $sourcedir.'/'.$file;
544
            $target = $targetdir.'/'.$file;
545
 
546
            if (is_dir($source)) {
11 efrain 547
                mkdir($target, $CFG->directorypermissions, true);
1 efrain 548
            } else {
549
                rename($source, $target);
550
            }
551
        }
552
    }
553
}