Merge branch 'MDL-71860-master' of git://github.com/lameze/moodle
[moodle.git] / lib / filestorage / file_system_filedir.php
blob8f9eb56e08970a6c58c3925966883797b75904c2
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.
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.
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
25 defined('MOODLE_INTERNAL') || die();
27 /**
28 * File system class used for low level access to real files in filedir.
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
35 class file_system_filedir extends file_system {
37 /**
38 * @var string The path to the local copy of the filedir.
40 protected $filedir = null;
42 /**
43 * @var string The path to the trashdir.
45 protected $trashdir = null;
47 /**
48 * @var string Default directory permissions for new dirs.
50 protected $dirpermissions = null;
52 /**
53 * @var string Default file permissions for new files.
55 protected $filepermissions = null;
58 /**
59 * Perform any custom setup for this type of file_system.
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';
70 if (isset($CFG->trashdir)) {
71 $this->trashdir = $CFG->trashdir;
72 } else {
73 $this->trashdir = $CFG->dataroot.'/trashdir';
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');
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);
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');
105 * Get the full path for the specified hash, including the path to the filedir.
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
111 protected function get_local_path_from_hash($contenthash, $fetchifnotfound = false) {
112 return $this->get_fulldir_from_hash($contenthash) . '/' .$contenthash;
116 * Get a remote filepath for the specified stored file.
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
122 public 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);
130 return $filepath;
134 * Get a remote filepath for the specified stored file.
136 * @param stored_file $file The file to serve.
137 * @return string full path to pool file with file content
139 public function get_remote_path_from_storedfile(stored_file $file) {
140 return $this->get_local_path_from_storedfile($file, false);
144 * Get the full path for the specified hash, including the path to the filedir.
146 * @param string $contenthash The content hash
147 * @return string The full path to the content file
149 protected function get_remote_path_from_hash($contenthash) {
150 return $this->get_local_path_from_hash($contenthash, false);
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.
157 * Note: This function does not ensure that the file is present on disk.
159 * @param stored_file $file The file to fetch details for.
160 * @return string The full path to the content directory
162 protected function get_fulldir_from_storedfile(stored_file $file) {
163 return $this->get_fulldir_from_hash($file->get_contenthash());
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.
170 * @param string $contenthash The content hash
171 * @return string The full path to the content directory
173 protected function get_fulldir_from_hash($contenthash) {
174 return $this->filedir . '/' . $this->get_contentdir_from_hash($contenthash);
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.
182 * @param string $contenthash The content hash
183 * @return string The directory within filedir
185 protected function get_contentdir_from_hash($contenthash) {
186 $l1 = $contenthash[0] . $contenthash[1];
187 $l2 = $contenthash[2] . $contenthash[3];
188 return "$l1/$l2";
192 * Get the content path for the specified content hash within filedir.
194 * This does not include the filedir, and is often used by file systems
195 * as the object key for storage and retrieval.
197 * @param string $contenthash The content hash
198 * @return string The filepath within filedir
200 protected function get_contentpath_from_hash($contenthash) {
201 return $this->get_contentdir_from_hash($contenthash) . '/' . $contenthash;
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.
208 * @param string $contenthash The content hash
209 * @return string The full path to the trash directory
211 protected function get_trash_fulldir_from_hash($contenthash) {
212 return $this->trashdir . '/' . $this->get_contentdir_from_hash($contenthash);
216 * Get the full path for the specified hash in the trash, including the path to the trashdir.
218 * @param string $contenthash The content hash
219 * @return string The full path to the trash file
221 protected function get_trash_fullpath_from_hash($contenthash) {
222 return $this->trashdir . '/' . $this->get_contentpath_from_hash($contenthash);
226 * Copy content of file to given pathname.
228 * @param stored_file $file The file to be copied
229 * @param string $target real path to the new file
230 * @return bool success
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);
238 * Tries to recover missing content of file from trash.
240 * @param stored_file $file stored_file instance
241 * @return bool success
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;
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}/{$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;
261 // The alternative trash file in trash root exists.
262 $trashfile = $alttrashfile;
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;
270 if (!is_dir($contentdir)) {
271 if (!mkdir($contentdir, $this->dirpermissions, true)) {
272 // Unable to create the target directory.
273 return false;
277 // Perform a rename - these are generally atomic which gives us big
278 // performance wins, especially for large files.
279 return rename($trashfile, $contentfile);
283 * Marks pool file as candidate for deleting.
285 * @param string $contenthash
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;
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;
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);
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;
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);
324 * Cleanup the trash directory.
326 public function cron() {
327 $this->empty_trash();
330 protected function empty_trash() {
331 fulldelete($this->trashdir);
332 set_config('fileslastcleanup', time());
336 * Add the supplied file to the file system.
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.
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)
346 public function add_file_from_path($pathname, $contenthash = null) {
348 list($contenthash, $filesize) = $this->validate_hash_and_file_size($contenthash, $pathname);
350 $hashpath = $this->get_fulldir_from_hash($contenthash);
351 $hashfile = $this->get_local_path_from_hash($contenthash, false);
353 $newfile = true;
355 if (file_exists($hashfile)) {
356 if (filesize($hashfile) === $filesize) {
357 return array($contenthash, $filesize, false);
359 if (file_storage::hash_from_path($hashfile) === $contenthash) {
360 // Jackpot! We have a hash collision.
361 mkdir("$this->filedir/jackpot/", $this->dirpermissions, true);
362 copy($pathname, "$this->filedir/jackpot/{$contenthash}_1");
363 copy($hashfile, "$this->filedir/jackpot/{$contenthash}_2");
364 throw new file_pool_content_exception($contenthash);
366 debugging("Replacing invalid content file $contenthash");
367 unlink($hashfile);
368 $newfile = false;
371 if (!is_dir($hashpath)) {
372 if (!mkdir($hashpath, $this->dirpermissions, true)) {
373 // Permission trouble.
374 throw new file_exception('storedfilecannotcreatefiledirs');
378 // Let's try to prevent some race conditions.
380 $prev = ignore_user_abort(true);
381 if (file_exists($hashfile.'.tmp')) {
382 @unlink($hashfile.'.tmp');
384 if (!copy($pathname, $hashfile.'.tmp')) {
385 // Borked permissions or out of disk space.
386 @unlink($hashfile.'.tmp');
387 ignore_user_abort($prev);
388 throw new file_exception('storedfilecannotcreatefile');
390 if (file_storage::hash_from_path($hashfile.'.tmp') !== $contenthash) {
391 // Highly unlikely edge case, but this can happen on an NFS volume with no space remaining.
392 @unlink($hashfile.'.tmp');
393 ignore_user_abort($prev);
394 throw new file_exception('storedfilecannotcreatefile');
396 if (!rename($hashfile.'.tmp', $hashfile)) {
397 // Something very strange went wrong.
398 @unlink($hashfile . '.tmp');
399 // Note, we don't try to clean up $hashfile. Almost certainly, if it exists
400 // (e.g. written by another process?) it will be right, so don't wipe it.
401 ignore_user_abort($prev);
402 throw new file_exception('storedfilecannotcreatefile');
404 chmod($hashfile, $this->filepermissions); // Fix permissions if needed.
405 if (file_exists($hashfile.'.tmp')) {
406 // Just in case anything fails in a weird way.
407 @unlink($hashfile.'.tmp');
409 ignore_user_abort($prev);
411 return array($contenthash, $filesize, $newfile);
415 * Add a file with the supplied content to the file system.
417 * Note: If overriding this function, it is advisable to store the file
418 * in the path returned by get_local_path_from_hash as there may be
419 * subsequent uses of the file in the same request.
421 * @param string $content file content - binary string
422 * @return array (contenthash, filesize, newfile)
424 public function add_file_from_string($content) {
425 global $CFG;
427 $contenthash = file_storage::hash_from_string($content);
428 // Binary length.
429 $filesize = strlen($content);
431 $hashpath = $this->get_fulldir_from_hash($contenthash);
432 $hashfile = $this->get_local_path_from_hash($contenthash, false);
434 $newfile = true;
436 if (file_exists($hashfile)) {
437 if (filesize($hashfile) === $filesize) {
438 return array($contenthash, $filesize, false);
440 if (file_storage::hash_from_path($hashfile) === $contenthash) {
441 // Jackpot! We have a hash collision.
442 mkdir("$this->filedir/jackpot/", $this->dirpermissions, true);
443 copy($hashfile, "$this->filedir/jackpot/{$contenthash}_1");
444 file_put_contents("$this->filedir/jackpot/{$contenthash}_2", $content);
445 throw new file_pool_content_exception($contenthash);
447 debugging("Replacing invalid content file $contenthash");
448 unlink($hashfile);
449 $newfile = false;
452 if (!is_dir($hashpath)) {
453 if (!mkdir($hashpath, $this->dirpermissions, true)) {
454 // Permission trouble.
455 throw new file_exception('storedfilecannotcreatefiledirs');
459 // Hopefully this works around most potential race conditions.
461 $prev = ignore_user_abort(true);
463 if (!empty($CFG->preventfilelocking)) {
464 $newsize = file_put_contents($hashfile.'.tmp', $content);
465 } else {
466 $newsize = file_put_contents($hashfile.'.tmp', $content, LOCK_EX);
469 if ($newsize === false) {
470 // Borked permissions most likely.
471 ignore_user_abort($prev);
472 throw new file_exception('storedfilecannotcreatefile');
474 if (filesize($hashfile.'.tmp') !== $filesize) {
475 // Out of disk space?
476 unlink($hashfile.'.tmp');
477 ignore_user_abort($prev);
478 throw new file_exception('storedfilecannotcreatefile');
480 if (!rename($hashfile.'.tmp', $hashfile)) {
481 // Something very strange went wrong.
482 @unlink($hashfile . '.tmp');
483 // Note, we don't try to clean up $hashfile. Almost certainly, if it exists
484 // (e.g. written by another process?) it will be right, so don't wipe it.
485 ignore_user_abort($prev);
486 throw new file_exception('storedfilecannotcreatefile');
488 chmod($hashfile, $this->filepermissions); // Fix permissions if needed.
489 if (file_exists($hashfile.'.tmp')) {
490 // Just in case anything fails in a weird way.
491 @unlink($hashfile.'.tmp');
493 ignore_user_abort($prev);
495 return array($contenthash, $filesize, $newfile);