foldershare-8.x-1.2/src/ManageFileSystem.php

src/ManageFileSystem.php
<?php

namespace Drupal\foldershare;

use Drupal\file\FileInterface;
use Drupal\Core\File\FileSystemInterface;

use Drupal\foldershare\Utilities\FileUtilities;

/**
 * Manages file storage in the local file system.
 *
 * @ingroup foldershare
 */
final class ManageFileSystem {

  /*--------------------------------------------------------------------
   *
   * Constants.
   *
   *--------------------------------------------------------------------*/

  /**
   * The number of base-10 digits of file ID used for directory and file names.
   *
   * Directory and file names are generated automatically based upon the
   * file entity IDs of stored files.
   *
   * The number of digits is typically 4, which supports 10,000 files
   * or subdirectories in each subdirectory.  Keeping this number of
   * items small improves performance for operations that must open and
   * read directories. But keeping it large reduces the directory
   * depth, which can slightly improve file path handling.
   *
   * Operating system file systems may be limited in the number of separate
   * files and directories that they can support. For 32-bit file systems,
   * this limit is around 2 billion total.
   *
   * The following examples illustrate URIs when varying this number from
   * 1 to 10 for a File entity 123,456 with a module file directory DIR in
   * the public file system:
   *
   * - DIGITS_PER_SERVER_DIRECTORY_NAME = 1:
   *   - public://DIR/0/0/0/0/0/0/0/0/0/0/0/0/0/0/1/2/3/4/5/6
   *
   * - DIGITS_PER_SERVER_DIRECTORY_NAME = 2:
   *   - public://DIR/00/00/00/00/00/00/00/12/34/56
   *
   * - DIGITS_PER_SERVER_DIRECTORY_NAME = 3:
   *   - public://DIR/000/000/000/000/001/234/56
   *
   * - DIGITS_PER_SERVER_DIRECTORY_NAME = 4:
   *   - public://DIR/0000/0000/0000/0012/3456
   *
   * - DIGITS_PER_SERVER_DIRECTORY_NAME = 5:
   *   - public://DIR/00000/00000/00001/23456
   *
   * - DIGITS_PER_SERVER_DIRECTORY_NAME = 6:
   *   - public://DIR/000000/000000/001234/56
   *
   * - DIGITS_PER_SERVER_DIRECTORY_NAME = 7:
   *   - public://DIR/0000000/0000000/123456
   *
   * - DIGITS_PER_SERVER_DIRECTORY_NAME = 8:
   *   - public://DIR/00000000/00000012/3456
   *
   * - DIGITS_PER_SERVER_DIRECTORY_NAME = 9:
   *   - public://DIR/000000000/000001234/56
   *
   * - DIGITS_PER_SERVER_DIRECTORY_NAME = 10:
   *   - public://DIR/0000000000/0000123456
   *
   * @var int
   * @see ::getFileUri()
   */
  const DIGITS_PER_SERVER_DIRECTORY_NAME = 4;

  /**
   * The name of the top-level folder into which the module places files.
   *
   * The folder is within the public or private file system, depending
   * upon which file system is selected by the site administrator using
   * the module's settings.
   *
   * @var string
   * @see ::getFileUri()
   */
  const FILE_DIRECTORY = 'foldersharefiles';

  /*---------------------------------------------------------------------
   *
   * Temporary files.
   *
   *---------------------------------------------------------------------*/

  /**
   * Creates an empty temporary file in the module's temporary directory.
   *
   * A new empty file is created in the module's temporary files directory.
   * The file has a randomly generated name that does not collide with
   * anything else in the directory. Permissions are set appropriately for
   * web server access.
   *
   * @return string
   *   Returns a URI for a local temp file on success, and NULL on error.
   *
   * @see ::getTempDirectoryUri()
   * @see ::createLocalTempDirectory()
   */
  public static function createLocalTempFile() {
    // Create a temporary file with a random name in the temp directory.
    // The random name starts with the given prefix. Some OSes (e.g. Windows)
    // limit this prefix to three characters.
    $uri = FileUtilities::tempnam(self::getTempDirectoryUri(), 'tmp');
    if ($uri === FALSE) {
      return NULL;
    }

    // Insure the file is accessible.
    if (FileUtilities::chmod($uri) === FALSE) {
      return NULL;
    }

    return $uri;
  }

