2dcbe2b894f3097e5149fd70f788e80bde89f870
[moodle.git] / lib / form / classes / filetypes_util.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Provides the {@link core_form\filetypes_util} class.
19  *
20  * @package     core_form
21  * @copyright   2017 David Mudr├ík <david@moodle.com>
22  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace core_form;
27 use core_collator;
28 use core_filetypes;
29 use core_text;
31 defined('MOODLE_INTERNAL') || die();
33 /**
34  * Utility class for handling with file types in the forms.
35  *
36  * This class is supposed to serve as a helper class for {@link MoodleQuickForm_filetypes}
37  * and {@link admin_setting_filetypes} classes.
38  *
39  * The file types can be specified in a syntax compatible with what filepicker
40  * and filemanager support via the "accepted_types" option: a list of extensions
41  * (e.g. ".doc"), mimetypes ("image/png") or groups ("audio").
42  *
43  * @copyright 2017 David Mudrak <david@moodle.com>
44  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
45  */
46 class filetypes_util {
48     /** @var array Cache of all file type groups for the {@link self::get_groups_info()}. */
49     protected $cachegroups = null;
51     /**
52      * Converts the argument into an array (list) of file types.
53      *
54      * The list can be separated by whitespace, end of lines, commas, colons and semicolons.
55      * Empty values are not returned. Values are converted to lowercase.
56      * Duplicates are removed. Glob evaluation is not supported.
57      *
58      * The return value can be used as the accepted_types option for the filepicker.
59      *
60      * @param string|array $types List of file extensions, groups or mimetypes
61      * @return array of strings
62      */
63     public function normalize_file_types($types) {
65         if ($types === '') {
66             return [];
67         }
69         // Turn string into a list.
70         if (!is_array($types)) {
71             $types = preg_split('/[\s,;:"\']+/', $types, null, PREG_SPLIT_NO_EMPTY);
72         }
74         // Fix whitespace and normalize the syntax a bit.
75         foreach ($types as $i => $type) {
76             $type = str_replace('*.', '.', $type);
77             $type = core_text::strtolower($type);
78             $type = trim($type);
80             if ($type === '*') {
81                 return ['*'];
82             }
84             $types[$i] = $type;
85         }
87         // Do not make the user think that globs (like ".doc?") would work.
88         foreach ($types as $i => $type) {
89             if (strpos($type, '*') !== false or strpos($type, '?') !== false) {
90                 unset($types[$i]);
91             }
92         }
94         foreach ($types as $i => $type) {
95             if (substr($type, 0, 1) === '.') {
96                 // It looks like an extension.
97                 $type = '.'.ltrim($type, '.');
98                 $types[$i] = clean_param($type, PARAM_FILE);
99             } else if ($this->looks_like_mimetype($type)) {
100                 // All good, it looks like a mimetype.
101                 continue;
102             } else if ($this->is_filetype_group($type)) {
103                 // All good, it is a known type group.
104                 continue;
105             } else {
106                 // We assume the user typed something like "png" so we consider
107                 // it an extension.
108                 $types[$i] = '.'.$type;
109             }
110         }
112         $types = array_filter($types, 'strlen');
113         $types = array_keys(array_flip($types));
115         return $types;
116     }
118     /**
119      * Does the given file type looks like a valid MIME type?
120      *
121      * This does not check of the MIME type is actually registered here/known.
122      *
123      * @param string $type
124      * @return bool
125      */
126     public function looks_like_mimetype($type) {
127         return (bool)preg_match('~^[-\.a-z0-9]+/[a-z0-9]+([-\.\+][a-z0-9]+)*$~', $type);
128     }
130     /**
131      * Is the given string a known filetype group?
132      *
133      * @param string $type
134      * @return bool|object false or the group info
135      */
136     public function is_filetype_group($type) {
138         $info = $this->get_groups_info();
140         if (isset($info[$type])) {
141             return $info[$type];
143         } else {
144             return false;
145         }
146     }
148     /**
149      * Provides a list of all known file type groups and their properties.
150      *
151      * @return array
152      */
153     public function get_groups_info() {
155         if ($this->cachegroups !== null) {
156             return $this->cachegroups;
157         }
159         $groups = [];
161         foreach (core_filetypes::get_types() as $ext => $info) {
162             if (isset($info['groups']) && is_array($info['groups'])) {
163                 foreach ($info['groups'] as $group) {
164                     if (!isset($groups[$group])) {
165                         $groups[$group] = (object) [
166                             'extensions' => [],
167                             'mimetypes' => [],
168                         ];
169                     }
170                     $groups[$group]->extensions['.'.$ext] = true;
171                     if (isset($info['type'])) {
172                         $groups[$group]->mimetypes[$info['type']] = true;
173                     }
174                 }
175             }
176         }
178         foreach ($groups as $group => $info) {
179             $info->extensions = array_keys($info->extensions);
180             $info->mimetypes = array_keys($info->mimetypes);
181         }
183         $this->cachegroups = $groups;
184         return $this->cachegroups;
185     }
187     /**
188      * Return a human readable name of the filetype group.
189      *
190      * @param string $group
191      * @return string
192      */
193     public function get_group_description($group) {
195         if (get_string_manager()->string_exists('group:'.$group, 'core_mimetypes')) {
196             return get_string('group:'.$group, 'core_mimetypes');
197         } else {
198             return s($group);
199         }
200     }
202     /**
203      * Describe the list of file types for human user.
204      *
205      * Given the list of file types, return a list of human readable
206      * descriptive names of relevant groups, types or file formats.
207      *
208      * @param string|array $types
209      * @return object
210      */
211     public function describe_file_types($types) {
213         $descriptions = [];
214         $types = $this->normalize_file_types($types);
216         foreach ($types as $type) {
217             if ($type === '*') {
218                 $desc = get_string('filetypesany', 'core_form');
219                 $descriptions[$desc] = [];
220             } else if ($group = $this->is_filetype_group($type)) {
221                 $desc = $this->get_group_description($type);
222                 $descriptions[$desc] = $group->extensions;
224             } else if ($this->looks_like_mimetype($type)) {
225                 $desc = get_mimetype_description($type);
226                 $descriptions[$desc] = file_get_typegroup('extension', [$type]);
228             } else {
229                 $desc = get_mimetype_description(['filename' => 'fakefile'.$type]);
230                 if (isset($descriptions[$desc])) {
231                     $descriptions[$desc][] = $type;
232                 } else {
233                     $descriptions[$desc] = [$type];
234                 }
235             }
236         }
238         $data = [];
240         foreach ($descriptions as $desc => $exts) {
241             sort($exts);
242             $data[] = (object)[
243                 'description' => $desc,
244                 'extensions' => join(' ', $exts),
245             ];
246         }
248         core_collator::asort_objects_by_property($data, 'description', core_collator::SORT_NATURAL);
250         return (object)[
251             'hasdescriptions' => !empty($data),
252             'descriptions' => array_values($data),
253         ];
254     }
256     /**
257      * Prepares data for the filetypes-browser.mustache
258      *
259      * @param string|array $onlytypes Allow selection from these file types only; for example 'web_image'.
260      * @param bool $allowall Allow to select 'All file types'. Does not apply with onlytypes are set.
261      * @param string|array $current Current values that should be selected.
262      * @return object
263      */
264     public function data_for_browser($onlytypes=null, $allowall=true, $current=null) {
266         $groups = [];
267         $current = $this->normalize_file_types($current);
269         // Firstly populate the tree of extensions categorized into groups.
271         foreach ($this->get_groups_info() as $groupkey => $groupinfo) {
272             if (empty($groupinfo->extensions)) {
273                 continue;
274             }
276             $group = (object) [
277                 'key' => $groupkey,
278                 'name' => $this->get_group_description($groupkey),
279                 'selectable' => true,
280                 'selected' => in_array($groupkey, $current),
281                 'ext' => implode(' ', $groupinfo->extensions),
282                 'expanded' => false,
283             ];
285             $types = [];
287             foreach ($groupinfo->extensions as $extension) {
288                 if ($onlytypes && !$this->is_whitelisted($extension, $onlytypes)) {
289                     $group->selectable = false;
290                     $group->expanded = true;
291                     $group->ext = '';
292                     continue;
293                 }
295                 $desc = get_mimetype_description(['filename' => 'fakefile'.$extension]);
297                 if ($selected = in_array($extension, $current)) {
298                     $group->expanded = true;
299                 }
301                 $types[] = (object) [
302                     'key' => $extension,
303                     'name' => get_mimetype_description(['filename' => 'fakefile'.$extension]),
304                     'selected' => $selected,
305                     'ext' => $extension,
306                 ];
307             }
309             if (empty($types)) {
310                 continue;
311             }
313             core_collator::asort_objects_by_property($types, 'name', core_collator::SORT_NATURAL);
315             $group->types = array_values($types);
316             $groups[] = $group;
317         }
319         core_collator::asort_objects_by_property($groups, 'name', core_collator::SORT_NATURAL);
321         // Append all other uncategorized extensions.
323         $others = [];
325         foreach (core_filetypes::get_types() as $extension => $info) {
326             // Reserved for unknown file types. Not available here.
327             if ($extension === 'xxx') {
328                 continue;
329             }
330             $extension = '.'.$extension;
331             if ($onlytypes && !$this->is_whitelisted($extension, $onlytypes)) {
332                 continue;
333             }
334             if (!isset($info['groups']) || empty($info['groups'])) {
335                 $others[] = (object) [
336                     'key' => $extension,
337                     'name' => get_mimetype_description(['filename' => 'fakefile'.$extension]),
338                     'selected' => in_array($extension, $current),
339                     'ext' => $extension,
340                 ];
341             }
342         }
344         core_collator::asort_objects_by_property($others, 'name', core_collator::SORT_NATURAL);
346         if (!empty($others)) {
347             $groups[] = (object) [
348                 'key' => '',
349                 'name' => get_string('filetypesothers', 'core_form'),
350                 'selectable' => false,
351                 'selected' => false,
352                 'ext' => '',
353                 'types' => array_values($others),
354                 'expanded' => true,
355             ];
356         }
358         if (empty($onlytypes) and $allowall) {
359             array_unshift($groups, (object) [
360                 'key' => '*',
361                 'name' => get_string('filetypesany', 'core_form'),
362                 'selectable' => true,
363                 'selected' => in_array('*', $current),
364                 'ext' => null,
365                 'types' => [],
366                 'expanded' => false,
367             ]);
368         }
370         $groups = array_values($groups);
372         return $groups;
373     }
375     /**
376      * Expands the file types into the list of file extensions.
377      *
378      * The groups and mimetypes are expanded into the list of their associated file
379      * extensions. Depending on the $keepgroups and $keepmimetypes, the groups
380      * and mimetypes themselves are either kept in the list or removed.
381      *
382      * @param string|array $types
383      * @param bool $keepgroups Keep the group item in the list after expansion
384      * @param bool $keepmimetypes Keep the mimetype item in the list after expansion
385      * @return array list of extensions and eventually groups and types
386      */
387     public function expand($types, $keepgroups=false, $keepmimetypes=false) {
389         $expanded = [];
391         foreach ($this->normalize_file_types($types) as $type) {
392             if ($group = $this->is_filetype_group($type)) {
393                 foreach ($group->extensions as $ext) {
394                     $expanded[$ext] = true;
395                 }
396                 if ($keepgroups) {
397                     $expanded[$type] = true;
398                 }
400             } else if ($this->looks_like_mimetype($type)) {
401                 // A mime type expands to the associated extensions.
402                 foreach (file_get_typegroup('extension', [$type]) as $ext) {
403                     $expanded[$ext] = true;
404                 }
405                 if ($keepmimetypes) {
406                     $expanded[$type] = true;
407                 }
409             } else {
410                 // Single extension expands to itself.
411                 $expanded[$type] = true;
412             }
413         }
415         return array_keys($expanded);
416     }
418     /**
419      * Should the given file type be considered as a part of the given whitelist.
420      *
421      * If multiple types are provided, all of them must be part of the
422      * whitelist. Empty type is part of any whitelist. Any type is part of an
423      * empty whitelist.
424      *
425      * @param string|array $types File types to be checked
426      * @param string|array $whitelist An array or string of whitelisted types
427      * @return boolean
428      */
429     public function is_whitelisted($types, $whitelist) {
430         return empty($this->get_not_whitelisted($types, $whitelist));
431     }
433     /**
434      * Returns all types that are not part of the give whitelist.
435      *
436      * This is similar check to the {@link self::is_whitelisted()} but this one
437      * actually returns the extra types.
438      *
439      * @param string|array $types File types to be checked
440      * @param string|array $whitelist An array or string of whitelisted types
441      * @return array Types not present in the whitelist
442      */
443     public function get_not_whitelisted($types, $whitelist) {
445         $whitelistedtypes = $this->expand($whitelist, true, true);
447         if (empty($whitelistedtypes) || $whitelistedtypes == ['*']) {
448             return [];
449         }
451         $giventypes = $this->normalize_file_types($types);
453         if (empty($giventypes)) {
454             return [];
455         }
457         return array_diff($giventypes, $whitelistedtypes);
458     }
460     /**
461      * Is the given filename of an allowed file type?
462      *
463      * Empty whitelist is interpretted as "any file type is allowed" rather
464      * than "no file can be uploaded".
465      *
466      * @param string $filename the file name
467      * @param string|array $whitelist list of allowed file extensions
468      * @return boolean True if the file type is allowed, false if not
469      */
470     public function is_allowed_file_type($filename, $whitelist) {
472         $allowedextensions = $this->expand($whitelist);
474         if (empty($allowedextensions) || $allowedextensions == ['*']) {
475             return true;
476         }
478         $haystack = strrev(trim(core_text::strtolower($filename)));
480         foreach ($allowedextensions as $extension) {
481             if (strpos($haystack, strrev($extension)) === 0) {
482                 // The file name ends with the extension.
483                 return true;
484             }
485         }
487         return false;
488     }
490     /**
491      * Returns file types from the list that are not recognized
492      *
493      * @param string|array $types list of user-defined file types
494      * @return array A list of unknown file types.
495      */
496     public function get_unknown_file_types($types) {
497         $unknown = [];
499         foreach ($this->normalize_file_types($types) as $type) {
500             if ($type === '*') {
501                 // Any file is considered as a known type.
502                 continue;
503             } else if ($type === '.xxx') {
504                 $unknown[$type] = true;
505             } else if ($this->is_filetype_group($type)) {
506                 // The type is a group that exists.
507                 continue;
508             } else if ($this->looks_like_mimetype($type)) {
509                 // If there's no extension associated with that mimetype, we consider it unknown.
510                 if (empty(file_get_typegroup('extension', [$type]))) {
511                     $unknown[$type] = true;
512                 }
513             } else {
514                 $coretypes = core_filetypes::get_types();
515                 $typecleaned = str_replace(".", "", $type);
516                 if (empty($coretypes[$typecleaned])) {
517                     // If there's no extension, it doesn't exist.
518                     $unknown[$type] = true;
519                 }
520             }
521         }
523         return array_keys($unknown);
524     }