From 4fff948910e263f371c906195365c5a5cda389ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mudr=C3=A1k?= Date: Thu, 8 Oct 2015 12:51:27 +0200 Subject: [PATCH] MDL-49329 admin: Convert install plugins tool to use new APIs Most of the functionality provided by this tool (typically the validation and actual deployment of the plugin package) has been moved to the core level. So this is becoming just a thin wrapper and user interface for installing new plugins via the administration UI. Also fixes MDL-49600 as we no longer keep the unzipped contents of the packages in the persistent temp directories. --- admin/tool/installaddon/classes/installer.php | 381 ++---------------- .../classes/installfromzip_form.php | 21 +- admin/tool/installaddon/deploy.php | 78 ---- admin/tool/installaddon/index.php | 114 ++++-- .../lang/en/tool_installaddon.php | 15 +- admin/tool/installaddon/permcheck.php | 6 +- admin/tool/installaddon/renderer.php | 146 +------ admin/tool/installaddon/settings.php | 6 - admin/tool/installaddon/styles.css | 55 --- .../tests/fixtures/testable_installer.php | 58 +++ .../installaddon/tests/installer_test.php | 102 ++--- admin/tool/installaddon/validate.php | 92 ----- 12 files changed, 237 insertions(+), 837 deletions(-) delete mode 100644 admin/tool/installaddon/deploy.php create mode 100644 admin/tool/installaddon/tests/fixtures/testable_installer.php delete mode 100644 admin/tool/installaddon/validate.php diff --git a/admin/tool/installaddon/classes/installer.php b/admin/tool/installaddon/classes/installer.php index 6b4061d5e27df..07e1fe402e552 100644 --- a/admin/tool/installaddon/classes/installer.php +++ b/admin/tool/installaddon/classes/installer.php @@ -16,7 +16,7 @@ // along with Moodle. If not, see . /** - * Provides tool_installaddon_installer related classes + * Provides tool_installaddon_installer class. * * @package tool_installaddon * @subpackage classes @@ -103,69 +103,16 @@ public function get_installfromzip_form() { } /** - * Saves the ZIP file from the {@link tool_installaddon_installfromzip_form} form + * Makes a unique writable storage for uploaded ZIP packages. * - * The file is saved into the given temporary location for inspection and eventual - * deployment. The form is expected to be submitted and validated. + * We need the saved ZIP to survive across multiple requests so that it can + * be used by the plugin manager after the installation is confirmed. In + * other words, we cannot use make_request_directory() here. * - * @param tool_installaddon_installfromzip_form $form - * @param string $targetdir full path to the directory where the ZIP should be stored to - * @return string filename of the saved file relative to the given target + * @return string full path to the directory */ - public function save_installfromzip_file(tool_installaddon_installfromzip_form $form, $targetdir) { - - $filename = clean_param($form->get_new_filename('zipfile'), PARAM_FILE); - $form->save_file('zipfile', $targetdir.'/'.$filename); - - return $filename; - } - - /** - * Extracts the saved file previously saved by {self::save_installfromzip_file()} - * - * The list of files found in the ZIP is returned via $zipcontentfiles parameter - * by reference. 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 extract_installfromzip_file($zipfilepath, $targetdir, $rootdir = '') { - global $CFG; - require_once($CFG->libdir.'/filelib.php'); - - $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; + public function make_installfromzip_storage() { + return make_unique_writable_directory(make_temp_directory('tool_installaddon')); } /** @@ -186,57 +133,6 @@ public function get_plugin_types_menu() { return $menu; } - /** - * Returns the full path of the root of the given plugin type - * - * Null is returned if the plugin type is not known. False is returned if the plugin type - * root is expected but not found. Otherwise, string is returned. - * - * @param string $plugintype - * @return string|bool|null - */ - public function get_plugintype_root($plugintype) { - - $plugintypepath = null; - foreach (core_component::get_plugin_types() as $type => $fullpath) { - if ($type === $plugintype) { - $plugintypepath = $fullpath; - break; - } - } - if (is_null($plugintypepath)) { - return null; - } - - if (!is_dir($plugintypepath)) { - return false; - } - - return $plugintypepath; - } - - /** - * Is it possible to create a new plugin directory for the given plugin type? - * - * @throws coding_exception for invalid plugin types or non-existing plugin type locations - * @param string $plugintype - * @return boolean - */ - public function is_plugintype_writable($plugintype) { - - $plugintypepath = $this->get_plugintype_root($plugintype); - - if (is_null($plugintypepath)) { - throw new coding_exception('Unknown plugin type!'); - } - - if ($plugintypepath === false) { - throw new coding_exception('Plugin type location does not exist!'); - } - - return is_writable($plugintypepath); - } - /** * Hook method to handle the remote request to install an add-on * @@ -245,13 +141,12 @@ public function is_plugintype_writable($plugintype) { * it. * * This hook is called early from admin/tool/installaddon/index.php page so that - * it has opportunity to take over the UI. + * it has opportunity to take over the UI and display the first confirmation screen. * * @param tool_installaddon_renderer $output * @param string|null $request - * @param bool $confirmed */ - public function handle_remote_request(tool_installaddon_renderer $output, $request, $confirmed = false) { + public function handle_remote_request(tool_installaddon_renderer $output, $request) { if (is_null($request)) { return; @@ -265,192 +160,34 @@ public function handle_remote_request(tool_installaddon_renderer $output, $reque } list($plugintype, $pluginname) = core_component::normalize_component($data->component); + $pluginman = core_plugin_manager::instance(); - $plugintypepath = $this->get_plugintype_root($plugintype); + $plugintypepath = $pluginman->get_plugintype_root($plugintype); if (file_exists($plugintypepath.'/'.$pluginname)) { echo $output->remote_request_alreadyinstalled_page($data, $this->index_url()); exit(); } - if (!$this->is_plugintype_writable($plugintype)) { + if (!$pluginman->is_plugintype_writable($plugintype)) { $continueurl = $this->index_url(array('installaddonrequest' => $request)); echo $output->remote_request_permcheck_page($data, $plugintypepath, $continueurl, $this->index_url()); exit(); } - $continueurl = $this->index_url(array( - 'installaddonrequest' => $request, - 'confirm' => 1, - 'sesskey' => sesskey())); - - if (!$confirmed) { - echo $output->remote_request_confirm_page($data, $continueurl, $this->index_url()); + if (!$pluginman->is_remote_plugin_installable($data->component, $data->version, $reason)) { + $data->reason = $reason; + echo $output->remote_request_non_installable_page($data, $this->index_url()); exit(); } - // The admin has confirmed their intention to install the add-on. - require_sesskey(); - - // Fetch the plugin info. The essential information is the URL to download the ZIP - // and the MD5 hash of the ZIP, obtained via HTTPS. - $client = \core\update\api::client(); - $pluginfo = $client->get_plugin_info($data->component, $data->version); - - if (empty($pluginfo) or empty($pluginfo->version)) { - echo $output->remote_request_pluginfo_failure($data, $this->index_url()); - exit(); - } - - // Fetch the ZIP with the plugin version - $jobid = md5(rand().uniqid('', true)); - $sourcedir = make_temp_directory('tool_installaddon/'.$jobid.'/source'); - $zipfilename = 'downloaded.zip'; - - try { - $this->download_file($pluginfo->version->downloadurl, $sourcedir.'/'.$zipfilename); - - } catch (tool_installaddon_installer_exception $e) { - if (debugging()) { - throw $e; - } else { - echo $output->installer_exception($e, $this->index_url()); - exit(); - } - } - - // Check the MD5 checksum - $md5expected = $pluginfo->version->downloadmd5; - $md5actual = md5_file($sourcedir.'/'.$zipfilename); - if ($md5expected !== $md5actual) { - $e = new tool_installaddon_installer_exception('err_zip_md5', array('expected' => $md5expected, 'actual' => $md5actual)); - if (debugging()) { - throw $e; - } else { - echo $output->installer_exception($e, $this->index_url()); - exit(); - } - } - - // Redirect to the validation page. - $nexturl = new moodle_url('/admin/tool/installaddon/validate.php', array( - 'sesskey' => sesskey(), - 'jobid' => $jobid, - 'zip' => $zipfilename, - 'type' => $plugintype)); - redirect($nexturl); - } - - /** - * Download the given file into the given destination. - * - * This is basically a simplified version of {@link download_file_content()} from - * Moodle itself, tuned for fetching files from moodle.org servers. Same code is used - * in mdeploy.php for fetching available updates. - * - * TODO This all will be rewritten to use new plugin manager features. - * - * @param string $source file url starting with http(s):// - * @param string $target store the downloaded content to this file (full path) - * @throws tool_installaddon_installer_exception - */ - public function download_file($source, $target) { - global $CFG; - require_once($CFG->libdir.'/filelib.php'); - - $targetfile = fopen($target, 'w'); - - if (!$targetfile) { - throw new tool_installaddon_installer_exception('err_download_write_file', $target); - } - - $options = array( - 'file' => $targetfile, - 'timeout' => 300, - 'followlocation' => true, - 'maxredirs' => 3, - 'ssl_verifypeer' => true, - 'ssl_verifyhost' => 2, - ); - - $curl = new curl(array('proxy' => true)); - - $result = $curl->download_one($source, null, $options); - - $curlinfo = $curl->get_info(); - - fclose($targetfile); - - if ($result !== true) { - throw new tool_installaddon_installer_exception('err_curl_exec', array( - 'url' => $source, 'errorno' => $curl->get_errno(), 'error' => $result)); - - } else if (empty($curlinfo['http_code']) or $curlinfo['http_code'] != 200) { - throw new tool_installaddon_installer_exception('err_curl_http_code', array( - 'url' => $source, 'http_code' => $curlinfo['http_code'])); - - } else if (isset($curlinfo['ssl_verify_result']) and $curlinfo['ssl_verify_result'] != 0) { - throw new tool_installaddon_installer_exception('err_curl_ssl_verify', array( - 'url' => $source, 'ssl_verify_result' => $curlinfo['ssl_verify_result'])); - } - } - - /** - * Moves the given source into a new location recursively - * - * This is cross-device safe implementation to be used instead of the native rename() function. - * See https://bugs.php.net/bug.php?id=54097 for more details. - * - * @param string $source full path to the existing directory - * @param string $target full path to the new location of the directory - * @param int $dirpermissions - * @param int $filepermissions - */ - public function move_directory($source, $target, $dirpermissions, $filepermissions) { - - if (file_exists($target)) { - throw new tool_installaddon_installer_exception('err_folder_already_exists', array('path' => $target)); - } - - if (is_dir($source)) { - $handle = opendir($source); - } else { - throw new tool_installaddon_installer_exception('err_no_such_folder', array('path' => $source)); - } - - if (!file_exists($target)) { - // Do not use make_writable_directory() here - it is intended for dataroot only. - mkdir($target, true); - @chmod($target, $dirpermissions); - } - - if (!is_writable($target)) { - closedir($handle); - throw new tool_installaddon_installer_exception('err_folder_not_writable', array('path' => $target)); - } - - while ($filename = readdir($handle)) { - $sourcepath = $source.'/'.$filename; - $targetpath = $target.'/'.$filename; - - if ($filename === '.' or $filename === '..') { - continue; - } - - if (is_dir($sourcepath)) { - $this->move_directory($sourcepath, $targetpath, $dirpermissions, $filepermissions); - - } else { - rename($sourcepath, $targetpath); - @chmod($targetpath, $filepermissions); - } - } - - closedir($handle); - - rmdir($source); + $continueurl = $this->index_url(array( + 'installremote' => $data->component, + 'installremoteversion' => $data->version + )); - clearstatcache(); + echo $output->remote_request_confirm_page($data, $continueurl, $this->index_url()); + exit(); } /** @@ -460,11 +197,11 @@ public function move_directory($source, $target, $dirpermissions, $filepermissio * are supported. * * @param string $zipfilepath full path to the saved ZIP file - * @param string $workdir full path to the directory we can use for extracting required bits from the archive * @return string|bool declared component name or false if unable to detect */ - public function detect_plugin_component($zipfilepath, $workdir) { + public function detect_plugin_component($zipfilepath) { + $workdir = make_request_directory(); $versionphp = $this->extract_versionphp_file($zipfilepath, $workdir); if (empty($versionphp)) { @@ -533,58 +270,6 @@ protected function should_send_site_info() { return true; } - /** - * Renames the root directory of the extracted ZIP package. - * - * This method does not validate the presence of the single root directory - * (the validator does it later). 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 the 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)) { - debugging('Unable to rename rootdir of non-existing content', DEBUG_DEVELOPER); - return $files; - } - - if (file_exists($dirname.'/'.$rootdir)) { - debugging('Unable to rename rootdir to already existing folder', DEBUG_DEVELOPER); - 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; - } - /** * Decode the request from the Moodle Plugins directory * @@ -722,21 +407,3 @@ protected function detect_plugin_component_from_versionphp($code) { return false; } } - - -/** - * General exception thrown by {@link tool_installaddon_installer} class - * - * @copyright 2013 David Mudrak - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class tool_installaddon_installer_exception extends moodle_exception { - - /** - * @param string $errorcode exception description identifier - * @param mixed $debuginfo debugging data to display - */ - public function __construct($errorcode, $a=null, $debuginfo=null) { - parent::__construct($errorcode, 'tool_installaddon', '', $a, print_r($debuginfo, true)); - } -} diff --git a/admin/tool/installaddon/classes/installfromzip_form.php b/admin/tool/installaddon/classes/installfromzip_form.php index fc7c840778041..7aca466eced7c 100644 --- a/admin/tool/installaddon/classes/installfromzip_form.php +++ b/admin/tool/installaddon/classes/installfromzip_form.php @@ -86,6 +86,21 @@ public function require_explicit_plugintype() { $mform->insertElementBefore($typedetectionfailed, 'permcheck'); } + /** + * Warn that the selected plugin type does not match the detected one. + * + * @param string $detected detected plugin type + */ + public function selected_plugintype_mismatch($detected) { + + $mform = $this->_form; + $mform->addRule('plugintype', get_string('required'), 'required', null, 'client'); + $mform->setAdvanced('plugintype', false); + $mform->setAdvanced('permcheck', false); + $mform->insertElementBefore($mform->createElement('static', 'selectedplugintypemismatch', '', + html_writer::span(get_string('typedetectionmismatch', 'tool_installaddon', $detected), 'error')), 'permcheck'); + } + /** * Validate the form fields * @@ -95,12 +110,12 @@ public function require_explicit_plugintype() { */ public function validation($data, $files) { - $installer = $this->_customdata['installer']; + $pluginman = core_plugin_manager::instance(); $errors = parent::validation($data, $files); if (!empty($data['plugintype'])) { - if (!$installer->is_plugintype_writable($data['plugintype'])) { - $path = $installer->get_plugintype_root($data['plugintype']); + if (!$pluginman->is_plugintype_writable($data['plugintype'])) { + $path = $pluginman->get_plugintype_root($data['plugintype']); $errors['plugintype'] = get_string('permcheckresultno', 'tool_installaddon', array('path' => $path)); } } diff --git a/admin/tool/installaddon/deploy.php b/admin/tool/installaddon/deploy.php deleted file mode 100644 index b80af832fa78e..0000000000000 --- a/admin/tool/installaddon/deploy.php +++ /dev/null @@ -1,78 +0,0 @@ -. - -/** - * Deploy the validated contents of the ZIP package to the $CFG->dirroot - * - * @package tool_installaddon - * @copyright 2013 David Mudrak - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -require(dirname(__FILE__) . '/../../../config.php'); -require_once($CFG->libdir.'/filelib.php'); - -require_login(); -require_capability('moodle/site:config', context_system::instance()); - -if (!empty($CFG->disableonclickaddoninstall)) { - notice(get_string('featuredisabled', 'tool_installaddon')); -} - -require_sesskey(); - -$jobid = required_param('jobid', PARAM_ALPHANUM); -$plugintype = required_param('type', PARAM_ALPHANUMEXT); -$pluginname = required_param('name', PARAM_PLUGIN); - -$zipcontentpath = $CFG->tempdir.'/tool_installaddon/'.$jobid.'/contents'; - -if (!is_dir($zipcontentpath)) { - debugging('Invalid location of the extracted ZIP package: '.s($zipcontentpath), DEBUG_DEVELOPER); - redirect(new moodle_url('/admin/tool/installaddon/index.php'), - get_string('invaliddata', 'core_error')); -} - -if (!is_dir($zipcontentpath.'/'.$pluginname)) { - debugging('Invalid location of the plugin root directory: '.$zipcontentpath.'/'.$pluginname, DEBUG_DEVELOPER); - redirect(new moodle_url('/admin/tool/installaddon/index.php'), - get_string('invaliddata', 'core_error')); -} - -$installer = tool_installaddon_installer::instance(); - -if (!$installer->is_plugintype_writable($plugintype)) { - debugging('Plugin type location not writable', DEBUG_DEVELOPER); - redirect(new moodle_url('/admin/tool/installaddon/index.php'), - get_string('invaliddata', 'core_error')); -} - -$plugintypepath = $installer->get_plugintype_root($plugintype); - -if (file_exists($plugintypepath.'/'.$pluginname)) { - debugging('Target location already exists', DEBUG_DEVELOPER); - redirect(new moodle_url('/admin/tool/installaddon/index.php'), - get_string('invaliddata', 'core_error')); -} - -// Copy permissions form the plugin type directory. -$dirpermissions = fileperms($plugintypepath); -$filepermissions = ($dirpermissions & 0666); // Strip execute flags. - -$installer->move_directory($zipcontentpath.'/'.$pluginname, $plugintypepath.'/'.$pluginname, $dirpermissions, $filepermissions); -fulldelete($CFG->tempdir.'/tool_installaddon/'.$jobid); -redirect(new moodle_url('/admin')); diff --git a/admin/tool/installaddon/index.php b/admin/tool/installaddon/index.php index e493296b6ffb7..7bbe5600a27b4 100644 --- a/admin/tool/installaddon/index.php +++ b/admin/tool/installaddon/index.php @@ -32,6 +32,7 @@ notice(get_string('featuredisabled', 'tool_installaddon')); } +$pluginman = core_plugin_manager::instance(); $installer = tool_installaddon_installer::instance(); $output = $PAGE->get_renderer('tool_installaddon'); @@ -39,8 +40,55 @@ // Handle the eventual request for installing from remote repository. $remoterequest = optional_param('installaddonrequest', null, PARAM_RAW); -$confirmed = optional_param('confirm', false, PARAM_BOOL); -$installer->handle_remote_request($output, $remoterequest, $confirmed); +$installer->handle_remote_request($output, $remoterequest); + +// Handle the confirmed installation request. +$installremote = optional_param('installremote', null, PARAM_COMPONENT); +$installremoteversion = optional_param('installremoteversion', null, PARAM_INT); +$installremoteconfirm = optional_param('installremoteconfirm', false, PARAM_BOOL); + +if ($installremote and $installremoteversion) { + require_sesskey(); + require_once($CFG->libdir.'/upgradelib.php'); + + $PAGE->set_pagelayout('maintenance'); + $PAGE->set_popup_notification_allowed(false); + + if ($pluginman->is_remote_plugin_installable($installremote, $installremoteversion)) { + $installable = array($pluginman->get_remote_plugin_info($installremote, $installremoteversion, true)); + upgrade_install_plugins($installable, $installremoteconfirm, + get_string('installfromrepo', 'tool_installaddon'), + new moodle_url($PAGE->url, array('installremote' => $installremote, + 'installremoteversion' => $installremoteversion, 'installremoteconfirm' => 1) + ) + ); + } + // We should never get here. + throw new moodle_exception('installing_non_installable_component', 'tool_installaddon'); +} + +// Handle installation of a plugin from the ZIP file. +$installzipcomponent = optional_param('installzipcomponent', null, PARAM_COMPONENT); +$installzipstorage = optional_param('installzipstorage', null, PARAM_FILE); +$installzipconfirm = optional_param('installzipconfirm', false, PARAM_BOOL); + +if ($installzipcomponent and $installzipstorage) { + require_sesskey(); + require_once($CFG->libdir.'/upgradelib.php'); + + $PAGE->set_pagelayout('maintenance'); + $PAGE->set_popup_notification_allowed(false); + + $installable = array((object)array( + 'component' => $installzipcomponent, + 'zipfilepath' => make_temp_directory('tool_installaddon').'/'.$installzipstorage.'/plugin.zip', + )); + upgrade_install_plugins($installable, $installzipconfirm, get_string('installfromzip', 'tool_installaddon'), + new moodle_url($installer->index_url(), array('installzipcomponent' => $installzipcomponent, + 'installzipstorage' => $installzipstorage, 'installzipconfirm' => 1) + ) + ); +} $form = $installer->get_installfromzip_form(); @@ -48,35 +96,47 @@ redirect($PAGE->url); } else if ($data = $form->get_data()) { - // Save the ZIP file into a temporary location. - $jobid = md5(rand().uniqid('', true)); - $sourcedir = make_temp_directory('tool_installaddon/'.$jobid.'/source'); - $zipfilename = $installer->save_installfromzip_file($form, $sourcedir); - if (empty($data->plugintype)) { - $versiondir = make_temp_directory('tool_installaddon/'.$jobid.'/version'); - $detected = $installer->detect_plugin_component($sourcedir.'/'.$zipfilename, $versiondir); - if (empty($detected)) { + $storage = $installer->make_installfromzip_storage(); + $form->save_file('zipfile', $storage.'/plugin.zip'); + + $ziprootdir = $pluginman->get_plugin_zip_root_dir($storage.'/plugin.zip'); + if (empty($ziprootdir)) { + echo $output->zip_not_valid_plugin_package_page($installer->index_url()); + die(); + } + + $component = $installer->detect_plugin_component($storage.'/plugin.zip'); + if (!empty($component) and !empty($data->plugintype)) { + // If the plugin type was explicitly set, make sure it matches the detected one. + list($detectedtype, $detectedname) = core_component::normalize_component($component); + if ($detectedtype !== $data->plugintype) { + $form->selected_plugintype_mismatch($detectedtype); + echo $output->index_page(); + die(); + } + } + if (empty($component)) { + // This should not happen as all plugins are supposed to declare their + // component. Still, let admins upload legacy packages if they want/need. + if (empty($data->plugintype)) { $form->require_explicit_plugintype(); + echo $output->index_page(); + die(); + } + if (!empty($data->rootdir)) { + $usepluginname = $data->rootdir; } else { - list($detectedtype, $detectedname) = core_component::normalize_component($detected); - if ($detectedtype and $detectedname and $detectedtype !== 'core') { - $data->plugintype = $detectedtype; - } else { - $form->require_explicit_plugintype(); - } + $usepluginname = $ziprootdir; } + $component = $data->plugintype.'_'.$usepluginname; } - // Redirect to the validation page. - if (!empty($data->plugintype)) { - $nexturl = new moodle_url('/admin/tool/installaddon/validate.php', array( - 'sesskey' => sesskey(), - 'jobid' => $jobid, - 'zip' => $zipfilename, - 'type' => $data->plugintype, - 'rootdir' => $data->rootdir)); - redirect($nexturl); - } + + redirect($installer->index_url(array( + 'installzipcomponent' => $component, + 'installzipstorage' => basename($storage), + 'sesskey' => sesskey(), + ))); } -// Output starts here. +// Display the tool main page. echo $output->index_page(); diff --git a/admin/tool/installaddon/lang/en/tool_installaddon.php b/admin/tool/installaddon/lang/en/tool_installaddon.php index 06340eed18362..da1aee9642fb9 100644 --- a/admin/tool/installaddon/lang/en/tool_installaddon.php +++ b/admin/tool/installaddon/lang/en/tool_installaddon.php @@ -31,13 +31,13 @@ $string['featuredisabled'] = 'The plugin installer is disabled on this site.'; $string['installaddon'] = 'Install plugin!'; $string['installaddons'] = 'Install plugins'; -$string['installexception'] = 'Oops... An error occurred while trying to install the plugin. Turn debugging mode on to see details of the error.'; $string['installfromrepo'] = 'Install plugins from the Moodle plugins directory'; $string['installfromrepo_help'] = 'You will be redirected to the Moodle plugins directory to search for and install a plugin. Note that your site full name, URL and Moodle version will be sent as well, to make the installation process easier for you.'; $string['installfromzip'] = 'Install plugin from ZIP file'; $string['installfromzip_help'] = 'An alternative to installing a plugin directly from the Moodle plugins directory is to upload a ZIP package of the plugin. The ZIP package should have the same structure as a package downloaded from the Moodle plugins directory.'; $string['installfromzipfile'] = 'ZIP package'; -$string['installfromzipfile_help'] = 'The plugin ZIP package must contain just one directory, named to match the plugin. The ZIP will be extracted into an appropriate location for the plugin type. If the package has been downloaded from the Moodle plugins directory then it will have this structure.'; +$string['installfromzipfile_help'] = 'The plugin ZIP package must contain just one directory, named to match the plugin name. The ZIP will be extracted into an appropriate location for the plugin type. If the package has been downloaded from the Moodle plugins directory then it will have this structure.'; +$string['installfromzipinvalid'] = 'The plugin ZIP package must contain just one directory, named to match the plugin name. Provided file is not a valid plugin ZIP package.'; $string['installfromziprootdir'] = 'Rename the root directory'; $string['installfromziprootdir_help'] = 'Some ZIP packages, such as those generated by Github, may contain an incorrect root directory name. If so, the correct name may be entered here.'; $string['installfromzipsubmit'] = 'Install plugin from the ZIP file'; @@ -56,13 +56,6 @@ $string['remoterequestinvalid'] = 'There is a request to install a plugin from the Moodle plugins directory on this site. Unfortunately the request is not valid and so the plugin cannot be installed.'; $string['remoterequestpermcheck'] = 'There is a request to install plugin {$a->name} ({$a->component}) version {$a->version} from the Moodle plugins directory on this site. However, the location {$a->typepath} is not writable. You need to give write access for the web server user to the location, then press the continue button to repeat the check.'; $string['remoterequestpluginfoexception'] = 'Oops... An error occurred while trying to obtain information about the plugin {$a->name} ({$a->component}) version {$a->version}. The plugin cannot be installed. Turn debugging mode on to see details of the error.'; +$string['remoterequestnoninstallable'] = 'There is a request to install plugin {$a->name} ({$a->component}) version {$a->version} from the Moodle plugins directory on this site. However, the plugin installation pre-check failed (reason code: {$a->reason}).'; $string['typedetectionfailed'] = 'Unable to detect the plugin type. Please choose the plugin type manually.'; -$string['validation'] = 'Plugin package validation'; -$string['validationresult0'] = 'Validation failed!'; -$string['validationresult0_help'] = 'A serious problem was detected and so it is not safe to install the plugin. See the validation log messages for details.'; -$string['validationresult1'] = 'Validation passed!'; -$string['validationresult2_help'] = 'No serious problems were detected. You can continue with the plugin installation. See the validation log messages for more details and eventual warnings.'; -$string['validationresult1_help'] = 'The plugin package has been validated and no serious problems were detected.'; -$string['validationresultinfo'] = 'Info'; -$string['validationresultmsg'] = 'Message'; -$string['validationresultstatus'] = 'Status'; +$string['typedetectionmismatch'] = 'The selected plugin type does not match the one declared by the plugin: {$a}'; diff --git a/admin/tool/installaddon/permcheck.php b/admin/tool/installaddon/permcheck.php index 9bd433f07e1fb..e271b07483e8f 100644 --- a/admin/tool/installaddon/permcheck.php +++ b/admin/tool/installaddon/permcheck.php @@ -52,9 +52,9 @@ die(); } -$installer = tool_installaddon_installer::instance(); +$pluginman = core_plugin_manager::instance(); -$plugintypepath = $installer->get_plugintype_root($plugintype); +$plugintypepath = $pluginman->get_plugintype_root($plugintype); if (empty($plugintypepath)) { header('HTTP/1.1 400 Bad Request'); @@ -63,7 +63,7 @@ $response = array('path' => $plugintypepath); -if ($installer->is_plugintype_writable($plugintype)) { +if ($pluginman->is_plugintype_writable($plugintype)) { $response['writable'] = 1; } else { $response['writable'] = 0; diff --git a/admin/tool/installaddon/renderer.php b/admin/tool/installaddon/renderer.php index daa027f0c69ab..e69aa6789d555 100644 --- a/admin/tool/installaddon/renderer.php +++ b/admin/tool/installaddon/renderer.php @@ -37,9 +37,6 @@ class tool_installaddon_renderer extends plugin_renderer_base { /** @var tool_installaddon_installer */ protected $installer = null; - /** @var \core\update\validator */ - protected $validator = null; - /** * Sets the tool_installaddon_installer instance being used. * @@ -54,20 +51,6 @@ public function set_installer_instance(tool_installaddon_installer $installer) { } } - /** - * Sets the \core\update\validator instance being used. - * - * @throws coding_exception if the validator has been already set - * @param \core\update\validator $validator - */ - public function set_validator_instance(\core\update\validator $validator) { - if (is_null($this->validator)) { - $this->validator = $validator; - } else { - throw new coding_exception('Attempting to reset the validator instance.'); - } - } - /** * Defines the index page layout * @@ -96,22 +79,18 @@ public function index_page() { } /** - * Defines the validation results page layout + * Inform the user that the ZIP is not a valid plugin package file. * + * @param moodle_url $continueurl * @return string */ - public function validation_page() { - - if (is_null($this->installer)) { - throw new coding_exception('Installer instance has not been set.'); - } - - if (is_null($this->validator)) { - throw new coding_exception('Validator instance has not been set.'); - } + public function zip_not_valid_plugin_package_page(moodle_url $continueurl) { - $out = $this->validation_page_heading(); - $out .= $this->validation_page_messages(); + $out = $this->output->header(); + $out .= $this->output->heading(get_string('installfromzip', 'tool_installaddon')); + $out .= $this->output->box(get_string('installfromzipinvalid', 'tool_installaddon'), 'generalbox', 'notice'); + $out .= $this->output->continue_button($continueurl, 'get'); + $out .= $this->output->footer(); return $out; } @@ -191,39 +170,17 @@ public function remote_request_permcheck_page(stdClass $data, $plugintypepath, m } /** - * Inform the user about pluginfo service call failure - * - * @param stdClass $data decoded request data - * @param moodle_url $continueurl - * @return string - */ - public function remote_request_pluginfo_failure(stdClass $data, moodle_url $continueurl) { - - $out = $this->output->header(); - $out .= $this->output->heading(get_string('installfromrepo', 'tool_installaddon')); - $out .= $this->output->box(get_string('remoterequestpluginfoexception', 'tool_installaddon', $data), 'generalbox', 'notice'); - $out .= $this->output->continue_button($continueurl, 'get'); - $out .= $this->output->footer(); - - return $out; - } - - /** - * Inform the user about the installer exception - * - * This implementation does not actually use the passed exception. Custom renderers might want to - * display additional data obtained via {@link get_exception_info()}. Also note, this method is called - * in non-debugging mode only. If debugging is allowed at the site, default exception handler is triggered. + * Inform the user that the requested remote plugin is not installable. * - * @param tool_installaddon_installer_exception $e thrown exception + * @param stdClass $data decoded request data with ->reason property added * @param moodle_url $continueurl * @return string */ - public function installer_exception(tool_installaddon_installer_exception $e, moodle_url $continueurl) { + public function remote_request_non_installable_page(stdClass $data, moodle_url $continueurl) { $out = $this->output->header(); $out .= $this->output->heading(get_string('installfromrepo', 'tool_installaddon')); - $out .= $this->output->box(get_string('installexception', 'tool_installaddon'), 'generalbox', 'notice'); + $out .= $this->output->box(get_string('remoterequestnoninstallable', 'tool_installaddon', $data), 'generalbox', 'notice'); $out .= $this->output->continue_button($continueurl, 'get'); $out .= $this->output->footer(); @@ -276,83 +233,4 @@ protected function index_page_upload() { return $out; } - - /** - * Renders the page title and the overall validation verdict - * - * @return string - */ - protected function validation_page_heading() { - - $heading = $this->output->heading(get_string('validation', 'tool_installaddon')); - - if ($this->validator->get_result()) { - $status = $this->output->container( - html_writer::span(get_string('validationresult1', 'tool_installaddon'), 'verdict'). - $this->output->help_icon('validationresult1', 'tool_installaddon'), - array('validationresult', 'success') - ); - } else { - $status = $this->output->container( - html_writer::span(get_string('validationresult0', 'tool_installaddon'), 'verdict'). - $this->output->help_icon('validationresult0', 'tool_installaddon'), - array('validationresult', 'failure') - ); - } - - return $heading . $status; - } - - /** - * Renders validation log messages. - * - * @return string - */ - protected function validation_page_messages() { - - $validator = $this->validator; // We need this to be able to use their constants. - $messages = $validator->get_messages(); - - if (empty($messages)) { - return ''; - } - - $table = new html_table(); - $table->attributes['class'] = 'validationmessages generaltable'; - $table->head = array( - get_string('validationresultstatus', 'tool_installaddon'), - get_string('validationresultmsg', 'tool_installaddon'), - get_string('validationresultinfo', 'tool_installaddon') - ); - $table->colclasses = array('msgstatus', 'msgtext', 'msginfo'); - - $stringman = get_string_manager(); - - foreach ($messages as $message) { - - if ($message->level === $validator::DEBUG and !debugging()) { - continue; - } - - $msgstatus = $validator->message_level_name($message->level); - $msgtext = $validator->message_code_name($message->msgcode); - $msginfo = $validator->message_code_info($message->msgcode, $message->addinfo); - if (empty($msginfo) and $message->addinfo !== null) { - $msginfo = html_writer::tag('pre', s(print_r($message->addinfo, true))); - } - $msghelpicon = $validator->message_help_icon($message->msgcode); - if ($msghelpicon) { - $msghelp = $this->output->render($msghelpicon); - } else { - $msghelp = ''; - } - - $row = new html_table_row(array($msgstatus, $msgtext.$msghelp, $msginfo)); - $row->attributes['class'] = 'level-'.$message->level.' '.$message->msgcode; - - $table->data[] = $row; - } - - return html_writer::table($table); - } } diff --git a/admin/tool/installaddon/settings.php b/admin/tool/installaddon/settings.php index b533360b23371..422930fef6555 100644 --- a/admin/tool/installaddon/settings.php +++ b/admin/tool/installaddon/settings.php @@ -30,10 +30,4 @@ $ADMIN->add('modules', new admin_externalpage('tool_installaddon_index', get_string('installaddons', 'tool_installaddon'), "$CFG->wwwroot/$CFG->admin/tool/installaddon/index.php"), 'modsettings'); - - $ADMIN->add('modules', new admin_externalpage('tool_installaddon_validate', - get_string('validation', 'tool_installaddon'), - "$CFG->wwwroot/$CFG->admin/tool/installaddon/validate.php", - 'moodle/site:config', - true), 'modsettings'); } diff --git a/admin/tool/installaddon/styles.css b/admin/tool/installaddon/styles.css index 05155ca612c57..3de50f35259ff 100644 --- a/admin/tool/installaddon/styles.css +++ b/admin/tool/installaddon/styles.css @@ -11,58 +11,3 @@ #page-admin-tool-installaddon-index #installfromrepobox .singlebutton input[type=submit] { padding: 1em; } - -#page-admin-tool-installaddon-validate .validationresult { - margin: 2em auto; - text-align: center; -} - -#page-admin-tool-installaddon-validate .validationresult .verdict { - margin: 0em 0.5em; - padding: 0.5em; - border: 2px solid; - -webkit-border-radius: 5px; - -moz-border-radius: 5px; - border-radius: 5px; - font-weight: bold; -} - -#page-admin-tool-installaddon-validate .validationresult.success .verdict { - background-color: #e7f1c3; - border-color: #aaeeaa; -} - -#page-admin-tool-installaddon-validate .validationresult.failure .verdict { - background-color: #ffd3d9; - border-color: #eeaaaa; -} - -#page-admin-tool-installaddon-validate .validationmessages { - margin: 0px auto; -} - -#page-admin-tool-installaddon-validate .validationmessages .level-error .msgstatus { - background-color: #ffd3d9; -} - -#page-admin-tool-installaddon-validate .validationmessages .level-warning .msgstatus { - background-color: #f3f2aa; -} - -#page-admin-tool-installaddon-validate .validationmessages .level-info .msgstatus { - background-color: #e7f1c3; -} - -#page-admin-tool-installaddon-validate .validationmessages .level-debug .msgstatus { - background-color: #d2ebff; -} - -#page-admin-tool-installaddon-validate .postvalidationbuttons { - text-align: center; - margin: 1em auto; -} - -#page-admin-tool-installaddon-validate .postvalidationbuttons .singlebutton { - display: inline-block; - margin: 1em 1em; -} diff --git a/admin/tool/installaddon/tests/fixtures/testable_installer.php b/admin/tool/installaddon/tests/fixtures/testable_installer.php new file mode 100644 index 0000000000000..807382017be90 --- /dev/null +++ b/admin/tool/installaddon/tests/fixtures/testable_installer.php @@ -0,0 +1,58 @@ +. + +/** + * @package tool_installaddon + * @subpackage fixtures + * @category test + * @copyright 2013, 2015 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Testable subclass of the tested class + * + * @copyright 2013 David Mudrak + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class testable_tool_installaddon_installer extends tool_installaddon_installer { + + public function get_site_fullname() { + return strip_tags('

