MDL-41580 SCORM: allow imsmanifest.xml to be used in file system repos
[moodle.git] / repository / filesystem / lib.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  * This plugin is used to access files on server file system
19  *
20  * @since 2.0
21  * @package    repository_filesystem
22  * @copyright  2010 Dongsheng Cai {@link http://dongsheng.org}
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
25 require_once($CFG->dirroot . '/repository/lib.php');
26 require_once($CFG->libdir . '/filelib.php');
28 /**
29  * repository_filesystem class
30  *
31  * Create a repository from your local filesystem
32  * *NOTE* for security issue, we use a fixed repository path
33  * which is %moodledata%/repository
34  *
35  * @package    repository
36  * @copyright  2009 Dongsheng Cai {@link http://dongsheng.org}
37  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
38  */
39 class repository_filesystem extends repository {
41     /**
42      * Constructor
43      *
44      * @param int $repositoryid repository ID
45      * @param int $context context ID
46      * @param array $options
47      */
48     public function __construct($repositoryid, $context = SYSCONTEXTID, $options = array()) {
49         global $CFG;
50         parent::__construct($repositoryid, $context, $options);
51         $root = $CFG->dataroot . '/repository/';
52         $subdir = $this->get_option('fs_path');
54         $this->root_path = $root;
55         if (!empty($subdir)) {
56             $this->root_path .= $subdir . '/';
57         }
59         if (!empty($options['ajax'])) {
60             if (!is_dir($this->root_path)) {
61                 $created = mkdir($this->root_path, $CFG->directorypermissions, true);
62                 $ret = array();
63                 $ret['msg'] = get_string('invalidpath', 'repository_filesystem');
64                 $ret['nosearch'] = true;
65                 if ($options['ajax'] && !$created) {
66                     echo json_encode($ret);
67                     exit;
68                 }
69             }
70         }
71     }
72     public function get_listing($path = '', $page = '') {
73         global $CFG, $OUTPUT;
74         $list = array();
75         $list['list'] = array();
76         // process breacrumb trail
77         $list['path'] = array(
78             array('name'=>get_string('root', 'repository_filesystem'), 'path'=>'')
79         );
80         $trail = '';
81         if (!empty($path)) {
82             $parts = explode('/', $path);
83             if (count($parts) > 1) {
84                 foreach ($parts as $part) {
85                     if (!empty($part)) {
86                         $trail .= ('/'.$part);
87                         $list['path'][] = array('name'=>$part, 'path'=>$trail);
88                     }
89                 }
90             } else {
91                 $list['path'][] = array('name'=>$path, 'path'=>$path);
92             }
93             $this->root_path .= ($path.'/');
94         }
95         $list['manage'] = false;
96         $list['dynload'] = true;
97         $list['nologin'] = true;
98         $list['nosearch'] = true;
99         // retrieve list of files and directories and sort them
100         $fileslist = array();
101         $dirslist = array();
102         if ($dh = opendir($this->root_path)) {
103             while (($file = readdir($dh)) != false) {
104                 if ( $file != '.' and $file !='..') {
105                     if (is_file($this->root_path.$file)) {
106                         $fileslist[] = $file;
107                     } else {
108                         $dirslist[] = $file;
109                     }
110                 }
111             }
112         }
113         core_collator::asort($fileslist, core_collator::SORT_STRING);
114         core_collator::asort($dirslist, core_collator::SORT_STRING);
115         // fill the $list['list']
116         foreach ($dirslist as $file) {
117             if (!empty($path)) {
118                 $current_path = $path . '/'. $file;
119             } else {
120                 $current_path = $file;
121             }
122             $list['list'][] = array(
123                 'title' => $file,
124                 'children' => array(),
125                 'datecreated' => filectime($this->root_path.$file),
126                 'datemodified' => filemtime($this->root_path.$file),
127                 'thumbnail' => $OUTPUT->pix_url(file_folder_icon(90))->out(false),
128                 'path' => $current_path
129                 );
130         }
131         foreach ($fileslist as $file) {
132             $node = array(
133                 'title' => $file,
134                 'source' => $path.'/'.$file,
135                 'size' => filesize($this->root_path.$file),
136                 'datecreated' => filectime($this->root_path.$file),
137                 'datemodified' => filemtime($this->root_path.$file),
138                 'thumbnail' => $OUTPUT->pix_url(file_extension_icon($file, 90))->out(false),
139                 'icon' => $OUTPUT->pix_url(file_extension_icon($file, 24))->out(false)
140             );
141             if (file_extension_in_typegroup($file, 'image') && ($imageinfo = @getimagesize($this->root_path . $file))) {
142                 // This means it is an image and we can return dimensions and try to generate thumbnail/icon.
143                 $token = $node['datemodified'] . $node['size']; // To prevent caching by browser.
144                 $node['realthumbnail'] = $this->get_thumbnail_url($path . '/' . $file, 'thumb', $token)->out(false);
145                 $node['realicon'] = $this->get_thumbnail_url($path . '/' . $file, 'icon', $token)->out(false);
146                 $node['image_width'] = $imageinfo[0];
147                 $node['image_height'] = $imageinfo[1];
148             }
149             $list['list'][] = $node;
150         }
151         $list['list'] = array_filter($list['list'], array($this, 'filter'));
152         return $list;
153     }
155     public function check_login() {
156         return true;
157     }
158     public function print_login() {
159         return true;
160     }
161     public function global_search() {
162         return false;
163     }
165     /**
166      * Return file path
167      * @return array
168      */
169     public function get_file($file, $title = '') {
170         global $CFG;
171         if ($file{0} == '/') {
172             $file = $this->root_path.substr($file, 1, strlen($file)-1);
173         } else {
174             $file = $this->root_path.$file;
175         }
176         // this is a hack to prevent move_to_file deleteing files
177         // in local repository
178         $CFG->repository_no_delete = true;
179         return array('path'=>$file, 'url'=>'');
180     }
182     /**
183      * Return the source information
184      *
185      * @param stdClass $filepath
186      * @return string|null
187      */
188     public function get_file_source_info($filepath) {
189         return $filepath;
190     }
192     public function logout() {
193         return true;
194     }
196     public static function get_instance_option_names() {
197         return array('fs_path', 'relativefiles');
198     }
200     public function set_option($options = array()) {
201         $options['fs_path'] = clean_param($options['fs_path'], PARAM_PATH);
202         $options['relativefiles'] = clean_param($options['relativefiles'], PARAM_INT);
203         $ret = parent::set_option($options);
204         return $ret;
205     }
207     public static function instance_config_form($mform) {
208         global $CFG, $PAGE;
209         if (has_capability('moodle/site:config', context_system::instance())) {
210             $path = $CFG->dataroot . '/repository/';
211             if (!is_dir($path)) {
212                 mkdir($path, $CFG->directorypermissions, true);
213             }
214             if ($handle = opendir($path)) {
215                 $fieldname = get_string('path', 'repository_filesystem');
216                 $choices = array();
217                 while (false !== ($file = readdir($handle))) {
218                     if (is_dir($path.$file) && $file != '.' && $file!= '..') {
219                         $choices[$file] = $file;
220                         $fieldname = '';
221                     }
222                 }
223                 if (empty($choices)) {
224                     $mform->addElement('static', '', '', get_string('nosubdir', 'repository_filesystem', $path));
225                     $mform->addElement('hidden', 'fs_path', '');
226                     $mform->setType('fs_path', PARAM_PATH);
227                 } else {
228                     $mform->addElement('select', 'fs_path', $fieldname, $choices);
229                     $mform->addElement('static', null, '',  get_string('information','repository_filesystem', $path));
230                 }
231                 closedir($handle);
232             }
233             $mform->addElement('checkbox', 'relativefiles', get_string('relativefiles', 'repository_filesystem'),
234                 get_string('relativefiles_desc', 'repository_filesystem'));
235             $mform->setType('relativefiles', PARAM_INT);
237         } else {
238             $mform->addElement('static', null, '',  get_string('nopermissions', 'error', get_string('configplugin', 'repository_filesystem')));
239             return false;
240         }
241     }
243     public static function create($type, $userid, $context, $params, $readonly=0) {
244         global $PAGE;
245         if (has_capability('moodle/site:config', context_system::instance())) {
246             return parent::create($type, $userid, $context, $params, $readonly);
247         } else {
248             require_capability('moodle/site:config', context_system::instance());
249             return false;
250         }
251     }
252     public static function instance_form_validation($mform, $data, $errors) {
253         if (empty($data['fs_path'])) {
254             $errors['fs_path'] = get_string('invalidadminsettingname', 'error', 'fs_path');
255         }
256         return $errors;
257     }
259     /**
260      * User cannot use the external link to dropbox
261      *
262      * @return int
263      */
264     public function supported_returntypes() {
265         return FILE_INTERNAL | FILE_REFERENCE;
266     }
268     /**
269      * Return reference file life time
270      *
271      * @param string $ref
272      * @return int
273      */
274     public function get_reference_file_lifetime($ref) {
275         // Does not cost us much to synchronise within our own filesystem, set to 1 minute
276         return 60;
277     }
279     /**
280      * Return human readable reference information
281      *
282      * @param string $reference value of DB field files_reference.reference
283      * @param int $filestatus status of the file, 0 - ok, 666 - source missing
284      * @return string
285      */
286     public function get_reference_details($reference, $filestatus = 0) {
287         $details = $this->get_name().': '.$reference;
288         if ($filestatus) {
289             return get_string('lostsource', 'repository', $details);
290         } else {
291             return $details;
292         }
293     }
295     /**
296      * Returns information about file in this repository by reference
297      *
298      * Returns null if file not found or is not readable
299      *
300      * @param stdClass $reference file reference db record
301      * @return stdClass|null contains one of the following:
302      *   - 'filesize' if file should not be copied to moodle filepool
303      *   - 'filepath' if file should be copied to moodle filepool
304      */
305     public function get_file_by_reference($reference) {
306         $ref = $reference->reference;
307         if ($ref{0} == '/') {
308             $filepath = $this->root_path.substr($ref, 1, strlen($ref)-1);
309         } else {
310             $filepath = $this->root_path.$ref;
311         }
312         if (file_exists($filepath) && is_readable($filepath)) {
313             if (file_extension_in_typegroup($filepath, 'web_image')) {
314                 // return path to image files so it will be copied into moodle filepool
315                 // we need the file in filepool to generate an image thumbnail
316                 return (object)array('filepath' => $filepath);
317             } else {
318                 // return just the file size so file will NOT be copied into moodle filepool
319                 return (object)array(
320                     'filesize' => filesize($filepath)
321                 );
322             }
323         } else {
324             return null;
325         }
326     }
328     /**
329      * Repository method to serve the referenced file
330      *
331      * @see send_stored_file
332      *
333      * @param stored_file $storedfile the file that contains the reference
334      * @param int $lifetime Number of seconds before the file should expire from caches (default 24 hours)
335      * @param int $filter 0 (default)=no filtering, 1=all files, 2=html files only
336      * @param bool $forcedownload If true (default false), forces download of file rather than view in browser/plugin
337      * @param array $options additional options affecting the file serving
338      */
339     public function send_file($storedfile, $lifetime=86400 , $filter=0, $forcedownload=false, array $options = null) {
340         $reference = $storedfile->get_reference();
341         if ($reference{0} == '/') {
342             $file = $this->root_path.substr($reference, 1, strlen($reference)-1);
343         } else {
344             $file = $this->root_path.$reference;
345         }
346         if (is_readable($file)) {
347             $filename = $storedfile->get_filename();
348             if ($options && isset($options['filename'])) {
349                 $filename = $options['filename'];
350             }
351             $dontdie = ($options && isset($options['dontdie']));
352             send_file($file, $filename, $lifetime , $filter, false, $forcedownload, '', $dontdie);
353         } else {
354             send_file_not_found();
355         }
356     }
358     /**
359      * Is this repository accessing private data?
360      *
361      * @return bool
362      */
363     public function contains_private_data() {
364         return false;
365     }
367     /**
368      * Returns url of thumbnail file.
369      *
370      * @param string $filepath current path in repository (dir and filename)
371      * @param string $thumbsize 'thumb' or 'icon'
372      * @param string $token identifier of the file contents - to prevent browser from caching changed file
373      * @return moodle_url
374      */
375     protected function get_thumbnail_url($filepath, $thumbsize, $token) {
376         return moodle_url::make_pluginfile_url($this->context->id, 'repository_filesystem', $thumbsize, $this->id,
377                 '/' . trim($filepath, '/') . '/', $token);
378     }
380     /**
381      * Returns the stored thumbnail file, generates it if not present.
382      *
383      * @param string $filepath current path in repository (dir and filename)
384      * @param string $thumbsize 'thumb' or 'icon'
385      * @return null|stored_file
386      */
387     public function get_thumbnail($filepath, $thumbsize) {
388         global $CFG;
390         $filepath = trim($filepath, '/');
391         $origfile = $this->root_path . $filepath;
392         // As thumbnail filename we use original file content hash.
393         if (!($filecontents = @file_get_contents($origfile))) {
394             // File is not found or is not readable.
395             return null;
396         }
397         $filename = sha1($filecontents);
398         unset($filecontents);
400         // Try to get generated thumbnail for this file.
401         $fs = get_file_storage();
402         if (!($file = $fs->get_file(SYSCONTEXTID, 'repository_filesystem', $thumbsize, $this->id, '/' . $filepath . '/', $filename))) {
403             // Thumbnail not found . Generate and store thumbnail.
404             require_once($CFG->libdir . '/gdlib.php');
405             if ($thumbsize === 'thumb') {
406                 $size = 90;
407             } else {
408                 $size = 24;
409             }
410             if (!$data = @generate_image_thumbnail($origfile, $size, $size)) {
411                 // Generation failed.
412                 return null;
413             }
414             $record = array(
415                 'contextid' => SYSCONTEXTID,
416                 'component' => 'repository_filesystem',
417                 'filearea' => $thumbsize,
418                 'itemid' => $this->id,
419                 'filepath' => '/' . $filepath . '/',
420                 'filename' => $filename,
421             );
422             $file = $fs->create_file_from_string($record, $data);
423         }
424         return $file;
425     }
427     /**
428      * Run in cron for particular repository instance. Removes thumbnails for deleted/modified files.
429      *
430      * @param stored_file[] $storedfiles
431      */
432     public function remove_obsolete_thumbnails($storedfiles) {
433         // Group found files by filepath ('filepath' in Moodle file storage is dir+name in filesystem repository).
434         $files = array();
435         foreach ($storedfiles as $file) {
436             if (!isset($files[$file->get_filepath()])) {
437                 $files[$file->get_filepath()] = array();
438             }
439             $files[$file->get_filepath()][] = $file;
440         }
442         // Loop through all files and make sure the original exists and has the same contenthash.
443         $deletedcount = 0;
444         foreach ($files as $filepath => $filesinpath) {
445             if ($filecontents = @file_get_contents($this->root_path . trim($filepath, '/'))) {
446                 // 'filename' in Moodle file storage is contenthash of the file in filesystem repository.
447                 $filename = sha1($filecontents);
448                 foreach ($filesinpath as $file) {
449                     if ($file->get_filename() !== $filename && $file->get_filename() !== '.') {
450                         // Contenthash does not match, this is an old thumbnail.
451                         $deletedcount++;
452                         $file->delete();
453                     }
454                 }
455             } else {
456                 // Thumbnail exist but file not.
457                 foreach ($filesinpath as $file) {
458                     if ($file->get_filename() !== '.') {
459                         $deletedcount++;
460                     }
461                     $file->delete();
462                 }
463             }
464         }
465         if ($deletedcount) {
466             mtrace(" instance {$this->id}: deleted $deletedcount thumbnails");
467         }
468     }
470     /**
471      *  Gets a file relative to this file in the repository and sends it to the browser.
472      *
473      * @param stored_file $mainfile The main file we are trying to access relative files for.
474      * @param string $relativepath the relative path to the file we are trying to access.
475      */
476     public function send_relative_file(stored_file $mainfile, $relativepath) {
477         global $CFG;
478         // Check if this repository is allowed to use relative linking.
479         $allowlinks = $this->supports_relative_file();
480         $lifetime = isset($CFG->filelifetime) ? $CFG->filelifetime : 86400;
481         if (!empty($allowlinks)) {
482             // Get path to the mainfile.
483             $mainfilepath = $mainfile->get_source();
485             // Strip out filename from the path.
486             $filename = $mainfile->get_filename();
487             $basepath = strstr($mainfilepath, $filename, true);
489             $fullrelativefilepath = realpath($this->root_path.$basepath.$relativepath);
491             // Sanity check to make sure this path is inside this repository and the file exists.
492             if (strpos($fullrelativefilepath, $this->root_path) === 0 && file_exists($fullrelativefilepath)) {
493                 send_file($fullrelativefilepath, basename($relativepath), $lifetime, 0);
494             }
495         }
496         send_file_not_found();
497     }
499     /**
500      * helper function to check if the repository supports send_relative_file.
501      *
502      * @return true|false
503      */
504     public function supports_relative_file() {
505         return $this->get_option('relativefiles');
506     }
509 /**
510  * Generates and sends the thumbnail for an image in filesystem.
511  *
512  * @param stdClass $course course object
513  * @param stdClass $cm course module object
514  * @param stdClass $context context object
515  * @param string $filearea file area
516  * @param array $args extra arguments
517  * @param bool $forcedownload whether or not force download
518  * @param array $options additional options affecting the file serving
519  * @return bool
520  */
521 function repository_filesystem_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options=array()) {
522     global $OUTPUT;
523     // Allowed filearea is either thumb or icon - size of the thumbnail.
524     if ($filearea !== 'thumb' && $filearea !== 'icon') {
525         return false;
526     }
528     // As itemid we pass repository instance id.
529     $itemid = array_shift($args);
530     // Filename is some token that we can ignore (used only to make sure browser does not serve cached copy when file is changed).
531     array_pop($args);
532     // As filepath we use full filepath (dir+name) of the file in this instance of filesystem repository.
533     $filepath = implode('/', $args);
535     // Make sure file exists in the repository and is accessible.
536     $repo = repository::get_repository_by_id($itemid, $context);
537     $repo->check_capability();
538     // Find stored or generated thumbnail.
539     if (!($file = $repo->get_thumbnail($filepath, $filearea))) {
540         // Generation failed, redirect to default icon for file extension.
541         redirect($OUTPUT->pix_url(file_extension_icon($file, 90)));
542     }
543     send_stored_file($file, 360, 0, $forcedownload, $options);
546 /**
547  * Cron callback for repository_filesystem. Deletes the thumbnails for deleted or changed files.
548  */
549 function repository_filesystem_cron() {
550     $fs = get_file_storage();
551     // Find all generated thumbnails and group them in array by itemid (itemid == repository instance id).
552     $allfiles = array_merge(
553             $fs->get_area_files(SYSCONTEXTID, 'repository_filesystem', 'thumb'),
554             $fs->get_area_files(SYSCONTEXTID, 'repository_filesystem', 'icon')
555     );
556     $filesbyitem = array();
557     foreach ($allfiles as $file) {
558         if (!isset($filesbyitem[$file->get_itemid()])) {
559             $filesbyitem[$file->get_itemid()] = array();
560         }
561         $filesbyitem[$file->get_itemid()][] = $file;
562     }
563     // Find all instances of repository_filesystem.
564     $instances = repository::get_instances(array('type' => 'filesystem'));
565     // Loop through all itemids of generated thumbnails.
566     foreach ($filesbyitem as $itemid => $files) {
567         if (!isset($instances[$itemid]) || !($instances[$itemid] instanceof repository_filesystem)) {
568             // Instance was deleted.
569             $fs->delete_area_files(SYSCONTEXTID, 'repository_filesystem', 'thumb', $itemid);
570             $fs->delete_area_files(SYSCONTEXTID, 'repository_filesystem', 'icon', $itemid);
571             mtrace(" instance $itemid does not exist: deleted all thumbnails");
572         } else {
573             // Instance has some generated thumbnails, check that they are not outdated.
574             $instances[$itemid]->remove_obsolete_thumbnails($files);
575         }
576     }