  /**
   * Creates an empty temporary directory in the module's data directory tree.
   *
   * A new empty directory is created in the module's temporary files directory.
   * The directory has a randomly generated name that does not collide with
   * anything else in the directory. Permissions are set appropriate for
   * web server access.
   *
   * @return string
   *   Returns a URI for a local temp directory on success, and NULL on error.
   *
   * @see ::getTempDirectoryUri()
   * @see ::createLocalTempFile()
   */
  public static function createLocalTempDirectory() {
    // There is no "tempnam()" for directories, only files. Use it to get
    // a safe random name, then replace the file with a directory.
    $uri = self::createLocalTempFile();
    if ($uri === NULL ||
        FileUtilities::unlink($uri) === FALSE ||
        FileUtilities::mkdir($uri) === FALSE ||
        FileUtilities::chmod($uri) === FALSE) {
      return NULL;
    }

    return $uri;
  }

  /**
   * Returns the URI for a temporary directory.
   *
   * @return string
   *   The URI for the directory path.
   */
  public static function getTempDirectoryUri() {
    // Use the Drupal standard "temporary" URI scheme, which goes to the
    // site's configured temp directory.
    return 'temporary://';
  }

  /*---------------------------------------------------------------------
   *
   * URIs.
   *
   *---------------------------------------------------------------------*/

  /**
   * Returns the URI for a file managed by the module.
   *
   * The returned URI has the form "SCHEME://SUBDIR/DIRS.../FILE.EXTENSION"
   * where:
   *
   * - SCHEME is either "public" or "private", based upon the module's file
   *   storage configuration.
   *
   * - SUBDIR is the name of the module's subdirectory for its files within
   *   the public or private file system.
   *
   * - DIRS.../FILE is a chain of numeric subdirectory names and a numeric
   *   file name (see below).
   *
   * - EXTENSION is the filename extension from the user-visible file name.
   *
   * Numeric subdirectory names are based upon the File object's entity ID,
   * and not the user-chosen file name or folder names. Such names have
   * multiple possible problems:
   *
   * - User file and directory names are not guaranteed to be unique site-wide,
   *   so they cannot safely coexist with names from other users unless some
   *   safe organization mechanism is introduced.
   *
   * - User file and directory names may use the full UTF-8 character set
   *   (except for a few restricted characters, like '/', ':', and '\'), but
   *   the underlying file system may not support the same characters.
   *
   * - USer file and diretory names may be up to the maximum length supported
   *   by the module (such as 255 characters), but the underlying file system
   *   may be more restrictive. For instance, file paths (the chain of
   *   directory names from '/' down to and including the file name) may be
   *   limited as well (such as to 1024 characters), which imposes another
   *   limit on directory depth.
   *
   * - User files may be nested within folders, and those folders within
   *   folders, to an arbitrary depth, but the underlying file system may
   *   have depth limits.
   *
   * - Any number of user files may be stored within the same folder, but
   *   the underlying file system may have limits or performance issues with
   *   very large directories.
   *
   * For these reasons, user-chosen file and directory names cannot be used
   * as-is. They must be replaced with alternative names that are safe for
   * any file system and unique.
   *
   * This module creates directory and file names based upon the unique
   * integer entity ID of the File object referencing the file. These IDs
   * are stored in Drupal as 64-bit integers, which can be represented as
   * 20 zero-padded digits (0..9).  These digits are then divided into groups
   * of consecutive digits based upon a module-wide configuration constant
   * DIGITS_PER_SERVER_DIRECTORY_NAME.  A typical value for this constant
   * is 4. This causes a 20-digit number to be split into 5 groups of 4 digits
   * each. The last of these groups is used as a numeric name for the file,
   * while the preceding groups are used as numeric names for subdirectories.
   *
   * As more File objects are created for use by this module, more numeric
   * files are added to the directories. When an entity ID rolls over to
   * update the 5th digit, the file is put into the next numeric subdirectory,
   * and so on. This keeps directory sizes under control (with a maximum of
   * 10^4 = 10,000 files each).
   *
   * The numeric file name has the original file's filename extension appended
   * so that file field formatters and other Drupal tools that look at paths
   * and filenames will see a proper extension.
   *
   * Once the file path is built for the given File entity, any directories
   * needed are created, permissions are set, and .htaccess files are added.
   *
   * @param \Drupal\file\FileInterface $file
   *   The file current stored locally, or soon to be stored locally.
   *
   * @return string
   *   The URI for the directory path to the file.
   *
   * @see ::getTempDirectoryUri()
   * @see ::prepareDirectories()
   * @see \Drupal\foldershare\Settings::getFileScheme()
   */
  public static function getFileUri(FileInterface $file) {
    //
    // Use multi-byte character string functions throughout. Local file
    // systems and URIs use UTF-8.
    //
    // Create file path
    // ----------------
    // The file URI is composed of:
    // - The configured public or private scheme.
    // - The configured subdirectory for files within the site.
    // - A chain of numeric directory names for the File entity ID.
    // - A final numeric file name.
    // - The file name extension of the original file.
    //
    // Start the path with the file directory.
    $path = self::FILE_DIRECTORY;

    // Entity IDs in Drupal are 64-bits. The maximum number of base-10 digits
    // is therefore 20. The DIGITS_PER_SERVER_DIRECTORY_NAME constant
    // determines how many digits are used in the directory and file names.
    $digits = sprintf('%020d', (int) $file->id());

    // Add numeric subdirectories based on the entity ID.
    // The last name added is for the file itself.
    for ($pos = 0; $pos < 20;) {
      $path .= '/';
      for ($i = 0; $i < self::DIGITS_PER_SERVER_DIRECTORY_NAME; ++$i) {
        $path .= $digits[$pos++];
      }
    }

    // Add the file's extension, if any.
    $ext = ManageFilenameExtensions::getExtensionFromPath($file->getFilename());
    if (empty($ext) === FALSE) {
      $path .= '.' . $ext;
    }

    //
    // Create directories
    // ------------------
    // Create any numeric directories that don't already exist, set their
    // permissions, and add .htaccess files as needed.
    self::prepareFileDirectories($path);

    // Add "public" or "private" to create the URI.
    return Settings::getFileScheme() . '://' . $path;
  }

