You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

561 lines
19 KiB

<?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);
}
}
}
}