MDL-58297 filestorage: New functions for hash calculation
[moodle.git] / lib / filestorage / file_system_filedir.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  * Core file system class definition.
19  *
20  * @package   core_files
21  * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 defined('MOODLE_INTERNAL') || die();
27 /**
28  * File system class used for low level access to real files in filedir.
29  *
30  * @package   core_files
31  * @category  files
32  * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
33  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
34  */
35 class file_system_filedir extends file_system {
37     /**
38      * @var string The path to the local copy of the filedir.
39      */
40     protected $filedir = null;
42     /**
43      * @var string The path to the trashdir.
44      */
45     protected $trashdir = null;
47     /**
48      * @var string Default directory permissions for new dirs.
49      */
50     protected $dirpermissions = null;
52     /**
53      * @var string Default file permissions for new files.
54      */
55     protected $filepermissions = null;
58     /**
59      * Perform any custom setup for this type of file_system.
60      */
61     public function __construct() {
62         global $CFG;
64         if (isset($CFG->filedir)) {
65             $this->filedir = $CFG->filedir;
66         } else {
67             $this->filedir = $CFG->dataroot.'/filedir';
68         }
70         if (isset($CFG->trashdir)) {
71             $this->trashdir = $CFG->trashdir;
72         } else {
73             $this->trashdir = $CFG->dataroot.'/trashdir';
74         }
76         $this->dirpermissions = $CFG->directorypermissions;
77         $this->filepermissions = $CFG->filepermissions;
79         // Make sure the file pool directory exists.
80         if (!is_dir($this->filedir)) {
81             if (!mkdir($this->filedir, $this->dirpermissions, true)) {
82                 // Permission trouble.
83                 throw new file_exception('storedfilecannotcreatefiledirs');
84             }
86             // Place warning file in file pool root.
87             if (!file_exists($this->filedir.'/warning.txt')) {
88                 file_put_contents($this->filedir.'/warning.txt',
89                         'This directory contains the content of uploaded files and is controlled by Moodle code. ' .
90                         'Do not manually move, change or rename any of the files and subdirectories here.');
91                 chmod($this->filedir . '/warning.txt', $this->filepermissions);
92             }
93         }
95         // Make sure the trashdir directory exists too.
96         if (!is_dir($this->trashdir)) {
97             if (!mkdir($this->trashdir, $this->dirpermissions, true)) {
98                 // Permission trouble.
99                 throw new file_exception('storedfilecannotcreatefiledirs');
100             }
101         }
102     }
104     /**
105      * Get the full path for the specified hash, including the path to the filedir.
106      *
107      * @param string $contenthash The content hash
108      * @param bool $fetchifnotfound Whether to attempt to fetch from the remote path if not found.
109      * @return string The full path to the content file
110      */
111     protected function get_local_path_from_hash($contenthash, $fetchifnotfound = false) {
112         return $this->get_fulldir_from_hash($contenthash) . DIRECTORY_SEPARATOR . $contenthash;
113     }
115     /**
116      * Get a remote filepath for the specified stored file.
117      *
118      * @param stored_file $file The file to fetch the path for
119      * @param bool $fetchifnotfound Whether to attempt to fetch from the remote path if not found.
120      * @return string The full path to the content file
121      */
122     protected function get_local_path_from_storedfile(stored_file $file, $fetchifnotfound = false) {
123         $filepath = $this->get_local_path_from_hash($file->get_contenthash(), $fetchifnotfound);
125         // Try content recovery.
126         if ($fetchifnotfound && !is_readable($filepath)) {
127             $this->recover_file($file);
128         }
130         return $filepath;
131     }
133     /**
134      * Get a remote filepath for the specified stored file.
135      *
136      * @param stored_file $file The file to serve.
137      * @return string full path to pool file with file content
138      */
139     protected function get_remote_path_from_storedfile(stored_file $file) {
140         return $this->get_local_path_from_storedfile($file, false);
141     }
143     /**
144      * Get the full path for the specified hash, including the path to the filedir.
145      *
146      * @param string $contenthash The content hash
147      * @return string The full path to the content file
148      */
149     protected function get_remote_path_from_hash($contenthash) {
150         return $this->get_local_path_from_hash($contenthash, false);
151     }
153     /**
154      * Get the full directory to the stored file, including the path to the
155      * filedir, and the directory which the file is actually in.
156      *
157      * Note: This function does not ensure that the file is present on disk.
158      *
159      * @param stored_file $file The file to fetch details for.
160      * @return string The full path to the content directory
161      */
162     protected function get_fulldir_from_storedfile(stored_file $file) {
163         return $this->get_fulldir_from_hash($file->get_contenthash());
164     }
166     /**
167      * Get the full directory to the stored file, including the path to the
168      * filedir, and the directory which the file is actually in.
169      *
170      * @param string $contenthash The content hash
171      * @return string The full path to the content directory
172      */
173     protected function get_fulldir_from_hash($contenthash) {
174         return $this->filedir . DIRECTORY_SEPARATOR . $this->get_contentdir_from_hash($contenthash);
175     }
177     /**
178      * Get the content directory for the specified content hash.
179      * This is the directory that the file will be in, but without the
180      * fulldir.
181      *
182      * @param string $contenthash The content hash
183      * @return string The directory within filedir
184      */
185     protected function get_contentdir_from_hash($contenthash) {
186         $l1 = $contenthash[0] . $contenthash[1];
187         $l2 = $contenthash[2] . $contenthash[3];
188         return "$l1/$l2";
189     }
191     /**
192      * Get the content path for the specified content hash within filedir.
193      *
194      * This does not include the filedir, and is often used by file systems
195      * as the object key for storage and retrieval.
196      *
197      * @param string $contenthash The content hash
198      * @return string The filepath within filedir
199      */
200     protected function get_contentpath_from_hash($contenthash) {
201         return $this->get_contentdir_from_hash($contenthash) . "/$contenthash";
202     }
204     /**
205      * Get the full directory for the specified hash in the trash, including the path to the
206      * trashdir, and the directory which the file is actually in.
207      *
208      * @param string $contenthash The content hash
209      * @return string The full path to the trash directory
210      */
211     protected function get_trash_fulldir_from_hash($contenthash) {
212         return $this->trashdir . DIRECTORY_SEPARATOR . $this->get_contentdir_from_hash($contenthash);
213     }
215     /**
216      * Get the full path for the specified hash in the trash, including the path to the trashdir.
217      *
218      * @param string $contenthash The content hash
219      * @return string The full path to the trash file
220      */
221     protected function get_trash_fullpath_from_hash($contenthash) {
222         return $this->trashdir . DIRECTORY_SEPARATOR . $this->get_contentpath_from_hash($contenthash);
223     }
225     /**
226      * Copy content of file to given pathname.
227      *
228      * @param stored_file $file The file to be copied
229      * @param string $target real path to the new file
230      * @return bool success
231      */
232     public function copy_content_from_storedfile(stored_file $file, $target) {
233         $source = $this->get_local_path_from_storedfile($file, true);
234         return copy($source, $target);
235     }
237     /**
238      * Tries to recover missing content of file from trash.
239      *
240      * @param stored_file $file stored_file instance
241      * @return bool success
242      */
243     protected function recover_file(stored_file $file) {
244         $contentfile = $this->get_local_path_from_storedfile($file, false);
246         if (file_exists($contentfile)) {
247             // The file already exists on the file system. No need to recover.
248             return true;
249         }
251         $contenthash = $file->get_contenthash();
252         $contentdir = $this->get_fulldir_from_storedfile($file);
253         $trashfile = $this->get_trash_fullpath_from_hash($contenthash);
254         $alttrashfile = $this->trashdir . DIRECTORY_SEPARATOR . $contenthash;
256         if (!is_readable($trashfile)) {
257             // The trash file was not found. Check the alternative trash file too just in case.
258             if (!is_readable($alttrashfile)) {
259                 return false;
260             }
261             // The alternative trash file in trash root exists.
262             $trashfile = $alttrashfile;
263         }
265         if (filesize($trashfile) != $file->get_filesize() or file_storage::hash_from_path($trashfile) != $contenthash) {
266             // The files are different. Leave this one in trash - something seems to be wrong with it.
267             return false;
268         }
270         if (!is_dir($contentdir)) {
271             if (!mkdir($contentdir, $this->dirpermissions, true)) {
272                 // Unable to create the target directory.
273                 return false;
274             }
275         }
277         // Perform a rename - these are generally atomic which gives us big
278         // performance wins, especially for large files.
279         return rename($trashfile, $contentfile);
280     }
282     /**
283      * Marks pool file as candidate for deleting.
284      *
285      * @param string $contenthash
286      */
287     public function remove_file($contenthash) {
288         if (!self::is_file_removable($contenthash)) {
289             // Don't remove the file - it's still in use.
290             return;
291         }
293         if (!$this->is_file_readable_remotely_by_hash($contenthash)) {
294             // The file wasn't found in the first place. Just ignore it.
295             return;
296         }
298         $trashpath  = $this->get_trash_fulldir_from_hash($contenthash);
299         $trashfile  = $this->get_trash_fullpath_from_hash($contenthash);
300         $contentfile = $this->get_local_path_from_hash($contenthash, true);
302         if (!is_dir($trashpath)) {
303             mkdir($trashpath, $this->dirpermissions, true);
304         }
306         if (file_exists($trashfile)) {
307             // A copy of this file is already in the trash.
308             // Remove the old version.
309             unlink($contentfile);
310             return;
311         }
313         // Move the contentfile to the trash, and fix permissions as required.
314         rename($contentfile, $trashfile);
316         // Fix permissions, only if needed.
317         $currentperms = octdec(substr(decoct(fileperms($trashfile)), -4));
318         if ((int)$this->filepermissions !== $currentperms) {
319             chmod($trashfile, $this->filepermissions);
320         }
321     }
323     /**
324      * Cleanup the trash directory.
325      */
326     public function cron() {
327         $this->empty_trash();
328     }
330     protected function empty_trash() {
331         fulldelete($this->trashdir);
332         set_config('fileslastcleanup', time());
333     }
335     /**
336      * Add the supplied file to the file system.
337      *
338      * Note: If overriding this function, it is advisable to store the file
339      * in the path returned by get_local_path_from_hash as there may be
340      * subsequent uses of the file in the same request.
341      *
342      * @param string $pathname Path to file currently on disk
343      * @param string $contenthash SHA1 hash of content if known (performance only)
344      * @return array (contenthash, filesize, newfile)
345      */
346     public function add_file_from_path($pathname, $contenthash = null) {
347         global $CFG;
349         if (!is_readable($pathname)) {
350             throw new file_exception('storedfilecannotread', '', $pathname);
351         }
353         $filesize = filesize($pathname);
354         if ($filesize === false) {
355             throw new file_exception('storedfilecannotread', '', $pathname);
356         }
358         if (is_null($contenthash)) {
359             $contenthash = file_storage::hash_from_path($pathname);
360         } else if ($CFG->debugdeveloper) {
361             $filehash = file_storage::hash_from_path($pathname);
362             if ($filehash === false) {
363                 throw new file_exception('storedfilecannotread', '', $pathname);
364             }
365             if ($filehash !== $contenthash) {
366                 // Hopefully this never happens, if yes we need to fix calling code.
367                 debugging("Invalid contenthash submitted for file $pathname", DEBUG_DEVELOPER);
368                 $contenthash = $filehash;
369             }
370         }
371         if ($contenthash === false) {
372             throw new file_exception('storedfilecannotread', '', $pathname);
373         }
375         if ($filesize > 0 and $contenthash === file_storage::hash_from_string('')) {
376             // Did the file change or is file_storage::hash_from_path() borked for this file?
377             clearstatcache();
378             $contenthash = file_storage::hash_from_path($pathname);
379             $filesize = filesize($pathname);
381             if ($contenthash === false or $filesize === false) {
382                 throw new file_exception('storedfilecannotread', '', $pathname);
383             }
384             if ($filesize > 0 and $contenthash === file_storage::hash_from_string('')) {
385                 // This is very weird...
386                 throw new file_exception('storedfilecannotread', '', $pathname);
387             }
388         }
390         $hashpath = $this->get_fulldir_from_hash($contenthash);
391         $hashfile = $this->get_local_path_from_hash($contenthash, false);
393         $newfile = true;
395         if (file_exists($hashfile)) {
396             if (filesize($hashfile) === $filesize) {
397                 return array($contenthash, $filesize, false);
398             }
399             if (file_storage::hash_from_path($hashfile) === $contenthash) {
400                 // Jackpot! We have a hash collision.
401                 mkdir("$this->filedir/jackpot/", $this->dirpermissions, true);
402                 copy($pathname, "$this->filedir/jackpot/{$contenthash}_1");
403                 copy($hashfile, "$this->filedir/jackpot/{$contenthash}_2");
404                 throw new file_pool_content_exception($contenthash);
405             }
406             debugging("Replacing invalid content file $contenthash");
407             unlink($hashfile);
408             $newfile = false;
409         }
411         if (!is_dir($hashpath)) {
412             if (!mkdir($hashpath, $this->dirpermissions, true)) {
413                 // Permission trouble.
414                 throw new file_exception('storedfilecannotcreatefiledirs');
415             }
416         }
418         // Let's try to prevent some race conditions.
420         $prev = ignore_user_abort(true);
421         @unlink($hashfile.'.tmp');
422         if (!copy($pathname, $hashfile.'.tmp')) {
423             // Borked permissions or out of disk space.
424             @unlink($hashfile.'.tmp');
425             ignore_user_abort($prev);
426             throw new file_exception('storedfilecannotcreatefile');
427         }
428         if (file_storage::hash_from_path($hashfile.'.tmp') !== $contenthash) {
429             // Highly unlikely edge case, but this can happen on an NFS volume with no space remaining.
430             @unlink($hashfile.'.tmp');
431             ignore_user_abort($prev);
432             throw new file_exception('storedfilecannotcreatefile');
433         }
434         rename($hashfile.'.tmp', $hashfile);
435         chmod($hashfile, $this->filepermissions); // Fix permissions if needed.
436         @unlink($hashfile.'.tmp'); // Just in case anything fails in a weird way.
437         ignore_user_abort($prev);
439         return array($contenthash, $filesize, $newfile);
440     }
442     /**
443      * Add a file with the supplied content to the file system.
444      *
445      * Note: If overriding this function, it is advisable to store the file
446      * in the path returned by get_local_path_from_hash as there may be
447      * subsequent uses of the file in the same request.
448      *
449      * @param string $content file content - binary string
450      * @return array (contenthash, filesize, newfile)
451      */
452     public function add_file_from_string($content) {
453         global $CFG;
455         $contenthash = file_storage::hash_from_string($content);
456         // Binary length.
457         $filesize = strlen($content);
459         $hashpath = $this->get_fulldir_from_hash($contenthash);
460         $hashfile = $this->get_local_path_from_hash($contenthash, false);
462         $newfile = true;
464         if (file_exists($hashfile)) {
465             if (filesize($hashfile) === $filesize) {
466                 return array($contenthash, $filesize, false);
467             }
468             if (file_storage::hash_from_path($hashfile) === $contenthash) {
469                 // Jackpot! We have a hash collision.
470                 mkdir("$this->filedir/jackpot/", $this->dirpermissions, true);
471                 copy($hashfile, "$this->filedir/jackpot/{$contenthash}_1");
472                 file_put_contents("$this->filedir/jackpot/{$contenthash}_2", $content);
473                 throw new file_pool_content_exception($contenthash);
474             }
475             debugging("Replacing invalid content file $contenthash");
476             unlink($hashfile);
477             $newfile = false;
478         }
480         if (!is_dir($hashpath)) {
481             if (!mkdir($hashpath, $this->dirpermissions, true)) {
482                 // Permission trouble.
483                 throw new file_exception('storedfilecannotcreatefiledirs');
484             }
485         }
487         // Hopefully this works around most potential race conditions.
489         $prev = ignore_user_abort(true);
491         if (!empty($CFG->preventfilelocking)) {
492             $newsize = file_put_contents($hashfile.'.tmp', $content);
493         } else {
494             $newsize = file_put_contents($hashfile.'.tmp', $content, LOCK_EX);
495         }
497         if ($newsize === false) {
498             // Borked permissions most likely.
499             ignore_user_abort($prev);
500             throw new file_exception('storedfilecannotcreatefile');
501         }
502         if (filesize($hashfile.'.tmp') !== $filesize) {
503             // Out of disk space?
504             unlink($hashfile.'.tmp');
505             ignore_user_abort($prev);
506             throw new file_exception('storedfilecannotcreatefile');
507         }
508         rename($hashfile.'.tmp', $hashfile);
509         chmod($hashfile, $this->filepermissions); // Fix permissions if needed.
510         @unlink($hashfile.'.tmp'); // Just in case anything fails in a weird way.
511         ignore_user_abort($prev);
513         return array($contenthash, $filesize, $newfile);
514     }