foldershare-8.x-1.2/src/ManageFileSystem.php
src/ManageFileSystem.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 | <?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: * * - DIGITS_PER_SERVER_DIRECTORY_NAME = 2: * * - DIGITS_PER_SERVER_DIRECTORY_NAME = 3: * * - DIGITS_PER_SERVER_DIRECTORY_NAME = 4: * * - DIGITS_PER_SERVER_DIRECTORY_NAME = 5: * * - DIGITS_PER_SERVER_DIRECTORY_NAME = 6: * * - DIGITS_PER_SERVER_DIRECTORY_NAME = 7: * * - DIGITS_PER_SERVER_DIRECTORY_NAME = 8: * * - DIGITS_PER_SERVER_DIRECTORY_NAME = 9: * * - DIGITS_PER_SERVER_DIRECTORY_NAME = 10: * * @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. } /*--------------------------------------------------------------------- * * 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 ); } } |