Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

Differences Between: [Versions 310 and 402] [Versions 311 and 402] [Versions 39 and 402] [Versions 400 and 402]

   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/>.
  16  
  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   */
  24  
  25  defined('MOODLE_INTERNAL') || die();
  26  
  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 {
  36  
  37      /**
  38       * @var string The path to the local copy of the filedir.
  39       */
  40      protected $filedir = null;
  41  
  42      /**
  43       * @var string The path to the trashdir.
  44       */
  45      protected $trashdir = null;
  46  
  47      /**
  48       * @var string Default directory permissions for new dirs.
  49       */
  50      protected $dirpermissions = null;
  51  
  52      /**
  53       * @var string Default file permissions for new files.
  54       */
  55      protected $filepermissions = null;
  56  
  57  
  58      /**
  59       * Perform any custom setup for this type of file_system.
  60       */
  61      public function __construct() {
  62          global $CFG;
  63  
  64          if (isset($CFG->filedir)) {
  65              $this->filedir = $CFG->filedir;
  66          } else {
  67              $this->filedir = $CFG->dataroot.'/filedir';
  68          }
  69  
  70          if (isset($CFG->trashdir)) {
  71              $this->trashdir = $CFG->trashdir;
  72          } else {
  73              $this->trashdir = $CFG->dataroot.'/trashdir';
  74          }
  75  
  76          $this->dirpermissions = $CFG->directorypermissions;
  77          $this->filepermissions = $CFG->filepermissions;
  78  
  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              }
  85  
  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          }
  94  
  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      }
 103  
 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) . '/' .$contenthash;
 113      }
 114  
 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      public function get_local_path_from_storedfile(stored_file $file, $fetchifnotfound = false) {
 123          $filepath = $this->get_local_path_from_hash($file->get_contenthash(), $fetchifnotfound);
 124  
 125          // Try content recovery.
 126          if ($fetchifnotfound && !is_readable($filepath)) {
 127              $this->recover_file($file);
 128          }
 129  
 130          return $filepath;
 131      }
 132  
 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      public function get_remote_path_from_storedfile(stored_file $file) {
 140          return $this->get_local_path_from_storedfile($file, false);
 141      }
 142  
 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      }
 152  
 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      }
 165  
 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 . '/' . $this->get_contentdir_from_hash($contenthash);
 175      }
 176  
 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      }
 190  
 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      }
 203  
 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 . '/' . $this->get_contentdir_from_hash($contenthash);
 213      }
 214  
 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 . '/' . $this->get_contentpath_from_hash($contenthash);
 223      }
 224  
 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      }
 236  
 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);
 245  
 246          if (file_exists($contentfile)) {
 247              // The file already exists on the file system. No need to recover.
 248              return true;
 249          }
 250  
 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}";
 255  
 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          }
 264  
 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          }
 269  
 270          if (!is_dir($contentdir)) {
 271              if (!mkdir($contentdir, $this->dirpermissions, true)) {
 272                  // Unable to create the target directory.
 273                  return false;
 274              }
 275          }
 276  
 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      }
 281  
 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          }
 292  
 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          }
 297  
 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);
 301  
 302          if (!is_dir($trashpath)) {
 303              mkdir($trashpath, $this->dirpermissions, true);
 304          }
 305  
 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          }
 312  
 313          // Move the contentfile to the trash, and fix permissions as required.
 314          rename($contentfile, $trashfile);
 315  
 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      }
 322  
 323      /**
 324       * Cleanup the trash directory.
 325       */
 326      public function cron() {
 327          $this->empty_trash();
 328      }
 329  
 330      protected function empty_trash() {
 331          fulldelete($this->trashdir);
 332          set_config('fileslastcleanup', time());
 333      }
 334  
 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  
 348          list($contenthash, $filesize) = $this->validate_hash_and_file_size($contenthash, $pathname);
 349  
 350          $hashpath = $this->get_fulldir_from_hash($contenthash);
 351          $hashfile = $this->get_local_path_from_hash($contenthash, false);
 352  
 353          $newfile = true;
 354  
 355          $hashsize = self::check_file_exists_and_get_size($hashfile);
 356          if ($hashsize !== null) {
 357              if ($hashsize === $filesize) {
 358                  return array($contenthash, $filesize, false);
 359              }
 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);
 366              }
 367              debugging("Replacing invalid content file $contenthash");
 368              unlink($hashfile);
 369              $newfile = false;
 370          }
 371  
 372          if (!is_dir($hashpath)) {
 373              if (!mkdir($hashpath, $this->dirpermissions, true)) {
 374                  // Permission trouble.
 375                  throw new file_exception('storedfilecannotcreatefiledirs');
 376              }
 377          }
 378  
 379          // Let's try to prevent some race conditions.
 380  
 381          $prev = ignore_user_abort(true);
 382          if (file_exists($hashfile.'.tmp')) {
 383              @unlink($hashfile.'.tmp');
 384          }
 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');
 390          }
 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');
 396          }
 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');
 404          }
 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');
 409          }
 410          ignore_user_abort($prev);
 411  
 412          return array($contenthash, $filesize, $newfile);
 413      }
 414  
 415      /**
 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.
 419       *
 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
 422       */
 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;
 427          }
 428  
 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;
 436          }
 437  
 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;
 444          }
 445  
 446          // It still thinks the file exists, but filesize failed, so we had better return an invalid
 447          // value for filesize.
 448          return -1;
 449      }
 450  
 451      /**
 452       * Add a file with the supplied content to the file system.
 453       *
 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.
 457       *
 458       * @param string $content file content - binary string
 459       * @return array (contenthash, filesize, newfile)
 460       */
 461      public function add_file_from_string($content) {
 462          global $CFG;
 463  
 464          $contenthash = file_storage::hash_from_string($content);
 465          // Binary length.
 466          $filesize = strlen($content ?? '');
 467  
 468          $hashpath = $this->get_fulldir_from_hash($contenthash);
 469          $hashfile = $this->get_local_path_from_hash($contenthash, false);
 470  
 471          $newfile = true;
 472  
 473          $hashsize = self::check_file_exists_and_get_size($hashfile);
 474          if ($hashsize !== null) {
 475              if ($hashsize === $filesize) {
 476                  return array($contenthash, $filesize, false);
 477              }
 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);
 484              }
 485              debugging("Replacing invalid content file $contenthash");
 486              unlink($hashfile);
 487              $newfile = false;
 488          }
 489  
 490          if (!is_dir($hashpath)) {
 491              if (!mkdir($hashpath, $this->dirpermissions, true)) {
 492                  // Permission trouble.
 493                  throw new file_exception('storedfilecannotcreatefiledirs');
 494              }
 495          }
 496  
 497          // Hopefully this works around most potential race conditions.
 498  
 499          $prev = ignore_user_abort(true);
 500  
 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);
 505          }
 506  
 507          if ($newsize === false) {
 508              // Borked permissions most likely.
 509              ignore_user_abort($prev);
 510              throw new file_exception('storedfilecannotcreatefile');
 511          }
 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');
 517          }
 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');
 525          }
 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');
 530          }
 531          ignore_user_abort($prev);
 532  
 533          return array($contenthash, $filesize, $newfile);
 534      }
 535  
 536  }