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);
}
}
