Merge branch 'MDL-27432_gd_error' of git://github.com/andyjdavis/moodle
[moodle.git] / lib / filestorage / file_storage.php
1 <?php
3 // This file is part of Moodle - http://moodle.org/
4 //
5 // Moodle is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
9 //
10 // Moodle is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 // GNU General Public License for more details.
14 //
15 // You should have received a copy of the GNU General Public License
16 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
19 /**
20  * Core file storage class definition.
21  *
22  * @package    core
23  * @subpackage filestorage
24  * @copyright  2008 Petr Skoda {@link http://skodak.org}
25  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26  */
28 defined('MOODLE_INTERNAL') || die();
30 require_once("$CFG->libdir/filestorage/stored_file.php");
32 /**
33  * File storage class used for low level access to stored files.
34  *
35  * Only owner of file area may use this class to access own files,
36  * for example only code in mod/assignment/* may access assignment
37  * attachments. When some other part of moodle needs to access
38  * files of modules it has to use file_browser class instead or there
39  * has to be some callback API.
40  *
41  * @copyright 2008 Petr Skoda {@link http://skodak.org}
42  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
43  * @since     Moodle 2.0
44  */
45 class file_storage {
46     /** @var string Directory with file contents */
47     private $filedir;
48     /** @var string Contents of deleted files not needed any more */
49     private $trashdir;
50     /** @var string tempdir */
51     private $tempdir;
52     /** @var int Permissions for new directories */
53     private $dirpermissions;
54     /** @var int Permissions for new files */
55     private $filepermissions;
57     /**
58      * Constructor - do not use directly use @see get_file_storage() call instead.
59      *
60      * @param string $filedir full path to pool directory
61      * @param string $trashdir temporary storage of deleted area
62      * @param string $tempdir temporary storage of various files
63      * @param int $dirpermissions new directory permissions
64      * @param int $filepermissions new file permissions
65      */
66     public function __construct($filedir, $trashdir, $tempdir, $dirpermissions, $filepermissions) {
67         $this->filedir         = $filedir;
68         $this->trashdir        = $trashdir;
69         $this->tempdir         = $tempdir;
70         $this->dirpermissions  = $dirpermissions;
71         $this->filepermissions = $filepermissions;
73         // make sure the file pool directory exists
74         if (!is_dir($this->filedir)) {
75             if (!mkdir($this->filedir, $this->dirpermissions, true)) {
76                 throw new file_exception('storedfilecannotcreatefiledirs'); // permission trouble
77             }
78             // place warning file in file pool root
79             if (!file_exists($this->filedir.'/warning.txt')) {
80                 file_put_contents($this->filedir.'/warning.txt',
81                                   'This directory contains the content of uploaded files and is controlled by Moodle code. Do not manually move, change or rename any of the files and subdirectories here.');
82             }
83         }
84         // make sure the file pool directory exists
85         if (!is_dir($this->trashdir)) {
86             if (!mkdir($this->trashdir, $this->dirpermissions, true)) {
87                 throw new file_exception('storedfilecannotcreatefiledirs'); // permission trouble
88             }
89         }
90     }
92     /**
93      * Calculates sha1 hash of unique full path name information.
94      *
95      * This hash is a unique file identifier - it is used to improve
96      * performance and overcome db index size limits.
97      *
98      * @param int $contextid
99      * @param string $component
100      * @param string $filearea
101      * @param int $itemid
102      * @param string $filepath
103      * @param string $filename
104      * @return string sha1 hash
105      */
106     public static function get_pathname_hash($contextid, $component, $filearea, $itemid, $filepath, $filename) {
107         return sha1("/$contextid/$component/$filearea/$itemid".$filepath.$filename);
108     }
110     /**
111      * Does this file exist?
112      *
113      * @param int $contextid
114      * @param string $component
115      * @param string $filearea
116      * @param int $itemid
117      * @param string $filepath
118      * @param string $filename
119      * @return bool
120      */
121     public function file_exists($contextid, $component, $filearea, $itemid, $filepath, $filename) {
122         $filepath = clean_param($filepath, PARAM_PATH);
123         $filename = clean_param($filename, PARAM_FILE);
125         if ($filename === '') {
126             $filename = '.';
127         }
129         $pathnamehash = $this->get_pathname_hash($contextid, $component, $filearea, $itemid, $filepath, $filename);
130         return $this->file_exists_by_hash($pathnamehash);
131     }
133     /**
134      * Does this file exist?
135      *
136      * @param string $pathnamehash
137      * @return bool
138      */
139     public function file_exists_by_hash($pathnamehash) {
140         global $DB;
142         return $DB->record_exists('files', array('pathnamehash'=>$pathnamehash));
143     }
145     /**
146      * Create instance of file class from database record.
147      *
148      * @param stdClass $file_record record from the files table
149      * @return stored_file instance of file abstraction class
150      */
151     public function get_file_instance(stdClass $file_record) {
152         return new stored_file($this, $file_record, $this->filedir);
153     }
155     /**
156      * Fetch file using local file id.
157      *
158      * Please do not rely on file ids, it is usually easier to use
159      * pathname hashes instead.
160      *
161      * @param int $fileid
162      * @return stored_file instance if exists, false if not
163      */
164     public function get_file_by_id($fileid) {
165         global $DB;
167         if ($file_record = $DB->get_record('files', array('id'=>$fileid))) {
168             return $this->get_file_instance($file_record);
169         } else {
170             return false;
171         }
172     }
174     /**
175      * Fetch file using local file full pathname hash
176      *
177      * @param string $pathnamehash
178      * @return stored_file instance if exists, false if not
179      */
180     public function get_file_by_hash($pathnamehash) {
181         global $DB;
183         if ($file_record = $DB->get_record('files', array('pathnamehash'=>$pathnamehash))) {
184             return $this->get_file_instance($file_record);
185         } else {
186             return false;
187         }
188     }
190     /**
191      * Fetch locally stored file.
192      *
193      * @param int $contextid
194      * @param string $component
195      * @param string $filearea
196      * @param int $itemid
197      * @param string $filepath
198      * @param string $filename
199      * @return stored_file instance if exists, false if not
200      */
201     public function get_file($contextid, $component, $filearea, $itemid, $filepath, $filename) {
202         $filepath = clean_param($filepath, PARAM_PATH);
203         $filename = clean_param($filename, PARAM_FILE);
205         if ($filename === '') {
206             $filename = '.';
207         }
209         $pathnamehash = $this->get_pathname_hash($contextid, $component, $filearea, $itemid, $filepath, $filename);
210         return $this->get_file_by_hash($pathnamehash);
211     }
213     /**
214      * Are there any files (or directories)
215      * @param int $contextid
216      * @param string $component
217      * @param string $filearea
218      * @param bool|int $itemid tem id or false if all items
219      * @param bool $ignoredirs
220      * @return bool empty
221      */
222     public function is_area_empty($contextid, $component, $filearea, $itemid = false, $ignoredirs = true) {
223         global $DB;
225         $params = array('contextid'=>$contextid, 'component'=>$component, 'filearea'=>$filearea);
226         $where = "contextid = :contextid AND component = :component AND filearea = :filearea";
228         if ($itemid !== false) {
229             $params['itemid'] = $itemid;
230             $where .= " AND itemid = :itemid";
231         }
233         if ($ignoredirs) {
234             $sql = "SELECT 'x'
235                       FROM {files}
236                      WHERE $where AND filename <> '.'";
237         } else {
238             $sql = "SELECT 'x'
239                       FROM {files}
240                      WHERE $where AND (filename <> '.' OR filepath <> '/')";
241         }
243         return !$DB->record_exists_sql($sql, $params);
244     }
246     /**
247      * Returns all area files (optionally limited by itemid)
248      *
249      * @param int $contextid
250      * @param string $component
251      * @param string $filearea
252      * @param int $itemid (all files if not specified)
253      * @param string $sort
254      * @param bool $includedirs
255      * @return array of stored_files indexed by pathanmehash
256      */
257     public function get_area_files($contextid, $component, $filearea, $itemid = false, $sort="sortorder, itemid, filepath, filename", $includedirs = true) {
258         global $DB;
260         $conditions = array('contextid'=>$contextid, 'component'=>$component, 'filearea'=>$filearea);
261         if ($itemid !== false) {
262             $conditions['itemid'] = $itemid;
263         }
265         $result = array();
266         $file_records = $DB->get_records('files', $conditions, $sort);
267         foreach ($file_records as $file_record) {
268             if (!$includedirs and $file_record->filename === '.') {
269                 continue;
270             }
271             $result[$file_record->pathnamehash] = $this->get_file_instance($file_record);
272         }
273         return $result;
274     }
276     /**
277      * Returns array based tree structure of area files
278      *
279      * @param int $contextid
280      * @param string $component
281      * @param string $filearea
282      * @param int $itemid
283      * @return array each dir represented by dirname, subdirs, files and dirfile array elements
284      */
285     public function get_area_tree($contextid, $component, $filearea, $itemid) {
286         $result = array('dirname'=>'', 'dirfile'=>null, 'subdirs'=>array(), 'files'=>array());
287         $files = $this->get_area_files($contextid, $component, $filearea, $itemid, "sortorder, itemid, filepath, filename", true);
288         // first create directory structure
289         foreach ($files as $hash=>$dir) {
290             if (!$dir->is_directory()) {
291                 continue;
292             }
293             unset($files[$hash]);
294             if ($dir->get_filepath() === '/') {
295                 $result['dirfile'] = $dir;
296                 continue;
297             }
298             $parts = explode('/', trim($dir->get_filepath(),'/'));
299             $pointer =& $result;
300             foreach ($parts as $part) {
301                 if ($part === '') {
302                     continue;
303                 }
304                 if (!isset($pointer['subdirs'][$part])) {
305                     $pointer['subdirs'][$part] = array('dirname'=>$part, 'dirfile'=>null, 'subdirs'=>array(), 'files'=>array());
306                 }
307                 $pointer =& $pointer['subdirs'][$part];
308             }
309             $pointer['dirfile'] = $dir;
310             unset($pointer);
311         }
312         foreach ($files as $hash=>$file) {
313             $parts = explode('/', trim($file->get_filepath(),'/'));
314             $pointer =& $result;
315             foreach ($parts as $part) {
316                 if ($part === '') {
317                     continue;
318                 }
319                 $pointer =& $pointer['subdirs'][$part];
320             }
321             $pointer['files'][$file->get_filename()] = $file;
322             unset($pointer);
323         }
324         return $result;
325     }
327     /**
328      * Returns all files and optionally directories
329      *
330      * @param int $contextid
331      * @param string $component
332      * @param string $filearea
333      * @param int $itemid
334      * @param int $filepath directory path
335      * @param bool $recursive include all subdirectories
336      * @param bool $includedirs include files and directories
337      * @param string $sort
338      * @return array of stored_files indexed by pathanmehash
339      */
340     public function get_directory_files($contextid, $component, $filearea, $itemid, $filepath, $recursive = false, $includedirs = true, $sort = "filepath, filename") {
341         global $DB;
343         if (!$directory = $this->get_file($contextid, $component, $filearea, $itemid, $filepath, '.')) {
344             return array();
345         }
347         if ($recursive) {
349             $dirs = $includedirs ? "" : "AND filename <> '.'";
350             $length = textlib_get_instance()->strlen($filepath);
352             $sql = "SELECT *
353                       FROM {files}
354                      WHERE contextid = :contextid AND component = :component AND filearea = :filearea AND itemid = :itemid
355                            AND ".$DB->sql_substr("filepath", 1, $length)." = :filepath
356                            AND id <> :dirid
357                            $dirs
358                   ORDER BY $sort";
359             $params = array('contextid'=>$contextid, 'component'=>$component, 'filearea'=>$filearea, 'itemid'=>$itemid, 'filepath'=>$filepath, 'dirid'=>$directory->get_id());
361             $files = array();
362             $dirs  = array();
363             $file_records = $DB->get_records_sql($sql, $params);
364             foreach ($file_records as $file_record) {
365                 if ($file_record->filename == '.') {
366                     $dirs[$file_record->pathnamehash] = $this->get_file_instance($file_record);
367                 } else {
368                     $files[$file_record->pathnamehash] = $this->get_file_instance($file_record);
369                 }
370             }
371             $result = array_merge($dirs, $files);
373         } else {
374             $result = array();
375             $params = array('contextid'=>$contextid, 'component'=>$component, 'filearea'=>$filearea, 'itemid'=>$itemid, 'filepath'=>$filepath, 'dirid'=>$directory->get_id());
377             $length = textlib_get_instance()->strlen($filepath);
379             if ($includedirs) {
380                 $sql = "SELECT *
381                           FROM {files}
382                          WHERE contextid = :contextid AND component = :component AND filearea = :filearea
383                                AND itemid = :itemid AND filename = '.'
384                                AND ".$DB->sql_substr("filepath", 1, $length)." = :filepath
385                                AND id <> :dirid
386                       ORDER BY $sort";
387                 $reqlevel = substr_count($filepath, '/') + 1;
388                 $file_records = $DB->get_records_sql($sql, $params);
389                 foreach ($file_records as $file_record) {
390                     if (substr_count($file_record->filepath, '/') !== $reqlevel) {
391                         continue;
392                     }
393                     $result[$file_record->pathnamehash] = $this->get_file_instance($file_record);
394                 }
395             }
397             $sql = "SELECT *
398                       FROM {files}
399                      WHERE contextid = :contextid AND component = :component AND filearea = :filearea AND itemid = :itemid
400                            AND filepath = :filepath AND filename <> '.'
401                   ORDER BY $sort";
403             $file_records = $DB->get_records_sql($sql, $params);
404             foreach ($file_records as $file_record) {
405                 $result[$file_record->pathnamehash] = $this->get_file_instance($file_record);
406             }
407         }
409         return $result;
410     }
412     /**
413      * Delete all area files (optionally limited by itemid).
414      *
415      * @param int $contextid
416      * @param string $component
417      * @param string $filearea (all areas in context if not specified)
418      * @param int $itemid (all files if not specified)
419      * @return bool success
420      */
421     public function delete_area_files($contextid, $component = false, $filearea = false, $itemid = false) {
422         global $DB;
424         $conditions = array('contextid'=>$contextid);
425         if ($component !== false) {
426             $conditions['component'] = $component;
427         }
428         if ($filearea !== false) {
429             $conditions['filearea'] = $filearea;
430         }
431         if ($itemid !== false) {
432             $conditions['itemid'] = $itemid;
433         }
435         $file_records = $DB->get_records('files', $conditions);
436         foreach ($file_records as $file_record) {
437             $this->get_file_instance($file_record)->delete();
438         }
440         return true; // BC only
441     }
443     /**
444      * Delete all the files from certain areas where itemid is limited by an
445      * arbitrary bit of SQL.
446      *
447      * @param int $contextid the id of the context the files belong to. Must be given.
448      * @param string $component the owning component. Must be given.
449      * @param string $filearea the file area name. Must be given.
450      * @param string $itemidstest an SQL fragment that the itemid must match. Used
451      *      in the query like WHERE itemid $itemidstest. Must used named parameters,
452      *      and may not used named parameters called contextid, component or filearea.
453      * @param array $params any query params used by $itemidstest.
454      */
455     public function delete_area_files_select($contextid, $component,
456             $filearea, $itemidstest, array $params = null) {
457         global $DB;
459         $where = "contextid = :contextid
460                 AND component = :component
461                 AND filearea = :filearea
462                 AND itemid $itemidstest";
463         $params['contextid'] = $contextid;
464         $params['component'] = $component;
465         $params['filearea'] = $filearea;
467         $file_records = $DB->get_recordset_select('files', $where, $params);
468         foreach ($file_records as $file_record) {
469             $this->get_file_instance($file_record)->delete();
470         }
471         $file_records->close();
472     }
474     /**
475      * Move all the files in a file area from one context to another.
476      * @param integer $oldcontextid the context the files are being moved from.
477      * @param integer $newcontextid the context the files are being moved to.
478      * @param string $component the plugin that these files belong to.
479      * @param string $filearea the name of the file area.
480      * @return integer the number of files moved, for information.
481      */
482     public function move_area_files_to_new_context($oldcontextid, $newcontextid, $component, $filearea, $itemid = false) {
483         // Note, this code is based on some code that Petr wrote in
484         // forum_move_attachments in mod/forum/lib.php. I moved it here because
485         // I needed it in the question code too.
486         $count = 0;
488         $oldfiles = $this->get_area_files($oldcontextid, $component, $filearea, $itemid, 'id', false);
489         foreach ($oldfiles as $oldfile) {
490             $filerecord = new stdClass();
491             $filerecord->contextid = $newcontextid;
492             $this->create_file_from_storedfile($filerecord, $oldfile);
493             $count += 1;
494         }
496         if ($count) {
497             $this->delete_area_files($oldcontextid, $component, $filearea, $itemid);
498         }
500         return $count;
501     }
503     /**
504      * Recursively creates directory.
505      *
506      * @param int $contextid
507      * @param string $component
508      * @param string $filearea
509      * @param int $itemid
510      * @param string $filepath
511      * @param string $filename
512      * @return bool success
513      */
514     public function create_directory($contextid, $component, $filearea, $itemid, $filepath, $userid = null) {
515         global $DB;
517         // validate all parameters, we do not want any rubbish stored in database, right?
518         if (!is_number($contextid) or $contextid < 1) {
519             throw new file_exception('storedfileproblem', 'Invalid contextid');
520         }
522         $component = clean_param($component, PARAM_COMPONENT);
523         if (empty($component)) {
524             throw new file_exception('storedfileproblem', 'Invalid component');
525         }
527         $filearea = clean_param($filearea, PARAM_AREA);
528         if (empty($filearea)) {
529             throw new file_exception('storedfileproblem', 'Invalid filearea');
530         }
532         if (!is_number($itemid) or $itemid < 0) {
533             throw new file_exception('storedfileproblem', 'Invalid itemid');
534         }
536         $filepath = clean_param($filepath, PARAM_PATH);
537         if (strpos($filepath, '/') !== 0 or strrpos($filepath, '/') !== strlen($filepath)-1) {
538             // path must start and end with '/'
539             throw new file_exception('storedfileproblem', 'Invalid file path');
540         }
542         $pathnamehash = $this->get_pathname_hash($contextid, $component, $filearea, $itemid, $filepath, '.');
544         if ($dir_info = $this->get_file_by_hash($pathnamehash)) {
545             return $dir_info;
546         }
548         static $contenthash = null;
549         if (!$contenthash) {
550             $this->add_string_to_pool('');
551             $contenthash = sha1('');
552         }
554         $now = time();
556         $dir_record = new stdClass();
557         $dir_record->contextid = $contextid;
558         $dir_record->component = $component;
559         $dir_record->filearea  = $filearea;
560         $dir_record->itemid    = $itemid;
561         $dir_record->filepath  = $filepath;
562         $dir_record->filename  = '.';
563         $dir_record->contenthash  = $contenthash;
564         $dir_record->filesize  = 0;
566         $dir_record->timecreated  = $now;
567         $dir_record->timemodified = $now;
568         $dir_record->mimetype     = null;
569         $dir_record->userid       = $userid;
571         $dir_record->pathnamehash = $pathnamehash;
573         $DB->insert_record('files', $dir_record);
574         $dir_info = $this->get_file_by_hash($pathnamehash);
576         if ($filepath !== '/') {
577             //recurse to parent dirs
578             $filepath = trim($filepath, '/');
579             $filepath = explode('/', $filepath);
580             array_pop($filepath);
581             $filepath = implode('/', $filepath);
582             $filepath = ($filepath === '') ? '/' : "/$filepath/";
583             $this->create_directory($contextid, $component, $filearea, $itemid, $filepath, $userid);
584         }
586         return $dir_info;
587     }
589     /**
590      * Add new local file based on existing local file.
591      *
592      * @param mixed $file_record object or array describing changes
593      * @param mixed $fileorid id or stored_file instance of the existing local file
594      * @return stored_file instance of newly created file
595      */
596     public function create_file_from_storedfile($file_record, $fileorid) {
597         global $DB;
599         if ($fileorid instanceof stored_file) {
600             $fid = $fileorid->get_id();
601         } else {
602             $fid = $fileorid;
603         }
605         $file_record = (array)$file_record; // we support arrays too, do not modify the submitted record!
607         unset($file_record['id']);
608         unset($file_record['filesize']);
609         unset($file_record['contenthash']);
610         unset($file_record['pathnamehash']);
612         if (!$newrecord = $DB->get_record('files', array('id'=>$fid))) {
613             throw new file_exception('storedfileproblem', 'File does not exist');
614         }
616         unset($newrecord->id);
618         foreach ($file_record as $key=>$value) {
619             // validate all parameters, we do not want any rubbish stored in database, right?
620             if ($key == 'contextid' and (!is_number($value) or $value < 1)) {
621                 throw new file_exception('storedfileproblem', 'Invalid contextid');
622             }
624             if ($key == 'component') {
625                 $value = clean_param($value, PARAM_COMPONENT);
626                 if (empty($value)) {
627                     throw new file_exception('storedfileproblem', 'Invalid component');
628                 }
629             }
631             if ($key == 'filearea') {
632                 $value = clean_param($value, PARAM_AREA);
633                 if (empty($value)) {
634                     throw new file_exception('storedfileproblem', 'Invalid filearea');
635                 }
636             }
638             if ($key == 'itemid' and (!is_number($value) or $value < 0)) {
639                 throw new file_exception('storedfileproblem', 'Invalid itemid');
640             }
643             if ($key == 'filepath') {
644                 $value = clean_param($value, PARAM_PATH);
645                 if (strpos($value, '/') !== 0 or strrpos($value, '/') !== strlen($value)-1) {
646                     // path must start and end with '/'
647                     throw new file_exception('storedfileproblem', 'Invalid file path');
648                 }
649             }
651             if ($key == 'filename') {
652                 $value = clean_param($value, PARAM_FILE);
653                 if ($value === '') {
654                     // path must start and end with '/'
655                     throw new file_exception('storedfileproblem', 'Invalid file name');
656                 }
657             }
659             if ($key === 'timecreated' or $key === 'timemodified') {
660                 if (!is_number($value)) {
661                     throw new file_exception('storedfileproblem', 'Invalid file '.$key);
662                 }
663                 if ($value < 0) {
664                     //NOTE: unfortunately I make a mistake when creating the "files" table, we can not have negative numbers there, on the other hand no file should be older than 1970, right? (skodak)
665                     $value = 0;
666                 }
667             }
669             $newrecord->$key = $value;
670         }
672         $newrecord->pathnamehash = $this->get_pathname_hash($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->filename);
674         if ($newrecord->filename === '.') {
675             // special case - only this function supports directories ;-)
676             $directory = $this->create_directory($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->userid);
677             // update the existing directory with the new data
678             $newrecord->id = $directory->get_id();
679             $DB->update_record('files', $newrecord);
680             return $this->get_file_instance($newrecord);
681         }
683         try {
684             $newrecord->id = $DB->insert_record('files', $newrecord);
685         } catch (dml_exception $e) {
686             throw new stored_file_creation_exception($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid,
687                                                      $newrecord->filepath, $newrecord->filename, $e->debuginfo);
688         }
690         $this->create_directory($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->userid);
692         return $this->get_file_instance($newrecord);
693     }
695     /**
696      * Add new local file.
697      *
698      * @param mixed $file_record object or array describing file
699      * @param string $path path to file or content of file
700      * @param array $options @see download_file_content() options
701      * @param bool $usetempfile use temporary file for download, may prevent out of memory problems
702      * @return stored_file instance
703      */
704     public function create_file_from_url($file_record, $url, array $options = NULL, $usetempfile = false) {
706         $file_record = (array)$file_record;  //do not modify the submitted record, this cast unlinks objects
707         $file_record = (object)$file_record; // we support arrays too
709         $headers        = isset($options['headers'])        ? $options['headers'] : null;
710         $postdata       = isset($options['postdata'])       ? $options['postdata'] : null;
711         $fullresponse   = isset($options['fullresponse'])   ? $options['fullresponse'] : false;
712         $timeout        = isset($options['timeout'])        ? $options['timeout'] : 300;
713         $connecttimeout = isset($options['connecttimeout']) ? $options['connecttimeout'] : 20;
714         $skipcertverify = isset($options['skipcertverify']) ? $options['skipcertverify'] : false;
715         $calctimeout    = isset($options['calctimeout'])    ? $options['calctimeout'] : false;
717         if (!isset($file_record->filename)) {
718             $parts = explode('/', $url);
719             $filename = array_pop($parts);
720             $file_record->filename = clean_param($filename, PARAM_FILE);
721         }
722         $source = !empty($file_record->source) ? $file_record->source : $url;
723         $file_record->source = clean_param($source, PARAM_URL);
725         if ($usetempfile) {
726             check_dir_exists($this->tempdir);
727             $tmpfile = tempnam($this->tempdir, 'newfromurl');
728             $content = download_file_content($url, $headers, $postdata, $fullresponse, $timeout, $connecttimeout, $skipcertverify, $tmpfile, $calctimeout);
729             if ($content === false) {
730                 throw new file_exception('storedfileproblem', 'Can not fetch file form URL');
731             }
732             try {
733                 $newfile = $this->create_file_from_pathname($file_record, $tmpfile);
734                 @unlink($tmpfile);
735                 return $newfile;
736             } catch (Exception $e) {
737                 @unlink($tmpfile);
738                 throw $e;
739             }
741         } else {
742             $content = download_file_content($url, $headers, $postdata, $fullresponse, $timeout, $connecttimeout, $skipcertverify, NULL, $calctimeout);
743             if ($content === false) {
744                 throw new file_exception('storedfileproblem', 'Can not fetch file form URL');
745             }
746             return $this->create_file_from_string($file_record, $content);
747         }
748     }
750     /**
751      * Add new local file.
752      *
753      * @param mixed $file_record object or array describing file
754      * @param string $path path to file or content of file
755      * @return stored_file instance
756      */
757     public function create_file_from_pathname($file_record, $pathname) {
758         global $DB;
760         $file_record = (array)$file_record;  //do not modify the submitted record, this cast unlinks objects
761         $file_record = (object)$file_record; // we support arrays too
763         // validate all parameters, we do not want any rubbish stored in database, right?
764         if (!is_number($file_record->contextid) or $file_record->contextid < 1) {
765             throw new file_exception('storedfileproblem', 'Invalid contextid');
766         }
768         $file_record->component = clean_param($file_record->component, PARAM_COMPONENT);
769         if (empty($file_record->component)) {
770             throw new file_exception('storedfileproblem', 'Invalid component');
771         }
773         $file_record->filearea = clean_param($file_record->filearea, PARAM_AREA);
774         if (empty($file_record->filearea)) {
775             throw new file_exception('storedfileproblem', 'Invalid filearea');
776         }
778         if (!is_number($file_record->itemid) or $file_record->itemid < 0) {
779             throw new file_exception('storedfileproblem', 'Invalid itemid');
780         }
782         if (!empty($file_record->sortorder)) {
783             if (!is_number($file_record->sortorder) or $file_record->sortorder < 0) {
784                 $file_record->sortorder = 0;
785             }
786         } else {
787             $file_record->sortorder = 0;
788         }
790         $file_record->filepath = clean_param($file_record->filepath, PARAM_PATH);
791         if (strpos($file_record->filepath, '/') !== 0 or strrpos($file_record->filepath, '/') !== strlen($file_record->filepath)-1) {
792             // path must start and end with '/'
793             throw new file_exception('storedfileproblem', 'Invalid file path');
794         }
796         $file_record->filename = clean_param($file_record->filename, PARAM_FILE);
797         if ($file_record->filename === '') {
798             // filename must not be empty
799             throw new file_exception('storedfileproblem', 'Invalid file name');
800         }
802         $now = time();
803         if (isset($file_record->timecreated)) {
804             if (!is_number($file_record->timecreated)) {
805                 throw new file_exception('storedfileproblem', 'Invalid file timecreated');
806             }
807             if ($file_record->timecreated < 0) {
808                 //NOTE: unfortunately I make a mistake when creating the "files" table, we can not have negative numbers there, on the other hand no file should be older than 1970, right? (skodak)
809                 $file_record->timecreated = 0;
810             }
811         } else {
812             $file_record->timecreated = $now;
813         }
815         if (isset($file_record->timemodified)) {
816             if (!is_number($file_record->timemodified)) {
817                 throw new file_exception('storedfileproblem', 'Invalid file timemodified');
818             }
819             if ($file_record->timemodified < 0) {
820                 //NOTE: unfortunately I make a mistake when creating the "files" table, we can not have negative numbers there, on the other hand no file should be older than 1970, right? (skodak)
821                 $file_record->timemodified = 0;
822             }
823         } else {
824             $file_record->timemodified = $now;
825         }
827         $newrecord = new stdClass();
829         $newrecord->contextid = $file_record->contextid;
830         $newrecord->component = $file_record->component;
831         $newrecord->filearea  = $file_record->filearea;
832         $newrecord->itemid    = $file_record->itemid;
833         $newrecord->filepath  = $file_record->filepath;
834         $newrecord->filename  = $file_record->filename;
836         $newrecord->timecreated  = $file_record->timecreated;
837         $newrecord->timemodified = $file_record->timemodified;
838         $newrecord->mimetype     = empty($file_record->mimetype) ? mimeinfo('type', $file_record->filename) : $file_record->mimetype;
839         $newrecord->userid       = empty($file_record->userid) ? null : $file_record->userid;
840         $newrecord->source       = empty($file_record->source) ? null : $file_record->source;
841         $newrecord->author       = empty($file_record->author) ? null : $file_record->author;
842         $newrecord->license      = empty($file_record->license) ? null : $file_record->license;
843         $newrecord->sortorder    = $file_record->sortorder;
845         list($newrecord->contenthash, $newrecord->filesize, $newfile) = $this->add_file_to_pool($pathname);
847         $newrecord->pathnamehash = $this->get_pathname_hash($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->filename);
849         try {
850             $newrecord->id = $DB->insert_record('files', $newrecord);
851         } catch (dml_exception $e) {
852             if ($newfile) {
853                 $this->deleted_file_cleanup($newrecord->contenthash);
854             }
855             throw new stored_file_creation_exception($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid,
856                                                     $newrecord->filepath, $newrecord->filename, $e->debuginfo);
857         }
859         $this->create_directory($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->userid);
861         return $this->get_file_instance($newrecord);
862     }
864     /**
865      * Add new local file.
866      *
867      * @param mixed $file_record object or array describing file
868      * @param string $content content of file
869      * @return stored_file instance
870      */
871     public function create_file_from_string($file_record, $content) {
872         global $DB;
874         $file_record = (array)$file_record;  //do not modify the submitted record, this cast unlinks objects
875         $file_record = (object)$file_record; // we support arrays too
877         // validate all parameters, we do not want any rubbish stored in database, right?
878         if (!is_number($file_record->contextid) or $file_record->contextid < 1) {
879             throw new file_exception('storedfileproblem', 'Invalid contextid');
880         }
882         $file_record->component = clean_param($file_record->component, PARAM_COMPONENT);
883         if (empty($file_record->component)) {
884             throw new file_exception('storedfileproblem', 'Invalid component');
885         }
887         $file_record->filearea = clean_param($file_record->filearea, PARAM_AREA);
888         if (empty($file_record->filearea)) {
889             throw new file_exception('storedfileproblem', 'Invalid filearea');
890         }
892         if (!is_number($file_record->itemid) or $file_record->itemid < 0) {
893             throw new file_exception('storedfileproblem', 'Invalid itemid');
894         }
896         if (!empty($file_record->sortorder)) {
897             if (!is_number($file_record->sortorder) or $file_record->sortorder < 0) {
898                 $file_record->sortorder = 0;
899             }
900         } else {
901             $file_record->sortorder = 0;
902         }
904         $file_record->filepath = clean_param($file_record->filepath, PARAM_PATH);
905         if (strpos($file_record->filepath, '/') !== 0 or strrpos($file_record->filepath, '/') !== strlen($file_record->filepath)-1) {
906             // path must start and end with '/'
907             throw new file_exception('storedfileproblem', 'Invalid file path');
908         }
910         $file_record->filename = clean_param($file_record->filename, PARAM_FILE);
911         if ($file_record->filename === '') {
912             // path must start and end with '/'
913             throw new file_exception('storedfileproblem', 'Invalid file name');
914         }
916         $now = time();
917         if (isset($file_record->timecreated)) {
918             if (!is_number($file_record->timecreated)) {
919                 throw new file_exception('storedfileproblem', 'Invalid file timecreated');
920             }
921             if ($file_record->timecreated < 0) {
922                 //NOTE: unfortunately I make a mistake when creating the "files" table, we can not have negative numbers there, on the other hand no file should be older than 1970, right? (skodak)
923                 $file_record->timecreated = 0;
924             }
925         } else {
926             $file_record->timecreated = $now;
927         }
929         if (isset($file_record->timemodified)) {
930             if (!is_number($file_record->timemodified)) {
931                 throw new file_exception('storedfileproblem', 'Invalid file timemodified');
932             }
933             if ($file_record->timemodified < 0) {
934                 //NOTE: unfortunately I make a mistake when creating the "files" table, we can not have negative numbers there, on the other hand no file should be older than 1970, right? (skodak)
935                 $file_record->timemodified = 0;
936             }
937         } else {
938             $file_record->timemodified = $now;
939         }
941         $newrecord = new stdClass();
943         $newrecord->contextid = $file_record->contextid;
944         $newrecord->component = $file_record->component;
945         $newrecord->filearea  = $file_record->filearea;
946         $newrecord->itemid    = $file_record->itemid;
947         $newrecord->filepath  = $file_record->filepath;
948         $newrecord->filename  = $file_record->filename;
950         $newrecord->timecreated  = $file_record->timecreated;
951         $newrecord->timemodified = $file_record->timemodified;
952         $newrecord->mimetype     = empty($file_record->mimetype) ? mimeinfo('type', $file_record->filename) : $file_record->mimetype;
953         $newrecord->userid       = empty($file_record->userid) ? null : $file_record->userid;
954         $newrecord->source       = empty($file_record->source) ? null : $file_record->source;
955         $newrecord->author       = empty($file_record->author) ? null : $file_record->author;
956         $newrecord->license      = empty($file_record->license) ? null : $file_record->license;
957         $newrecord->sortorder    = $file_record->sortorder;
959         list($newrecord->contenthash, $newrecord->filesize, $newfile) = $this->add_string_to_pool($content);
961         $newrecord->pathnamehash = $this->get_pathname_hash($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->filename);
963         try {
964             $newrecord->id = $DB->insert_record('files', $newrecord);
965         } catch (dml_exception $e) {
966             if ($newfile) {
967                 $this->deleted_file_cleanup($newrecord->contenthash);
968             }
969             throw new stored_file_creation_exception($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid,
970                                                     $newrecord->filepath, $newrecord->filename, $e->debuginfo);
971         }
973         $this->create_directory($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->userid);
975         return $this->get_file_instance($newrecord);
976     }
978     /**
979      * Creates new image file from existing.
980      *
981      * @param mixed $file_record object or array describing new file
982      * @param mixed file id or stored file object
983      * @param int $newwidth in pixels
984      * @param int $newheight in pixels
985      * @param bool $keepaspectratio
986      * @param int $quality depending on image type 0-100 for jpeg, 0-9 (0 means no compression) for png
987      * @return stored_file instance
988      */
989     public function convert_image($file_record, $fid, $newwidth = NULL, $newheight = NULL, $keepaspectratio = true, $quality = NULL) {
990         if (!function_exists('imagecreatefromstring')) {
991             //Most likely the GD php extension isn't installed
992             //image conversion cannot succeed
993             throw new file_exception('storedfileproblem', 'imagecreatefromstring() doesnt exist. The PHP extension "GD" must be installed for image conversion.');
994         }
996         if ($fid instanceof stored_file) {
997             $fid = $fid->get_id();
998         }
1000         $file_record = (array)$file_record; // we support arrays too, do not modify the submitted record!
1002         if (!$file = $this->get_file_by_id($fid)) { // make sure file really exists and we we correct data
1003             throw new file_exception('storedfileproblem', 'File does not exist');
1004         }
1006         if (!$imageinfo = $file->get_imageinfo()) {
1007             throw new file_exception('storedfileproblem', 'File is not an image');
1008         }
1010         if (!isset($file_record['filename'])) {
1011             $file_record['filename'] = $file->get_filename();
1012         }
1014         if (!isset($file_record['mimetype'])) {
1015             $file_record['mimetype'] = mimeinfo('type', $file_record['filename']);
1016         }
1018         $width    = $imageinfo['width'];
1019         $height   = $imageinfo['height'];
1020         $mimetype = $imageinfo['mimetype'];
1022         if ($keepaspectratio) {
1023             if (0 >= $newwidth and 0 >= $newheight) {
1024                 // no sizes specified
1025                 $newwidth  = $width;
1026                 $newheight = $height;
1028             } else if (0 < $newwidth and 0 < $newheight) {
1029                 $xheight = ($newwidth*($height/$width));
1030                 if ($xheight < $newheight) {
1031                     $newheight = (int)$xheight;
1032                 } else {
1033                     $newwidth = (int)($newheight*($width/$height));
1034                 }
1036             } else if (0 < $newwidth) {
1037                 $newheight = (int)($newwidth*($height/$width));
1039             } else { //0 < $newheight
1040                 $newwidth = (int)($newheight*($width/$height));
1041             }
1043         } else {
1044             if (0 >= $newwidth) {
1045                 $newwidth = $width;
1046             }
1047             if (0 >= $newheight) {
1048                 $newheight = $height;
1049             }
1050         }
1052         $img = imagecreatefromstring($file->get_content());
1053         if ($height != $newheight or $width != $newwidth) {
1054             $newimg = imagecreatetruecolor($newwidth, $newheight);
1055             if (!imagecopyresized($newimg, $img, 0, 0, 0, 0, $newwidth, $newheight, $width, $height)) {
1056                 // weird
1057                 throw new file_exception('storedfileproblem', 'Can not resize image');
1058             }
1059             imagedestroy($img);
1060             $img = $newimg;
1061         }
1063         ob_start();
1064         switch ($file_record['mimetype']) {
1065             case 'image/gif':
1066                 imagegif($img);
1067                 break;
1069             case 'image/jpeg':
1070                 if (is_null($quality)) {
1071                     imagejpeg($img);
1072                 } else {
1073                     imagejpeg($img, NULL, $quality);
1074                 }
1075                 break;
1077             case 'image/png':
1078                 $quality = (int)$quality;
1079                 imagepng($img, NULL, $quality, NULL);
1080                 break;
1082             default:
1083                 throw new file_exception('storedfileproblem', 'Unsupported mime type');
1084         }
1086         $content = ob_get_contents();
1087         ob_end_clean();
1088         imagedestroy($img);
1090         if (!$content) {
1091             throw new file_exception('storedfileproblem', 'Can not convert image');
1092         }
1094         return $this->create_file_from_string($file_record, $content);
1095     }
1097     /**
1098      * Add file content to sha1 pool.
1099      *
1100      * @param string $pathname path to file
1101      * @param string $contenthash sha1 hash of content if known (performance only)
1102      * @return array (contenthash, filesize, newfile)
1103      */
1104     public function add_file_to_pool($pathname, $contenthash = NULL) {
1105         if (!is_readable($pathname)) {
1106             throw new file_exception('storedfilecannotread', '', $pathname);
1107         }
1109         if (is_null($contenthash)) {
1110             $contenthash = sha1_file($pathname);
1111         }
1113         $filesize = filesize($pathname);
1115         $hashpath = $this->path_from_hash($contenthash);
1116         $hashfile = "$hashpath/$contenthash";
1118         if (file_exists($hashfile)) {
1119             if (filesize($hashfile) !== $filesize) {
1120                 throw new file_pool_content_exception($contenthash);
1121             }
1122             $newfile = false;
1124         } else {
1125             if (!is_dir($hashpath)) {
1126                 if (!mkdir($hashpath, $this->dirpermissions, true)) {
1127                     throw new file_exception('storedfilecannotcreatefiledirs'); // permission trouble
1128                 }
1129             }
1130             $newfile = true;
1132             if (!copy($pathname, $hashfile)) {
1133                 throw new file_exception('storedfilecannotread', '', $pathname);
1134             }
1136             if (filesize($hashfile) !== $filesize) {
1137                 @unlink($hashfile);
1138                 throw new file_pool_content_exception($contenthash);
1139             }
1140             chmod($hashfile, $this->filepermissions); // fix permissions if needed
1141         }
1144         return array($contenthash, $filesize, $newfile);
1145     }
1147     /**
1148      * Add string content to sha1 pool.
1149      *
1150      * @param string $content file content - binary string
1151      * @return array (contenthash, filesize, newfile)
1152      */
1153     public function add_string_to_pool($content) {
1154         $contenthash = sha1($content);
1155         $filesize = strlen($content); // binary length
1157         $hashpath = $this->path_from_hash($contenthash);
1158         $hashfile = "$hashpath/$contenthash";
1161         if (file_exists($hashfile)) {
1162             if (filesize($hashfile) !== $filesize) {
1163                 throw new file_pool_content_exception($contenthash);
1164             }
1165             $newfile = false;
1167         } else {
1168             if (!is_dir($hashpath)) {
1169                 if (!mkdir($hashpath, $this->dirpermissions, true)) {
1170                     throw new file_exception('storedfilecannotcreatefiledirs'); // permission trouble
1171                 }
1172             }
1173             $newfile = true;
1175             file_put_contents($hashfile, $content);
1177             if (filesize($hashfile) !== $filesize) {
1178                 @unlink($hashfile);
1179                 throw new file_pool_content_exception($contenthash);
1180             }
1181             chmod($hashfile, $this->filepermissions); // fix permissions if needed
1182         }
1184         return array($contenthash, $filesize, $newfile);
1185     }
1187     /**
1188      * Return path to file with given hash.
1189      *
1190      * NOTE: must not be public, files in pool must not be modified
1191      *
1192      * @param string $contenthash
1193      * @return string expected file location
1194      */
1195     protected function path_from_hash($contenthash) {
1196         $l1 = $contenthash[0].$contenthash[1];
1197         $l2 = $contenthash[2].$contenthash[3];
1198         return "$this->filedir/$l1/$l2";
1199     }
1201     /**
1202      * Return path to file with given hash.
1203      *
1204      * NOTE: must not be public, files in pool must not be modified
1205      *
1206      * @param string $contenthash
1207      * @return string expected file location
1208      */
1209     protected function trash_path_from_hash($contenthash) {
1210         $l1 = $contenthash[0].$contenthash[1];
1211         $l2 = $contenthash[2].$contenthash[3];
1212         return "$this->trashdir/$l1/$l2";
1213     }
1215     /**
1216      * Tries to recover missing content of file from trash.
1217      *
1218      * @param object $file_record
1219      * @return bool success
1220      */
1221     public function try_content_recovery($file) {
1222         $contenthash = $file->get_contenthash();
1223         $trashfile = $this->trash_path_from_hash($contenthash).'/'.$contenthash;
1224         if (!is_readable($trashfile)) {
1225             if (!is_readable($this->trashdir.'/'.$contenthash)) {
1226                 return false;
1227             }
1228             // nice, at least alternative trash file in trash root exists
1229             $trashfile = $this->trashdir.'/'.$contenthash;
1230         }
1231         if (filesize($trashfile) != $file->get_filesize() or sha1_file($trashfile) != $contenthash) {
1232             //weird, better fail early
1233             return false;
1234         }
1235         $contentdir  = $this->path_from_hash($contenthash);
1236         $contentfile = $contentdir.'/'.$contenthash;
1237         if (file_exists($contentfile)) {
1238             //strange, no need to recover anything
1239             return true;
1240         }
1241         if (!is_dir($contentdir)) {
1242             if (!mkdir($contentdir, $this->dirpermissions, true)) {
1243                 return false;
1244             }
1245         }
1246         return rename($trashfile, $contentfile);
1247     }
1249     /**
1250      * Marks pool file as candidate for deleting.
1251      *
1252      * DO NOT call directly - reserved for core!!
1253      *
1254      * @param string $contenthash
1255      * @return void
1256      */
1257     public function deleted_file_cleanup($contenthash) {
1258         global $DB;
1260         //Note: this section is critical - in theory file could be reused at the same
1261         //      time, if this happens we can still recover the file from trash
1262         if ($DB->record_exists('files', array('contenthash'=>$contenthash))) {
1263             // file content is still used
1264             return;
1265         }
1266         //move content file to trash
1267         $contentfile = $this->path_from_hash($contenthash).'/'.$contenthash;
1268         if (!file_exists($contentfile)) {
1269             //weird, but no problem
1270             return;
1271         }
1272         $trashpath = $this->trash_path_from_hash($contenthash);
1273         $trashfile = $trashpath.'/'.$contenthash;
1274         if (file_exists($trashfile)) {
1275             // we already have this content in trash, no need to move it there
1276             unlink($contentfile);
1277             return;
1278         }
1279         if (!is_dir($trashpath)) {
1280             mkdir($trashpath, $this->dirpermissions, true);
1281         }
1282         rename($contentfile, $trashfile);
1283         chmod($trashfile, $this->filepermissions); // fix permissions if needed
1284     }
1286     /**
1287      * Cron cleanup job.
1288      *
1289      * @return void
1290      */
1291     public function cron() {
1292         global $CFG, $DB;
1294         // find out all stale draft areas (older than 4 days) and purge them
1295         // those are identified by time stamp of the /. root dir
1296         mtrace('Deleting old draft files... ', '');
1297         $old = time() - 60*60*24*4;
1298         $sql = "SELECT *
1299                   FROM {files}
1300                  WHERE component = 'user' AND filearea = 'draft' AND filepath = '/' AND filename = '.'
1301                        AND timecreated < :old";
1302         $rs = $DB->get_recordset_sql($sql, array('old'=>$old));
1303         foreach ($rs as $dir) {
1304             $this->delete_area_files($dir->contextid, $dir->component, $dir->filearea, $dir->itemid);
1305         }
1306         $rs->close();
1307         mtrace('done.');
1309         // remove trash pool files once a day
1310         // if you want to disable purging of trash put $CFG->fileslastcleanup=time(); into config.php
1311         if (empty($CFG->fileslastcleanup) or $CFG->fileslastcleanup < time() - 60*60*24) {
1312             require_once($CFG->libdir.'/filelib.php');
1313             // Delete files that are associated with a context that no longer exists.
1314             mtrace('Cleaning up files from deleted contexts... ', '');
1315             $sql = "SELECT DISTINCT f.contextid
1316                     FROM {files} f
1317                     LEFT OUTER JOIN {context} c ON f.contextid = c.id
1318                     WHERE c.id IS NULL";
1319             $rs = $DB->get_recordset_sql($sql);
1320             if ($rs->valid()) {
1321                 $fs = get_file_storage();
1322                 foreach ($rs as $ctx) {
1323                     $fs->delete_area_files($ctx->contextid);
1324                 }
1325             }
1326             $rs->close();
1327             mtrace('done.');
1329             mtrace('Deleting trash files... ', '');
1330             fulldelete($this->trashdir);
1331             set_config('fileslastcleanup', time());
1332             mtrace('done.');
1333         }
1334     }