mirror of
https://github.com/moodle/moodle.git
synced 2025-07-15 19:36:50 +02:00
There was a problem with core\update\code_manager::unzip_plugin_file() if it was used to extract a plugin package into a non-empty target directory and the plugin package root folder was being renamed at the same time. The problem was caused by the underlying helper method rename_extracted_rootdir() that worked only for ZIPs extracted to an empty temporary location. When the plugin was extracted to the actual dirroot with other existing plugin folders present, the method failed badly. The solution in the patch is to always extract the ZIP into a temporary empty location, perform the eventual root renaming there, and only then move the extracted contents to the final destination. Additionally we are changing the behaviour of the rename_extracted_rootdir() method so that now it throws exception if the plugin package contains multiple root folders (it should not happen in normal situations as such a plugin would not pass the pre-install validation). Unit tests did not catch this bug before because in the tests, the target directory had been empty. Now we are adding a new directory "aaa_another" to the target location to test in more realistic environment. Tests for the new behaviour of the renaming method are added, too. p.s. I noticed that moodle_exception class was not imported into the namespace and this is fixed now too (and covered with unit tests).
562 lines
19 KiB
PHP
562 lines
19 KiB
PHP
<?php
|
|
// This file is part of Moodle - http://moodle.org/
|
|
//
|
|
// Moodle is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// Moodle is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
/**
|
|
* Provides core\update\code_manager class.
|
|
*
|
|
* @package core_plugin
|
|
* @copyright 2012, 2013, 2015 David Mudrak <david@moodle.com>
|
|
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
|
*/
|
|
|
|
namespace core\update;
|
|
|
|
use core_component;
|
|
use coding_exception;
|
|
use moodle_exception;
|
|
use SplFileInfo;
|
|
use RecursiveDirectoryIterator;
|
|
use RecursiveIteratorIterator;
|
|
|
|
defined('MOODLE_INTERNAL') || die();
|
|
|
|
require_once($CFG->libdir.'/filelib.php');
|
|
|
|
/**
|
|
* General purpose class managing the plugins source code files deployment
|
|
*
|
|
* The class is able and supposed to
|
|
* - fetch and cache ZIP files distributed via the Moodle Plugins directory
|
|
* - unpack the ZIP files in a temporary storage
|
|
* - archive existing version of the plugin source code
|
|
* - move (deploy) the plugin source code into the $CFG->dirroot
|
|
*
|
|
* @copyright 2015 David Mudrak <david@moodle.com>
|
|
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
|
*/
|
|
class code_manager {
|
|
|
|
/** @var string full path to the Moodle app directory root */
|
|
protected $dirroot;
|
|
/** @var string full path to the temp directory root */
|
|
protected $temproot;
|
|
|
|
/**
|
|
* Instantiate the class instance
|
|
*
|
|
* @param string $dirroot full path to the moodle app directory root
|
|
* @param string $temproot full path to our temp directory
|
|
*/
|
|
public function __construct($dirroot=null, $temproot=null) {
|
|
global $CFG;
|
|
|
|
if (empty($dirroot)) {
|
|
$dirroot = $CFG->dirroot;
|
|
}
|
|
|
|
if (empty($temproot)) {
|
|
// Note we are using core_plugin here as that is the valid core
|
|
// subsystem we are part of. The namespace of this class (core\update)
|
|
// does not match it for legacy reasons. The data stored in the
|
|
// temp directory are expected to survive multiple requests and
|
|
// purging caches during the upgrade, so we make use of
|
|
// make_temp_directory(). The contents of it can be removed if needed,
|
|
// given the site is in the maintenance mode (so that cron is not
|
|
// executed) and the site is not being upgraded.
|
|
$temproot = make_temp_directory('core_plugin/code_manager');
|
|
}
|
|
|
|
$this->dirroot = $dirroot;
|
|
$this->temproot = $temproot;
|
|
|
|
$this->init_temp_directories();
|
|
}
|
|
|
|
/**
|
|
* Obtain the plugin ZIP file from the given URL
|
|
*
|
|
* The caller is supposed to know both downloads URL and the MD5 hash of
|
|
* the ZIP contents in advance, typically by using the API requests against
|
|
* the plugins directory.
|
|
*
|
|
* @param string $url
|
|
* @param string $md5
|
|
* @return string|bool full path to the file, false on error
|
|
*/
|
|
public function get_remote_plugin_zip($url, $md5) {
|
|
|
|
// Sanitize and validate the URL.
|
|
$url = str_replace(array("\r", "\n"), '', $url);
|
|
|
|
if (!preg_match('|^https?://|i', $url)) {
|
|
$this->debug('Error fetching plugin ZIP: unsupported transport protocol: '.$url);
|
|
return false;
|
|
}
|
|
|
|
// The cache location for the file.
|
|
$distfile = $this->temproot.'/distfiles/'.$md5.'.zip';
|
|
|
|
if (is_readable($distfile) and md5_file($distfile) === $md5) {
|
|
return $distfile;
|
|
} else {
|
|
@unlink($distfile);
|
|
}
|
|
|
|
// Download the file into a temporary location.
|
|
$tempdir = make_request_directory();
|
|
$tempfile = $tempdir.'/plugin.zip';
|
|
$result = $this->download_plugin_zip_file($url, $tempfile);
|
|
|
|
if (!$result) {
|
|
return false;
|
|
}
|
|
|
|
$actualmd5 = md5_file($tempfile);
|
|
|
|
// Make sure the actual md5 hash matches the expected one.
|
|
if ($actualmd5 !== $md5) {
|
|
$this->debug('Error fetching plugin ZIP: md5 mismatch.');
|
|
return false;
|
|
}
|
|
|
|
// If the file is empty, something went wrong.
|
|
if ($actualmd5 === 'd41d8cd98f00b204e9800998ecf8427e') {
|
|
return false;
|
|
}
|
|
|
|
// Store the file in our cache.
|
|
if (!rename($tempfile, $distfile)) {
|
|
return false;
|
|
}
|
|
|
|
return $distfile;
|
|
}
|
|
|
|
/**
|
|
* Extracts the saved plugin ZIP file.
|
|
*
|
|
* Returns the list of files found in the ZIP. The format of that list is
|
|
* array of (string)filerelpath => (bool|string) where the array value is
|
|
* either true or a string describing the problematic file.
|
|
*
|
|
* @see zip_packer::extract_to_pathname()
|
|
* @param string $zipfilepath full path to the saved ZIP file
|
|
* @param string $targetdir full path to the directory to extract the ZIP file to
|
|
* @param string $rootdir explicitly rename the root directory of the ZIP into this non-empty value
|
|
* @return array list of extracted files as returned by {@link zip_packer::extract_to_pathname()}
|
|
*/
|
|
public function unzip_plugin_file($zipfilepath, $targetdir, $rootdir = '') {
|
|
|
|
// Extract the package into a temporary location.
|
|
$fp = get_file_packer('application/zip');
|
|
$tempdir = make_request_directory();
|
|
$files = $fp->extract_to_pathname($zipfilepath, $tempdir);
|
|
|
|
if (!$files) {
|
|
return array();
|
|
}
|
|
|
|
// If requested, rename the root directory of the plugin.
|
|
if (!empty($rootdir)) {
|
|
$files = $this->rename_extracted_rootdir($tempdir, $rootdir, $files);
|
|
}
|
|
|
|
// Sometimes zip may not contain all parent directories, add them to make it consistent.
|
|
foreach ($files as $path => $status) {
|
|
if ($status !== true) {
|
|
continue;
|
|
}
|
|
$parts = explode('/', trim($path, '/'));
|
|
while (array_pop($parts)) {
|
|
if (empty($parts)) {
|
|
break;
|
|
}
|
|
$dir = implode('/', $parts).'/';
|
|
if (!isset($files[$dir])) {
|
|
$files[$dir] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Move the extracted files into the target location.
|
|
$this->move_extracted_plugin_files($tempdir, $targetdir, $files);
|
|
|
|
// Set the permissions of extracted subdirs and files.
|
|
$this->set_plugin_files_permissions($targetdir, $files);
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* Make an archive backup of the existing plugin folder.
|
|
*
|
|
* @param string $folderpath full path to the plugin folder
|
|
* @param string $targetzip full path to the zip file to be created
|
|
* @return bool true if file created, false if not
|
|
*/
|
|
public function zip_plugin_folder($folderpath, $targetzip) {
|
|
|
|
if (file_exists($targetzip)) {
|
|
throw new coding_exception('Attempting to create already existing ZIP file', $targetzip);
|
|
}
|
|
|
|
if (!is_writable(dirname($targetzip))) {
|
|
throw new coding_exception('Target ZIP location not writable', dirname($targetzip));
|
|
}
|
|
|
|
if (!is_dir($folderpath)) {
|
|
throw new coding_exception('Attempting to ZIP non-existing source directory', $folderpath);
|
|
}
|
|
|
|
$files = $this->list_plugin_folder_files($folderpath);
|
|
$fp = get_file_packer('application/zip');
|
|
return $fp->archive_to_pathname($files, $targetzip, false);
|
|
}
|
|
|
|
/**
|
|
* Archive the current plugin on-disk version.
|
|
*
|
|
* @param string $folderpath full path to the plugin folder
|
|
* @param string $component
|
|
* @param int $version
|
|
* @param bool $overwrite overwrite existing archive if found
|
|
* @return bool
|
|
*/
|
|
public function archive_plugin_version($folderpath, $component, $version, $overwrite=false) {
|
|
|
|
if ($component !== clean_param($component, PARAM_SAFEDIR)) {
|
|
// This should never happen, but just in case.
|
|
throw new moodle_exception('unexpected_plugin_component_format', 'core_plugin', '', null, $component);
|
|
}
|
|
|
|
if ((string)$version !== clean_param((string)$version, PARAM_FILE)) {
|
|
// Prevent some nasty injections via $plugin->version tricks.
|
|
throw new moodle_exception('unexpected_plugin_version_format', 'core_plugin', '', null, $version);
|
|
}
|
|
|
|
if (empty($component) or empty($version)) {
|
|
return false;
|
|
}
|
|
|
|
if (!is_dir($folderpath)) {
|
|
return false;
|
|
}
|
|
|
|
$archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
|
|
|
|
if (file_exists($archzip) and !$overwrite) {
|
|
return true;
|
|
}
|
|
|
|
$tmpzip = make_request_directory().'/'.$version.'.zip';
|
|
$zipped = $this->zip_plugin_folder($folderpath, $tmpzip);
|
|
|
|
if (!$zipped) {
|
|
return false;
|
|
}
|
|
|
|
// Assert that the file looks like a valid one.
|
|
list($expectedtype, $expectedname) = core_component::normalize_component($component);
|
|
$actualname = $this->get_plugin_zip_root_dir($tmpzip);
|
|
if ($actualname !== $expectedname) {
|
|
// This should not happen.
|
|
throw new moodle_exception('unexpected_archive_structure', 'core_plugin');
|
|
}
|
|
|
|
make_writable_directory(dirname($archzip));
|
|
return rename($tmpzip, $archzip);
|
|
}
|
|
|
|
/**
|
|
* Return the path to the ZIP file with the archive of the given plugin version.
|
|
*
|
|
* @param string $component
|
|
* @param int $version
|
|
* @return string|bool false if not found, full path otherwise
|
|
*/
|
|
public function get_archived_plugin_version($component, $version) {
|
|
|
|
if (empty($component) or empty($version)) {
|
|
return false;
|
|
}
|
|
|
|
$archzip = $this->temproot.'/archive/'.$component.'/'.$version.'.zip';
|
|
|
|
if (file_exists($archzip)) {
|
|
return $archzip;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns list of all files in the given directory.
|
|
*
|
|
* Given a path like /full/path/to/mod/workshop, it returns array like
|
|
*
|
|
* [workshop/] => /full/path/to/mod/workshop
|
|
* [workshop/lang/] => /full/path/to/mod/workshop/lang
|
|
* [workshop/lang/workshop.php] => /full/path/to/mod/workshop/lang/workshop.php
|
|
* ...
|
|
*
|
|
* Which mathes the format used by Moodle file packers.
|
|
*
|
|
* @param string $folderpath full path to the plugin directory
|
|
* @return array (string)relpath => (string)fullpath
|
|
*/
|
|
public function list_plugin_folder_files($folderpath) {
|
|
|
|
$folder = new RecursiveDirectoryIterator($folderpath);
|
|
$iterator = new RecursiveIteratorIterator($folder);
|
|
$folderpathinfo = new SplFileInfo($folderpath);
|
|
$strip = strlen($folderpathinfo->getPathInfo()->getRealPath()) + 1;
|
|
$files = array();
|
|
foreach ($iterator as $fileinfo) {
|
|
if ($fileinfo->getFilename() === '..') {
|
|
continue;
|
|
}
|
|
if (strpos($fileinfo->getRealPath(), $folderpathinfo->getRealPath() !== 0)) {
|
|
throw new moodle_exception('unexpected_filepath_mismatch', 'core_plugin');
|
|
}
|
|
$key = substr($fileinfo->getRealPath(), $strip);
|
|
if ($fileinfo->isDir() and substr($key, -1) !== '/') {
|
|
$key .= '/';
|
|
}
|
|
$files[str_replace(DIRECTORY_SEPARATOR, '/', $key)] = str_replace(DIRECTORY_SEPARATOR, '/', $fileinfo->getRealPath());
|
|
}
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* Detects the plugin's name from its ZIP file.
|
|
*
|
|
* Plugin ZIP packages are expected to contain a single directory and the
|
|
* directory name would become the plugin name once extracted to the Moodle
|
|
* dirroot.
|
|
*
|
|
* @param string $zipfilepath full path to the ZIP files
|
|
* @return string|bool false on error
|
|
*/
|
|
public function get_plugin_zip_root_dir($zipfilepath) {
|
|
|
|
$fp = get_file_packer('application/zip');
|
|
$files = $fp->list_files($zipfilepath);
|
|
|
|
if (empty($files)) {
|
|
return false;
|
|
}
|
|
|
|
$rootdirname = null;
|
|
foreach ($files as $file) {
|
|
$pathnameitems = explode('/', $file->pathname);
|
|
if (empty($pathnameitems)) {
|
|
return false;
|
|
}
|
|
// Set the expected name of the root directory in the first
|
|
// iteration of the loop.
|
|
if ($rootdirname === null) {
|
|
$rootdirname = $pathnameitems[0];
|
|
}
|
|
// Require the same root directory for all files in the ZIP
|
|
// package.
|
|
if ($rootdirname !== $pathnameitems[0]) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return $rootdirname;
|
|
}
|
|
|
|
// This is the end, my only friend, the end ... of external public API.
|
|
|
|
/**
|
|
* Makes sure all temp directories exist and are writable.
|
|
*/
|
|
protected function init_temp_directories() {
|
|
make_writable_directory($this->temproot.'/distfiles');
|
|
make_writable_directory($this->temproot.'/archive');
|
|
}
|
|
|
|
/**
|
|
* Raise developer debugging level message.
|
|
*
|
|
* @param string $msg
|
|
*/
|
|
protected function debug($msg) {
|
|
debugging($msg, DEBUG_DEVELOPER);
|
|
}
|
|
|
|
/**
|
|
* Download the ZIP file with the plugin package from the given location
|
|
*
|
|
* @param string $url URL to the file
|
|
* @param string $tofile full path to where to store the downloaded file
|
|
* @return bool false on error
|
|
*/
|
|
protected function download_plugin_zip_file($url, $tofile) {
|
|
|
|
if (file_exists($tofile)) {
|
|
$this->debug('Error fetching plugin ZIP: target location exists.');
|
|
return false;
|
|
}
|
|
|
|
$status = $this->download_file_content($url, $tofile);
|
|
|
|
if (!$status) {
|
|
$this->debug('Error fetching plugin ZIP.');
|
|
@unlink($tofile);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Thin wrapper for the core's download_file_content() function.
|
|
*
|
|
* @param string $url URL to the file
|
|
* @param string $tofile full path to where to store the downloaded file
|
|
* @return bool
|
|
*/
|
|
protected function download_file_content($url, $tofile) {
|
|
|
|
// Prepare the parameters for the download_file_content() function.
|
|
$headers = null;
|
|
$postdata = null;
|
|
$fullresponse = false;
|
|
$timeout = 300;
|
|
$connecttimeout = 20;
|
|
$skipcertverify = false;
|
|
$tofile = $tofile;
|
|
$calctimeout = false;
|
|
|
|
return download_file_content($url, $headers, $postdata, $fullresponse, $timeout,
|
|
$connecttimeout, $skipcertverify, $tofile, $calctimeout);
|
|
}
|
|
|
|
/**
|
|
* Renames the root directory of the extracted ZIP package.
|
|
*
|
|
* This internal helper method assumes that the plugin ZIP package has been
|
|
* extracted into a temporary empty directory so the plugin folder is the
|
|
* only folder there. The ZIP package is supposed to be validated so that
|
|
* it contains just a single root folder.
|
|
*
|
|
* @param string $dirname fullpath location of the extracted ZIP package
|
|
* @param string $rootdir the requested name of the root directory
|
|
* @param array $files list of extracted files
|
|
* @return array eventually amended list of extracted files
|
|
*/
|
|
protected function rename_extracted_rootdir($dirname, $rootdir, array $files) {
|
|
|
|
if (!is_dir($dirname)) {
|
|
$this->debug('Unable to rename rootdir of non-existing content');
|
|
return $files;
|
|
}
|
|
|
|
if (file_exists($dirname.'/'.$rootdir)) {
|
|
// This typically means the real root dir already has the $rootdir name.
|
|
return $files;
|
|
}
|
|
|
|
$found = null; // The name of the first subdirectory under the $dirname.
|
|
foreach (scandir($dirname) as $item) {
|
|
if (substr($item, 0, 1) === '.') {
|
|
continue;
|
|
}
|
|
if (is_dir($dirname.'/'.$item)) {
|
|
if ($found !== null and $found !== $item) {
|
|
// Multiple directories found.
|
|
throw new moodle_exception('unexpected_archive_structure', 'core_plugin');
|
|
}
|
|
$found = $item;
|
|
}
|
|
}
|
|
|
|
if (!is_null($found)) {
|
|
if (rename($dirname.'/'.$found, $dirname.'/'.$rootdir)) {
|
|
$newfiles = array();
|
|
foreach ($files as $filepath => $status) {
|
|
$newpath = preg_replace('~^'.preg_quote($found.'/').'~', preg_quote($rootdir.'/'), $filepath);
|
|
$newfiles[$newpath] = $status;
|
|
}
|
|
return $newfiles;
|
|
}
|
|
}
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* Sets the permissions of extracted subdirs and files
|
|
*
|
|
* As a result of unzipping, the subdirs and files are created with
|
|
* permissions set to $CFG->directorypermissions and $CFG->filepermissions.
|
|
* These are too benevolent by default (777 and 666 respectively) for PHP
|
|
* scripts and may lead to HTTP 500 errors in some environments.
|
|
*
|
|
* To fix this behaviour, we inherit the permissions of the plugin root
|
|
* directory itself.
|
|
*
|
|
* @param string $targetdir full path to the directory the ZIP file was extracted to
|
|
* @param array $files list of extracted files
|
|
*/
|
|
protected function set_plugin_files_permissions($targetdir, array $files) {
|
|
|
|
$dirpermissions = fileperms($targetdir);
|
|
$filepermissions = ($dirpermissions & 0666);
|
|
|
|
foreach ($files as $subpath => $notusedhere) {
|
|
$path = $targetdir.'/'.$subpath;
|
|
if (is_dir($path)) {
|
|
@chmod($path, $dirpermissions);
|
|
} else {
|
|
@chmod($path, $filepermissions);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Moves the extracted contents of the plugin ZIP into the target location.
|
|
*
|
|
* @param string $sourcedir full path to the directory the ZIP file was extracted to
|
|
* @param mixed $targetdir full path to the directory where the files should be moved to
|
|
* @param array $files list of extracted files
|
|
*/
|
|
protected function move_extracted_plugin_files($sourcedir, $targetdir, array $files) {
|
|
global $CFG;
|
|
|
|
foreach ($files as $file => $status) {
|
|
if ($status !== true) {
|
|
throw new moodle_exception('corrupted_archive_structure', 'core_plugin', '', $file, $status);
|
|
}
|
|
|
|
$source = $sourcedir.'/'.$file;
|
|
$target = $targetdir.'/'.$file;
|
|
|
|
if (is_dir($source)) {
|
|
continue;
|
|
|
|
} else {
|
|
if (!is_dir(dirname($target))) {
|
|
mkdir(dirname($target), $CFG->directorypermissions, true);
|
|
}
|
|
rename($source, $target);
|
|
}
|
|
}
|
|
}
|
|
}
|