  /**
   * Creates module file directories and adds .htaccess files.
   *
   * The given URI is converted to a file system path and all missing
   * directories on the path created and their permissions set.
   *
   * If the module's file directory does not have a .htaccess file, or
   * there is a file but it may have been edited, then a new .htaccess
   * file is added that block's direct web server access.
   *
   * @param string $path
   *   The local file path for a file under management by this module.
   *
   * @return bool
   *   Returns TRUE on success and FALSE on an error. The only error case
   *   occurs when a directory cannot be created, and a message is logged.
   *
   * @see \Drupal\Core\File\FileSystemInterface::prepareDirectory()
   */
  private static function prepareFileDirectories(string $path) {
    //
    // Setup.
    // ------
    // Get the current public/private file system choice for file storage,
    // then build a path to the parent directory.
    $manager = \Drupal::service('stream_wrapper_manager');
    $fileSystem = \Drupal::service('file_system');

    $fileScheme = Settings::getFileScheme();
    $stream = $manager->getViaScheme($fileScheme);

    if ($stream === FALSE) {
      // Fail with unknown scheme.
      return FALSE;
    }

    $streamDirectory = $stream->getDirectoryPath();
    $parentPath = FileUtilities::dirname($streamDirectory . '/' . $path);

    //
    // Create parent directories, if needed.
    // -------------------------------------
    // If one or more parent directories on the file's path do not exist yet.
    // Create them, setting permissions.
    if (FileUtilities::fileExists($parentPath) === FALSE) {
      if ($fileSystem->prepareDirectory(
        $parentPath,
        (FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS)) === FALSE) {
        // The parent directory creation failed. There are several possible
        // reasons for this:
        // - The directory already exists.
        // - The file system is full.
        // - The file system is offline.
        // - Something horrific is going on.
        //
        // Of these, only the first case is interesting. While above we
        // checked if the directory already exists, it is possible that
        // another process has snuck in and created the directory before
        // we've tried to create it. That will cause a failure here.
        //
        // Ideally, that other process was an instance of Drupal using
        // FolderShare. In that case the file prepare would have occurred
        // correctly, with permissions set, etc. But we can't be sure.
        // So, let's try again to be sure, but only if the directory now
        // appears to exist.
        $failure = TRUE;
        if (FileUtilities::fileExists($parentPath) === TRUE) {
          if ($fileSystem->prepareDirectory(
            $parentPath,
            (FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS)) === TRUE) {
            // It worked this time!
            $failure = FALSE;
          }
        }

        if ($failure === TRUE) {
          // The parent directories could not be created.
          ManageLog::critical(
            "Local file system: '@path' could not be created.\nThe directory is needed by the @module module to store uploaded files. There may be a problem with the server's file system directories and/or permissions.",
            [
              '@path'   => $parentPath,
              '@module' => Constants::MODULE,
            ]);
          return FALSE;
        }
      }
      elseif (FileUtilities::fileExists($parentPath) === FALSE) {
        // The parent directory creation succeeded, but the directory isn't
        // there? This should not be possible.
        ManageLog::critical(
          "Local file system: '@path' could not be read.\nThe directory is needed by the @module module to store uploaded files. There may be a problem with the server's file system permissions.",
          [
            '@path'   => $parentPath,
            '@module' => Constants::MODULE,
          ]);
        return FALSE;
      }
    }

    if ($fileScheme !== 'private') {
      //
      // Create .htaccess file in top directory, if needed.
      // --------------------------------------------------
      // Drupal can be configured to use one or both of:
      // - a public (default) directory served directly by the web server.
      // - a private directory out of view from the web server.
      //
      // For a private directory, Drupal requires and adds a .htaccess file
      // that insures that if (somehow) a web server can see the directory,
      // everything within the directory is blocked from being directly served
      // by the web server. Instead, file accesses are redirected to Drupal,
      // which can do access control.
      //
      // For a public directory, Drupal adds a .htaccess file that allows
      // a web server to directly serve the files. File accesses then do not
      // invoke Drupal, and there is no access control.
      //
      // FolderShare always wants access control. When the module is configured
      // to use a private directory, there is nothing more required. Drupal's
      // default .htaccess file already directs accesses back to Drupal, even
      // for subdirectories and files deep below.
      //
      // When FolderShare is configured to use a public directory, however,
      // the Drupal default .htaccess at the top of the files directory tree
      // is not suitable. The module's subdirectory needs its own .htaccess
      // file that closes down direct web server access. Thereafter, accesses
      // to files anywhwere in the module's files subdirectory cause redirects
      // to Drupal where we can do access control.
      //
      // SECURITY NOTE:
      // - A .htaccess file is not required when using a private file system.
      //   We assume that the private file system is, indeed, private and that
      //   a web server therefore cannot see the files stored there, or an
      //   .htaccess file stored there.
      //
      // - On the first use of the directory, there will be no .htaccess file
      //   and one must be created.
      //
      // - On further use of the directory, it is possible that a hacker or
      //   misguided site admin has edited the .htaccess file to open access.
      //   This is not appropriate since the files in this directory are
      //   strictly under FolderShare management. To prevent a possible
      //   access control bypass, the .htaccess file is OVERWRITTEN every
      //   time this function is called.
      $filesPath = $streamDirectory . '/' . self::FILE_DIRECTORY;

      // file_save_access's third argument (TRUE) indicates the function
      // should overwrite any existing .htaccess file. Unfortunately, it
      // does not actually do this... if the file doesn't have write
      // permission. In order to force the issue, we need to first change
      // the file's permission to gain write permission, if the file exists.
      $htaccess = $filesPath . '/.htaccess';
      if (FileUtilities::fileExists($htaccess) === TRUE) {
        @chmod($htaccess, 0666);
      }

      file_save_htaccess($filesPath, TRUE, TRUE);

      // SECURITY NOTE:
      // - It is possible that a hacker or site admin has added a .htaccess
      //   file to intermediate directories. These should be removed, but
      //   detecting them requires a top-down directory tree traversal,
      //   which is expensive.
    }

    // The parent directories now all exist, though the file does not yet.
    return TRUE;
  }

