diff --git a/admin/templates/setting_filetypes.mustache b/admin/templates/setting_filetypes.mustache
new file mode 100644
index 00000000000..7075ea25a4b
--- /dev/null
+++ b/admin/templates/setting_filetypes.mustache
@@ -0,0 +1,52 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template core_admin/setting_filetypes
+
+ Renders the admin_setting_filetypes setting element.
+
+ Context variables required for this template:
+ * id - element id
+ * name - form element name
+ * value - element value
+ * descriptions - data for the core_form/filetypes-descriptions template
+
+ Example context (json):
+ {
+ "id": "test0",
+ "name": "test",
+ "value": ".jpg,.gif",
+ "descriptions": {
+ "hasdescriptions": true,
+ "descriptions": [
+ {
+ "description": "Image (JPEG)",
+ "extensions": ".jpeg .jpe .jpg"
+ },
+ {
+ "description": "Image (GIF)",
+ "extensions": ".gif"
+ }
+ ]
+ }
+ }
+}}
+
diff --git a/lang/en/form.php b/lang/en/form.php
index 79ea736f429..2ed6cbf733c 100644
--- a/lang/en/form.php
+++ b/lang/en/form.php
@@ -42,6 +42,7 @@ $string['err_numeric'] = 'You must enter a number here.';
$string['err_rangelength'] = 'You must enter between {$a->format[0]} and {$a->format[1]} characters here.';
$string['err_required'] = 'You must supply a value here.';
$string['filetypesany'] = 'All file types';
+$string['filetypesnotwhitelisted'] = 'These file types are not allowed here: {$a}';
$string['filetypesothers'] = 'Other files';
$string['general'] = 'General';
$string['hideadvanced'] = 'Hide advanced';
diff --git a/lib/adminlib.php b/lib/adminlib.php
index 74a92b359be..7fc7d302fca 100644
--- a/lib/adminlib.php
+++ b/lib/adminlib.php
@@ -10519,3 +10519,121 @@ class admin_setting_scsscode extends admin_setting_configtextarea {
return true;
}
}
+
+
+/**
+ * Administration setting to define a list of file types.
+ *
+ * @copyright 2016 Jonathon Fowler
+ * @copyright 2017 David Mudrák
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class admin_setting_filetypes extends admin_setting_configtext {
+
+ /** @var array Allow selection from these file types only. */
+ protected $onlytypes = [];
+
+ /** @var bool Allow selection of 'All file types' (will be stored as '*'). */
+ protected $allowall = true;
+
+ /** @var core_form\filetypes_util instance to use as a helper. */
+ protected $util = null;
+
+ /**
+ * Constructor.
+ *
+ * @param string $name Unique ascii name like 'mycoresetting' or 'myplugin/mysetting'
+ * @param string $visiblename Localised label of the setting
+ * @param string $description Localised description of the setting
+ * @param string $defaultsetting Default setting value.
+ * @param array $options Setting widget options, an array with optional keys:
+ * 'onlytypes' => array Allow selection from these file types only; for example ['onlytypes' => ['web_image']].
+ * 'allowall' => bool Allow to select 'All file types', defaults to true. Does not apply if onlytypes are set.
+ */
+ public function __construct($name, $visiblename, $description, $defaultsetting = '', array $options = []) {
+
+ parent::__construct($name, $visiblename, $description, $defaultsetting, PARAM_RAW);
+
+ if (array_key_exists('onlytypes', $options) && is_array($options['onlytypes'])) {
+ $this->onlytypes = $options['onlytypes'];
+ }
+
+ if (!$this->onlytypes && array_key_exists('allowall', $options)) {
+ $this->allowall = (bool)$options['allowall'];
+ }
+
+ $this->util = new \core_form\filetypes_util();
+ }
+
+ /**
+ * Normalize the user's input and write it to the database as comma separated list.
+ *
+ * Comma separated list as a text representation of the array was chosen to
+ * make this compatible with how the $CFG->courseoverviewfilesext values are stored.
+ *
+ * @param string $data Value submitted by the admin.
+ * @return string Epty string if all good, error message otherwise.
+ */
+ public function write_setting($data) {
+ return parent::write_setting(implode(',', $this->util->normalize_file_types($data)));
+ }
+
+ /**
+ * Validate data before storage
+ *
+ * @param string $data The setting values provided by the admin
+ * @return bool|string True if ok, the string if error found
+ */
+ public function validate($data) {
+
+ // No need to call parent's validation here as we are PARAM_RAW.
+
+ if ($this->util->is_whitelisted($data, $this->onlytypes)) {
+ return true;
+
+ } else {
+ $troublemakers = $this->util->get_not_whitelisted($data, $this->onlytypes);
+ return get_string('filetypesnotwhitelisted', 'core_form', implode(' ', $troublemakers));
+ }
+ }
+
+ /**
+ * Return an HTML string for the setting element.
+ *
+ * @param string $data The current setting value
+ * @param string $query Admin search query to be highlighted
+ * @return string HTML to be displayed
+ */
+ public function output_html($data, $query='') {
+ global $OUTPUT, $PAGE;
+
+ $default = $this->get_defaultsetting();
+ $context = (object) [
+ 'id' => $this->get_id(),
+ 'name' => $this->get_full_name(),
+ 'value' => $data,
+ 'descriptions' => $this->util->describe_file_types($data),
+ ];
+ $element = $OUTPUT->render_from_template('core_admin/setting_filetypes', $context);
+
+ $PAGE->requires->js_call_amd('core_form/filetypes', 'init', [
+ $this->get_id(),
+ $this->visiblename,
+ $this->onlytypes,
+ $this->allowall,
+ ]);
+
+ return format_admin_setting($this, $this->visiblename, $element, $this->description, true, '', $default, $query);
+ }
+
+ /**
+ * Should the values be always displayed in LTR mode?
+ *
+ * We always return true here because these values are not RTL compatible.
+ *
+ * @return bool True because these values are not RTL compatible.
+ */
+ public function get_force_ltr() {
+ return true;
+ }
+}
diff --git a/lib/form/classes/filetypes_util.php b/lib/form/classes/filetypes_util.php
index dc686e2902c..51276edb56f 100644
--- a/lib/form/classes/filetypes_util.php
+++ b/lib/form/classes/filetypes_util.php
@@ -414,25 +414,42 @@ class filetypes_util {
* Should the given file type be considered as a part of the given whitelist.
*
* If multiple types are provided, all of them must be part of the
- * whitelist.
+ * whitelist. Empty type is part of any whitelist. Any type is part of an
+ * empty whitelist.
*
- * @param string $types One or more types in a string (space , or ; separated)
- * @param string|array $whitelist an array or string of whitelisted types
+ * @param string|array $types File types to be checked
+ * @param string|array $whitelist An array or string of whitelisted types
* @return boolean
*/
public function is_whitelisted($types, $whitelist) {
+ return empty($this->get_not_whitelisted($types, $whitelist));
+ }
+
+ /**
+ * Returns all types that are not part of the give whitelist.
+ *
+ * This is similar check to the {@link self::is_whitelisted()} but this one
+ * actually returns the extra types.
+ *
+ * @param string|array $types File types to be checked
+ * @param string|array $whitelist An array or string of whitelisted types
+ * @return array Types not present in the whitelist
+ */
+ public function get_not_whitelisted($types, $whitelist) {
$whitelistedtypes = $this->expand($whitelist, true, true);
if (empty($whitelistedtypes) || $whitelistedtypes == ['*']) {
- return true;
+ return [];
}
$giventypes = $this->normalize_file_types($types);
- $intersection = array_intersect($giventypes, $whitelistedtypes);
+ if (empty($giventypes)) {
+ return [];
+ }
- return !empty($intersection);
+ return array_diff($giventypes, $whitelistedtypes);
}
/**
diff --git a/lib/form/tests/filetypes_util_test.php b/lib/form/tests/filetypes_util_test.php
index 45dec2f9500..d5419cd519a 100644
--- a/lib/form/tests/filetypes_util_test.php
+++ b/lib/form/tests/filetypes_util_test.php
@@ -219,6 +219,7 @@ class filetypes_util_testcase extends advanced_testcase {
$this->assertTrue($util->is_whitelisted('audio', 'text/plain audio video'));
$this->assertTrue($util->is_whitelisted('text/plain', 'text/plain audio video'));
$this->assertTrue($util->is_whitelisted('jpg jpe jpeg', 'image/jpeg'));
+ $this->assertTrue($util->is_whitelisted(['jpg', 'jpe', '.png'], 'image'));
// These should be intuitively false.
$this->assertFalse($util->is_whitelisted('.gif', 'text/plain'));
@@ -229,9 +230,44 @@ class filetypes_util_testcase extends advanced_testcase {
// Not all documents (and also the group itself) is not a plain text.
$this->assertFalse($util->is_whitelisted('document', 'text/plain'));
+ // This may look wrong at the first sight as you might expect that the
+ // mimetype should simply map to an extension ...
+ $this->assertFalse($util->is_whitelisted('image/jpeg', '.jpg'));
+
+ // But it is principally same situation as this (there is no 1:1 mapping).
+ $this->assertFalse($util->is_whitelisted('.c', '.txt'));
+ $this->assertTrue($util->is_whitelisted('.txt .c', 'text/plain'));
+ $this->assertFalse($util->is_whitelisted('text/plain', '.c'));
+
// Any type is included if the filter is empty.
$this->assertTrue($util->is_whitelisted('txt', ''));
$this->assertTrue($util->is_whitelisted('txt', '*'));
+
+ // Empty value is part of any whitelist.
+ $this->assertTrue($util->is_whitelisted('', '.txt'));
+ }
+
+ /**
+ * Test getting types not present in a whitelist.
+ */
+ public function test_get_not_whitelisted() {
+
+ $this->resetAfterTest(true);
+ $util = new filetypes_util();
+
+ $this->assertEmpty($util->get_not_whitelisted('txt', 'text/plain'));
+ $this->assertEmpty($util->get_not_whitelisted('txt', '.doc .txt .rtf'));
+ $this->assertEmpty($util->get_not_whitelisted('txt', 'text/plain'));
+ $this->assertEmpty($util->get_not_whitelisted(['jpg', 'jpe', 'jpeg'], 'image/jpeg'));
+ $this->assertEmpty($util->get_not_whitelisted('', 'foo/bar'));
+ $this->assertEmpty($util->get_not_whitelisted('.foobar', ''));
+ $this->assertEmpty($util->get_not_whitelisted('.foobar', '*'));
+
+ // Returned list is normalized so extensions have the dot added.
+ $this->assertContains('.exe', $util->get_not_whitelisted('exe', '.c .h'));
+
+ // If this looks wrong to you, see {@link test_is_whitelisted()} for more details on this behaviour.
+ $this->assertContains('image/jpeg', $util->get_not_whitelisted('image/jpeg', '.jpg .jpeg'));
}
/**
diff --git a/lib/upgrade.txt b/lib/upgrade.txt
index c923bfb4056..23520d7ee8f 100644
--- a/lib/upgrade.txt
+++ b/lib/upgrade.txt
@@ -1,6 +1,13 @@
This files describes API changes in core libraries and APIs,
information provided here is intended especially for developers.
+=== 3.4 ===
+
+* Added new moodleform element 'filetypes' and new admin setting widget 'admin_setting_filetypes'. These new widgets
+ allow users to define a list of file types; either by typing them manually or selecting them from a list. The widgets
+ directly support the syntax used to feed the 'accepted_types' option of the filemanager and filepicker elements. File
+ types can be specified as extensions (.jpg or just jpg), mime types (text/plain) or groups (image).
+
=== 3.3.1 ===
* ldap_get_entries_moodle() now always returns lower-cased attribute names in the returned entries.