1<?php
2
3/*
4 * This file is part of the TYPO3 CMS project.
5 *
6 * It is free software; you can redistribute it and/or modify it under
7 * the terms of the GNU General Public License, either version 2
8 * of the License, or any later version.
9 *
10 * For the full copyright and license information, please read the
11 * LICENSE.txt file that was distributed with this source code.
12 *
13 * The TYPO3 project - inspiring people to share!
14 */
15
16namespace TYPO3\CMS\Core\Resource\Driver;
17
18use Psr\Http\Message\ResponseInterface;
19use TYPO3\CMS\Core\Charset\CharsetConverter;
20use TYPO3\CMS\Core\Core\Environment;
21use TYPO3\CMS\Core\Http\Response;
22use TYPO3\CMS\Core\Http\SelfEmittableLazyOpenStream;
23use TYPO3\CMS\Core\Resource\Exception;
24use TYPO3\CMS\Core\Resource\Exception\ExistingTargetFileNameException;
25use TYPO3\CMS\Core\Resource\Exception\FileOperationErrorException;
26use TYPO3\CMS\Core\Resource\Exception\FolderDoesNotExistException;
27use TYPO3\CMS\Core\Resource\Exception\InvalidConfigurationException;
28use TYPO3\CMS\Core\Resource\Exception\InvalidFileNameException;
29use TYPO3\CMS\Core\Resource\Exception\InvalidPathException;
30use TYPO3\CMS\Core\Resource\Exception\ResourcePermissionsUnavailableException;
31use TYPO3\CMS\Core\Resource\FolderInterface;
32use TYPO3\CMS\Core\Resource\ResourceStorage;
33use TYPO3\CMS\Core\Type\File\FileInfo;
34use TYPO3\CMS\Core\Utility\GeneralUtility;
35use TYPO3\CMS\Core\Utility\PathUtility;
36
37/**
38 * Driver for the local file system
39 */
40class LocalDriver extends AbstractHierarchicalFilesystemDriver implements StreamableDriverInterface
41{
42    /**
43     * @var string
44     */
45    const UNSAFE_FILENAME_CHARACTER_EXPRESSION = '\\x00-\\x2C\\/\\x3A-\\x3F\\x5B-\\x60\\x7B-\\xBF';
46
47    /**
48     * The absolute base path. It always contains a trailing slash.
49     *
50     * @var string
51     */
52    protected $absoluteBasePath;
53
54    /**
55     * A list of all supported hash algorithms, written all lower case.
56     *
57     * @var array
58     */
59    protected $supportedHashAlgorithms = ['sha1', 'md5'];
60
61    /**
62     * The base URL that points to this driver's storage. As long is this
63     * is not set, it is assumed that this folder is not publicly available
64     *
65     * @var string
66     */
67    protected $baseUri;
68
69    /** @var array */
70    protected $mappingFolderNameToRole = [
71        '_recycler_' => FolderInterface::ROLE_RECYCLER,
72        '_temp_' => FolderInterface::ROLE_TEMPORARY,
73        'user_upload' => FolderInterface::ROLE_USERUPLOAD,
74    ];
75
76    /**
77     * @param array $configuration
78     */
79    public function __construct(array $configuration = [])
80    {
81        parent::__construct($configuration);
82        // The capabilities default of this driver. See CAPABILITY_* constants for possible values
83        $this->capabilities =
84            ResourceStorage::CAPABILITY_BROWSABLE
85            | ResourceStorage::CAPABILITY_PUBLIC
86            | ResourceStorage::CAPABILITY_WRITABLE
87            | ResourceStorage::CAPABILITY_HIERARCHICAL_IDENTIFIERS;
88    }
89
90    /**
91     * Merges the capabilities merged by the user at the storage
92     * configuration into the actual capabilities of the driver
93     * and returns the result.
94     *
95     * @param int $capabilities
96     * @return int
97     */
98    public function mergeConfigurationCapabilities($capabilities)
99    {
100        $this->capabilities &= $capabilities;
101
102        return $this->capabilities;
103    }
104
105    /**
106     * Processes the configuration for this driver.
107     */
108    public function processConfiguration()
109    {
110        $this->absoluteBasePath = $this->calculateBasePath($this->configuration);
111        $this->determineBaseUrl();
112        if ($this->baseUri === null) {
113            // remove public flag
114            $this->capabilities &= ~ResourceStorage::CAPABILITY_PUBLIC;
115        }
116    }
117
118    /**
119     * Initializes this object. This is called by the storage after the driver
120     * has been attached.
121     */
122    public function initialize()
123    {
124    }
125
126    /**
127     * Determines the base URL for this driver, from the configuration or
128     * the TypoScript frontend object
129     */
130    protected function determineBaseUrl()
131    {
132        // only calculate baseURI if the storage does not enforce jumpUrl Script
133        if ($this->hasCapability(ResourceStorage::CAPABILITY_PUBLIC)) {
134            if (!empty($this->configuration['baseUri'])) {
135                $this->baseUri = rtrim($this->configuration['baseUri'], '/') . '/';
136            } elseif (str_starts_with($this->absoluteBasePath, Environment::getPublicPath())) {
137                // use site-relative URLs
138                $temporaryBaseUri = rtrim(PathUtility::stripPathSitePrefix($this->absoluteBasePath), '/');
139                if ($temporaryBaseUri !== '') {
140                    $uriParts = explode('/', $temporaryBaseUri);
141                    $uriParts = array_map('rawurlencode', $uriParts);
142                    $temporaryBaseUri = implode('/', $uriParts) . '/';
143                }
144                $this->baseUri = $temporaryBaseUri;
145            }
146        }
147    }
148
149    /**
150     * Calculates the absolute path to this drivers storage location.
151     *
152     * @throws Exception\InvalidConfigurationException
153     * @param array $configuration
154     * @return string
155     * @throws Exception\InvalidPathException
156     */
157    protected function calculateBasePath(array $configuration)
158    {
159        if (!array_key_exists('basePath', $configuration) || empty($configuration['basePath'])) {
160            throw new InvalidConfigurationException(
161                'Configuration must contain base path.',
162                1346510477
163            );
164        }
165
166        if (!empty($configuration['pathType']) && $configuration['pathType'] === 'relative') {
167            $relativeBasePath = $configuration['basePath'];
168            $absoluteBasePath = Environment::getPublicPath() . '/' . $relativeBasePath;
169        } else {
170            $absoluteBasePath = $configuration['basePath'];
171        }
172        $absoluteBasePath = $this->canonicalizeAndCheckFilePath($absoluteBasePath);
173        $absoluteBasePath = rtrim($absoluteBasePath, '/') . '/';
174        if (!is_dir($absoluteBasePath)) {
175            throw new InvalidConfigurationException(
176                'Base path "' . $absoluteBasePath . '" does not exist or is no directory.',
177                1299233097
178            );
179        }
180        return $absoluteBasePath;
181    }
182
183    /**
184     * Returns the public URL to a file.
185     * For the local driver, this will always return a path relative to public web path.
186     *
187     * @param string $identifier
188     * @return string|null NULL if file is missing or deleted, the generated url otherwise
189     */
190    public function getPublicUrl($identifier)
191    {
192        $publicUrl = null;
193        if ($this->baseUri !== null) {
194            $uriParts = explode('/', ltrim($identifier, '/'));
195            $uriParts = array_map('rawurlencode', $uriParts);
196            $identifier = implode('/', $uriParts);
197            $publicUrl = $this->baseUri . $identifier;
198        }
199        return $publicUrl;
200    }
201
202    /**
203     * Returns the Identifier of the root level folder of the storage.
204     *
205     * @return string
206     */
207    public function getRootLevelFolder()
208    {
209        return '/';
210    }
211
212    /**
213     * Returns identifier of the default folder new files should be put into.
214     *
215     * @return string
216     */
217    public function getDefaultFolder()
218    {
219        $identifier = '/user_upload/';
220        $createFolder = !$this->folderExists($identifier);
221        if ($createFolder === true) {
222            $identifier = $this->createFolder('user_upload');
223        }
224        return $identifier;
225    }
226
227    /**
228     * Creates a folder, within a parent folder.
229     * If no parent folder is given, a rootlevel folder will be created
230     *
231     * @param string $newFolderName
232     * @param string $parentFolderIdentifier
233     * @param bool $recursive
234     * @return string the Identifier of the new folder
235     */
236    public function createFolder($newFolderName, $parentFolderIdentifier = '', $recursive = false)
237    {
238        $parentFolderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($parentFolderIdentifier);
239        $newFolderName = trim($newFolderName, '/');
240        if ($recursive === false) {
241            $newFolderName = $this->sanitizeFileName($newFolderName);
242            $newIdentifier = $this->canonicalizeAndCheckFolderIdentifier($parentFolderIdentifier . $newFolderName . '/');
243            GeneralUtility::mkdir($this->getAbsolutePath($newIdentifier));
244        } else {
245            $parts = GeneralUtility::trimExplode('/', $newFolderName);
246            $parts = array_map([$this, 'sanitizeFileName'], $parts);
247            $newFolderName = implode('/', $parts);
248            $newIdentifier = $this->canonicalizeAndCheckFolderIdentifier(
249                $parentFolderIdentifier . $newFolderName . '/'
250            );
251            GeneralUtility::mkdir_deep($this->getAbsolutePath($newIdentifier));
252        }
253        return $newIdentifier;
254    }
255
256    /**
257     * Returns information about a file.
258     *
259     * @param string $fileIdentifier In the case of the LocalDriver, this is the (relative) path to the file.
260     * @param array $propertiesToExtract Array of properties which should be extracted, if empty all will be extracted
261     * @return array
262     * @throws \InvalidArgumentException
263     */
264    public function getFileInfoByIdentifier($fileIdentifier, array $propertiesToExtract = [])
265    {
266        $absoluteFilePath = $this->getAbsolutePath($fileIdentifier);
267        // don't use $this->fileExists() because we need the absolute path to the file anyways, so we can directly
268        // use PHP's filesystem method.
269        if (!file_exists($absoluteFilePath) || !is_file($absoluteFilePath)) {
270            throw new \InvalidArgumentException('File ' . $fileIdentifier . ' does not exist.', 1314516809);
271        }
272
273        $dirPath = PathUtility::dirname($fileIdentifier);
274        $dirPath = $this->canonicalizeAndCheckFolderIdentifier($dirPath);
275        return $this->extractFileInformation($absoluteFilePath, $dirPath, $propertiesToExtract);
276    }
277
278    /**
279     * Returns information about a folder.
280     *
281     * @param string $folderIdentifier In the case of the LocalDriver, this is the (relative) path to the file.
282     * @return array
283     * @throws Exception\FolderDoesNotExistException
284     */
285    public function getFolderInfoByIdentifier($folderIdentifier)
286    {
287        $folderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier);
288
289        if (!$this->folderExists($folderIdentifier)) {
290            throw new FolderDoesNotExistException(
291                'Folder "' . $folderIdentifier . '" does not exist.',
292                1314516810
293            );
294        }
295        $absolutePath = $this->getAbsolutePath($folderIdentifier);
296        return [
297            'identifier' => $folderIdentifier,
298            'name' => PathUtility::basename($folderIdentifier),
299            'mtime' => filemtime($absolutePath),
300            'ctime' => filectime($absolutePath),
301            'storage' => $this->storageUid,
302        ];
303    }
304
305    /**
306     * Returns a string where any character not matching [.a-zA-Z0-9_-] is
307     * substituted by '_'
308     * Trailing dots are removed
309     *
310     * Previously in \TYPO3\CMS\Core\Utility\File\BasicFileUtility::cleanFileName()
311     *
312     * @param string $fileName Input string, typically the body of a fileName
313     * @param string $charset Charset of the a fileName (defaults to utf-8)
314     * @return string Output string with any characters not matching [.a-zA-Z0-9_-] is substituted by '_' and trailing dots removed
315     * @throws Exception\InvalidFileNameException
316     */
317    public function sanitizeFileName($fileName, $charset = 'utf-8')
318    {
319        if ($charset === 'utf-8') {
320            $fileName = \Normalizer::normalize((string)$fileName) ?: $fileName;
321        }
322
323        // Handle UTF-8 characters
324        if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['UTF8filesystem']) {
325            // Allow ".", "-", 0-9, a-z, A-Z and everything beyond U+C0 (latin capital letter a with grave)
326            $cleanFileName = (string)preg_replace('/[' . self::UNSAFE_FILENAME_CHARACTER_EXPRESSION . ']/u', '_', trim($fileName));
327        } else {
328            $fileName = GeneralUtility::makeInstance(CharsetConverter::class)->specCharsToASCII($charset, $fileName);
329            // Replace unwanted characters with underscores
330            $cleanFileName = (string)preg_replace('/[' . self::UNSAFE_FILENAME_CHARACTER_EXPRESSION . '\\xC0-\\xFF]/', '_', trim($fileName));
331        }
332        // Strip trailing dots and return
333        $cleanFileName = rtrim($cleanFileName, '.');
334        if ($cleanFileName === '') {
335            throw new InvalidFileNameException(
336                'File name ' . $fileName . ' is invalid.',
337                1320288991
338            );
339        }
340        return $cleanFileName;
341    }
342
343    /**
344     * Generic wrapper for extracting a list of items from a path.
345     *
346     * @param string $folderIdentifier
347     * @param int $start The position to start the listing; if not set, start from the beginning
348     * @param int $numberOfItems The number of items to list; if set to zero, all items are returned
349     * @param array $filterMethods The filter methods used to filter the directory items
350     * @param bool $includeFiles
351     * @param bool $includeDirs
352     * @param bool $recursive
353     * @param string $sort Property name used to sort the items.
354     *                     Among them may be: '' (empty, no sorting), name,
355     *                     fileext, size, tstamp and rw.
356     *                     If a driver does not support the given property, it
357     *                     should fall back to "name".
358     * @param bool $sortRev TRUE to indicate reverse sorting (last to first)
359     * @return array
360     * @throws \InvalidArgumentException
361     */
362    protected function getDirectoryItemList($folderIdentifier, $start, $numberOfItems, array $filterMethods, $includeFiles = true, $includeDirs = true, $recursive = false, $sort = '', $sortRev = false)
363    {
364        $folderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier);
365        $realPath = $this->getAbsolutePath($folderIdentifier);
366        if (!is_dir($realPath)) {
367            throw new \InvalidArgumentException(
368                'Cannot list items in directory ' . $folderIdentifier . ' - does not exist or is no directory',
369                1314349666
370            );
371        }
372
373        $items = $this->retrieveFileAndFoldersInPath($realPath, $recursive, $includeFiles, $includeDirs, $sort, $sortRev);
374        $iterator = new \ArrayIterator($items);
375        if ($iterator->count() === 0) {
376            return [];
377        }
378
379        // $c is the counter for how many items we still have to fetch (-1 is unlimited)
380        $c = $numberOfItems > 0 ? $numberOfItems : - 1;
381        $items = [];
382        while ($iterator->valid() && ($numberOfItems === 0 || $c > 0)) {
383            // $iteratorItem is the file or folder name
384            $iteratorItem = $iterator->current();
385            // go on to the next iterator item now as we might skip this one early
386            $iterator->next();
387
388            try {
389                if (
390                !$this->applyFilterMethodsToDirectoryItem(
391                    $filterMethods,
392                    $iteratorItem['name'],
393                    $iteratorItem['identifier'],
394                    $this->getParentFolderIdentifierOfIdentifier($iteratorItem['identifier'])
395                )
396                ) {
397                    continue;
398                }
399                if ($start > 0) {
400                    $start--;
401                } else {
402                    $items[$iteratorItem['identifier']] = $iteratorItem['identifier'];
403                    // Decrement item counter to make sure we only return $numberOfItems
404                    // we cannot do this earlier in the method (unlike moving the iterator forward) because we only add the
405                    // item here
406                    --$c;
407                }
408            } catch (InvalidPathException $e) {
409            }
410        }
411        return $items;
412    }
413
414    /**
415     * Applies a set of filter methods to a file name to find out if it should be used or not. This is e.g. used by
416     * directory listings.
417     *
418     * @param array $filterMethods The filter methods to use
419     * @param string $itemName
420     * @param string $itemIdentifier
421     * @param string $parentIdentifier
422     * @throws \RuntimeException
423     * @return bool
424     */
425    protected function applyFilterMethodsToDirectoryItem(array $filterMethods, $itemName, $itemIdentifier, $parentIdentifier)
426    {
427        foreach ($filterMethods as $filter) {
428            if (is_callable($filter)) {
429                $result = $filter($itemName, $itemIdentifier, $parentIdentifier, [], $this);
430                // We use -1 as the "don't include“ return value, for historic reasons,
431                // as call_user_func() used to return FALSE if calling the method failed.
432                if ($result === -1) {
433                    return false;
434                }
435                if ($result === false) {
436                    throw new \RuntimeException(
437                        'Could not apply file/folder name filter ' . $filter[0] . '::' . $filter[1],
438                        1476046425
439                    );
440                }
441            }
442        }
443        return true;
444    }
445
446    /**
447     * Returns a file inside the specified path
448     *
449     * @param string $fileName
450     * @param string $folderIdentifier
451     * @return string File Identifier
452     */
453    public function getFileInFolder($fileName, $folderIdentifier)
454    {
455        return $this->canonicalizeAndCheckFileIdentifier($folderIdentifier . '/' . $fileName);
456    }
457
458    /**
459     * Returns a list of files inside the specified path
460     *
461     * @param string $folderIdentifier
462     * @param int $start
463     * @param int $numberOfItems
464     * @param bool $recursive
465     * @param array $filenameFilterCallbacks The method callbacks to use for filtering the items
466     * @param string $sort Property name used to sort the items.
467     *                     Among them may be: '' (empty, no sorting), name,
468     *                     fileext, size, tstamp and rw.
469     *                     If a driver does not support the given property, it
470     *                     should fall back to "name".
471     * @param bool $sortRev TRUE to indicate reverse sorting (last to first)
472     * @return array of FileIdentifiers
473     */
474    public function getFilesInFolder($folderIdentifier, $start = 0, $numberOfItems = 0, $recursive = false, array $filenameFilterCallbacks = [], $sort = '', $sortRev = false)
475    {
476        return $this->getDirectoryItemList($folderIdentifier, $start, $numberOfItems, $filenameFilterCallbacks, true, false, $recursive, $sort, $sortRev);
477    }
478
479    /**
480     * Returns the number of files inside the specified path
481     *
482     * @param string $folderIdentifier
483     * @param bool $recursive
484     * @param array $filenameFilterCallbacks callbacks for filtering the items
485     * @return int Number of files in folder
486     */
487    public function countFilesInFolder($folderIdentifier, $recursive = false, array $filenameFilterCallbacks = [])
488    {
489        return count($this->getFilesInFolder($folderIdentifier, 0, 0, $recursive, $filenameFilterCallbacks));
490    }
491
492    /**
493     * Returns a list of folders inside the specified path
494     *
495     * @param string $folderIdentifier
496     * @param int $start
497     * @param int $numberOfItems
498     * @param bool $recursive
499     * @param array $folderNameFilterCallbacks The method callbacks to use for filtering the items
500     * @param string $sort Property name used to sort the items.
501     *                     Among them may be: '' (empty, no sorting), name,
502     *                     fileext, size, tstamp and rw.
503     *                     If a driver does not support the given property, it
504     *                     should fall back to "name".
505     * @param bool $sortRev TRUE to indicate reverse sorting (last to first)
506     * @return array of Folder Identifier
507     */
508    public function getFoldersInFolder($folderIdentifier, $start = 0, $numberOfItems = 0, $recursive = false, array $folderNameFilterCallbacks = [], $sort = '', $sortRev = false)
509    {
510        return $this->getDirectoryItemList($folderIdentifier, $start, $numberOfItems, $folderNameFilterCallbacks, false, true, $recursive, $sort, $sortRev);
511    }
512
513    /**
514     * Returns the number of folders inside the specified path
515     *
516     * @param string  $folderIdentifier
517     * @param bool $recursive
518     * @param array   $folderNameFilterCallbacks callbacks for filtering the items
519     * @return int Number of folders in folder
520     */
521    public function countFoldersInFolder($folderIdentifier, $recursive = false, array $folderNameFilterCallbacks = [])
522    {
523        return count($this->getFoldersInFolder($folderIdentifier, 0, 0, $recursive, $folderNameFilterCallbacks));
524    }
525
526    /**
527     * Returns a list with the names of all files and folders in a path, optionally recursive.
528     *
529     * @param string $path The absolute path
530     * @param bool $recursive If TRUE, recursively fetches files and folders
531     * @param bool $includeFiles
532     * @param bool $includeDirs
533     * @param string $sort Property name used to sort the items.
534     *                     Among them may be: '' (empty, no sorting), name,
535     *                     fileext, size, tstamp and rw.
536     *                     If a driver does not support the given property, it
537     *                     should fall back to "name".
538     * @param bool $sortRev TRUE to indicate reverse sorting (last to first)
539     * @return array
540     */
541    protected function retrieveFileAndFoldersInPath($path, $recursive = false, $includeFiles = true, $includeDirs = true, $sort = '', $sortRev = false)
542    {
543        $pathLength = strlen($this->getAbsoluteBasePath());
544        $iteratorMode = \FilesystemIterator::UNIX_PATHS | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_FILEINFO | \FilesystemIterator::FOLLOW_SYMLINKS;
545        if ($recursive) {
546            $iterator = new \RecursiveIteratorIterator(
547                new \RecursiveDirectoryIterator($path, $iteratorMode),
548                \RecursiveIteratorIterator::SELF_FIRST,
549                \RecursiveIteratorIterator::CATCH_GET_CHILD
550            );
551        } else {
552            $iterator = new \RecursiveDirectoryIterator($path, $iteratorMode);
553        }
554
555        $directoryEntries = [];
556        while ($iterator->valid()) {
557            /** @var \SplFileInfo $entry */
558            $entry = $iterator->current();
559            $isFile = $entry->isFile();
560            $isDirectory = $isFile ? false : $entry->isDir();
561            if (
562                (!$isFile && !$isDirectory) // skip non-files/non-folders
563                || ($isFile && !$includeFiles) // skip files if they are excluded
564                || ($isDirectory && !$includeDirs) // skip directories if they are excluded
565                || $entry->getFilename() === '' // skip empty entries
566                || !$entry->isReadable() // skip unreadable entries
567            ) {
568                $iterator->next();
569                continue;
570            }
571            $entryIdentifier = '/' . substr($entry->getPathname(), $pathLength);
572            $entryName = PathUtility::basename($entryIdentifier);
573            if ($isDirectory) {
574                $entryIdentifier .= '/';
575            }
576            $entryArray = [
577                'identifier' => $entryIdentifier,
578                'name' => $entryName,
579                'type' => $isDirectory ? 'dir' : 'file',
580            ];
581            $directoryEntries[$entryIdentifier] = $entryArray;
582            $iterator->next();
583        }
584        return $this->sortDirectoryEntries($directoryEntries, $sort, $sortRev);
585    }
586
587    /**
588     * Sort the directory entries by a certain key
589     *
590     * @param array $directoryEntries Array of directory entry arrays from
591     *                                retrieveFileAndFoldersInPath()
592     * @param string $sort Property name used to sort the items.
593     *                     Among them may be: '' (empty, no sorting), name,
594     *                     fileext, size, tstamp and rw.
595     *                     If a driver does not support the given property, it
596     *                     should fall back to "name".
597     * @param bool $sortRev TRUE to indicate reverse sorting (last to first)
598     * @return array Sorted entries. Content of the keys is undefined.
599     */
600    protected function sortDirectoryEntries($directoryEntries, $sort = '', $sortRev = false)
601    {
602        $entriesToSort = [];
603        foreach ($directoryEntries as $entryArray) {
604            $dir      = pathinfo($entryArray['name'], PATHINFO_DIRNAME) . '/';
605            $fullPath = $this->getAbsoluteBasePath() . $entryArray['identifier'];
606            switch ($sort) {
607                case 'size':
608                    $sortingKey = '0';
609                    if ($entryArray['type'] === 'file') {
610                        $sortingKey = $this->getSpecificFileInformation($fullPath, $dir, 'size');
611                    }
612                    // Add a character for a natural order sorting
613                    $sortingKey .= 's';
614                    break;
615                case 'rw':
616                    $perms = $this->getPermissions($entryArray['identifier']);
617                    $sortingKey = ($perms['r'] ? 'R' : '')
618                        . ($perms['w'] ? 'W' : '');
619                    break;
620                case 'fileext':
621                    $sortingKey = pathinfo($entryArray['name'], PATHINFO_EXTENSION);
622                    break;
623                case 'tstamp':
624                    $sortingKey = $this->getSpecificFileInformation($fullPath, $dir, 'mtime');
625                    // Add a character for a natural order sorting
626                    $sortingKey .= 't';
627                    break;
628                case 'name':
629                case 'file':
630                default:
631                    $sortingKey = $entryArray['name'];
632            }
633            $i = 0;
634            while (isset($entriesToSort[$sortingKey . $i])) {
635                $i++;
636            }
637            $entriesToSort[$sortingKey . $i] = $entryArray;
638        }
639        uksort($entriesToSort, 'strnatcasecmp');
640
641        if ($sortRev) {
642            $entriesToSort = array_reverse($entriesToSort);
643        }
644
645        return $entriesToSort;
646    }
647
648    /**
649     * Extracts information about a file from the filesystem.
650     *
651     * @param string $filePath The absolute path to the file
652     * @param string $containerPath The relative path to the file's container
653     * @param array $propertiesToExtract array of properties which should be returned, if empty all will be extracted
654     * @return array
655     */
656    protected function extractFileInformation($filePath, $containerPath, array $propertiesToExtract = [])
657    {
658        if (empty($propertiesToExtract)) {
659            $propertiesToExtract = [
660                'size', 'atime', 'mtime', 'ctime', 'mimetype', 'name', 'extension',
661                'identifier', 'identifier_hash', 'storage', 'folder_hash',
662            ];
663        }
664        $fileInformation = [];
665        foreach ($propertiesToExtract as $property) {
666            $fileInformation[$property] = $this->getSpecificFileInformation($filePath, $containerPath, $property);
667        }
668        return $fileInformation;
669    }
670
671    /**
672     * Extracts a specific FileInformation from the FileSystems.
673     *
674     * @param string $fileIdentifier
675     * @param string $containerPath
676     * @param string $property
677     *
678     * @return bool|int|string
679     * @throws \InvalidArgumentException
680     */
681    public function getSpecificFileInformation($fileIdentifier, $containerPath, $property)
682    {
683        $identifier = $this->canonicalizeAndCheckFileIdentifier($containerPath . PathUtility::basename($fileIdentifier));
684
685        /** @var FileInfo $fileInfo */
686        $fileInfo = GeneralUtility::makeInstance(FileInfo::class, $fileIdentifier);
687        switch ($property) {
688            case 'size':
689                return $fileInfo->getSize();
690            case 'atime':
691                return $fileInfo->getATime();
692            case 'mtime':
693                return $fileInfo->getMTime();
694            case 'ctime':
695                return $fileInfo->getCTime();
696            case 'name':
697                return PathUtility::basename($fileIdentifier);
698            case 'extension':
699                return PathUtility::pathinfo($fileIdentifier, PATHINFO_EXTENSION);
700            case 'mimetype':
701                return (string)$fileInfo->getMimeType();
702            case 'identifier':
703                return $identifier;
704            case 'storage':
705                return $this->storageUid;
706            case 'identifier_hash':
707                return $this->hashIdentifier($identifier);
708            case 'folder_hash':
709                return $this->hashIdentifier($this->getParentFolderIdentifierOfIdentifier($identifier));
710            default:
711                throw new \InvalidArgumentException(sprintf('The information "%s" is not available.', $property), 1476047422);
712        }
713    }
714
715    /**
716     * Returns the absolute path of the folder this driver operates on.
717     *
718     * @return string
719     */
720    protected function getAbsoluteBasePath()
721    {
722        return $this->absoluteBasePath;
723    }
724
725    /**
726     * Returns the absolute path of a file or folder.
727     *
728     * @param string $fileIdentifier
729     * @return string
730     * @throws Exception\InvalidPathException
731     */
732    protected function getAbsolutePath($fileIdentifier)
733    {
734        $relativeFilePath = ltrim($this->canonicalizeAndCheckFileIdentifier($fileIdentifier), '/');
735        $path = $this->absoluteBasePath . $relativeFilePath;
736        return $path;
737    }
738
739    /**
740     * Creates a (cryptographic) hash for a file.
741     *
742     * @param string $fileIdentifier
743     * @param string $hashAlgorithm The hash algorithm to use
744     * @return string
745     * @throws \RuntimeException
746     * @throws \InvalidArgumentException
747     */
748    public function hash($fileIdentifier, $hashAlgorithm)
749    {
750        if (!in_array($hashAlgorithm, $this->supportedHashAlgorithms)) {
751            throw new \InvalidArgumentException('Hash algorithm "' . $hashAlgorithm . '" is not supported.', 1304964032);
752        }
753        switch ($hashAlgorithm) {
754            case 'sha1':
755                $hash = sha1_file($this->getAbsolutePath($fileIdentifier));
756                break;
757            case 'md5':
758                $hash = md5_file($this->getAbsolutePath($fileIdentifier));
759                break;
760            default:
761                throw new \RuntimeException('Hash algorithm ' . $hashAlgorithm . ' is not implemented.', 1329644451);
762        }
763        return $hash;
764    }
765
766    /**
767     * Adds a file from the local server hard disk to a given path in TYPO3s virtual file system.
768     * This assumes that the local file exists, so no further check is done here!
769     * After a successful the original file must not exist anymore.
770     *
771     * @param string $localFilePath within public web path
772     * @param string $targetFolderIdentifier
773     * @param string $newFileName optional, if not given original name is used
774     * @param bool $removeOriginal if set the original file will be removed after successful operation
775     * @return string the identifier of the new file
776     * @throws \RuntimeException
777     * @throws \InvalidArgumentException
778     */
779    public function addFile($localFilePath, $targetFolderIdentifier, $newFileName = '', $removeOriginal = true)
780    {
781        $localFilePath = $this->canonicalizeAndCheckFilePath($localFilePath);
782        // as for the "virtual storage" for backwards-compatibility, this check always fails, as the file probably lies under public web path
783        // thus, it is not checked here
784        // @todo is check in storage
785        if (str_starts_with($localFilePath, $this->absoluteBasePath) && $this->storageUid > 0) {
786            throw new \InvalidArgumentException('Cannot add a file that is already part of this storage.', 1314778269);
787        }
788        $newFileName = $this->sanitizeFileName($newFileName !== '' ? $newFileName : PathUtility::basename($localFilePath));
789        $newFileIdentifier = $this->canonicalizeAndCheckFolderIdentifier($targetFolderIdentifier) . $newFileName;
790        $targetPath = $this->getAbsolutePath($newFileIdentifier);
791
792        if ($removeOriginal) {
793            if (is_uploaded_file($localFilePath)) {
794                $result = move_uploaded_file($localFilePath, $targetPath);
795            } else {
796                $result = rename($localFilePath, $targetPath);
797            }
798        } else {
799            $result = copy($localFilePath, $targetPath);
800        }
801        if ($result === false || !file_exists($targetPath)) {
802            throw new \RuntimeException(
803                'Adding file ' . $localFilePath . ' at ' . $newFileIdentifier . ' failed.',
804                1476046453
805            );
806        }
807        clearstatcache();
808        // Change the permissions of the file
809        GeneralUtility::fixPermissions($targetPath);
810        return $newFileIdentifier;
811    }
812
813    /**
814     * Checks if a file exists.
815     *
816     * @param string $fileIdentifier
817     *
818     * @return bool
819     */
820    public function fileExists($fileIdentifier)
821    {
822        $absoluteFilePath = $this->getAbsolutePath($fileIdentifier);
823        return is_file($absoluteFilePath);
824    }
825
826    /**
827     * Checks if a file inside a folder exists
828     *
829     * @param string $fileName
830     * @param string $folderIdentifier
831     * @return bool
832     */
833    public function fileExistsInFolder($fileName, $folderIdentifier)
834    {
835        $identifier = $folderIdentifier . '/' . $fileName;
836        $identifier = $this->canonicalizeAndCheckFileIdentifier($identifier);
837        return $this->fileExists($identifier);
838    }
839
840    /**
841     * Checks if a folder exists.
842     *
843     * @param string $folderIdentifier
844     *
845     * @return bool
846     */
847    public function folderExists($folderIdentifier)
848    {
849        $absoluteFilePath = $this->getAbsolutePath($folderIdentifier);
850        return is_dir($absoluteFilePath);
851    }
852
853    /**
854     * Checks if a folder inside a folder exists.
855     *
856     * @param string $folderName
857     * @param string $folderIdentifier
858     * @return bool
859     */
860    public function folderExistsInFolder($folderName, $folderIdentifier)
861    {
862        $identifier = $folderIdentifier . '/' . $folderName;
863        $identifier = $this->canonicalizeAndCheckFolderIdentifier($identifier);
864        return $this->folderExists($identifier);
865    }
866
867    /**
868     * Returns the Identifier for a folder within a given folder.
869     *
870     * @param string $folderName The name of the target folder
871     * @param string $folderIdentifier
872     * @return string
873     */
874    public function getFolderInFolder($folderName, $folderIdentifier)
875    {
876        $folderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier . '/' . $folderName);
877        return $folderIdentifier;
878    }
879
880    /**
881     * Replaces the contents (and file-specific metadata) of a file object with a local file.
882     *
883     * @param string $fileIdentifier
884     * @param string $localFilePath
885     * @return bool TRUE if the operation succeeded
886     * @throws \RuntimeException
887     */
888    public function replaceFile($fileIdentifier, $localFilePath)
889    {
890        $filePath = $this->getAbsolutePath($fileIdentifier);
891        if (is_uploaded_file($localFilePath)) {
892            $result = move_uploaded_file($localFilePath, $filePath);
893        } else {
894            $result = rename($localFilePath, $filePath);
895        }
896        GeneralUtility::fixPermissions($filePath);
897        if ($result === false) {
898            throw new \RuntimeException('Replacing file ' . $fileIdentifier . ' with ' . $localFilePath . ' failed.', 1315314711);
899        }
900        return $result;
901    }
902
903    /**
904     * Copies a file *within* the current storage.
905     * Note that this is only about an intra-storage copy action, where a file is just
906     * copied to another folder in the same storage.
907     *
908     * @param string $fileIdentifier
909     * @param string $targetFolderIdentifier
910     * @param string $fileName
911     * @return string the Identifier of the new file
912     */
913    public function copyFileWithinStorage($fileIdentifier, $targetFolderIdentifier, $fileName)
914    {
915        $sourcePath = $this->getAbsolutePath($fileIdentifier);
916        $newIdentifier = $targetFolderIdentifier . '/' . $fileName;
917        $newIdentifier = $this->canonicalizeAndCheckFileIdentifier($newIdentifier);
918
919        $absoluteFilePath = $this->getAbsolutePath($newIdentifier);
920        copy($sourcePath, $absoluteFilePath);
921        GeneralUtility::fixPermissions($absoluteFilePath);
922        return $newIdentifier;
923    }
924
925    /**
926     * Moves a file *within* the current storage.
927     * Note that this is only about an inner-storage move action, where a file is just
928     * moved to another folder in the same storage.
929     *
930     * @param string $fileIdentifier
931     * @param string $targetFolderIdentifier
932     * @param string $newFileName
933     * @return string
934     * @throws \RuntimeException
935     */
936    public function moveFileWithinStorage($fileIdentifier, $targetFolderIdentifier, $newFileName)
937    {
938        $sourcePath = $this->getAbsolutePath($fileIdentifier);
939        $targetIdentifier = $targetFolderIdentifier . '/' . $newFileName;
940        $targetIdentifier = $this->canonicalizeAndCheckFileIdentifier($targetIdentifier);
941        $result = rename($sourcePath, $this->getAbsolutePath($targetIdentifier));
942        if ($result === false) {
943            throw new \RuntimeException('Moving file ' . $sourcePath . ' to ' . $targetIdentifier . ' failed.', 1315314712);
944        }
945        return $targetIdentifier;
946    }
947
948    /**
949     * Copies a file to a temporary path and returns that path.
950     *
951     * @param string $fileIdentifier
952     * @return string The temporary path
953     * @throws \RuntimeException
954     */
955    protected function copyFileToTemporaryPath($fileIdentifier)
956    {
957        $sourcePath = $this->getAbsolutePath($fileIdentifier);
958        $temporaryPath = $this->getTemporaryPathForFile($fileIdentifier);
959        $result = copy($sourcePath, $temporaryPath);
960        touch($temporaryPath, (int)filemtime($sourcePath));
961        if ($result === false) {
962            throw new \RuntimeException(
963                'Copying file "' . $fileIdentifier . '" to temporary path "' . $temporaryPath . '" failed.',
964                1320577649
965            );
966        }
967        return $temporaryPath;
968    }
969
970    /**
971     * Moves a file or folder to the given directory, renaming the source in the process if
972     * a file or folder of the same name already exists in the target path.
973     *
974     * @param string $filePath
975     * @param string $recycleDirectory
976     * @return bool
977     */
978    protected function recycleFileOrFolder($filePath, $recycleDirectory)
979    {
980        $destinationFile = $recycleDirectory . '/' . PathUtility::basename($filePath);
981        if (file_exists($destinationFile)) {
982            $timeStamp = \DateTimeImmutable::createFromFormat('U.u', (string)microtime(true))->format('YmdHisu');
983            $destinationFile = $recycleDirectory . '/' . $timeStamp . '_' . PathUtility::basename($filePath);
984        }
985        $result = rename($filePath, $destinationFile);
986        // Update the mtime for the file, so the recycler garbage collection task knows which files to delete
987        // Using ctime() is not possible there since this is not supported on Windows
988        if ($result) {
989            touch($destinationFile);
990        }
991        return $result;
992    }
993
994    /**
995     * Creates a map of old and new file/folder identifiers after renaming or
996     * moving a folder. The old identifier is used as the key, the new one as the value.
997     *
998     * @param array $filesAndFolders
999     * @param string $sourceFolderIdentifier
1000     * @param string $targetFolderIdentifier
1001     *
1002     * @return array
1003     * @throws Exception\FileOperationErrorException
1004     */
1005    protected function createIdentifierMap(array $filesAndFolders, $sourceFolderIdentifier, $targetFolderIdentifier)
1006    {
1007        $identifierMap = [];
1008        $identifierMap[$sourceFolderIdentifier] = $targetFolderIdentifier;
1009        foreach ($filesAndFolders as $oldItem) {
1010            if ($oldItem['type'] === 'dir') {
1011                $oldIdentifier = $oldItem['identifier'];
1012                $newIdentifier = $this->canonicalizeAndCheckFolderIdentifier(
1013                    str_replace($sourceFolderIdentifier, $targetFolderIdentifier, $oldItem['identifier'])
1014                );
1015            } else {
1016                $oldIdentifier = $oldItem['identifier'];
1017                $newIdentifier = $this->canonicalizeAndCheckFileIdentifier(
1018                    str_replace($sourceFolderIdentifier, $targetFolderIdentifier, $oldItem['identifier'])
1019                );
1020            }
1021            if (!file_exists($this->getAbsolutePath($newIdentifier))) {
1022                throw new FileOperationErrorException(
1023                    sprintf('File "%1$s" was not found (should have been copied/moved from "%2$s").', $newIdentifier, $oldIdentifier),
1024                    1330119453
1025                );
1026            }
1027            $identifierMap[$oldIdentifier] = $newIdentifier;
1028        }
1029        return $identifierMap;
1030    }
1031
1032    /**
1033     * Folder equivalent to moveFileWithinStorage().
1034     *
1035     * @param string $sourceFolderIdentifier
1036     * @param string $targetFolderIdentifier
1037     * @param string $newFolderName
1038     *
1039     * @return array A map of old to new file identifiers
1040     * @throws \RuntimeException
1041     */
1042    public function moveFolderWithinStorage($sourceFolderIdentifier, $targetFolderIdentifier, $newFolderName)
1043    {
1044        $sourcePath = $this->getAbsolutePath($sourceFolderIdentifier);
1045        $relativeTargetPath = $this->canonicalizeAndCheckFolderIdentifier($targetFolderIdentifier . '/' . $newFolderName);
1046        $targetPath = $this->getAbsolutePath($relativeTargetPath);
1047        // get all files and folders we are going to move, to have a map for updating later.
1048        $filesAndFolders = $this->retrieveFileAndFoldersInPath($sourcePath, true);
1049        $result = rename($sourcePath, $targetPath);
1050        if ($result === false) {
1051            throw new \RuntimeException('Moving folder ' . $sourcePath . ' to ' . $targetPath . ' failed.', 1320711817);
1052        }
1053        // Create a mapping from old to new identifiers
1054        $identifierMap = $this->createIdentifierMap($filesAndFolders, $sourceFolderIdentifier, $relativeTargetPath);
1055        return $identifierMap;
1056    }
1057
1058    /**
1059     * Folder equivalent to copyFileWithinStorage().
1060     *
1061     * @param string $sourceFolderIdentifier
1062     * @param string $targetFolderIdentifier
1063     * @param string $newFolderName
1064     *
1065     * @return bool
1066     * @throws Exception\FileOperationErrorException
1067     */
1068    public function copyFolderWithinStorage($sourceFolderIdentifier, $targetFolderIdentifier, $newFolderName)
1069    {
1070        // This target folder path already includes the topmost level, i.e. the folder this method knows as $folderToCopy.
1071        // We can thus rely on this folder being present and just create the subfolder we want to copy to.
1072        $newFolderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($targetFolderIdentifier . '/' . $newFolderName);
1073        $sourceFolderPath = $this->getAbsolutePath($sourceFolderIdentifier);
1074        $targetFolderPath = $this->getAbsolutePath($newFolderIdentifier);
1075
1076        mkdir($targetFolderPath);
1077        /** @var \RecursiveDirectoryIterator $iterator */
1078        $iterator = new \RecursiveIteratorIterator(
1079            new \RecursiveDirectoryIterator($sourceFolderPath),
1080            \RecursiveIteratorIterator::SELF_FIRST,
1081            \RecursiveIteratorIterator::CATCH_GET_CHILD
1082        );
1083        // Rewind the iterator as this is important for some systems e.g. Windows
1084        $iterator->rewind();
1085        while ($iterator->valid()) {
1086            /** @var \RecursiveDirectoryIterator $current */
1087            $current = $iterator->current();
1088            $fileName = $current->getFilename();
1089            $itemSubPath = GeneralUtility::fixWindowsFilePath($iterator->getSubPathname());
1090            if ($current->isDir() && !($fileName === '..' || $fileName === '.')) {
1091                GeneralUtility::mkdir($targetFolderPath . '/' . $itemSubPath);
1092            } elseif ($current->isFile()) {
1093                $copySourcePath = $sourceFolderPath . '/' . $itemSubPath;
1094                $copyTargetPath = $targetFolderPath . '/' . $itemSubPath;
1095                $result = copy($copySourcePath, $copyTargetPath);
1096                if ($result === false) {
1097                    // rollback
1098                    GeneralUtility::rmdir($targetFolderIdentifier, true);
1099                    throw new FileOperationErrorException(
1100                        'Copying resource "' . $copySourcePath . '" to "' . $copyTargetPath . '" failed.',
1101                        1330119452
1102                    );
1103                }
1104            }
1105            $iterator->next();
1106        }
1107        GeneralUtility::fixPermissions($targetFolderPath, true);
1108        return true;
1109    }
1110
1111    /**
1112     * Renames a file in this storage.
1113     *
1114     * @param string $fileIdentifier
1115     * @param string $newName The target path (including the file name!)
1116     * @return string The identifier of the file after renaming
1117     * @throws Exception\ExistingTargetFileNameException
1118     * @throws \RuntimeException
1119     */
1120    public function renameFile($fileIdentifier, $newName)
1121    {
1122        // Makes sure the Path given as parameter is valid
1123        $newName = $this->sanitizeFileName($newName);
1124        $newIdentifier = rtrim(GeneralUtility::fixWindowsFilePath(PathUtility::dirname($fileIdentifier)), '/') . '/' . $newName;
1125        $newIdentifier = $this->canonicalizeAndCheckFileIdentifier($newIdentifier);
1126        // The target should not exist already
1127        if ($this->fileExists($newIdentifier)) {
1128            throw new ExistingTargetFileNameException(
1129                'The target file "' . $newIdentifier . '" already exists.',
1130                1320291063
1131            );
1132        }
1133        $sourcePath = $this->getAbsolutePath($fileIdentifier);
1134        $targetPath = $this->getAbsolutePath($newIdentifier);
1135        $result = rename($sourcePath, $targetPath);
1136        if ($result === false) {
1137            throw new \RuntimeException('Renaming file ' . $sourcePath . ' to ' . $targetPath . ' failed.', 1320375115);
1138        }
1139        return $newIdentifier;
1140    }
1141
1142    /**
1143     * Renames a folder in this storage.
1144     *
1145     * @param string $folderIdentifier
1146     * @param string $newName
1147     * @return array A map of old to new file identifiers of all affected files and folders
1148     * @throws \RuntimeException if renaming the folder failed
1149     */
1150    public function renameFolder($folderIdentifier, $newName)
1151    {
1152        $folderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($folderIdentifier);
1153        $newName = $this->sanitizeFileName($newName);
1154
1155        $newIdentifier = PathUtility::dirname($folderIdentifier) . '/' . $newName;
1156        $newIdentifier = $this->canonicalizeAndCheckFolderIdentifier($newIdentifier);
1157
1158        $sourcePath = $this->getAbsolutePath($folderIdentifier);
1159        $targetPath = $this->getAbsolutePath($newIdentifier);
1160        // get all files and folders we are going to move, to have a map for updating later.
1161        $filesAndFolders = $this->retrieveFileAndFoldersInPath($sourcePath, true);
1162        $result = rename($sourcePath, $targetPath);
1163        if ($result === false) {
1164            throw new \RuntimeException(sprintf('Renaming folder "%1$s" to "%2$s" failed."', $sourcePath, $targetPath), 1320375116);
1165        }
1166        try {
1167            // Create a mapping from old to new identifiers
1168            $identifierMap = $this->createIdentifierMap($filesAndFolders, $folderIdentifier, $newIdentifier);
1169        } catch (\Exception $e) {
1170            rename($targetPath, $sourcePath);
1171            throw new \RuntimeException(
1172                sprintf(
1173                    'Creating filename mapping after renaming "%1$s" to "%2$s" failed. Reverted rename operation.\\n\\nOriginal error: %3$s"',
1174                    $sourcePath,
1175                    $targetPath,
1176                    $e->getMessage()
1177                ),
1178                1334160746
1179            );
1180        }
1181        return $identifierMap;
1182    }
1183
1184    /**
1185     * Removes a file from the filesystem. This does not check if the file is
1186     * still used or if it is a bad idea to delete it for some other reason
1187     * this has to be taken care of in the upper layers (e.g. the Storage)!
1188     *
1189     * @param string $fileIdentifier
1190     * @return bool TRUE if deleting the file succeeded
1191     * @throws \RuntimeException
1192     */
1193    public function deleteFile($fileIdentifier)
1194    {
1195        $filePath = $this->getAbsolutePath($fileIdentifier);
1196        $result = unlink($filePath);
1197
1198        if ($result === false) {
1199            throw new \RuntimeException('Deletion of file ' . $fileIdentifier . ' failed.', 1320855304);
1200        }
1201        return $result;
1202    }
1203
1204    /**
1205     * Removes a folder from this storage.
1206     *
1207     * @param string $folderIdentifier
1208     * @param bool $deleteRecursively
1209     * @return bool
1210     * @throws Exception\FileOperationErrorException
1211     * @throws Exception\InvalidPathException
1212     */
1213    public function deleteFolder($folderIdentifier, $deleteRecursively = false)
1214    {
1215        $folderPath = $this->getAbsolutePath($folderIdentifier);
1216        $recycleDirectory = $this->getRecycleDirectory($folderPath);
1217        if (!empty($recycleDirectory) && $folderPath !== $recycleDirectory) {
1218            $result = $this->recycleFileOrFolder($folderPath, $recycleDirectory);
1219        } else {
1220            $result = GeneralUtility::rmdir($folderPath, $deleteRecursively);
1221        }
1222        if ($result === false) {
1223            throw new FileOperationErrorException(
1224                'Deleting folder "' . $folderIdentifier . '" failed.',
1225                1330119451
1226            );
1227        }
1228        return $result;
1229    }
1230
1231    /**
1232     * Checks if a folder contains files and (if supported) other folders.
1233     *
1234     * @param string $folderIdentifier
1235     * @return bool TRUE if there are no files and folders within $folder
1236     */
1237    public function isFolderEmpty($folderIdentifier)
1238    {
1239        $path = $this->getAbsolutePath($folderIdentifier);
1240        $dirHandle = opendir($path);
1241        if ($dirHandle === false) {
1242            return true;
1243        }
1244        while ($entry = readdir($dirHandle)) {
1245            if ($entry !== '.' && $entry !== '..') {
1246                closedir($dirHandle);
1247                return false;
1248            }
1249        }
1250        closedir($dirHandle);
1251        return true;
1252    }
1253
1254    /**
1255     * Returns (a local copy of) a file for processing it. This makes a copy
1256     * first when in writable mode, so if you change the file, you have to update it yourself afterwards.
1257     *
1258     * @param string $fileIdentifier
1259     * @param bool $writable Set this to FALSE if you only need the file for read operations.
1260     *                          This might speed up things, e.g. by using a cached local version.
1261     *                          Never modify the file if you have set this flag!
1262     * @return string The path to the file on the local disk
1263     */
1264    public function getFileForLocalProcessing($fileIdentifier, $writable = true)
1265    {
1266        if ($writable === false) {
1267            return $this->getAbsolutePath($fileIdentifier);
1268        }
1269        return $this->copyFileToTemporaryPath($fileIdentifier);
1270    }
1271
1272    /**
1273     * Returns the permissions of a file/folder as an array (keys r, w) of boolean flags
1274     *
1275     * @param string $identifier
1276     * @return array
1277     * @throws Exception\ResourcePermissionsUnavailableException
1278     */
1279    public function getPermissions($identifier)
1280    {
1281        $path = $this->getAbsolutePath($identifier);
1282        $permissionBits = fileperms($path);
1283        if ($permissionBits === false) {
1284            throw new ResourcePermissionsUnavailableException('Error while fetching permissions for ' . $path, 1319455097);
1285        }
1286        return [
1287            'r' => (bool)is_readable($path),
1288            'w' => (bool)is_writable($path),
1289        ];
1290    }
1291
1292    /**
1293     * Checks if a given identifier is within a container, e.g. if
1294     * a file or folder is within another folder. It will also return
1295     * TRUE if both canonicalized identifiers are equal.
1296     *
1297     * @param string $folderIdentifier
1298     * @param string $identifier identifier to be checked against $folderIdentifier
1299     * @return bool TRUE if $content is within or matches $folderIdentifier
1300     */
1301    public function isWithin($folderIdentifier, $identifier)
1302    {
1303        $folderIdentifier = $this->canonicalizeAndCheckFileIdentifier($folderIdentifier);
1304        $entryIdentifier = $this->canonicalizeAndCheckFileIdentifier($identifier);
1305        if ($folderIdentifier === $entryIdentifier) {
1306            return true;
1307        }
1308        // File identifier canonicalization will not modify a single slash so
1309        // we must not append another slash in that case.
1310        if ($folderIdentifier !== '/') {
1311            $folderIdentifier .= '/';
1312        }
1313        return str_starts_with($entryIdentifier, $folderIdentifier);
1314    }
1315
1316    /**
1317     * Creates a new (empty) file and returns the identifier.
1318     *
1319     * @param string $fileName
1320     * @param string $parentFolderIdentifier
1321     * @return string
1322     * @throws \RuntimeException
1323     */
1324    public function createFile($fileName, $parentFolderIdentifier)
1325    {
1326        $fileName = $this->sanitizeFileName(ltrim($fileName, '/'));
1327        $parentFolderIdentifier = $this->canonicalizeAndCheckFolderIdentifier($parentFolderIdentifier);
1328        $fileIdentifier = $this->canonicalizeAndCheckFileIdentifier(
1329            $parentFolderIdentifier . $fileName
1330        );
1331        $absoluteFilePath = $this->getAbsolutePath($fileIdentifier);
1332        $result = touch($absoluteFilePath);
1333        GeneralUtility::fixPermissions($absoluteFilePath);
1334        clearstatcache();
1335        if ($result !== true) {
1336            throw new \RuntimeException('Creating file ' . $fileIdentifier . ' failed.', 1320569854);
1337        }
1338        return $fileIdentifier;
1339    }
1340
1341    /**
1342     * Returns the contents of a file. Beware that this requires to load the
1343     * complete file into memory and also may require fetching the file from an
1344     * external location. So this might be an expensive operation (both in terms of
1345     * processing resources and money) for large files.
1346     *
1347     * @param string $fileIdentifier
1348     * @return string The file contents
1349     */
1350    public function getFileContents($fileIdentifier)
1351    {
1352        $filePath = $this->getAbsolutePath($fileIdentifier);
1353        return file_get_contents($filePath);
1354    }
1355
1356    /**
1357     * Sets the contents of a file to the specified value.
1358     *
1359     * @param string $fileIdentifier
1360     * @param string $contents
1361     * @return int The number of bytes written to the file
1362     * @throws \RuntimeException if the operation failed
1363     */
1364    public function setFileContents($fileIdentifier, $contents)
1365    {
1366        $filePath = $this->getAbsolutePath($fileIdentifier);
1367        $result = file_put_contents($filePath, $contents);
1368
1369        // Make sure later calls to filesize() etc. return correct values.
1370        clearstatcache(true, $filePath);
1371
1372        if ($result === false) {
1373            throw new \RuntimeException('Setting contents of file "' . $fileIdentifier . '" failed.', 1325419305);
1374        }
1375        return $result;
1376    }
1377
1378    /**
1379     * Returns the role of an item (currently only folders; can later be extended for files as well)
1380     *
1381     * @param string $folderIdentifier
1382     * @return string
1383     */
1384    public function getRole($folderIdentifier)
1385    {
1386        $name = PathUtility::basename($folderIdentifier);
1387        $role = $this->mappingFolderNameToRole[$name] ?? FolderInterface::ROLE_DEFAULT;
1388        return $role;
1389    }
1390
1391    /**
1392     * Directly output the contents of the file to the output
1393     * buffer. Should not take care of header files or flushing
1394     * buffer before. Will be taken care of by the Storage.
1395     *
1396     * @param string $identifier
1397     */
1398    public function dumpFileContents($identifier)
1399    {
1400        readfile($this->getAbsolutePath($this->canonicalizeAndCheckFileIdentifier($identifier)), false);
1401    }
1402
1403    /**
1404     * Stream file using a PSR-7 Response object.
1405     *
1406     * @param string $identifier
1407     * @param array $properties
1408     * @return ResponseInterface
1409     */
1410    public function streamFile(string $identifier, array $properties): ResponseInterface
1411    {
1412        $fileInfo = $this->getFileInfoByIdentifier($identifier, ['name', 'mimetype', 'mtime', 'size']);
1413        $downloadName = $properties['filename_overwrite'] ?? $fileInfo['name'] ?? '';
1414        $mimeType = $properties['mimetype_overwrite'] ?? $fileInfo['mimetype'] ?? '';
1415        $contentDisposition = ($properties['as_download'] ?? false) ? 'attachment' : 'inline';
1416
1417        $filePath = $this->getAbsolutePath($this->canonicalizeAndCheckFileIdentifier($identifier));
1418
1419        return new Response(
1420            new SelfEmittableLazyOpenStream($filePath),
1421            200,
1422            [
1423                'Content-Disposition' => $contentDisposition . '; filename="' . $downloadName . '"',
1424                'Content-Type' => $mimeType,
1425                'Content-Length' => (string)$fileInfo['size'],
1426                'Last-Modified' => gmdate('D, d M Y H:i:s', $fileInfo['mtime']) . ' GMT',
1427                // Cache-Control header is needed here to solve an issue with browser IE8 and lower
1428                // See for more information: http://support.microsoft.com/kb/323308
1429                'Cache-Control' => '',
1430            ]
1431        );
1432    }
1433
1434    /**
1435     * Get the path of the nearest recycler folder of a given $path.
1436     * Return an empty string if there is no recycler folder available.
1437     *
1438     * @param string $path
1439     * @return string
1440     */
1441    protected function getRecycleDirectory($path)
1442    {
1443        $recyclerSubdirectory = array_search(FolderInterface::ROLE_RECYCLER, $this->mappingFolderNameToRole, true);
1444        if ($recyclerSubdirectory === false) {
1445            return '';
1446        }
1447        $rootDirectory = rtrim($this->getAbsolutePath($this->getRootLevelFolder()), '/');
1448        $searchDirectory = PathUtility::dirname($path);
1449        // Check if file or folder to be deleted is inside a recycler directory
1450        if ($this->getRole($searchDirectory) === FolderInterface::ROLE_RECYCLER) {
1451            $searchDirectory = PathUtility::dirname($searchDirectory);
1452            // Check if file or folder to be deleted is inside the root recycler
1453            if ($searchDirectory == $rootDirectory) {
1454                return '';
1455            }
1456            $searchDirectory = PathUtility::dirname($searchDirectory);
1457        }
1458        // Search for the closest recycler directory
1459        while ($searchDirectory) {
1460            $recycleDirectory = $searchDirectory . '/' . $recyclerSubdirectory;
1461            if (is_dir($recycleDirectory)) {
1462                return $recycleDirectory;
1463            }
1464            if ($searchDirectory === $rootDirectory) {
1465                return '';
1466            }
1467            $searchDirectory = PathUtility::dirname($searchDirectory);
1468        }
1469
1470        return '';
1471    }
1472}
1473