  /**
   * Parses a URI for a module file and returns the File entity ID.
   *
   * @param string $path
   *   The local file path for a file managed by this module.
   *
   * @return int|bool
   *   Returns the integer File entity ID parsed from the path, or FALSE if
   *   the path is not for a file managed by this module.
   */
  public static function getFileEntityId(string $path) {
    //
    // Find start of numeric directory names
    // -------------------------------------
    // Look for use of the module's FILE_DIRECTORY name. If not found,
    // the path is malformed.
    $fileDirectory = self::FILE_DIRECTORY;
    $pos = mb_strpos($path, $fileDirectory);
    if ($pos === FALSE) {
      // Fail. FILE_DIRECTORY is not in the path, therefore this is not a
      // path to a module file.
      return FALSE;
    }

    //
    // Parse entity ID
    // ---------------
    // Strip the file directory from the front of the path, then use the
    // remainder to build the 20 digit entity ID. Return the integer form
    // of that ID.
    $remainder = mb_substr($path, ($pos + mb_strlen($fileDirectory) + 1));

    // Remove all of the slashes, joining together the numeric names of
    // the subdirectories.
    $digits = mb_ereg_replace('/', '', $remainder);

    // Remove the file extension, if any.
    $dotIndex = mb_strrpos($digits, '.');
    if ($dotIndex !== FALSE) {
      $digits = mb_substr($digits, 0, $dotIndex);
    }

    if (mb_strlen($digits) !== 20) {
      // Fail. Wrong number of digits. Not directory names and file name.
      return FALSE;
    }

    // Convert the numeric string to a file entity ID.
    if (is_numeric($digits) === FALSE) {
      // Fail. Malformed directory and file names.
      return FALSE;
    }

    return (int) intval($digits);
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc