Files
moodle/lib/classes/update/code_manager.php
David Mudrák 80c3c6501d MDL-49329 admin: Clean up code manager methods
The method move_plugin_directory() was a relict from previous 2.9
implementation within tool_installadon_installer and was originally
supposed to be used to move whole plugin folders. The archiving feature
has been finally implemented via using zip files (so that we do not have
actual PHP code present in the dataroot) and we do not need this method.
2015-10-09 14:43:33 +02:00

493 lines
16 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 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
* @param array list of extracted files as returned by {@link zip_packer::extract_to_pathname()}
*/
public function unzip_plugin_file($zipfilepath, $targetdir, $rootdir = '') {
$fp = get_file_packer('application/zip');
$files = $fp->extract_to_pathname($zipfilepath, $targetdir);
if (!$files) {
return array();
}
if (!empty($rootdir)) {
$files = $this->rename_extracted_rootdir($targetdir, $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;
}
}
}
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[$key] = $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 method does not validate the presence of the single root directory
* (it is the validator's duty). It just searches for the first directory
* under the given location and renames it.
*
* The method will not rename the root if the requested location already
* exists.
*
* @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)) {
$found = $item;
break;
}
}
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;
}
}