Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

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

   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          if (file_exists($hashfile)) {
 356              if (filesize($hashfile) === $filesize) {
 357                  return array($contenthash, $filesize, false);
 358              }
 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);
 365              }
 366              debugging("Replacing invalid content file $contenthash");
 367              unlink($hashfile);
 368              $newfile = false;
 369          }
 370  
 371          if (!is_dir($hashpath)) {
 372              if (!mkdir($hashpath, $this->dirpermissions, true)) {
 373                  // Permission trouble.
 374                  throw new file_exception('storedfilecannotcreatefiledirs');
 375              }
 376          }
 377  
 378          // Let's try to prevent some race conditions.
 379  
 380          $prev = ignore_user_abort(true);
 381          @unlink($hashfile.'.tmp');
 382          if (!copy($pathname, $hashfile.'.tmp')) {
 383              // Borked permissions or out of disk space.
 384              @unlink($hashfile.'.tmp');
 385              ignore_user_abort($prev);
 386              throw new file_exception('storedfilecannotcreatefile');
 387          }
 388          if (file_storage::hash_from_path($hashfile.'.tmp') !== $contenthash) {
 389              // Highly unlikely edge case, but this can happen on an NFS volume with no space remaining.
 390              @unlink($hashfile.'.tmp');
 391              ignore_user_abort($prev);
 392              throw new file_exception('storedfilecannotcreatefile');
 393          }
 394          if (!rename($hashfile.'.tmp', $hashfile)) {
 395              // Something very strange went wrong.
 396              @unlink($hashfile . '.tmp');
 397              // Note, we don't try to clean up $hashfile. Almost certainly, if it exists
 398              // (e.g. written by another process?) it will be right, so don't wipe it.
 399              ignore_user_abort($prev);
 400              throw new file_exception('storedfilecannotcreatefile');
 401          }
 402          chmod($hashfile, $this->filepermissions); // Fix permissions if needed.
 403          @unlink($hashfile.'.tmp'); // Just in case anything fails in a weird way.
 404          ignore_user_abort($prev);
 405  
 406          return array($contenthash, $filesize, $newfile);
 407      }
 408  
 409      /**
 410       * Add a file with the supplied content to the file system.
 411       *
 412       * Note: If overriding this function, it is advisable to store the file
 413       * in the path returned by get_local_path_from_hash as there may be
 414       * subsequent uses of the file in the same request.
 415       *
 416       * @param string $content file content - binary string
 417       * @return array (contenthash, filesize, newfile)
 418       */
 419      public function add_file_from_string($content) {
 420          global $CFG;
 421  
 422          $contenthash = file_storage::hash_from_string($content);
 423          // Binary length.
 424          $filesize = strlen($content);
 425  
 426          $hashpath = $this->get_fulldir_from_hash($contenthash);
 427          $hashfile = $this->get_local_path_from_hash($contenthash, false);
 428  
 429          $newfile = true;
 430  
 431          if (file_exists($hashfile)) {
 432              if (filesize($hashfile) === $filesize) {
 433                  return array($contenthash, $filesize, false);
 434              }
 435              if (file_storage::hash_from_path($hashfile) === $contenthash) {
 436                  // Jackpot! We have a hash collision.
 437                  mkdir("$this->filedir/jackpot/", $this->dirpermissions, true);
 438                  copy($hashfile, "$this->filedir/jackpot/{$contenthash}_1");
 439                  file_put_contents("$this->filedir/jackpot/{$contenthash}_2", $content);
 440                  throw new file_pool_content_exception($contenthash);
 441              }
 442              debugging("Replacing invalid content file $contenthash");
 443              unlink($hashfile);
 444              $newfile = false;
 445          }
 446  
 447          if (!is_dir($hashpath)) {
 448              if (!mkdir($hashpath, $this->dirpermissions, true)) {
 449                  // Permission trouble.
 450                  throw new file_exception('storedfilecannotcreatefiledirs');
 451              }
 452          }
 453  
 454          // Hopefully this works around most potential race conditions.
 455  
 456          $prev = ignore_user_abort(true);
 457  
 458          if (!empty($CFG->preventfilelocking)) {
 459              $newsize = file_put_contents($hashfile.'.tmp', $content);
 460          } else {
 461              $newsize = file_put_contents($hashfile.'.tmp', $content, LOCK_EX);
 462          }
 463  
 464          if ($newsize === false) {
 465              // Borked permissions most likely.
 466              ignore_user_abort($prev);
 467              throw new file_exception('storedfilecannotcreatefile');
 468          }
 469          if (filesize($hashfile.'.tmp') !== $filesize) {
 470              // Out of disk space?
 471              unlink($hashfile.'.tmp');
 472              ignore_user_abort($prev);
 473              throw new file_exception('storedfilecannotcreatefile');
 474          }
 475          if (!rename($hashfile.'.tmp', $hashfile)) {
 476              // Something very strange went wrong.
 477              @unlink($hashfile . '.tmp');
 478              // Note, we don't try to clean up $hashfile. Almost certainly, if it exists
 479              // (e.g. written by another process?) it will be right, so don't wipe it.
 480              ignore_user_abort($prev);
 481              throw new file_exception('storedfilecannotcreatefile');
 482          }
 483          chmod($hashfile, $this->filepermissions); // Fix permissions if needed.
 484          @unlink($hashfile.'.tmp'); // Just in case anything fails in a weird way.
 485          ignore_user_abort($prev);
 486  
 487          return array($contenthash, $filesize, $newfile);
 488      }
 489  
 490  }