+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+if ($hassiteconfig and empty($CFG->disableonclickaddoninstall)) {
+
+ $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
new file mode 100644
index 00000000000..05155ca612c
--- /dev/null
+++ b/admin/tool/installaddon/styles.css
@@ -0,0 +1,68 @@
+#page-admin-tool-installaddon-index #installfromrepobox {
+ text-align: center;
+ padding-top: 2em;
+ padding-bottom: 2em;
+}
+
+#page-admin-tool-installaddon-index #installfromrepobox .singlebutton {
+ display: inline-block;
+}
+
+#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/emptydir/emptydir/README.txt b/admin/tool/installaddon/tests/fixtures/emptydir/emptydir/README.txt
new file mode 100644
index 00000000000..158d760751b
--- /dev/null
+++ b/admin/tool/installaddon/tests/fixtures/emptydir/emptydir/README.txt
@@ -0,0 +1 @@
+Plugin must have more than one file.
diff --git a/admin/tool/installaddon/tests/fixtures/github/moodle-repository_mahara-master/lang/en/repository_mahara.php b/admin/tool/installaddon/tests/fixtures/github/moodle-repository_mahara-master/lang/en/repository_mahara.php
new file mode 100644
index 00000000000..b3d9bbc7f37
--- /dev/null
+++ b/admin/tool/installaddon/tests/fixtures/github/moodle-repository_mahara-master/lang/en/repository_mahara.php
@@ -0,0 +1 @@
+component = 'repository_mahara';
+$plugin->version = 2014010100;
diff --git a/admin/tool/installaddon/tests/fixtures/installed/greenbar/index.php b/admin/tool/installaddon/tests/fixtures/installed/greenbar/index.php
new file mode 100644
index 00000000000..c65b6f7652d
--- /dev/null
+++ b/admin/tool/installaddon/tests/fixtures/installed/greenbar/index.php
@@ -0,0 +1,3 @@
+version = 2013031900;
+$plugin->component = 'local_greenbar';
diff --git a/admin/tool/installaddon/tests/fixtures/multidir/one/version.php b/admin/tool/installaddon/tests/fixtures/multidir/one/version.php
new file mode 100644
index 00000000000..84af5f26a90
--- /dev/null
+++ b/admin/tool/installaddon/tests/fixtures/multidir/one/version.php
@@ -0,0 +1,3 @@
+component = 'local_one';
diff --git a/admin/tool/installaddon/tests/fixtures/multidir/two/README.txt b/admin/tool/installaddon/tests/fixtures/multidir/two/README.txt
new file mode 100644
index 00000000000..84e1459a130
--- /dev/null
+++ b/admin/tool/installaddon/tests/fixtures/multidir/two/README.txt
@@ -0,0 +1 @@
+Only one dir is allowed
diff --git a/admin/tool/installaddon/tests/fixtures/nolang/bah/index.php b/admin/tool/installaddon/tests/fixtures/nolang/bah/index.php
new file mode 100644
index 00000000000..b3d9bbc7f37
--- /dev/null
+++ b/admin/tool/installaddon/tests/fixtures/nolang/bah/index.php
@@ -0,0 +1 @@
+version = 2014122455;
+$plugin->version = 2014122455;
diff --git a/admin/tool/installaddon/tests/fixtures/nolang/bah/view.php b/admin/tool/installaddon/tests/fixtures/nolang/bah/view.php
new file mode 100644
index 00000000000..654f0709e84
--- /dev/null
+++ b/admin/tool/installaddon/tests/fixtures/nolang/bah/view.php
@@ -0,0 +1,3 @@
+version = 10; // Ignored, this should use $plugin
+$plugin->version = 2013031900;
+$plugin->component = 'local_foobar';
+$plugin->requires = 2013031200;
+$module->release = 'We are not an activity module!';
+$plugin->maturity = MATURITY_ALPHA;
+//$plugin->release = 'And this is commented';
diff --git a/admin/tool/installaddon/tests/fixtures/versionphp/version1.php b/admin/tool/installaddon/tests/fixtures/versionphp/version1.php
new file mode 100644
index 00000000000..61646f05b91
--- /dev/null
+++ b/admin/tool/installaddon/tests/fixtures/versionphp/version1.php
@@ -0,0 +1,52 @@
+Example version.php file
+
+version.php is required for all plugins but themes.
+
+Example of values
+
+
+ $plugin->version = 2011051000;
+ $plugin->requires = 2010112400;
+ $plugin->cron = 0;
+ $plugin->component = 'plugintype_pluginname';
+ $plugin->maturity = MATURITY_STABLE;
+ $plugin->release = '2.x (Build: 2011051000)';
+ $plugin->dependencies = array('mod_forum' => ANY_VERSION, 'mod_data' => 2010020300);
+
+
+Replace $plugin with $module for activity modules, as in
+
+
+ $module->version = 2012122400;
+
version = 1;
+
+ $plugin->component
+ = 'old_foobar';//$plugin->component='commented';
+
+ $plugin->component =
+ 'block_foobar';
+
+$plugin->version = 2013010100;
+ ////////$plugin->version = 0;
+ /* for activity
+ modules use:
+ $module->version = 2014131300;
+
+ ***/
+$plugin->version = "2010091855"; // Do not use quotes here.
+$plugin->version = '2010091856.9'; // Do not use quotes here.
+
+
+$plugin->requires = /* 2012010100 */ 2012122401 ;
+
+$module->maturity = MATURITY_STABLE;
+$module->maturity = 50; // If both present, the constant wins (on contrary to what PHP would do)
+$module->maturity = 'MATURITY_BETA'; // Do not use quotes here.
+
+$plugin->maturity = 10;
+$plugin->maturity = MATURITY_ALPHA;
+
+
+
+$module->release = 2.3; $plugin->release = 'v2.4';
+$module->release = "v2.3"; $plugin->release = 2.4;
diff --git a/admin/tool/installaddon/tests/fixtures/zips/invalidroot.zip b/admin/tool/installaddon/tests/fixtures/zips/invalidroot.zip
new file mode 100644
index 00000000000..a2090259878
Binary files /dev/null and b/admin/tool/installaddon/tests/fixtures/zips/invalidroot.zip differ
diff --git a/admin/tool/installaddon/tests/installer_test.php b/admin/tool/installaddon/tests/installer_test.php
new file mode 100644
index 00000000000..fd8d1d1c0b9
--- /dev/null
+++ b/admin/tool/installaddon/tests/installer_test.php
@@ -0,0 +1,158 @@
+.
+
+/**
+ * Provides the unit tests class and some helper classes
+ *
+ * @package tool_installaddon
+ * @category test
+ * @copyright 2013 David Mudrak
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/installaddon/classes/installer.php');
+
+
+/**
+ * Unit tests for the {@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_test extends advanced_testcase {
+
+ public function test_get_addons_repository_url() {
+ $installer = testable_tool_installaddon_installer::instance();
+ $url = $installer->get_addons_repository_url();
+ $query = parse_url($url, PHP_URL_QUERY);
+ $this->assertEquals(1, preg_match('~^site=(.+)$~', $query, $matches));
+ $site = rawurldecode($matches[1]);
+ $site = json_decode(base64_decode($site), true);
+ $this->assertEquals('array', gettype($site));
+ $this->assertEquals(3, count($site));
+ $this->assertSame('Nasty site', $site['fullname']);
+ $this->assertSame('file:///etc/passwd', $site['url']);
+ $this->assertSame("2.5'; DROP TABLE mdl_user; --", $site['majorversion']);
+ }
+
+ public function test_extract_installfromzip_file() {
+ $jobid = md5(rand().uniqid('test_', true));
+ $sourcedir = make_temp_directory('tool_installaddon/'.$jobid.'/source');
+ $contentsdir = make_temp_directory('tool_installaddon/'.$jobid.'/contents');
+ copy(dirname(__FILE__).'/fixtures/zips/invalidroot.zip', $sourcedir.'/testinvalidroot.zip');
+
+ $installer = tool_installaddon_installer::instance();
+ $files = $installer->extract_installfromzip_file($sourcedir.'/testinvalidroot.zip', $contentsdir, 'fixed_root');
+ $this->assertEquals('array', gettype($files));
+ $this->assertEquals(4, count($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();
+
+ $request = base64_encode(json_encode(array(
+ 'name' => 'Stamp collection
"; DELETE FROM mdl_users; --',
+ 'component' => 'mod_stampcoll',
+ 'version' => 2013032800,
+ )));
+ $request = $installer->testable_decode_remote_request($request);
+ $this->assertTrue(is_object($request));
+ // One, my little hobbit, never trusts the input parameters!
+ $this->assertEquals('Stamp collection"; DELETE FROM mdl_users; --', $request->name);
+ $this->assertEquals('mod_stampcoll', $request->component);
+ $this->assertEquals(2013032800, $request->version);
+
+ $request = base64_encode(json_encode(array(
+ 'name' => 'Theme with invalid version number',
+ 'component' => 'theme_invalid',
+ 'version' => '1.0',
+ )));
+ $this->assertSame(false, $installer->testable_decode_remote_request($request));
+
+ $request = base64_encode(json_encode(array(
+ 'name' => 'Invalid activity name',
+ 'component' => 'mod_invalid_activity',
+ 'version' => 2013032800,
+ )));
+ $this->assertSame(false, $installer->testable_decode_remote_request($request));
+
+ $request = base64_encode(json_encode(array(
+ 'name' => 'Moodle 3.0',
+ 'component' => 'core',
+ 'version' => 2022010100,
+ )));
+ $this->assertSame(false, $installer->testable_decode_remote_request($request));
+
+ $request = base64_encode(json_encode(array(
+ 'name' => 'Invalid core subsystem',
+ 'component' => 'core_cache',
+ 'version' => 2014123400,
+ )));
+ $this->assertSame(false, $installer->testable_decode_remote_request($request));
+
+ $request = base64_encode(json_encode(array(
+ 'name' => 'Non-existing plugintype',
+ 'component' => 'david_mudrak',
+ 'version' => 2012123199,
+ )));
+ $this->assertSame(false, $installer->testable_decode_remote_request($request));
+ }
+}
+
+
+/**
+ * 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;
+ }
+}
diff --git a/admin/tool/installaddon/tests/validator_test.php b/admin/tool/installaddon/tests/validator_test.php
new file mode 100644
index 00000000000..c008d9cfd31
--- /dev/null
+++ b/admin/tool/installaddon/tests/validator_test.php
@@ -0,0 +1,335 @@
+.
+
+/**
+ * Provides the unit tests class and some helper classes
+ *
+ * @package tool_installaddon
+ * @category test
+ * @copyright 2013 David Mudrak
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/installaddon/classes/validator.php');
+
+
+/**
+ * Unit tests for the {@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_validator_test extends basic_testcase {
+
+ public function test_validate_files_layout() {
+ $fixtures = dirname(__FILE__).'/fixtures';
+
+ // Non-existing directory.
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/nulldir', array(
+ 'null/' => true,
+ 'null/lang/' => true,
+ 'null/lang/en/' => true,
+ 'null/lang/en/null.php' => true));
+ $this->assertEquals('testable_tool_installaddon_validator', get_class($validator));
+ $this->assertFalse($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR,
+ 'filenotexists', array('file' => 'null/')));
+
+ // Missing expected file
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/plugindir', array(
+ 'foobar/' => true,
+ 'foobar/version.php' => true,
+ 'foobar/index.php' => true,
+ 'foobar/lang/' => true,
+ 'foobar/lang/en/' => true,
+ 'foobar/lang/en/local_foobar.php' => true,
+ 'foobar/NOTEXISTS.txt' => true));
+ $this->assertFalse($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR,
+ 'filenotexists', array('file' => 'foobar/NOTEXISTS.txt')));
+
+ // Errors during ZIP extraction
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/multidir', array(
+ 'one/' => true,
+ 'one/version.php' => 'Can not write target file',
+ 'two/' => true,
+ 'two/README.txt' => true));
+ $this->assertFalse($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'filestatus',
+ array('file' => 'one/version.php', 'status' => 'Can not write target file')));
+
+ // Insufficient number of extracted files
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/emptydir', array(
+ 'emptydir/' => true,
+ 'emptydir/README.txt' => true));
+ $this->assertFalse($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'filesnumber'));
+
+ // No wrapping directory
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/nowrapdir', array(
+ 'version.php' => true,
+ 'index.php' => true,
+ 'lang/' => true,
+ 'lang/en/' => true,
+ 'lang/en/foo.php' => true));
+ $this->assertFalse($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'onedir'));
+
+ // Multiple directories
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/multidir', array(
+ 'one/' => true,
+ 'one/version.php' => true,
+ 'two/' => true,
+ 'two/README.txt' => true));
+ $this->assertFalse($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'onedir'));
+
+ // Invalid root directory name
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/github', array(
+ 'moodle-repository_mahara-master/' => true,
+ 'moodle-repository_mahara-master/lang/' => true,
+ 'moodle-repository_mahara-master/lang/en/' => true,
+ 'moodle-repository_mahara-master/lang/en/repository_mahara.php' => true,
+ 'moodle-repository_mahara-master/version.php' => true));
+ $this->assertFalse($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'rootdirinvalid',
+ 'moodle-repository_mahara-master'));
+ }
+
+ public function test_validate_version_php() {
+ $fixtures = dirname(__FILE__).'/fixtures';
+
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/noversiontheme', array(
+ 'noversion/' => true,
+ 'noversion/lang/' => true,
+ 'noversion/lang/en/' => true,
+ 'noversion/lang/en/theme_noversion.php' => true));
+ $validator->assert_plugin_type('theme');
+ $validator->assert_moodle_version(0);
+ $this->assertTrue($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::DEBUG, 'missingversionphp'));
+ $this->assertTrue(is_null($validator->get_versionphp_info()));
+
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/noversionmod', array(
+ 'noversion/' => true,
+ 'noversion/lang/' => true,
+ 'noversion/lang/en/' => true,
+ 'noversion/lang/en/noversion.php' => true));
+ $validator->assert_plugin_type('mod');
+ $validator->assert_moodle_version(0);
+ $this->assertFalse($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'missingversionphp'));
+
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/plugindir', array(
+ 'foobar/' => true,
+ 'foobar/version.php' => true,
+ 'foobar/index.php' => true,
+ 'foobar/lang/' => true));
+ $validator->assert_plugin_type('block');
+ $validator->assert_moodle_version('2013031400.00');
+ $this->assertFalse($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'componentmismatchtype',
+ array('expected' => 'block', 'found' => 'local')));
+
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/plugindir', array(
+ 'foobar/' => true,
+ 'foobar/version.php' => true,
+ 'foobar/index.php' => true,
+ 'foobar/lang/' => true,
+ 'foobar/lang/en/' => true,
+ 'foobar/lang/en/local_foobar.php' => true));
+ $validator->assert_plugin_type('local');
+ $validator->assert_moodle_version('2013031400.00');
+ $this->assertTrue($validator->execute());
+ $this->assertTrue($validator->get_result());
+ $this->assertEquals('foobar', $validator->get_rootdir());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::INFO, 'rootdir', 'foobar'));
+ $versionphpinfo = $validator->get_versionphp_info();
+ $this->assertEquals('array', gettype($versionphpinfo));
+ $this->assertEquals(4, count($versionphpinfo));
+ $this->assertEquals(2013031900, $versionphpinfo['version']);
+ $this->assertEquals(2013031200, $versionphpinfo['requires']);
+ $this->assertEquals('local_foobar', $versionphpinfo['component']);
+ $this->assertEquals('MATURITY_ALPHA', $versionphpinfo['maturity']); // Note we get the constant name here.
+ $this->assertEquals(MATURITY_ALPHA, constant($versionphpinfo['maturity'])); // This is how to get the real value.
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::WARNING, 'maturity', 'MATURITY_ALPHA'));
+ }
+
+ public function test_validate_language_pack() {
+ $fixtures = dirname(__FILE__).'/fixtures';
+
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/nolang', array(
+ 'bah/' => true,
+ 'bah/index.php' => true,
+ 'bah/view.php' => true,
+ 'bah/version.php' => true));
+ $validator->assert_plugin_type('mod');
+ $validator->assert_moodle_version(0);
+ $this->assertFalse($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'missinglangenfolder'));
+
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/nolang', array(
+ 'bah/' => true,
+ 'bah/version.php' => true,
+ 'bah/lang/' => true,
+ 'bah/lang/en/' => true));
+ $validator->assert_plugin_type('mod');
+ $validator->assert_moodle_version(0);
+ $this->assertFalse($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'missinglangenfile'));
+
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/nolang', array(
+ 'bah/' => true,
+ 'bah/version.php' => true,
+ 'bah/lang/' => true,
+ 'bah/lang/en/' => true,
+ 'bah/lang/en/bleh.php' => true,
+ 'bah/lang/en/bah.php' => true));
+ $validator->assert_plugin_type('mod');
+ $validator->assert_moodle_version(0);
+ $this->assertTrue($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::WARNING, 'multiplelangenfiles'));
+ $this->assertTrue(is_null($validator->get_language_file_name()));
+
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/nolang', array(
+ 'bah/' => true,
+ 'bah/version.php' => true,
+ 'bah/lang/' => true,
+ 'bah/lang/en/' => true,
+ 'bah/lang/en/bah.php' => true));
+ $validator->assert_plugin_type('block');
+ $validator->assert_moodle_version(0);
+ $this->assertFalse($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'missingexpectedlangenfile', 'block_bah.php'));
+ $this->assertEquals('bah', $validator->get_language_file_name());
+
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/noversiontheme', array(
+ 'noversion/' => true,
+ 'noversion/lang/' => true,
+ 'noversion/lang/en/' => true,
+ 'noversion/lang/en/theme_noversion.php' => true));
+ $validator->assert_plugin_type('theme');
+ $validator->assert_moodle_version(0);
+ $this->assertTrue($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::DEBUG, 'foundlangfile', 'theme_noversion'));
+ $this->assertEquals('theme_noversion', $validator->get_language_file_name());
+
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/plugindir', array(
+ 'foobar/' => true,
+ 'foobar/version.php' => true,
+ 'foobar/index.php' => true,
+ 'foobar/lang/' => true,
+ 'foobar/lang/en/' => true,
+ 'foobar/lang/en/local_foobar.php' => true));
+ $validator->assert_plugin_type('local');
+ $validator->assert_moodle_version('2013031400.00');
+ $this->assertTrue($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::DEBUG, 'foundlangfile', 'local_foobar'));
+ $this->assertEquals('local_foobar', $validator->get_language_file_name());
+ }
+
+ public function test_validate_target_location() {
+ $fixtures = dirname(__FILE__).'/fixtures';
+
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/installed', array(
+ 'greenbar/' => true,
+ 'greenbar/version.php' => true,
+ 'greenbar/index.php' => true,
+ 'greenbar/lang/' => true,
+ 'greenbar/lang/en/' => true,
+ 'greenbar/lang/en/local_greenbar.php' => true));
+ $validator->assert_plugin_type('local');
+ $validator->assert_moodle_version('2013031400.00');
+ $this->assertFalse($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'targetexists',
+ $validator->get_plugintype_location('local').'/greenbar'));
+
+ $validator = testable_tool_installaddon_validator::instance($fixtures.'/plugindir', array(
+ 'foobar/' => true,
+ 'foobar/version.php' => true,
+ 'foobar/index.php' => true,
+ 'foobar/lang/' => true,
+ 'foobar/lang/en/' => true,
+ 'foobar/lang/en/local_foobar.php' => true));
+ $validator->assert_plugin_type('local');
+ $validator->assert_moodle_version('2013031400.00');
+ $this->assertTrue($validator->execute());
+ $this->assertTrue($this->has_message($validator->get_messages(), $validator::INFO, 'pathwritable',
+ $validator->get_plugintype_location('local')));
+ }
+
+ public function test_parse_version_php() {
+ $fixtures = dirname(__FILE__).'/fixtures/versionphp';
+
+ $validator = testable_tool_installaddon_validator::instance($fixtures, array());
+ $this->assertEquals('testable_tool_installaddon_validator', get_class($validator));
+
+ $info = $validator->testable_parse_version_php($fixtures.'/version1.php');
+ $this->assertEquals('array', gettype($info));
+ $this->assertEquals(7, count($info));
+ $this->assertEquals('block_foobar', $info['plugin->component']); // Later in the file.
+ $this->assertEquals('2013010100', $info['plugin->version']); // Numeric wins over strings.
+ $this->assertEquals('2012122401', $info['plugin->requires']); // Commented.
+ $this->assertEquals('MATURITY_STABLE', $info['module->maturity']); // Constant wins regardless the order (non-PHP behaviour).
+ $this->assertEquals('MATURITY_ALPHA', $info['plugin->maturity']); // Constant wins regardless the order (non-PHP behaviour).
+ $this->assertEquals('v2.3', $info['module->release']); // String wins over numeric (non-PHP behaviour).
+ $this->assertEquals('v2.4', $info['plugin->release']); // String wins over numeric (non-PHP behaviour).
+ }
+
+ // Helper methods //////////////////////////////////////////////////////////
+
+ protected function has_message(array $messages, $level, $msgcode, $addinfo = null) {
+ foreach ($messages as $message) {
+ if ($message->level === $level and $message->msgcode === $msgcode and $message->addinfo === $addinfo) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
+
+
+/**
+ * Provides access to protected methods we want to explicitly test
+ *
+ * @copyright 2013 David Mudrak
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class testable_tool_installaddon_validator extends tool_installaddon_validator {
+
+ public function testable_parse_version_php($fullpath) {
+ return parent::parse_version_php($fullpath);
+ }
+
+ public function get_plugintype_location($plugintype) {
+
+ $testableroot = make_temp_directory('testable_tool_installaddon_validator/plugintypes');
+ if (!is_dir($testableroot.'/'.$plugintype)) {
+ make_temp_directory('testable_tool_installaddon_validator/plugintypes/'.$plugintype);
+ }
+
+ if ($plugintype === 'local') {
+ // We need the following for the test_validate_target_location() method
+ make_temp_directory('testable_tool_installaddon_validator/plugintypes/local/greenbar');
+ }
+
+ return $testableroot.'/'.$plugintype;
+ }
+}
diff --git a/admin/tool/installaddon/validate.php b/admin/tool/installaddon/validate.php
new file mode 100644
index 00000000000..2fc66cf3bc9
--- /dev/null
+++ b/admin/tool/installaddon/validate.php
@@ -0,0 +1,80 @@
+.
+
+/**
+ * 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');
+require_once(dirname(__FILE__).'/classes/installer.php');
+require_once(dirname(__FILE__).'/classes/validator.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 = tool_installaddon_validator::instance($zipcontentpath, $zipcontentfiles);
+$validator->assert_plugin_type($plugintype);
+$validator->assert_moodle_version($CFG->version);
+$result = $validator->execute();
+
+if ($result) {
+ $validator->set_continue_url(new moodle_url('/admin/tool/installaddon/deploy.php', array(
+ 'sesskey' => sesskey(),
+ 'jobid' => $jobid,
+ 'type' => $plugintype,
+ 'name' => $validator->get_rootdir())));
+
+} else {
+ fulldelete($CFG->tempdir.'/tool_installaddon/'.$jobid);
+}
+
+// Display the validation results.
+$output = $PAGE->get_renderer('tool_installaddon');
+$output->set_installer_instance($installer);
+$output->set_validator_instance($validator);
+echo $output->validation_page();
diff --git a/admin/tool/installaddon/version.php b/admin/tool/installaddon/version.php
new file mode 100644
index 00000000000..94ab2e5c22f
--- /dev/null
+++ b/admin/tool/installaddon/version.php
@@ -0,0 +1,29 @@
+.
+
+/**
+ * @package tool_installaddon
+ * @copyright 2013 David Mudrak
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->component = 'tool_installaddon';
+$plugin->version = 2013031400;
+$plugin->requires = 2013031400;
+$plugin->maturity = MATURITY_BETA;
diff --git a/admin/tool/installaddon/yui/permcheck/permcheck.js b/admin/tool/installaddon/yui/permcheck/permcheck.js
new file mode 100644
index 00000000000..ad0e4bd60cd
--- /dev/null
+++ b/admin/tool/installaddon/yui/permcheck/permcheck.js
@@ -0,0 +1,105 @@
+/**
+ * Check for write permission for the selected plugin type
+ *
+ * @module moodle-tool_installaddon-permcheck
+ * @author David Mudrak
+ */
+YUI.add('moodle-tool_installaddon-permcheck', function(Y) {
+
+ M.tool_installaddon = M.tool_installaddon || {};
+
+ /**
+ * @class permcheck
+ * @static
+ */
+ M.tool_installaddon.permcheck = {
+
+ /**
+ * @method init
+ * @param {Object} config Configuration passed from the PHP
+ */
+ init : function(config) {
+ this.config = config;
+ var plugintypesel = Y.one('#tool_installaddon_installfromzip_plugintype');
+ if (plugintypesel) {
+ plugintypesel.on('change', this.check_for_permission, this);
+ }
+ },
+
+ /**
+ * @method check_for_permission
+ * @param {Event} e
+ */
+ check_for_permission : function(e) {
+ var plugintype = e.currentTarget.get('value');
+ if (plugintype == '') {
+ return;
+ }
+ Y.log('Selected plugin type: ' + plugintype, 'debug', 'moodle-tool_installaddon-permcheck');
+ Y.io(this.config.permcheckurl, {
+ 'method' : 'GET',
+ 'data' : {
+ 'sesskey' : M.cfg.sesskey,
+ 'plugintype' : plugintype
+ },
+ 'arguments' : {
+ 'plugintypeselector' : e.currentTarget,
+ 'showresult' : function(msg, status) {
+ var resultline = Y.one('#tool_installaddon_installfromzip_permcheck');
+ if (resultline) {
+ if (status === 'success') {
+ resultline.setContent('
' +
+ msg + '');
+ } else if (status === 'progress') {
+ resultline.setContent('
' +
+ msg + '');
+ } else {
+ resultline.setContent('
' +
+ msg + '');
+ }
+ }
+ }
+ },
+ 'on' : {
+ 'start' : function(transid, args) {
+ args.showresult(M.util.get_string('permcheckprogress', 'tool_installaddon'), 'progress');
+ },
+ 'success': function(transid, outcome, args) {
+ var response;
+ try {
+ response = Y.JSON.parse(outcome.responseText);
+ if (response.error) {
+ Y.log(response.error, 'error', 'moodle-tool_installaddon-permcheck');
+ args.showresult(M.util.get_string('permcheckerror', 'tool_installaddon', response), 'error');
+ } else if (response.path && response.writable == 1) {
+ args.showresult(M.util.get_string('permcheckresultyes', 'tool_installaddon', response), 'success');
+ } else if (response.path && response.writable == 0) {
+ args.showresult(M.util.get_string('permcheckresultno', 'tool_installaddon', response), 'error');
+ } else {
+ Y.log(response, 'debug', 'moodle-tool_installaddon-permcheck');
+ args.showresult(M.util.get_string('permcheckerror', 'tool_installaddon', response), 'error');
+ }
+
+ } catch (e) {
+ Y.log(e, 'error', 'moodle-tool_installaddon-permcheck');
+ args.showresult(M.util.get_string('permcheckerror', 'tool_installaddon'), 'error');
+ }
+ },
+ 'failure': function(transid, outcome, args) {
+ Y.log(outcome.statusText, 'error', 'moodle-tool_installaddon-permcheck');
+ args.showresult(M.util.get_string('permcheckerror', 'tool_installaddon'));
+ }
+ }
+ });
+ },
+
+ /**
+ * @property
+ * @type {Object}
+ */
+ config : null
+ };
+
+}, '@VERSION@', {
+ requires:['node', 'event', 'io-base']
+});
diff --git a/config-dist.php b/config-dist.php
index c9ce7613b08..7653437eb6a 100644
--- a/config-dist.php
+++ b/config-dist.php
@@ -438,6 +438,16 @@ $CFG->admin = 'admin';
//
// $CFG->disableupdatenotifications = true;
//
+// Use the following flag to completely disable the Automatic updates deployment
+// feature and hide it from the server administration UI.
+//
+// $CFG->disableupdateautodeploy = true;
+//
+// Use the following flag to completely disable the On-click add-on installation
+// feature and hide it from the server administration UI.
+//
+// $CFG->disableonclickaddoninstall = true;
+//
// As of version 2.4 Moodle serves icons as SVG images if the users browser appears
// to support SVG.
// For those wanting to control the serving of SVG images the following setting can
diff --git a/lib/pluginlib.php b/lib/pluginlib.php
index 5b23b23f989..7aa8d3156ff 100644
--- a/lib/pluginlib.php
+++ b/lib/pluginlib.php
@@ -96,6 +96,17 @@ class plugin_manager {
}
}
+ /**
+ * Returns the result of {@link get_plugin_types()} ordered for humans
+ *
+ * @see self::reorder_plugin_types()
+ * @param bool $fullpaths false means relative paths from dirroot
+ * @return array (string)name => (string)location
+ */
+ public function get_plugin_types($fullpaths = true) {
+ return $this->reorder_plugin_types(get_plugin_types($fullpaths));
+ }
+
/**
* Returns a tree of known plugins and information about them
*
@@ -119,8 +130,7 @@ class plugin_manager {
}
}
$this->pluginsinfo = array();
- $plugintypes = get_plugin_types();
- $plugintypes = $this->reorder_plugin_types($plugintypes);
+ $plugintypes = $this->get_plugin_types();
foreach ($plugintypes as $plugintype => $plugintyperootdir) {
if (in_array($plugintype, array('base', 'general'))) {
throw new coding_exception('Illegal usage of reserved word for plugin type');
@@ -219,6 +229,35 @@ class plugin_manager {
return $this->pluginsinfo[$type][$name]->displayname;
}
+ /**
+ * Returns a localized name of a plugin typed in singular form
+ *
+ * Most plugin types define their names in core_plugin lang file. In case of subplugins,
+ * we try to ask the parent plugin for the name. In the worst case, we will return
+ * the value of the passed $type parameter.
+ *
+ * @param string $type the type of the plugin, e.g. mod or workshopform
+ * @return string
+ */
+ public function plugintype_name($type) {
+
+ if (get_string_manager()->string_exists('type_' . $type, 'core_plugin')) {
+ // for most plugin types, their names are defined in core_plugin lang file
+ return get_string('type_' . $type, 'core_plugin');
+
+ } else if ($parent = $this->get_parent_of_subplugin($type)) {
+ // if this is a subplugin, try to ask the parent plugin for the name
+ if (get_string_manager()->string_exists('subplugintype_' . $type, $parent)) {
+ return $this->plugin_name($parent) . ' / ' . get_string('subplugintype_' . $type, $parent);
+ } else {
+ return $this->plugin_name($parent) . ' / ' . $type;
+ }
+
+ } else {
+ return $type;
+ }
+ }
+
/**
* Returns a localized name of a plugin type in plural form
*
@@ -570,9 +609,10 @@ class plugin_manager {
),
'tool' => array(
- 'assignmentupgrade', 'behat', 'capability', 'customlang', 'dbtransfer',
- 'generator', 'health', 'innodb', 'langimport', 'multilangupgrade', 'phpunit',
- 'profiling', 'qeupgradehelper', 'replace', 'spamcleaner', 'timezoneimport',
+ 'assignmentupgrade', 'behat', 'capability', 'customlang',
+ 'dbtransfer', 'generator', 'health', 'innodb', 'installaddon',
+ 'langimport', 'multilangupgrade', 'phpunit', 'profiling',
+ 'qeupgradehelper', 'replace', 'spamcleaner', 'timezoneimport',
'unittest', 'uploaduser', 'unsuproles', 'xmldb'
),
diff --git a/mdeploy.php b/mdeploy.php
index 303d3d1a442..6c54d59f934 100644
--- a/mdeploy.php
+++ b/mdeploy.php
@@ -18,14 +18,40 @@
/**
* Moodle deployment utility
*
- * This script looks after deploying available updates to the local Moodle site.
+ * This script looks after deploying new add-ons and available updates for them
+ * to the local Moodle site. It can operate via both HTTP and CLI mode.
+ * Moodle itself calls this utility via the HTTP mode when the admin is about to
+ * install or update an add-on. You can use the CLI mode in your custom deployment
+ * shell scripts.
*
* CLI usage example:
+ *
+ * $ sudo -u apache php mdeploy.php --install \
+ * --package=https://moodle.org/plugins/download.php/...zip \
+ * --typeroot=/var/www/moodle/htdocs/blocks
+ * --name=loancalc
+ * --md5=...
+ * --dataroot=/var/www/moodle/data
+ *
* $ sudo -u apache php mdeploy.php --upgrade \
* --package=https://moodle.org/plugins/download.php/...zip \
- * --dataroot=/home/mudrd8mz/moodledata/moodle24
+ * --typeroot=/var/www/moodle/htdocs/blocks
+ * --name=loancalc
+ * --md5=...
+ * --dataroot=/var/www/moodle/data
+ *
+ * When called via HTTP, additional parameters returnurl, passfile and password must be
+ * provided. Optional proxy configuration can be passed using parameters proxy, proxytype
+ * and proxyuserpwd.
+ *
+ * Changes
+ *
+ * 1.1 - Added support to install a new plugin from the Moodle Plugins directory.
+ * 1.0 - Initial version used in Moodle 2.4 to deploy available updates.
*
* @package core
+ * @subpackage mdeploy
+ * @version 1.1
* @copyright 2012 David Mudrak
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
@@ -775,7 +801,53 @@ class worker extends singleton_pattern {
$this->done();
} else if ($this->input->get_option('install')) {
- // Installing a new plugin not implemented yet.
+ $this->log('Plugin installation requested');
+
+ $plugintyperoot = $this->input->get_option('typeroot');
+ $pluginname = $this->input->get_option('name');
+ $source = $this->input->get_option('package');
+ $md5remote = $this->input->get_option('md5');
+
+ // Check if the plugin location if available for us.
+ $pluginlocation = $plugintyperoot.'/'.$pluginname;
+
+ $this->log('New plugin code location: '.$pluginlocation);
+
+ if (file_exists($pluginlocation)) {
+ throw new filesystem_exception('Unable to prepare the plugin location (directory already exists)');
+ }
+
+ if (!$this->create_directory_precheck($pluginlocation)) {
+ throw new filesystem_exception('Unable to prepare the plugin location (cannot create new directory)');
+ }
+
+ // Fetch the ZIP file into a temporary location.
+ $target = $this->target_location($source);
+ $this->log('Downloading package '.$source);
+
+ if ($this->download_file($source, $target)) {
+ $this->log('Package downloaded into '.$target);
+ } else {
+ $this->log('cURL error ' . $this->curlerrno . ' ' . $this->curlerror);
+ $this->log('Unable to download the file');
+ throw new download_file_exception('Unable to download the package');
+ }
+
+ // Compare MD5 checksum of the ZIP file
+ $md5local = md5_file($target);
+
+ if ($md5local !== $md5remote) {
+ $this->log('MD5 checksum failed. Expected: '.$md5remote.' Got: '.$md5local);
+ throw new checksum_exception('MD5 checksum failed');
+ }
+ $this->log('MD5 checksum ok');
+
+ // Unzip the plugin package file into the plugin location.
+ $this->unzip_plugin($target, $plugintyperoot, $pluginlocation, false);
+ $this->log('Package successfully extracted');
+
+ // Redirect to the given URL (in HTTP) or exit (in CLI).
+ $this->done();
}
// Print help in CLI by default.
@@ -1126,18 +1198,36 @@ class worker extends singleton_pattern {
}
/**
- * Checks to see if a source foldr could be safely moved into the given new location
+ * Checks to see if a source folder could be safely moved into the given new location
*
* @param string $destination full path to the new expected location of a folder
* @return bool
*/
protected function move_directory_target_precheck($target) {
- if (file_exists($target)) {
+ // Check if the target folder does not exist yet, can be created
+ // and removed again.
+ $result = $this->create_directory_precheck($target);
+
+ // At the moment, it seems to be enough to check. We may want to add
+ // more steps in the future.
+
+ return $result;
+ }
+
+ /**
+ * Make sure the given directory can be created (and removed)
+ *
+ * @param string $path full path to the folder
+ * @return bool
+ */
+ protected function create_directory_precheck($path) {
+
+ if (file_exists($path)) {
return false;
}
- $result = mkdir($target, 02777) && rmdir($target);
+ $result = mkdir($path, 02777) && rmdir($path);
return $result;
}
@@ -1265,7 +1355,7 @@ class worker extends singleton_pattern {
* @param string $ziplocation full path to the ZIP file
* @param string $plugintyperoot full path to the plugin's type location
* @param string $expectedlocation expected full path to the plugin after it is extracted
- * @param string $backuplocation location of the previous version of the plugin
+ * @param string|bool $backuplocation location of the previous version of the plugin or false for no backup
*/
protected function unzip_plugin($ziplocation, $plugintyperoot, $expectedlocation, $backuplocation) {
@@ -1273,7 +1363,9 @@ class worker extends singleton_pattern {
$result = $zip->open($ziplocation);
if ($result !== true) {
- $this->move_directory($backuplocation, $expectedlocation);
+ if ($backuplocation !== false) {
+ $this->move_directory($backuplocation, $expectedlocation);
+ }
throw new zip_exception('Unable to open the zip package');
}
@@ -1292,7 +1384,9 @@ class worker extends singleton_pattern {
if (!$zip->extractTo($plugintyperoot)) {
$zip->close();
$this->remove_directory($expectedlocation, true); // just in case something was created
- $this->move_directory_into($backuplocation, $expectedlocation);
+ if ($backuplocation !== false) {
+ $this->move_directory_into($backuplocation, $expectedlocation);
+ }
throw new zip_exception('Unable to extract the zip package');
}
diff --git a/mdeploytest.php b/mdeploytest.php
index 76800ebadb7..7fb81c59231 100644
--- a/mdeploytest.php
+++ b/mdeploytest.php
@@ -106,6 +106,13 @@ class testable_worker extends worker {
public function remove_directory($path, $keeppathroot = false) {
return parent::remove_directory($path, $keeppathroot);
}
+
+ /**
+ * Provides access to the protected method.
+ */
+ public function create_directory_precheck($path) {
+ return parent::create_directory_precheck($path);
+ }
}
@@ -284,4 +291,13 @@ class mdeploytest extends PHPUnit_Framework_TestCase {
$this->assertTrue($worker->remove_directory($root.'/c'));
$this->assertFalse(is_dir($root.'/c'));
}
+
+ public function test_create_directory_precheck() {
+ $worker = testable_worker::instance();
+
+ $root = sys_get_temp_dir().'/'.uniqid('mdeploytest', true);
+ $this->assertFalse(file_exists($root));
+ $this->assertTrue($worker->create_directory_precheck($root));
+ $this->assertFalse(file_exists($root)); // The precheck is supposed to remove it again.
+ }
}