<?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/>.

namespace core\update;

use core_collator;
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
 *
 * @package   core
 * @copyright 2012 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;

        // Iterate sorted file list (to ensure directories precede files within them).
        core_collator::ksort($files);
        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)) {
                mkdir($target, $CFG->directorypermissions, true);
            } else {
                rename($source, $target);
            }
        }
    }
}