Nasty site

'); + } + + public function get_site_url() { + return 'file:///etc/passwd'; + } + + public function get_site_major_version() { + return "2.5'; DROP TABLE mdl_user; --"; + } + + public function testable_decode_remote_request($request) { + return parent::decode_remote_request($request); + } + + protected function should_send_site_info() { + return true; + } + + public function testable_detect_plugin_component_from_versionphp($code) { + return parent::detect_plugin_component_from_versionphp($code); + } +} diff --git a/admin/tool/installaddon/tests/installer_test.php b/admin/tool/installaddon/tests/installer_test.php index 2b4ed9bcee78b..55a0608665045 100644 --- a/admin/tool/installaddon/tests/installer_test.php +++ b/admin/tool/installaddon/tests/installer_test.php @@ -26,6 +26,8 @@ defined('MOODLE_INTERNAL') || die(); +global $CFG; +require_once(__DIR__.'/fixtures/testable_installer.php'); /** * Unit tests for the {@link tool_installaddon_installer} class @@ -49,31 +51,6 @@ public function test_get_addons_repository_url() { $this->assertSame("2.5'; DROP TABLE mdl_user; --", $site['majorversion']); } - public function test_extract_installfromzip_file() { - global $CFG; - - $jobid = md5(rand().uniqid('test_', true)); - $sourcedir = make_temp_directory('tool_installaddon/'.$jobid.'/source'); - $contentsdir = make_temp_directory('tool_installaddon/'.$jobid.'/contents'); - copy($CFG->libdir.'/tests/fixtures/update_validator/zips/invalidroot.zip', $sourcedir.'/testinvalidroot.zip'); - - $installer = tool_installaddon_installer::instance(); - $files = $installer->extract_installfromzip_file($sourcedir.'/testinvalidroot.zip', $contentsdir, 'fixed_root'); - $this->assertInternalType('array', $files); - $this->assertCount(4, $files); - $this->assertSame(true, $files['fixed_root/']); - $this->assertSame(true, $files['fixed_root/lang/']); - $this->assertSame(true, $files['fixed_root/lang/en/']); - $this->assertSame(true, $files['fixed_root/lang/en/fixed_root.php']); - foreach ($files as $file => $status) { - if (substr($file, -1) === '/') { - $this->assertTrue(is_dir($contentsdir.'/'.$file)); - } else { - $this->assertTrue(is_file($contentsdir.'/'.$file)); - } - } - } - public function test_decode_remote_request() { $installer = testable_tool_installaddon_installer::instance(); @@ -132,68 +109,51 @@ public function test_decode_remote_request() { $this->assertSame(false, $installer->testable_decode_remote_request($request)); } - public function test_move_directory() { - $jobid = md5(rand().uniqid('test_', true)); - $jobroot = make_temp_directory('tool_installaddon/'.$jobid); - $contentsdir = make_temp_directory('tool_installaddon/'.$jobid.'/contents/sub/folder'); - file_put_contents($contentsdir.'/readme.txt', 'Hello world!'); - - $installer = tool_installaddon_installer::instance(); - $installer->move_directory($jobroot.'/contents', $jobroot.'/moved', 0777, 0666); - - $this->assertFalse(is_dir($jobroot.'/contents')); - $this->assertTrue(is_file($jobroot.'/moved/sub/folder/readme.txt')); - $this->assertSame('Hello world!', file_get_contents($jobroot.'/moved/sub/folder/readme.txt')); - } - public function test_detect_plugin_component() { global $CFG; - $jobid = md5(rand().uniqid('test_', true)); - $workdir = make_temp_directory('tool_installaddon/'.$jobid.'/version'); - $zipfile = $CFG->libdir.'/tests/fixtures/update_validator/zips/bar.zip'; $installer = tool_installaddon_installer::instance(); - $this->assertEquals('foo_bar', $installer->detect_plugin_component($zipfile, $workdir)); + + $zipfile = $CFG->libdir.'/tests/fixtures/update_validator/zips/bar.zip'; + $this->assertEquals('foo_bar', $installer->detect_plugin_component($zipfile)); + + $zipfile = $CFG->libdir.'/tests/fixtures/update_validator/zips/invalidroot.zip'; + $this->assertFalse($installer->detect_plugin_component($zipfile)); } public function test_detect_plugin_component_from_versionphp() { + global $CFG; + $installer = testable_tool_installaddon_installer::instance(); - $this->assertEquals('bar_bar_conan', $installer->detect_plugin_component_from_versionphp(' + $fixtures = $CFG->libdir.'/tests/fixtures/update_validator/'; + + $this->assertEquals('bar_bar_conan', $installer->testable_detect_plugin_component_from_versionphp(' $plugin->version = 2014121300; $plugin->component= "bar_bar_conan" ; // Go Arnie go!')); - } -} - - -/** - * Testable subclass of the tested class - * - * @copyright 2013 David Mudrak - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class testable_tool_installaddon_installer extends tool_installaddon_installer { - public function get_site_fullname() { - return strip_tags('

