Merge branch 'MDL-80220-main' of https://github.com/laurentdavid/moodle
[moodle.git] / lib / filestorage / file_system_filedir.php
blob75338755316d34426c0f1270ca86599f2bfc53a7
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 $hashsize = self::check_file_exists_and_get_size($hashfile);
356 if ($hashsize !== null) {
357 if ($hashsize === $filesize) {
358 return array($contenthash, $filesize, false);
360 if (file_storage::hash_from_path($hashfile) === $contenthash) {
361 // Jackpot! We have a hash collision.
362 mkdir("$this->filedir/jackpot/", $this->dirpermissions, true);
363 copy($pathname, "$this->filedir/jackpot/{$contenthash}_1");
364 copy($hashfile, "$this->filedir/jackpot/{$contenthash}_2");
365 throw new file_pool_content_exception($contenthash);
367 debugging("Replacing invalid content file $contenthash");
368 unlink($hashfile);
369 $newfile = false;
372 if (!is_dir($hashpath)) {
373 if (!mkdir($hashpath, $this->dirpermissions, true)) {
374 // Permission trouble.
375 throw new file_exception('storedfilecannotcreatefiledirs');
379 // Let's try to prevent some race conditions.
381 $prev = ignore_user_abort(true);
382 if (file_exists($hashfile.'.tmp')) {
383 @unlink($hashfile.'.tmp');
385 if (!copy($pathname, $hashfile.'.tmp')) {
386 // Borked permissions or out of disk space.
387 @unlink($hashfile.'.tmp');
388 ignore_user_abort($prev);
389 throw new file_exception('storedfilecannotcreatefile');
391 if (file_storage::hash_from_path($hashfile.'.tmp') !== $contenthash) {
392 // Highly unlikely edge case, but this can happen on an NFS volume with no space remaining.
393 @unlink($hashfile.'.tmp');
394 ignore_user_abort($prev);
395 throw new file_exception('storedfilecannotcreatefile');
397 if (!rename($hashfile.'.tmp', $hashfile)) {
398 // Something very strange went wrong.
399 @unlink($hashfile . '.tmp');
400 // Note, we don't try to clean up $hashfile. Almost certainly, if it exists
401 // (e.g. written by another process?) it will be right, so don't wipe it.
402 ignore_user_abort($prev);
403 throw new file_exception('storedfilecannotcreatefile');
405 chmod($hashfile, $this->filepermissions); // Fix permissions if needed.
406 if (file_exists($hashfile.'.tmp')) {
407 // Just in case anything fails in a weird way.
408 @unlink($hashfile.'.tmp');
410 ignore_user_abort($prev);
412 return array($contenthash, $filesize, $newfile);
416 * Checks if the file exists and gets its size. This function avoids a specific issue with
417 * networked file systems if they incorrectly report the file exists, but then decide it doesn't
418 * as soon as you try to get the file size.
420 * @param string $hashfile File to check
421 * @return int|null Null if the file does not exist, or the result of filesize(), or -1 if error
423 protected static function check_file_exists_and_get_size(string $hashfile): ?int {
424 if (!file_exists($hashfile)) {
425 // The file does not exist, return null.
426 return null;
429 // In some networked file systems, it's possible that file_exists will return true when
430 // the file doesn't exist (due to caching), but filesize will then return false because
431 // it doesn't exist.
432 $hashsize = @filesize($hashfile);
433 if ($hashsize !== false) {
434 // We successfully got a file size. Return it.
435 return $hashsize;
438 // If we can't get the filesize, let's check existence again to see if we really
439 // for sure think it exists.
440 clearstatcache();
441 if (!file_exists($hashfile)) {
442 // The file doesn't exist any more, so return null.
443 return null;
446 // It still thinks the file exists, but filesize failed, so we had better return an invalid
447 // value for filesize.
448 return -1;
452 * Add a file with the supplied content to the file system.
454 * Note: If overriding this function, it is advisable to store the file
455 * in the path returned by get_local_path_from_hash as there may be
456 * subsequent uses of the file in the same request.
458 * @param string $content file content - binary string
459 * @return array (contenthash, filesize, newfile)
461 public function add_file_from_string($content) {
462 global $CFG;
464 $contenthash = file_storage::hash_from_string($content);
465 // Binary length.
466 $filesize = strlen($content ?? '');
468 $hashpath = $this->get_fulldir_from_hash($contenthash);
469 $hashfile = $this->get_local_path_from_hash($contenthash, false);
471 $newfile = true;
473 $hashsize = self::check_file_exists_and_get_size($hashfile);
474 if ($hashsize !== null) {
475 if ($hashsize === $filesize) {
476 return array($contenthash, $filesize, false);
478 if (file_storage::hash_from_path($hashfile) === $contenthash) {
479 // Jackpot! We have a hash collision.
480 mkdir("$this->filedir/jackpot/", $this->dirpermissions, true);
481 copy($hashfile, "$this->filedir/jackpot/{$contenthash}_1");
482 file_put_contents("$this->filedir/jackpot/{$contenthash}_2", $content);
483 throw new file_pool_content_exception($contenthash);
485 debugging("Replacing invalid content file $contenthash");
486 unlink($hashfile);
487 $newfile = false;
490 if (!is_dir($hashpath)) {
491 if (!mkdir($hashpath, $this->dirpermissions, true)) {
492 // Permission trouble.
493 throw new file_exception('storedfilecannotcreatefiledirs');
497 // Hopefully this works around most potential race conditions.
499 $prev = ignore_user_abort(true);
501 if (!empty($CFG->preventfilelocking)) {
502 $newsize = file_put_contents($hashfile.'.tmp', $content);
503 } else {
504 $newsize = file_put_contents($hashfile.'.tmp', $content, LOCK_EX);
507 if ($newsize === false) {
508 // Borked permissions most likely.
509 ignore_user_abort($prev);
510 throw new file_exception('storedfilecannotcreatefile');
512 if (filesize($hashfile.'.tmp') !== $filesize) {
513 // Out of disk space?
514 unlink($hashfile.'.tmp');
515 ignore_user_abort($prev);
516 throw new file_exception('storedfilecannotcreatefile');
518 if (!rename($hashfile.'.tmp', $hashfile)) {
519 // Something very strange went wrong.
520 @unlink($hashfile . '.tmp');
521 // Note, we don't try to clean up $hashfile. Almost certainly, if it exists
522 // (e.g. written by another process?) it will be right, so don't wipe it.
523 ignore_user_abort($prev);
524 throw new file_exception('storedfilecannotcreatefile');
526 chmod($hashfile, $this->filepermissions); // Fix permissions if needed.
527 if (file_exists($hashfile.'.tmp')) {
528 // Just in case anything fails in a weird way.
529 @unlink($hashfile.'.tmp');
531 ignore_user_abort($prev);
533 return array($contenthash, $filesize, $newfile);