MDL-23044 Support for storing thumbnails in repository filesystem
[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');
198     }
200     public function set_option($options = array()) {
201         $options['fs_path'] = clean_param($options['fs_path'], PARAM_PATH);
202         $ret = parent::set_option($options);
203         return $ret;
204     }
206     public static function instance_config_form($mform) {
207         global $CFG, $PAGE;
208         if (has_capability('moodle/site:config', context_system::instance())) {
209             $path = $CFG->dataroot . '/repository/';
210             if (!is_dir($path)) {
211                 mkdir($path, $CFG->directorypermissions, true);
212             }
213             if ($handle = opendir($path)) {
214                 $fieldname = get_string('path', 'repository_filesystem');
215                 $choices = array();
216                 while (false !== ($file = readdir($handle))) {
217                     if (is_dir($path.$file) && $file != '.' && $file!= '..') {
218                         $choices[$file] = $file;
219                         $fieldname = '';
220                     }
221                 }
222                 if (empty($choices)) {
223                     $mform->addElement('static', '', '', get_string('nosubdir', 'repository_filesystem', $path));
224                     $mform->addElement('hidden', 'fs_path', '');
225                     $mform->setType('fs_path', PARAM_PATH);
226                 } else {
227                     $mform->addElement('select', 'fs_path', $fieldname, $choices);
228                     $mform->addElement('static', null, '',  get_string('information','repository_filesystem', $path));
229                 }
230                 closedir($handle);
231             }
232         } else {
233             $mform->addElement('static', null, '',  get_string('nopermissions', 'error', get_string('configplugin', 'repository_filesystem')));
234             return false;
235         }
236     }
238     public static function create($type, $userid, $context, $params, $readonly=0) {
239         global $PAGE;
240         if (has_capability('moodle/site:config', context_system::instance())) {
241             return parent::create($type, $userid, $context, $params, $readonly);
242         } else {
243             require_capability('moodle/site:config', context_system::instance());
244             return false;
245         }
246     }
247     public static function instance_form_validation($mform, $data, $errors) {
248         if (empty($data['fs_path'])) {
249             $errors['fs_path'] = get_string('invalidadminsettingname', 'error', 'fs_path');
250         }
251         return $errors;
252     }
254     /**
255      * User cannot use the external link to dropbox
256      *
257      * @return int
258      */
259     public function supported_returntypes() {
260         return FILE_INTERNAL | FILE_REFERENCE;
261     }
263     /**
264      * Return reference file life time
265      *
266      * @param string $ref
267      * @return int
268      */
269     public function get_reference_file_lifetime($ref) {
270         // Does not cost us much to synchronise within our own filesystem, set to 1 minute
271         return 60;
272     }
274     /**
275      * Return human readable reference information
276      *
277      * @param string $reference value of DB field files_reference.reference
278      * @param int $filestatus status of the file, 0 - ok, 666 - source missing
279      * @return string
280      */
281     public function get_reference_details($reference, $filestatus = 0) {
282         $details = $this->get_name().': '.$reference;
283         if ($filestatus) {
284             return get_string('lostsource', 'repository', $details);
285         } else {
286             return $details;
287         }
288     }
290     /**
291      * Returns information about file in this repository by reference
292      *
293      * Returns null if file not found or is not readable
294      *
295      * @param stdClass $reference file reference db record
296      * @return stdClass|null contains one of the following:
297      *   - 'filesize' if file should not be copied to moodle filepool
298      *   - 'filepath' if file should be copied to moodle filepool
299      */
300     public function get_file_by_reference($reference) {
301         $ref = $reference->reference;
302         if ($ref{0} == '/') {
303             $filepath = $this->root_path.substr($ref, 1, strlen($ref)-1);
304         } else {
305             $filepath = $this->root_path.$ref;
306         }
307         if (file_exists($filepath) && is_readable($filepath)) {
308             if (file_extension_in_typegroup($filepath, 'web_image')) {
309                 // return path to image files so it will be copied into moodle filepool
310                 // we need the file in filepool to generate an image thumbnail
311                 return (object)array('filepath' => $filepath);
312             } else {
313                 // return just the file size so file will NOT be copied into moodle filepool
314                 return (object)array(
315                     'filesize' => filesize($filepath)
316                 );
317             }
318         } else {
319             return null;
320         }
321     }
323     /**
324      * Repository method to serve the referenced file
325      *
326      * @see send_stored_file
327      *
328      * @param stored_file $storedfile the file that contains the reference
329      * @param int $lifetime Number of seconds before the file should expire from caches (default 24 hours)
330      * @param int $filter 0 (default)=no filtering, 1=all files, 2=html files only
331      * @param bool $forcedownload If true (default false), forces download of file rather than view in browser/plugin
332      * @param array $options additional options affecting the file serving
333      */
334     public function send_file($storedfile, $lifetime=86400 , $filter=0, $forcedownload=false, array $options = null) {
335         $reference = $storedfile->get_reference();
336         if ($reference{0} == '/') {
337             $file = $this->root_path.substr($reference, 1, strlen($reference)-1);
338         } else {
339             $file = $this->root_path.$reference;
340         }
341         if (is_readable($file)) {
342             $filename = $storedfile->get_filename();
343             if ($options && isset($options['filename'])) {
344                 $filename = $options['filename'];
345             }
346             $dontdie = ($options && isset($options['dontdie']));
347             send_file($file, $filename, $lifetime , $filter, false, $forcedownload, '', $dontdie);
348         } else {
349             send_file_not_found();
350         }
351     }
353     /**
354      * Is this repository accessing private data?
355      *
356      * @return bool
357      */
358     public function contains_private_data() {
359         return false;
360     }
362     /**
363      * Returns url of thumbnail file.
364      *
365      * @param string $filepath current path in repository (dir and filename)
366      * @param string $thumbsize 'thumb' or 'icon'
367      * @param string $token identifier of the file contents - to prevent browser from caching changed file
368      * @return moodle_url
369      */
370     protected function get_thumbnail_url($filepath, $thumbsize, $token) {
371         return moodle_url::make_pluginfile_url($this->context->id, 'repository_filesystem', $thumbsize, $this->id,
372                 '/'. trim($filepath, '/'). '/', $token);
373     }
375     /**
376      * Returns the stored thumbnail file, generates it if not present.
377      *
378      * @param string $filepath current path in repository (dir and filename)
379      * @param string $thumbsize 'thumb' or 'icon'
380      * @return null|stored_file
381      */
382     public function get_thumbnail($filepath, $thumbsize) {
383         global $CFG;
385         $filepath = trim($filepath, '/');
386         $origfile = $this->root_path. $filepath;
387         // As thumbnail filename we use original file content hash.
388         if (!($filecontents = @file_get_contents($origfile))) {
389             // File is not found or is not readable.
390             return null;
391         }
392         $filename = sha1($filecontents);
393         unset($filecontents);
395         // Try to get generated thumbnail for this file.
396         $fs = get_file_storage();
397         if (!($file = $fs->get_file(SYSCONTEXTID, 'repository_filesystem', $thumbsize, $this->id, '/'. $filepath. '/', $filename))) {
398             // Thumbnail not found. Generate and store thumbnail.
399             require_once($CFG->libdir. '/gdlib.php');
400             if ($thumbsize === 'thumb') {
401                 $size = 90;
402             } else {
403                 $size = 24;
404             }
405             if (!$data = @generate_image_thumbnail($origfile, $size, $size)) {
406                 // Generation failed.
407                 return null;
408             }
409             $record = array(
410                 'contextid' => SYSCONTEXTID,
411                 'component' => 'repository_filesystem',
412                 'filearea' => $thumbsize,
413                 'itemid' => $this->id,
414                 'filepath' => '/'. $filepath. '/',
415                 'filename' => $filename,
416             );
417             $file = $fs->create_file_from_string($record, $data);
418         }
419         return $file;
420     }
422     /**
423      * Cron for particular repository instance. Removes thumbnails for deleted/modified files.
424      */
425     public function cron() {
426         global $DB;
427         // Get all records for generated thumbnails (we don't use files api because we don't
428         // need creating instances of stored_file unless file is to be deleted).
429         $sql = "SELECT id, contextid, component, filearea, itemid, filepath, filename, contenthash
430                 FROM {files} f WHERE f.contextid = :contextid
431                            AND f.component = :component
432                            AND (f.filearea = :filearea1 OR f.filearea = :filearea2)
433                            AND itemid = :itemid";
434         $filesraw = $DB->get_records_sql($sql, array(
435             'contextid' => SYSCONTEXTID,
436             'component' => 'repository_filesystem',
437             'filearea1' => 'thumb',
438             'filearea2' => 'icon',
439             'itemid' => $this->id,
440         ));
441         // Group found files by filepath ('filepath' in Moodle file storage is dir+name in filesystem repository).
442         $files = array();
443         foreach ($filesraw as $filerecord) {
444             if (!isset($files[$filerecord->filepath])) {
445                 $files[$filerecord->filepath] = array();
446             }
447             $files[$filerecord->filepath][] = $filerecord;
448         }
449         $filesraw = null;
451         // Loop through all files and make sure the original exists and has the same contenthash.
452         $deletedcount = 0;
453         foreach ($files as $filepath => $filerecords) {
454             if ($filecontents = @file_get_contents($this->root_path. trim($filepath, '/'))) {
455                 // 'filename' in Moodle file storage is contenthash of the file in filesystem repository.
456                 $filename = sha1($filecontents);
457                 foreach ($filerecords as $filerecord) {
458                     if ($filerecord->filename !== $filename && $filerecord->filename !== '.') {
459                         // Contenthash does not match, this is an old thumbnail.
460                         $deletedcount++;
461                         get_file_storage()->get_file_instance($filerecord)->delete();
462                     }
463                 }
464             } else {
465                 // Thumbnail exist but file not.
466                 foreach ($filerecords as $filerecord) {
467                     if ($filerecord->filename !== '.') {
468                         $deletedcount++;
469                     }
470                     get_file_storage()->get_file_instance($filerecord)->delete();
471                 }
472             }
473         }
474         if ($deletedcount) {
475             mtrace(" instance {$this->id}: deleted $deletedcount thumbnails");
476         }
477     }
480 /**
481  * Generates and sends the thumbnail for an image in filesystem.
482  *
483  * @param stdClass $course course object
484  * @param stdClass $cm course module object
485  * @param stdClass $context context object
486  * @param string $filearea file area
487  * @param array $args extra arguments
488  * @param bool $forcedownload whether or not force download
489  * @param array $options additional options affecting the file serving
490  * @return bool
491  */
492 function repository_filesystem_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options=array()) {
493     global $OUTPUT;
494     // Allowed filearea is either thumb or icon - size of the thumbnail.
495     if ($filearea !== 'thumb' && $filearea !== 'icon') {
496         return false;
497     }
499     // As itemid we pass repository instance id.
500     $itemid = array_shift($args);
501     // Filename is some token that we can ignore (used only to make sure browser does not serve cached copy when file is changed).
502     array_pop($args);
503     // As filepath we use full filepath (dir+name) of the file in this instance of filesystem repository.
504     $filepath = implode('/', $args);
506     // Make sure file exists in the repository and is accessible.
507     $repo = repository::get_repository_by_id($itemid, $context);
508     $repo->check_capability();
509     // Find stored or generated thumbnail.
510     if (!($file = $repo->get_thumbnail($filepath, $filearea))) {
511         // Generation failed, redirect to default icon for file extension.
512         redirect($OUTPUT->pix_url(file_extension_icon($file, 90)));
513     }
514     send_stored_file($file, 360, 0, $forcedownload, $options);
517 /**
518  * Cron callback for repository_filesystem. Deletes the thumbnails for deleted or changed files.
519  */
520 function repository_filesystem_cron() {
521     global $DB;
522     // Find all repository instances ids that have generated thumbnails.
523     $sql = "SELECT DISTINCT itemid FROM {files} f WHERE f.contextid = :contextid
524                        AND f.component = :component
525                        AND (f.filearea = :filearea1 OR f.filearea = :filearea2)";
526     $itemids = $DB->get_fieldset_sql($sql, array(
527         'contextid' => SYSCONTEXTID,
528         'component' => 'repository_filesystem',
529         'filearea1' => 'thumb',
530         'filearea2' => 'icon'
531     ));
532     $instances = repository::get_instances(array('type' => 'filesystem'));
533     foreach ($itemids as $itemid) {
534         if (!isset($instances[$itemid])) {
535             // Instance was deleted.
536             $fs = get_file_storage();
537             $fs->delete_area_files(SYSCONTEXTID, 'repository_filesystem', 'thumb', $itemid);
538             $fs->delete_area_files(SYSCONTEXTID, 'repository_filesystem', 'icon', $itemid);
539             mtrace(" instance $itemid does not exist: deleted all thumbnails");
540         } else {
541             // Instance has some generated thumbnails, check that they are not outdated.
542             $instances[$itemid]->cron();
543         }
544     }