Nasty site

'); - } + $versionphp = file_get_contents($fixtures.'/github/moodle-repository_mahara-master/version.php'); + $this->assertEquals('repository_mahara', $installer->testable_detect_plugin_component_from_versionphp($versionphp)); - public function get_site_url() { - return 'file:///etc/passwd'; + $versionphp = file_get_contents($fixtures.'/nocomponent/baz/version.php'); + $this->assertFalse($installer->testable_detect_plugin_component_from_versionphp($versionphp)); } - public function get_site_major_version() { - return "2.5'; DROP TABLE mdl_user; --"; - } + public function test_make_installfromzip_storage() { + $installer = testable_tool_installaddon_installer::instance(); - public function testable_decode_remote_request($request) { - return parent::decode_remote_request($request); - } + // Check we get writable directory. + $storage1 = $installer->make_installfromzip_storage(); + $this->assertTrue(is_dir($storage1)); + $this->assertTrue(is_writable($storage1)); + file_put_contents($storage1.'/hello.txt', 'Find me if you can!'); - protected function should_send_site_info() { - return true; - } + // Check we get unique directory on each call. + $storage2 = $installer->make_installfromzip_storage(); + $this->assertTrue(is_dir($storage2)); + $this->assertTrue(is_writable($storage2)); + $this->assertFalse(file_exists($storage2.'/hello.txt')); - public function detect_plugin_component_from_versionphp($code) { - return parent::detect_plugin_component_from_versionphp($code); + // Check both are in the same parent directory. + $this->assertEquals(dirname($storage1), dirname($storage2)); } } diff --git a/admin/tool/installaddon/validate.php b/admin/tool/installaddon/validate.php deleted file mode 100644 index efd01e0c00af1..0000000000000 --- a/admin/tool/installaddon/validate.php +++ /dev/null @@ -1,92 +0,0 @@ -. - -/** - * The ZIP package validation. - * - * @package tool_installaddon - * @copyright 2013 David Mudrak - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -require(dirname(__FILE__) . '/../../../config.php'); -require_once($CFG->libdir.'/adminlib.php'); -require_once($CFG->libdir.'/filelib.php'); - -navigation_node::override_active_url(new moodle_url('/admin/tool/installaddon/index.php')); -admin_externalpage_setup('tool_installaddon_validate'); - -if (!empty($CFG->disableonclickaddoninstall)) { - notice(get_string('featuredisabled', 'tool_installaddon')); -} - -require_sesskey(); - -$jobid = required_param('jobid', PARAM_ALPHANUM); -$zipfilename = required_param('zip', PARAM_FILE); -$plugintype = required_param('type', PARAM_ALPHANUMEXT); -$rootdir = optional_param('rootdir', '', PARAM_PLUGIN); - -$zipfilepath = $CFG->tempdir.'/tool_installaddon/'.$jobid.'/source/'.$zipfilename; -if (!file_exists($zipfilepath)) { - redirect(new moodle_url('/admin/tool/installaddon/index.php'), - get_string('invaliddata', 'core_error')); -} - -$installer = tool_installaddon_installer::instance(); - -// Extract the ZIP contents. -fulldelete($CFG->tempdir.'/tool_installaddon/'.$jobid.'/contents'); -$zipcontentpath = make_temp_directory('tool_installaddon/'.$jobid.'/contents'); -$zipcontentfiles = $installer->extract_installfromzip_file($zipfilepath, $zipcontentpath, $rootdir); - -// Validate the contents of the plugin ZIP file. -$validator = \core\update\validator::instance($zipcontentpath, $zipcontentfiles); -$validator->assert_plugin_type($plugintype); -$validator->assert_moodle_version($CFG->version); -$result = $validator->execute(); - -// Display the validation results. -$output = $PAGE->get_renderer('tool_installaddon'); -$output->set_installer_instance($installer); -$output->set_validator_instance($validator); - -echo $output->header(); -echo $output->validation_page(); - -if ($result) { - $conturl = new moodle_url('/admin/tool/installaddon/deploy.php', array( - 'sesskey' => sesskey(), - 'jobid' => $jobid, - 'type' => $plugintype, - 'name' => $validator->get_rootdir()) - ); - $contbutton = $output->single_button($conturl, get_string('installaddon', 'tool_installaddon'), 'post', - array('class' => 'singlebutton continuebutton')); - echo $output->heading(get_string('acknowledgement', 'tool_installaddon'), 3); - echo $output->container(get_string('acknowledgementtext', 'tool_installaddon')); - -} else { - $contbutton = ''; - fulldelete($CFG->tempdir.'/tool_installaddon/'.$jobid); -} - -$cancelbutton = $output->single_button(new moodle_url('/admin/tool/installaddon/index.php'), get_string('cancel', 'core'), - 'get', array('class' => 'singlebutton cancelbutton')); - -echo $output->container($cancelbutton.$contbutton, 'postvalidationbuttons'); -echo $output->footer();