*/ protected array $mappingFolderNameToRole = [ '_recycler_' => FolderInterface::ROLE_RECYCLER, '_temp_' => FolderInterface::ROLE_TEMPORARY, 'user_upload' => FolderInterface::ROLE_USERUPLOAD, ]; public function __construct(array $configuration = []) { parent::__construct($configuration); // The capabilities default of this driver. See Capabilities::CAPABILITY_* constants for possible values $this->capabilities = new Capabilities( Capabilities::CAPABILITY_BROWSABLE | Capabilities::CAPABILITY_PUBLIC | Capabilities::CAPABILITY_WRITABLE | Capabilities::CAPABILITY_HIERARCHICAL_IDENTIFIERS ); } /** * Merges the capabilities from the user of the storage configuration into the actual * capabilities of the driver and returns the result. */ public function mergeConfigurationCapabilities(Capabilities $capabilities): Capabilities { $this->capabilities->and($capabilities); return $this->capabilities; } public function processConfiguration(): void { try { $this->absoluteBasePath = $this->calculateBasePath($this->configuration); } catch (InvalidConfigurationException $e) { // The storage is offline, but the absolute base path requires a "/" at the end. $this->absoluteBasePath = '/'; throw $e; } $this->determineBaseUrl(); if ($this->baseUri === null) { // remove public flag $this->capabilities->removeCapability(Capabilities::CAPABILITY_PUBLIC); } } /** * Initializes this object. This is called by the storage after the driver * has been attached. */ public function initialize(): void {} /** * Determines the base URL for this driver, from the configuration or * the public path. */ protected function determineBaseUrl(): void { // only calculate baseURI if the storage does not enforce jumpUrl Script if ($this->hasCapability(Capabilities::CAPABILITY_PUBLIC)) { if (!empty($this->configuration['baseUri'])) { $this->baseUri = rtrim($this->configuration['baseUri'], '/') . '/'; } elseif (str_starts_with($this->absoluteBasePath, Environment::getPublicPath())) { // use site-relative URLs $temporaryBaseUri = rtrim(PathUtility::stripPathSitePrefix($this->absoluteBasePath), '/'); if ($temporaryBaseUri !== '') { $uriParts = explode('/', $temporaryBaseUri); $uriParts = array_map(rawurlencode(...), $uriParts); $temporaryBaseUri = implode('/', $uriParts) . '/'; } $this->baseUri = $temporaryBaseUri; } } } /** * Calculates the absolute path to this driver's storage location */ protected function calculateBasePath(array $configuration): string { if (!array_key_exists('basePath', $configuration) || empty($configuration['basePath'])) { throw new InvalidConfigurationException( 'Configuration must contain base path.', 1346510477 ); } if (!empty($configuration['pathType']) && $configuration['pathType'] === 'relative') { $relativeBasePath = $configuration['basePath']; $absoluteBasePath = Environment::getPublicPath() . '/' . $relativeBasePath; } else { $absoluteBasePath = $configuration['basePath']; } $absoluteBasePath = $this->canonicalizeAndCheckFilePath($absoluteBasePath); $absoluteBasePath = rtrim($absoluteBasePath, '/') . '/'; if (!$this->isAllowedAbsolutePath($absoluteBasePath)) { throw new InvalidConfigurationException( 'Base path "' . $absoluteBasePath . '" is not within the allowed project root path or allowed lockRootPath.', 1704807715 ); } if (!is_dir($absoluteBasePath)) { throw new InvalidConfigurationException( 'Base path "' . $absoluteBasePath . '" does not exist or is no directory.', 1299233097 ); } return $absoluteBasePath; } /** * Returns a publicly accessible URL to a file or folder. * For the local driver, this will always return a path relative to public web path. * For non-public storages, this method returns null. * * @param non-empty-string $identifier * @return string|null NULL if file is missing or deleted, the generated url otherwise */ public function getPublicUrl(string $identifier): ?string { $publicUrl = null; if ($this->baseUri !== null) { $uriParts = explode('/', ltrim($identifier, '/')); $uriParts = array_map(rawurlencode(...), $uriParts); $identifier = implode('/', $uriParts); $publicUrl = $this->baseUri . $identifier; } return $publicUrl; } /** * Returns the identifier of the root level folder of the storage. * * @return non-empty-string */ public function getRootLevelFolder(): string { return '/'; } /** * Returns the identifier of the default folder where new files should be put into. * * @return non-empty-string */ public function getDefaultFolder(): string { $identifier = '/user_upload/'; $createFolder = !$this->folderExists($identifier); if ($createFolder === true) { $identifier = $this->createFolder('user_upload'); } return $identifier; } /** * Creates a folder, within a given parent folder. * If no parent folder is given, a folder on the root-level will be created * * @param non-empty-string $newFolderName * @return non-empty-string the identifier of the new folder */ public function createFolder(string $newFolderName, string $parentFolderIdentifier = '', bool $recursive = false): string { $parentFolderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($parentFolderIdentifier); $newFolderName = trim($newFolderName, '/'); if ($recursive === false) { $newFolderName = $this->sanitizeFileName($newFolderName); $newIdentifier = $this->canonicalizeAndCheckFolderIdentifier($parentFolderIdentifier . $newFolderName . '/'); GeneralUtility::mkdir($this->getAbsolutePath($newIdentifier)); } else { $parts = GeneralUtility::trimExplode('/', $newFolderName); $parts = array_map($this->sanitizeFileName(...), $parts); $newFolderName = implode('/', $parts); $newIdentifier = $this->canonicalizeAndCheckFolderIdentifier( $parentFolderIdentifier . $newFolderName . '/' ); GeneralUtility::mkdir_deep($this->getAbsolutePath($newIdentifier)); } return $newIdentifier; } /** * Returns information about a file. * * @param non-empty-string $fileIdentifier In the case of the LocalDriver, this is the (relative) path to the file. * @param list $propertiesToExtract Array of properties which should be extracted, if empty all will be extracted * @return array */ public function getFileInfoByIdentifier(string $fileIdentifier, array $propertiesToExtract = []): array { $absoluteFilePath = $this->getAbsolutePath($fileIdentifier); // don't use $this->fileExists() because we need the absolute path to the file anyway, so we can directly // use PHP's filesystem method. if (!file_exists($absoluteFilePath) || !is_file($absoluteFilePath)) { // @todo: This should be turned into a specific exception instead! throw new \InvalidArgumentException('File ' . $fileIdentifier . ' does not exist.', 1314516809); } $dirPath = PathUtility::dirname($fileIdentifier); $dirPath = $this->canonicalizeAndCheckFolderIdentifier($dirPath); return $this->extractFileInformation($absoluteFilePath, $dirPath, $propertiesToExtract); } /** * Returns information about a folder. * * @param string $folderIdentifier In the case of the LocalDriver, this is the (relative) path to the file. * @return array{ * identifier: non-empty-string, * name: string, * mtime: int, * ctime: int, * storage: int, * } */ public function getFolderInfoByIdentifier(string $folderIdentifier): array { $folderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier); if (!$this->folderExists($folderIdentifier)) { throw new FolderDoesNotExistException( 'Folder "' . $folderIdentifier . '" does not exist.', 1314516810 ); } $absolutePath = $this->getAbsolutePath($folderIdentifier); return [ 'identifier' => $folderIdentifier, 'name' => PathUtility::basename($folderIdentifier), 'mtime' => filemtime($absolutePath), 'ctime' => filectime($absolutePath), 'storage' => $this->storageUid, ]; } /** * Returns a string where any character not matching [.a-zA-Z0-9_-] is * substituted by '_' * Trailing dots are removed and characters are lowercased if using * a case insensitive file system. * * Previously in \TYPO3\CMS\Core\Utility\File\BasicFileUtility::cleanFileName() * * @param string $fileName Input string, typically the body of a fileName * @return non-empty-string Output string with any characters not matching [.a-zA-Z0-9_-] is substituted by '_' and trailing dots removed */ public function sanitizeFileName(string $fileName): string { $fileName = \Normalizer::normalize($fileName) ?: $fileName; // Handle UTF-8 characters if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['UTF8filesystem']) { // Allow ".", "-", 0-9, a-z, A-Z and everything beyond U+C0 (latin capital letter a with grave) $cleanFileName = (string)preg_replace('/[' . self::UNSAFE_FILENAME_CHARACTER_EXPRESSION . ']/u', '_', trim($fileName)); if (!$this->isCaseSensitiveFileSystem()) { $cleanFileName = mb_strtolower($cleanFileName, 'utf-8'); } } else { $fileName = GeneralUtility::makeInstance(CharsetConverter::class)->utf8_char_mapping($fileName); // Replace unwanted characters with underscores $cleanFileName = (string)preg_replace('/[' . self::UNSAFE_FILENAME_CHARACTER_EXPRESSION . '\\xC0-\\xFF]/', '_', trim($fileName)); if (!$this->isCaseSensitiveFileSystem()) { $cleanFileName = strtolower($cleanFileName); } } // Strip trailing dots and return $cleanFileName = rtrim($cleanFileName, '.'); if ($cleanFileName === '') { throw new InvalidFileNameException('File name ' . $fileName . ' is invalid.', 1320288991); } return $cleanFileName; } /** * Generic wrapper for extracting a list of items from a path. * * @param int $start The position to start the listing; if not set, start from the beginning * @param int $numberOfItems The number of items to list; if set to zero, all items are returned * @param array $filterMethods The filter methods used to filter the directory items * @param string $sort Property name used to sort the items. * Among them may be: '' (empty, no sorting), name, * fileext, size, tstamp and rw. * If a driver does not support the given property, it * should fall back to "name". * @param bool $sortRev TRUE to indicate reverse sorting (last to first) * @return array folder identifiers (where key and value are identical, but int-like identifiers * will get converted to int array keys) */ protected function getDirectoryItemList(string $folderIdentifier, int $start, int $numberOfItems, array $filterMethods, bool $includeFiles = true, bool $includeDirs = true, bool $recursive = false, string $sort = '', bool $sortRev = false): array { $folderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier); $realPath = $this->getAbsolutePath($folderIdentifier); if (!is_dir($realPath)) { throw new \InvalidArgumentException( 'Cannot list items in directory ' . $folderIdentifier . ' - does not exist or is no directory', 1314349666 ); } $items = $this->retrieveFileAndFoldersInPath($realPath, $recursive, $includeFiles, $includeDirs, $sort, $sortRev); $iterator = new \ArrayIterator($items); if ($iterator->count() === 0) { return []; } // $c is the counter for how many items we still have to fetch (-1 is unlimited) $c = $numberOfItems > 0 ? $numberOfItems : -1; $items = []; while ($iterator->valid() && ($numberOfItems === 0 || $c > 0)) { // $iteratorItem is the file or folder name $iteratorItem = $iterator->current(); // go on to the next iterator item now as we might skip this one early $iterator->next(); try { if ( !$this->applyFilterMethodsToDirectoryItem( $filterMethods, $iteratorItem['name'], $iteratorItem['identifier'], $this->getParentFolderIdentifierOfIdentifier($iteratorItem['identifier']) ) ) { continue; } if ($start > 0) { $start--; } else { // The identifier can also be an int-like string, resulting in int array keys. $items[$iteratorItem['identifier']] = $iteratorItem['identifier']; // Decrement item counter to make sure we only return $numberOfItems // we cannot do this earlier in the method (unlike moving the iterator forward) because we only add the // item here --$c; } } catch (InvalidPathException) { } } return $items; } /** * Applies a set of filter methods to a file name to find out if it should be used or not. * This is used by directory listings. * * @param array $filterMethods The filter methods to use */ protected function applyFilterMethodsToDirectoryItem(array $filterMethods, string $itemName, string $itemIdentifier, string $parentIdentifier): bool { foreach ($filterMethods as $filter) { if (is_callable($filter)) { $result = $filter($itemName, $itemIdentifier, $parentIdentifier, [], $this); // We use -1 as the "don't include“ return value, for historic reasons, // as call_user_func() used to return FALSE if calling the method failed. if ($result === -1) { return false; } if ($result === false) { throw new \RuntimeException( 'Could not apply file/folder name filter ' . $filter[0] . '::' . $filter[1], 1476046425 ); } } } return true; } /** * Returns a file inside the specified path. * * @param non-empty-string $fileName * @param non-empty-string $folderIdentifier * @return non-empty-string File Identifier */ public function getFileInFolder(string $fileName, string $folderIdentifier): string { return $this->canonicalizeAndCheckFileIdentifier($folderIdentifier . '/' . $fileName); } /** * Returns a list of files inside the specified path. * * @param string $folderIdentifier * @param int $start * @param int $numberOfItems * @param bool $recursive * @param array $filenameFilterCallbacks The method callbacks to use for filtering the items * @param string $sort Property name used to sort the items. * Among them may be: '' (empty, no sorting), name, * fileext, size, tstamp and rw. * If a driver does not support the given property, it * should fall back to "name". * @param bool $sortRev TRUE to indicate reverse sorting (last to first) * @return string[] of FileIdentifiers */ public function getFilesInFolder(string $folderIdentifier, int $start = 0, int $numberOfItems = 0, bool $recursive = false, array $filenameFilterCallbacks = [], string $sort = '', bool $sortRev = false): array { return $this->getDirectoryItemList($folderIdentifier, $start, $numberOfItems, $filenameFilterCallbacks, true, false, $recursive, $sort, $sortRev); } /** * Returns the number of files inside the specified path. * * @param non-empty-string $folderIdentifier * @param list $filenameFilterCallbacks callbacks for filtering the items * @return int<0, max> Number of files in folder */ public function countFilesInFolder(string $folderIdentifier, bool $recursive = false, array $filenameFilterCallbacks = []): int { return count($this->getFilesInFolder($folderIdentifier, 0, 0, $recursive, $filenameFilterCallbacks)); } /** * Returns a list of folders inside the specified path. * * @param string $folderIdentifier * @param int $start * @param int $numberOfItems * @param bool $recursive * @param array $folderNameFilterCallbacks The method callbacks to use for filtering the items * @param string $sort Property name used to sort the items. * Among them may be: '' (empty, no sorting), name, * fileext, size, tstamp and rw. * If a driver does not support the given property, it * should fall back to "name". * @param bool $sortRev TRUE to indicate reverse sorting (last to first) * @return array folder identifiers (where key and value are identical, but int-like identifiers * will get converted to int array keys) */ public function getFoldersInFolder(string $folderIdentifier, int $start = 0, int $numberOfItems = 0, bool $recursive = false, array $folderNameFilterCallbacks = [], string $sort = '', bool $sortRev = false): array { return $this->getDirectoryItemList($folderIdentifier, $start, $numberOfItems, $folderNameFilterCallbacks, false, true, $recursive, $sort, $sortRev); } /** * Returns the number of folders inside the specified path. * * @param non-empty-string $folderIdentifier * @param list $folderNameFilterCallbacks callbacks for filtering the items * @return int<0, max> Number of folders in folder */ public function countFoldersInFolder(string $folderIdentifier, bool $recursive = false, array $folderNameFilterCallbacks = []): int { return count($this->getFoldersInFolder($folderIdentifier, 0, 0, $recursive, $folderNameFilterCallbacks)); } /** * Returns a list with the names of all files and folders in a path, optionally recursive. * * @param string $path The absolute path * @param bool $recursive If TRUE, recursively fetches files and folders * @param string $sort Property name used to sort the items. * Among them may be: '' (empty, no sorting), name, * fileext, size, tstamp and rw. * If a driver does not support the given property, it * should fall back to "name". * @param bool $sortRev TRUE to indicate reverse sorting (last to first) */ protected function retrieveFileAndFoldersInPath(string $path, bool $recursive = false, bool $includeFiles = true, bool $includeDirs = true, string $sort = '', bool $sortRev = false): array { $pathLength = strlen($this->getAbsoluteBasePath()); $iteratorMode = \FilesystemIterator::UNIX_PATHS | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_FILEINFO | \FilesystemIterator::FOLLOW_SYMLINKS; if ($recursive) { $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($path, $iteratorMode), \RecursiveIteratorIterator::SELF_FIRST, \RecursiveIteratorIterator::CATCH_GET_CHILD ); } else { $iterator = new \RecursiveDirectoryIterator($path, $iteratorMode); } $directoryEntries = []; while ($iterator->valid()) { /** @var \SplFileInfo $entry */ $entry = $iterator->current(); $isFile = $entry->isFile(); $isDirectory = !$isFile && $entry->isDir(); if ( (!$isFile && !$isDirectory) // skip non-files/non-folders || ($isFile && !$includeFiles) // skip files if they are excluded || ($isDirectory && !$includeDirs) // skip directories if they are excluded || $entry->getFilename() === '' // skip empty entries || !$entry->isReadable() // skip unreadable entries ) { $iterator->next(); continue; } $entryIdentifier = '/' . substr($entry->getPathname(), $pathLength); $entryName = PathUtility::basename($entryIdentifier); if ($isDirectory) { $entryIdentifier .= '/'; } $entryArray = [ 'identifier' => $entryIdentifier, 'name' => $entryName, 'type' => $isDirectory ? 'dir' : 'file', ]; $directoryEntries[$entryIdentifier] = $entryArray; $iterator->next(); } return $this->sortDirectoryEntries($directoryEntries, $sort, $sortRev); } /** * Sort the directory entries by a certain key. * * @param array $directoryEntries Array of directory entry arrays from retrieveFileAndFoldersInPath() * @param string $sort Property name used to sort the items. * Among them may be: '' (empty, no sorting), name, * fileext, size, tstamp and rw. * If a driver does not support the given property, it * should fall back to "name". * @param bool $sortRev TRUE to indicate reverse sorting (last to first) * @return array Sorted entries. Content of the keys is undefined. */ protected function sortDirectoryEntries(array $directoryEntries, string $sort = '', bool $sortRev = false): array { if (count($directoryEntries) < 2) { return $directoryEntries; } $entriesToSort = []; foreach ($directoryEntries as $entryArray) { $dir = pathinfo($entryArray['name'], PATHINFO_DIRNAME) . '/'; $fullPath = $this->getAbsoluteBasePath() . $entryArray['identifier']; switch ($sort) { case 'size': $sortingKey = '0'; if ($entryArray['type'] === 'file') { $sortingKey = $this->getSpecificFileInformation($fullPath, $dir, 'size'); } // Add a character for a natural order sorting $sortingKey .= 's'; break; case 'rw': $perms = $this->getPermissions($entryArray['identifier']); $sortingKey = ($perms['r'] ? 'R' : '') . ($perms['w'] ? 'W' : ''); break; case 'fileext': $sortingKey = pathinfo($entryArray['name'], PATHINFO_EXTENSION); break; case 'tstamp': $sortingKey = $this->getSpecificFileInformation($fullPath, $dir, 'mtime'); // Add a character for a natural order sorting $sortingKey .= 't'; break; case 'crdate': $sortingKey = $this->getSpecificFileInformation($fullPath, $dir, 'ctime'); // Add a character for a natural order sorting $sortingKey .= 'c'; break; case 'name': case 'file': default: $sortingKey = $entryArray['name']; } $i = 0; while (isset($entriesToSort[$sortingKey . $i])) { $i++; } $entriesToSort[$sortingKey . $i] = $entryArray; } $sortMultiplier = $sortRev ? -1 : 1; uksort($entriesToSort, static function (string $entryA, string $entryB) use ($sortMultiplier): int { return strnatcasecmp($entryA, $entryB) * $sortMultiplier; }); return $entriesToSort; } /** * Extracts information about a file from the filesystem. * * @param string $filePath The absolute path to the file * @param string $containerPath The relative path to the file's container * @param array $propertiesToExtract array of properties which should be returned, if empty all will be extracted */ protected function extractFileInformation(string $filePath, string $containerPath, array $propertiesToExtract = []): array { if (empty($propertiesToExtract)) { $propertiesToExtract = [ 'size', 'atime', 'mtime', 'ctime', 'mimetype', 'name', 'extension', 'identifier', 'identifier_hash', 'storage', 'folder_hash', ]; } $fileInformation = []; foreach ($propertiesToExtract as $property) { $fileInformation[$property] = $this->getSpecificFileInformation($filePath, $containerPath, $property); } return $fileInformation; } /** * Extracts specific information of a file from the file system. */ public function getSpecificFileInformation(string $fileIdentifier, string $containerPath, string $property): bool|int|string|null { $identifier = $this->canonicalizeAndCheckFileIdentifier($containerPath . PathUtility::basename($fileIdentifier)); $fileInfo = GeneralUtility::makeInstance(FileInfo::class, $fileIdentifier); return match ($property) { 'size' => $fileInfo->getSize(), 'atime' => $fileInfo->getATime(), 'mtime' => $fileInfo->getMTime(), 'ctime' => $fileInfo->getCTime(), 'name' => PathUtility::basename($fileIdentifier), 'extension' => PathUtility::pathinfo($fileIdentifier, PATHINFO_EXTENSION), 'mimetype' => (string)$fileInfo->getMimeType(), 'identifier' => $identifier, 'storage' => $this->storageUid, 'identifier_hash' => $this->hashIdentifier($identifier), 'folder_hash' => $this->hashIdentifier($this->getParentFolderIdentifierOfIdentifier($identifier)), default => throw new \InvalidArgumentException( sprintf('The information "%s" is not available.', $property), 1476047422 ), }; } /** * Returns the absolute path of the folder this driver operates on. */ protected function getAbsoluteBasePath(): string { return $this->absoluteBasePath; } /** * Returns the absolute path of a file or folder. */ protected function getAbsolutePath(string $fileIdentifier): string { $relativeFilePath = ltrim($this->canonicalizeAndCheckFileIdentifier($fileIdentifier), '/'); return $this->absoluteBasePath . $relativeFilePath; } /** * Creates a (cryptographic) hash for a file. * * @param string $hashAlgorithm The hash algorithm to use */ public function hash(string $fileIdentifier, string $hashAlgorithm): string { $hashContext = hash_init($hashAlgorithm); hash_update_file($hashContext, $this->getAbsolutePath($fileIdentifier)); return hash_final($hashContext); } /** * Adds a file from the local server's hard drive to a given path in TYPO3s storage location. * This assumes that the local file exists, so no further check is done here. * After a successful "add" operation, the original file must not exist anymore. * * @param non-empty-string $localFilePath within public web path * @param non-empty-string $targetFolderIdentifier * @param string $newFileName optional, if not given original name is used * @param bool $removeOriginal if set the original file will be removed after successful operation * @return non-empty-string the identifier of the new file */ public function addFile(string $localFilePath, string $targetFolderIdentifier, string $newFileName = '', bool $removeOriginal = true): string { $localFilePath = $this->canonicalizeAndCheckFilePath($localFilePath); // as for the "virtual storage" for backwards-compatibility, this check always fails, as the file probably lies under public web path // thus, it is not checked here // @todo is check in storage if (str_starts_with($localFilePath, $this->absoluteBasePath) && $this->storageUid > 0) { throw new \InvalidArgumentException('Cannot add a file that is already part of this storage.', 1314778269); } $newFileName = $this->sanitizeFileName($newFileName !== '' ? $newFileName : PathUtility::basename($localFilePath)); $newFileIdentifier = $this->canonicalizeAndCheckFolderIdentifier($targetFolderIdentifier) . $newFileName; $targetPath = $this->getAbsolutePath($newFileIdentifier); if ($removeOriginal) { if (is_uploaded_file($localFilePath)) { $result = @move_uploaded_file($localFilePath, $targetPath); } else { $result = @rename($localFilePath, $targetPath); } } else { $result = @copy($localFilePath, $targetPath); } if ($result === false || !file_exists($targetPath)) { throw new \RuntimeException( 'Adding file ' . $localFilePath . ' at ' . $newFileIdentifier . ' failed.', 1476046453 ); } clearstatcache(); // Change the permissions of the file GeneralUtility::fixPermissions($targetPath); return $newFileIdentifier; } /** * Checks if a file exists on the file system. * * @param non-empty-string $fileIdentifier */ public function fileExists(string $fileIdentifier): bool { $absoluteFilePath = $this->getAbsolutePath($fileIdentifier); return is_file($absoluteFilePath); } /** * Checks if a file inside a folder exists. * * @param non-empty-string $fileName * @param non-empty-string $folderIdentifier */ public function fileExistsInFolder(string $fileName, string $folderIdentifier): bool { $identifier = $folderIdentifier . '/' . $fileName; $identifier = $this->canonicalizeAndCheckFileIdentifier($identifier); return $this->fileExists($identifier); } /** * Checks if a folder exists. * * @param non-empty-string $folderIdentifier */ public function folderExists(string $folderIdentifier): bool { $absoluteFilePath = $this->getAbsolutePath($folderIdentifier); return is_dir($absoluteFilePath); } /** * Checks if a folder inside a folder exists. * * @param non-empty-string $folderName * @param non-empty-string $folderIdentifier */ public function folderExistsInFolder(string $folderName, string $folderIdentifier): bool { $identifier = $folderIdentifier . '/' . $folderName; $identifier = $this->canonicalizeAndCheckFolderIdentifier($identifier); return $this->folderExists($identifier); } /** * Returns the identifier for a folder within a given folder. * * @param non-empty-string $folderName The name of the target folder * @param non-empty-string $folderIdentifier * @return non-empty-string */ public function getFolderInFolder(string $folderName, string $folderIdentifier): string { return $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier . '/' . $folderName); } /** * Replaces the contents (and file-specific metadata) of a file with another file from the server's hard disk. * * @param non-empty-string $fileIdentifier * @param non-empty-string $localFilePath */ public function replaceFile(string $fileIdentifier, string $localFilePath): bool { $filePath = $this->getAbsolutePath($fileIdentifier); if (is_uploaded_file($localFilePath)) { $result = @move_uploaded_file($localFilePath, $filePath); } else { $result = @rename($localFilePath, $filePath); } GeneralUtility::fixPermissions($filePath); if ($result === false) { throw new \RuntimeException('Replacing file ' . $fileIdentifier . ' with ' . $localFilePath . ' failed.', 1315314711); } return true; } /** * Copies a file *within* the current storage. * The responsibility of this method in the Driver is only about an intra-storage copy action, * where a file is just copied to another folder in the same storage. * * @param non-empty-string $fileIdentifier * @param non-empty-string $targetFolderIdentifier * @param non-empty-string $fileName * @return non-empty-string the identifier of the new file */ public function copyFileWithinStorage(string $fileIdentifier, string $targetFolderIdentifier, string $fileName): string { $sourcePath = $this->getAbsolutePath($fileIdentifier); $newIdentifier = $targetFolderIdentifier . '/' . $fileName; $newIdentifier = $this->canonicalizeAndCheckFileIdentifier($newIdentifier); $absoluteFilePath = $this->getAbsolutePath($newIdentifier); @copy($sourcePath, $absoluteFilePath); GeneralUtility::fixPermissions($absoluteFilePath); return $newIdentifier; } /** * Moves a file *within* the current storage. * The responsibility of this method in the Driver is only about an intra-storage move action, * where a file is just moved to another folder in the same storage. * * @param non-empty-string $fileIdentifier * @param non-empty-string $targetFolderIdentifier * @param non-empty-string $newFileName * @return non-empty-string */ public function moveFileWithinStorage(string $fileIdentifier, string $targetFolderIdentifier, string $newFileName): string { $sourcePath = $this->getAbsolutePath($fileIdentifier); $targetIdentifier = $targetFolderIdentifier . '/' . $newFileName; $targetIdentifier = $this->canonicalizeAndCheckFileIdentifier($targetIdentifier); $result = @rename($sourcePath, $this->getAbsolutePath($targetIdentifier)); if ($result === false) { throw new \RuntimeException('Moving file ' . $sourcePath . ' to ' . $targetIdentifier . ' failed.', 1315314712); } return $targetIdentifier; } /** * Copies a file to a temporary path and returns that path. */ protected function copyFileToTemporaryPath(string $fileIdentifier): string { $sourcePath = $this->getAbsolutePath($fileIdentifier); $temporaryPath = $this->getTemporaryPathForFile($fileIdentifier); $result = @copy($sourcePath, $temporaryPath); if ($result === false) { throw new \RuntimeException( 'Copying file "' . $fileIdentifier . '" to temporary path "' . $temporaryPath . '" failed.', 1320577649 ); } @touch($temporaryPath, (int)filemtime($sourcePath)); return $temporaryPath; } /** * Moves a file or folder to the given directory, renaming the source in the process if * a file or folder of the same name already exists in the target path. */ protected function recycleFileOrFolder(string $filePath, string $recycleDirectory): bool { $destinationFile = $recycleDirectory . '/' . PathUtility::basename($filePath); if (file_exists($destinationFile)) { $timeStamp = \DateTimeImmutable::createFromFormat('U.u', (string)microtime(true))->format('YmdHisu'); $destinationFile = $recycleDirectory . '/' . $timeStamp . '_' . PathUtility::basename($filePath); } $result = @rename($filePath, $destinationFile); // Update the mtime for the file, so the recycler garbage collection task knows which files to delete // Using ctime() is not possible there since this is not supported on Windows if ($result) { @touch($destinationFile); } return $result; } /** * Creates a map of old and new file/folder identifiers after renaming or * moving a folder. The old identifier is used as the key, the new one as the value. * @return array */ protected function createIdentifierMap(array $filesAndFolders, string $sourceFolderIdentifier, string $targetFolderIdentifier): array { $identifierMap = []; $identifierMap[$sourceFolderIdentifier] = $targetFolderIdentifier; foreach ($filesAndFolders as $oldItem) { $oldIdentifier = $oldItem['identifier']; if ($oldItem['type'] === 'dir') { $newIdentifier = $this->canonicalizeAndCheckFolderIdentifier( str_replace($sourceFolderIdentifier, $targetFolderIdentifier, $oldItem['identifier']) ); } else { $newIdentifier = $this->canonicalizeAndCheckFileIdentifier( str_replace($sourceFolderIdentifier, $targetFolderIdentifier, $oldItem['identifier']) ); } if (!file_exists($this->getAbsolutePath($newIdentifier))) { throw new FileOperationErrorException( sprintf('File "%1$s" was not found (should have been copied/moved from "%2$s").', $newIdentifier, $oldIdentifier), 1330119453 ); } $identifierMap[$oldIdentifier] = $newIdentifier; } return $identifierMap; } /** * Folder equivalent to moveFileWithinStorage(). * * @param non-empty-string $sourceFolderIdentifier * @param non-empty-string $targetFolderIdentifier * @param non-empty-string $newFolderName * @return array A map of old to new file identifiers */ public function moveFolderWithinStorage(string $sourceFolderIdentifier, string $targetFolderIdentifier, string $newFolderName): array { $sourcePath = $this->getAbsolutePath($sourceFolderIdentifier); $relativeTargetPath = $this->canonicalizeAndCheckFolderIdentifier($targetFolderIdentifier . '/' . $newFolderName); $targetPath = $this->getAbsolutePath($relativeTargetPath); // get all files and folders we are going to move, to have a map for updating later. $filesAndFolders = $this->retrieveFileAndFoldersInPath($sourcePath, true); $result = @rename($sourcePath, $targetPath); if ($result === false) { throw new \RuntimeException('Moving folder ' . $sourcePath . ' to ' . $targetPath . ' failed.', 1320711817); } // Create a mapping from old to new identifiers return $this->createIdentifierMap($filesAndFolders, $sourceFolderIdentifier, $relativeTargetPath); } /** * Folder equivalent to copyFileWithinStorage(). * * @param non-empty-string $sourceFolderIdentifier * @param non-empty-string $targetFolderIdentifier * @param non-empty-string $newFolderName * @return true */ public function copyFolderWithinStorage(string $sourceFolderIdentifier, string $targetFolderIdentifier, string $newFolderName): bool { // This target folder path already includes the topmost level, i.e. the folder this method knows as $folderToCopy. // We can thus rely on this folder being present and just create the subfolder we want to copy to. $newFolderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($targetFolderIdentifier . '/' . $newFolderName); $sourceFolderPath = $this->getAbsolutePath($sourceFolderIdentifier); $targetFolderPath = $this->getAbsolutePath($newFolderIdentifier); GeneralUtility::mkdir($targetFolderPath); $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($sourceFolderPath), \RecursiveIteratorIterator::SELF_FIRST, \RecursiveIteratorIterator::CATCH_GET_CHILD ); // Rewind the iterator as this is important for some systems e.g. Windows $iterator->rewind(); while ($iterator->valid()) { /** @var \RecursiveDirectoryIterator $current */ $current = $iterator->current(); $fileName = $current->getFilename(); $itemSubPath = GeneralUtility::fixWindowsFilePath((string)$iterator->getSubPathname()); if ($current->isDir() && !($fileName === '..' || $fileName === '.')) { GeneralUtility::mkdir($targetFolderPath . '/' . $itemSubPath); } elseif ($current->isFile()) { $copySourcePath = $sourceFolderPath . '/' . $itemSubPath; $copyTargetPath = $targetFolderPath . '/' . $itemSubPath; $result = @copy($copySourcePath, $copyTargetPath); if ($result === false) { // rollback GeneralUtility::rmdir($targetFolderIdentifier, true); throw new FileOperationErrorException( 'Copying resource "' . $copySourcePath . '" to "' . $copyTargetPath . '" failed.', 1330119452 ); } } $iterator->next(); } GeneralUtility::fixPermissions($targetFolderPath, true); return true; } /** * Renames a file in this storage. * * @param non-empty-string $fileIdentifier * @param non-empty-string $newName The target path (including the file name!) * @return non-empty-string The identifier of the file after renaming */ public function renameFile(string $fileIdentifier, string $newName): string { // Makes sure the Path given as parameter is valid $newName = $this->sanitizeFileName($newName); $newIdentifier = rtrim(GeneralUtility::fixWindowsFilePath(PathUtility::dirname($fileIdentifier)), '/') . '/' . $newName; $newIdentifier = $this->canonicalizeAndCheckFileIdentifier($newIdentifier); // The target should not exist already if ($this->fileExists($newIdentifier)) { throw new ExistingTargetFileNameException( 'The target file "' . $newIdentifier . '" already exists.', 1320291063 ); } $sourcePath = $this->getAbsolutePath($fileIdentifier); $targetPath = $this->getAbsolutePath($newIdentifier); $result = @rename($sourcePath, $targetPath); if ($result === false) { throw new \RuntimeException('Renaming file ' . $sourcePath . ' to ' . $targetPath . ' failed.', 1320375115); } return $newIdentifier; } /** * Renames a folder in this storage. * * @param non-empty-string $folderIdentifier * @param non-empty-string $newName * @return array A map of old to new file identifiers of all affected files and folders * @throws \RuntimeException if renaming the folder failed */ public function renameFolder(string $folderIdentifier, string $newName): array { $folderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier); $newName = $this->sanitizeFileName($newName); $newIdentifier = PathUtility::dirname($folderIdentifier) . '/' . $newName; $newIdentifier = $this->canonicalizeAndCheckFolderIdentifier($newIdentifier); $sourcePath = $this->getAbsolutePath($folderIdentifier); $targetPath = $this->getAbsolutePath($newIdentifier); // get all files and folders we are going to move, to have a map for updating later. $filesAndFolders = $this->retrieveFileAndFoldersInPath($sourcePath, true); $result = @rename($sourcePath, $targetPath); if ($result === false) { throw new \RuntimeException(sprintf('Renaming folder "%1$s" to "%2$s" failed."', $sourcePath, $targetPath), 1320375116); } try { // Create a mapping from old to new identifiers $identifierMap = $this->createIdentifierMap($filesAndFolders, $folderIdentifier, $newIdentifier); } catch (\Exception $e) { @rename($targetPath, $sourcePath); throw new \RuntimeException( sprintf( 'Creating filename mapping after renaming "%1$s" to "%2$s" failed. Reverted rename operation.\\n\\nOriginal error: %3$s"', $sourcePath, $targetPath, $e->getMessage() ), 1334160746 ); } return $identifierMap; } /** * Removes a file from the filesystem. This does not check if the file is * still used or if it is a bad idea to delete it for some other reason * this has to be taken care of in the upper layers (e.g. the ResourceStorage). * * @param non-empty-string $fileIdentifier */ public function deleteFile(string $fileIdentifier): bool { $filePath = $this->getAbsolutePath($fileIdentifier); $result = @unlink($filePath); if ($result === false) { throw new \RuntimeException('Deletion of file ' . $fileIdentifier . ' failed.', 1320855304); } return true; } /** * Removes a folder from the hard drive. * * @param non-empty-string $folderIdentifier */ public function deleteFolder(string $folderIdentifier, bool $deleteRecursively = false): bool { $folderPath = $this->getAbsolutePath($folderIdentifier); $recycleDirectory = $this->getRecycleDirectory($folderPath); if (!empty($recycleDirectory) && $folderPath !== $recycleDirectory) { $result = $this->recycleFileOrFolder($folderPath, $recycleDirectory); } else { $result = GeneralUtility::rmdir($folderPath, $deleteRecursively); } if ($result === false) { throw new FileOperationErrorException( 'Deleting folder "' . $folderIdentifier . '" failed.', 1330119451 ); } return $result; } /** * Checks if a folder contains files and (if supported) other folders. * * @param non-empty-string $folderIdentifier * @return bool TRUE if there are no files and folders within $folder */ public function isFolderEmpty(string $folderIdentifier): bool { $path = $this->getAbsolutePath($folderIdentifier); $dirHandle = opendir($path); if ($dirHandle === false) { return true; } while ($entry = readdir($dirHandle)) { if ($entry !== '.' && $entry !== '..') { closedir($dirHandle); return false; } } closedir($dirHandle); return true; } /** * Returns (a local copy of) a file for further processing. This makes a copy * first when in writable mode, so if you change the file, you have to update it yourself afterward. * * @param non-empty-string $fileIdentifier * @param bool $writable Set this to FALSE if you only need the file for read operations. * This might speed up things, e.g. by using a cached local version. * Never modify the file if you have set this flag! * @return non-empty-string The path to the file on the local disk */ public function getFileForLocalProcessing(string $fileIdentifier, bool $writable = true): string { if ($writable === false) { return $this->getAbsolutePath($fileIdentifier); } return $this->copyFileToTemporaryPath($fileIdentifier); } /** * Returns the permissions of a file/folder as an array (keys r, w) of boolean flags. * * @param non-empty-string $identifier * @return array{r: bool, w: bool} */ public function getPermissions(string $identifier): array { $path = $this->getAbsolutePath($identifier); $permissionBits = fileperms($path); if ($permissionBits === false) { throw new ResourcePermissionsUnavailableException('Error while fetching permissions for ' . $path, 1319455097); } return [ 'r' => is_readable($path), 'w' => is_writable($path), ]; } /** * Checks if a given identifier is within a container, e.g. if * a file or folder is within another folder. It will also return * TRUE if both canonical identifiers are equal. * * @param non-empty-string $folderIdentifier * @param non-empty-string $identifier identifier to be checked against $folderIdentifier * @return bool TRUE if $content is within or matches $folderIdentifier */ public function isWithin(string $folderIdentifier, string $identifier): bool { $folderIdentifier = $this->canonicalizeAndCheckFileIdentifier($folderIdentifier); $entryIdentifier = $this->canonicalizeAndCheckFileIdentifier($identifier); if ($folderIdentifier === $entryIdentifier) { return true; } // File identifier canonicalization will not modify a single slash so // we must not append another slash in that case. if ($folderIdentifier !== '/') { $folderIdentifier .= '/'; } return str_starts_with($entryIdentifier, $folderIdentifier); } /** * Creates a new (empty) file and returns the identifier. * * @param non-empty-string $fileName * @param non-empty-string $parentFolderIdentifier * @return non-empty-string */ public function createFile(string $fileName, string $parentFolderIdentifier): string { $fileName = $this->sanitizeFileName(ltrim($fileName, '/')); $parentFolderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($parentFolderIdentifier); $fileIdentifier = $this->canonicalizeAndCheckFileIdentifier( $parentFolderIdentifier . $fileName ); $absoluteFilePath = $this->getAbsolutePath($fileIdentifier); $result = @touch($absoluteFilePath); if ($result !== true) { throw new \RuntimeException('Creating file ' . $fileIdentifier . ' failed.', 1320569854); } GeneralUtility::fixPermissions($absoluteFilePath); clearstatcache(); return $fileIdentifier; } /** * Returns the contents of a file. Beware that this requires to load the * complete file into memory and also may require fetching the file from an * external location. So this might be an expensive operation (both in terms of * processing resources and money) for large files. * * @param non-empty-string $fileIdentifier * @return string The file contents if file exists, otherwise an empty string */ public function getFileContents(string $fileIdentifier): string { $filePath = $this->getAbsolutePath($fileIdentifier); return is_readable($filePath) ? (string)file_get_contents($filePath) : ''; } /** * Sets the contents of a file to the specified value. * * @param non-empty-string $fileIdentifier * @return int<0, max> The number of bytes written to the file * @throws \RuntimeException if the operation failed */ public function setFileContents(string $fileIdentifier, string $contents): int { $filePath = $this->getAbsolutePath($fileIdentifier); $result = file_put_contents($filePath, $contents); // Make sure later calls to filesize() etc. return correct values. clearstatcache(true, $filePath); if ($result === false) { throw new \RuntimeException('Setting contents of file "' . $fileIdentifier . '" failed.', 1325419305); } return $result; } /** * Returns the role of an item. This is currently only implemented for folder, * but could be extended to files as well. */ public function getRole(string $folderIdentifier): string { $name = PathUtility::basename($folderIdentifier); return $this->mappingFolderNameToRole[$name] ?? FolderInterface::ROLE_DEFAULT; } /** * Directly output the contents of the file to the output buffer. * Should not take care of header files or flushing buffer before. Will be taken care of by the Storage. * * @param non-empty-string $identifier */ public function dumpFileContents(string $identifier): void { readfile($this->getAbsolutePath($this->canonicalizeAndCheckFileIdentifier($identifier))); } /** * Stream file using a PSR-7 Response object. */ public function streamFile(string $identifier, array $properties): ResponseInterface { $fileInfo = $this->getFileInfoByIdentifier($identifier, ['name', 'mimetype', 'mtime', 'size']); $downloadName = $properties['filename_overwrite'] ?? $fileInfo['name'] ?? ''; $mimeType = $properties['mimetype_overwrite'] ?? $fileInfo['mimetype'] ?? ''; $contentDisposition = ($properties['as_download'] ?? false) ? 'attachment' : 'inline'; $filePath = $this->getAbsolutePath($this->canonicalizeAndCheckFileIdentifier($identifier)); return new Response( new SelfEmittableLazyOpenStream($filePath), 200, [ 'Content-Disposition' => $contentDisposition . '; filename="' . $downloadName . '"', 'Content-Type' => $mimeType, 'Content-Length' => (string)$fileInfo['size'], 'Last-Modified' => gmdate('D, d M Y H:i:s', $fileInfo['mtime']) . ' GMT', // Cache-Control header is needed here to solve an issue with browser IE8 and lower // See for more information: http://support.microsoft.com/kb/323308 'Cache-Control' => '', ] ); } /** * Get the path of the nearest recycler folder of a given path. * Return an empty string if there is no recycler folder available in the base path. */ protected function getRecycleDirectory(string $path): string { $recyclerSubdirectory = array_search(FolderInterface::ROLE_RECYCLER, $this->mappingFolderNameToRole, true); if ($recyclerSubdirectory === false) { return ''; } $rootDirectory = rtrim($this->getAbsolutePath($this->getRootLevelFolder()), '/'); $searchDirectory = PathUtility::dirname($path); // Check if file or folder to be deleted is inside a recycler directory if ($this->getRole($searchDirectory) === FolderInterface::ROLE_RECYCLER) { $searchDirectory = PathUtility::dirname($searchDirectory); // Check if file or folder to be deleted is inside the root recycler if ($searchDirectory == $rootDirectory) { return ''; } $searchDirectory = PathUtility::dirname($searchDirectory); } // Search for the closest recycler directory while ($searchDirectory) { $recycleDirectory = $searchDirectory . '/' . $recyclerSubdirectory; if (is_dir($recycleDirectory)) { return $recycleDirectory; } if ($searchDirectory === $rootDirectory) { return ''; } $searchDirectory = PathUtility::dirname($searchDirectory); } return ''; } /** * Wrapper for `GeneralUtility::isAllowedAbsPath`, which implicitly invokes * `GeneralUtility::validPathStr` (like in `parent::isPathValid`). */ protected function isAllowedAbsolutePath(string $path): bool { return GeneralUtility::isAllowedAbsPath($path); } }