diff --git a/typo3/sysext/core/Classes/Resource/LocalPath.php b/typo3/sysext/core/Classes/Resource/LocalPath.php new file mode 100644 index 0000000000000000000000000000000000000000..00dca5a92598383cff7bca0347370fccd1dd2898 --- /dev/null +++ b/typo3/sysext/core/Classes/Resource/LocalPath.php @@ -0,0 +1,111 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Resource; + +use LogicException; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Utility\PathUtility; + +/** + * Model representing an absolute or relative path in the local file system + * @internal + */ +class LocalPath +{ + public const TYPE_ABSOLUTE = 1; + public const TYPE_RELATIVE = 2; + + /** + * @var string + */ + protected $raw; + + /** + * @var string|null + */ + protected $relative; + + /** + * @var string + */ + protected $absolute; + + /** + * @var int + */ + protected $type; + + public function __construct(string $value, int $type) + { + if ($type !== self::TYPE_ABSOLUTE && $type !== self::TYPE_RELATIVE) { + throw new LogicException(sprintf('Unexpected type "%d"', $type), 1625826491); + } + + // @todo `../` is erased here, check again if this is a valid scenario + // value and absolute have leading and trailing slash, e.g. '/some/path/' + $value = '/' . trim(PathUtility::getCanonicalPath($value), '/'); + $value .= $value !== '/' ? '/' : ''; + $this->raw = $value; + $this->type = $type; + + $publicPath = Environment::getPublicPath(); + if ($type === self::TYPE_RELATIVE) { + $this->relative = $value; + $this->absolute = PathUtility::getCanonicalPath($publicPath . $value) . '/'; + } elseif ($type === self::TYPE_ABSOLUTE) { + $this->absolute = $value; + $this->relative = strpos($value, $publicPath) === 0 + ? substr($value, strlen($publicPath)) + : null; + } + } + + /** + * @return string normalize path as provided + */ + public function getRaw(): string + { + return $this->raw; + } + + /** + * @return string|null (calculated) relative path to public path - `null` if outside public path + */ + public function getRelative(): ?string + { + return $this->relative; + } + + /** + * @return string (calculated) absolute path + */ + public function getAbsolute(): string + { + return $this->absolute; + } + + public function isAbsolute(): bool + { + return $this->type === self::TYPE_ABSOLUTE; + } + + public function isRelative(): bool + { + return $this->type === self::TYPE_RELATIVE; + } +} diff --git a/typo3/sysext/core/Classes/Resource/StorageRepository.php b/typo3/sysext/core/Classes/Resource/StorageRepository.php index 1bf9ebe076c01cca77e55214ac2961ba60b1aae0..c7090d0710e6457bbfdaba9ac04d7588f8e6e4f3 100644 --- a/typo3/sysext/core/Classes/Resource/StorageRepository.php +++ b/typo3/sysext/core/Classes/Resource/StorageRepository.php @@ -44,7 +44,7 @@ class StorageRepository implements LoggerAwareInterface protected $storageRowCache; /** - * @var array|null + * @var array<int, LocalPath>|null */ protected $localDriverStorageCache; @@ -395,20 +395,39 @@ class StorageRepository implements LoggerAwareInterface if ($this->localDriverStorageCache === null) { $this->initializeLocalStorageCache(); } - + // normalize path information (`//`, `../`) + $localPath = PathUtility::getCanonicalPath($localPath); + if ($localPath[0] !== '/') { + $localPath = '/' . $localPath; + } $bestMatchStorageUid = 0; $bestMatchLength = 0; foreach ($this->localDriverStorageCache as $storageUid => $basePath) { - $matchLength = strlen((string)PathUtility::getCommonPrefix([$basePath, $localPath])); - $basePathLength = strlen($basePath); - - if ($matchLength >= $basePathLength && $matchLength > $bestMatchLength) { - $bestMatchStorageUid = (int)$storageUid; - $bestMatchLength = $matchLength; + // try to match (resolved) relative base-path + if ($basePath->getRelative() !== null + && null !== $commonPrefix = PathUtility::getCommonPrefix([$basePath->getRelative(), $localPath]) + ) { + $matchLength = strlen($commonPrefix); + $basePathLength = strlen($basePath->getRelative()); + if ($matchLength >= $basePathLength && $matchLength > $bestMatchLength) { + $bestMatchStorageUid = $storageUid; + $bestMatchLength = $matchLength; + } + } + // try to match (resolved) absolute base-path + if (null !== $commonPrefix = PathUtility::getCommonPrefix([$basePath->getAbsolute(), $localPath])) { + $matchLength = strlen($commonPrefix); + $basePathLength = strlen($basePath->getAbsolute()); + if ($matchLength >= $basePathLength && $matchLength > $bestMatchLength) { + $bestMatchStorageUid = $storageUid; + $bestMatchLength = $matchLength; + } } } - if ($bestMatchStorageUid !== 0) { - $localPath = substr($localPath, $bestMatchLength); + if ($bestMatchLength > 0) { + // $commonPrefix always has trailing slash, which needs to be excluded + // (commonPrefix: /some/path/, localPath: /some/path/file.png --> /file.png; keep leading slash) + $localPath = substr($localPath, $bestMatchLength - 1); } return $bestMatchStorageUid; } @@ -418,14 +437,29 @@ class StorageRepository implements LoggerAwareInterface */ protected function initializeLocalStorageCache(): void { + $this->localDriverStorageCache = [ + // implicit legacy storage in project's public path + 0 => new LocalPath('/', LocalPath::TYPE_RELATIVE) + ]; $storageObjects = $this->findByStorageType('Local'); - - $storageCache = []; foreach ($storageObjects as $localStorage) { $configuration = $localStorage->getConfiguration(); - $storageCache[$localStorage->getUid()] = $configuration['basePath']; + if (!isset($configuration['basePath']) || !isset($configuration['pathType'])) { + continue; + } + if ($configuration['pathType'] === 'relative') { + $pathType = LocalPath::TYPE_RELATIVE; + } elseif ($configuration['pathType'] === 'absolute') { + $pathType = LocalPath::TYPE_ABSOLUTE; + } else { + continue; + } + $this->localDriverStorageCache[$localStorage->getUid()] = GeneralUtility::makeInstance( + LocalPath::class, + $configuration['basePath'], + $pathType + ); } - $this->localDriverStorageCache = $storageCache; } /** diff --git a/typo3/sysext/core/Tests/Functional/Resource/StorageRepositoryTest.php b/typo3/sysext/core/Tests/Functional/Resource/StorageRepositoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..1a950056981428b7d6ef80e95b745227f29e824f --- /dev/null +++ b/typo3/sysext/core/Tests/Functional/Resource/StorageRepositoryTest.php @@ -0,0 +1,147 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Functional\Resource; + +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Resource\StorageRepository; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +class StorageRepositoryTest extends FunctionalTestCase +{ + /** + * @var StorageRepository + */ + private $subject; + + protected function setUp(): void + { + parent::setUp(); + $this->subject = GeneralUtility::makeInstance(StorageRepository::class); + } + + protected function tearDown(): void + { + unset($this->storageRepository, $this->subject); + parent::tearDown(); + } + + public function bestStorageIsResolvedDataProvider(): array + { + // `{public}` will be replaced by public project path (not having trailing slash) + // double slashes `//` are used on purpose for given file identifiers + return $this->mapToDataSet([ + // legacy storage + '/favicon.ico' => '0:/favicon.ico', + 'favicon.ico' => '0:/favicon.ico', + + '{public}//favicon.ico' => '0:/favicon.ico', + '{public}/favicon.ico' => '0:/favicon.ico', + + // using storages with relative path + '/fileadmin/img.png' => '1:/img.png', + 'fileadmin/img.png' => '1:/img.png', + '/fileadmin/images/img.png' => '1:/images/img.png', + 'fileadmin/images/img.png' => '1:/images/img.png', + '/documents/doc.pdf' => '2:/doc.pdf', + 'documents/doc.pdf' => '2:/doc.pdf', + '/fileadmin/nested/images/img.png' => '3:/images/img.png', + 'fileadmin/nested/images/img.png' => '3:/images/img.png', + + '{public}//fileadmin/img.png' => '1:/img.png', + '{public}/fileadmin/img.png' => '1:/img.png', + '{public}//fileadmin/images/img.png' => '1:/images/img.png', + '{public}/fileadmin/images/img.png' => '1:/images/img.png', + '{public}//documents/doc.pdf' => '2:/doc.pdf', + '{public}/documents/doc.pdf' => '2:/doc.pdf', + '{public}//fileadmin/nested/images/img.png' => '3:/images/img.png', + '{public}/fileadmin/nested/images/img.png' => '3:/images/img.png', + + // using storages with absolute path + '/files/img.png' => '4:/img.png', + 'files/img.png' => '4:/img.png', + '/files/images/img.png' => '4:/images/img.png', + 'files/images/img.png' => '4:/images/img.png', + '/docs/doc.pdf' => '5:/doc.pdf', + 'docs/doc.pdf' => '5:/doc.pdf', + '/files/nested/images/img.png' => '6:/images/img.png', + 'files/nested/images/img.png' => '6:/images/img.png', + + '{public}//files/img.png' => '4:/img.png', + '{public}/files/img.png' => '4:/img.png', + '{public}//files/images/img.png' => '4:/images/img.png', + '{public}/files/images/img.png' => '4:/images/img.png', + '{public}//docs/doc.pdf' => '5:/doc.pdf', + '{public}/docs/doc.pdf' => '5:/doc.pdf', + '{public}//files/nested/images/img.png' => '6:/images/img.png', + '{public}/files/nested/images/img.png' => '6:/images/img.png', + ]); + } + + /** + * @param string $sourceIdentifier + * @param string $expectedCombinedIdentifier + * @test + * @dataProvider bestStorageIsResolvedDataProvider + */ + public function bestStorageIsResolved(string $sourceIdentifier, string $expectedCombinedIdentifier): void + { + $this->createLocalStorages(); + $sourceIdentifier = str_replace('{public}', Environment::getPublicPath(), $sourceIdentifier); + $storage = $this->subject->getStorageObject(0, [], $sourceIdentifier); + $combinedIdentifier = sprintf('%d:%s', $storage->getUid(), $sourceIdentifier); + self::assertSame( + $expectedCombinedIdentifier, + $combinedIdentifier, + sprintf('Given identifier "%s"', $sourceIdentifier) + ); + } + + private function createLocalStorages(): void + { + $publicPath = Environment::getPublicPath(); + $prefixDelegate = function (string $value) use ($publicPath): string { + return $publicPath . '/' . $value; + }; + // array indexes are not relevant here, but are those expected to be used as storage UID (`1:/file.png`) + // @todo it is possible to create ambiguous storages, e.g. `fileadmin/` AND `/fileadmin/` + $relativeNames = [1 => 'fileadmin/', 2 => 'documents/', 3 => 'fileadmin/nested/']; + $absoluteNames = array_map($prefixDelegate, [4 => 'files/', 5 => 'docs/', 6 => 'files/nested']); + foreach ($relativeNames as $relativeName) { + $this->subject->createLocalStorage('rel:' . $relativeName, $relativeName, 'relative'); + } + foreach ($absoluteNames as $absoluteName) { + $this->subject->createLocalStorage('abs:' . $absoluteName, $absoluteName, 'absolute'); + } + // path is outside public project path - which is expected to cause problems (that's why it's tested) + $outsideName = dirname($publicPath) . '/outside/'; + $this->subject->createLocalStorage('abs:' . $outsideName, $outsideName, 'absolute'); + } + + /** + * @param array<string, string> $map + * @return array<string, string[]> + */ + private function mapToDataSet(array $map): array + { + array_walk($map, function (&$item, string $key) { + $item = [$key, $item]; + }); + return $map; + } +} diff --git a/typo3/sysext/core/Tests/Unit/Resource/StorageRepositoryTest.php b/typo3/sysext/core/Tests/Unit/Resource/StorageRepositoryTest.php index 54828cfdcde912fd57bff892def9c547ffe02212..40c3f4d3aa4274daf66541590aa20bcc73c0cc44 100644 --- a/typo3/sysext/core/Tests/Unit/Resource/StorageRepositoryTest.php +++ b/typo3/sysext/core/Tests/Unit/Resource/StorageRepositoryTest.php @@ -20,6 +20,7 @@ namespace TYPO3\CMS\Core\Tests\Unit\Resource; use Psr\EventDispatcher\EventDispatcherInterface; use TYPO3\CMS\Core\Resource\Driver\AbstractDriver; use TYPO3\CMS\Core\Resource\Driver\DriverRegistry; +use TYPO3\CMS\Core\Resource\LocalPath; use TYPO3\CMS\Core\Resource\StorageRepository; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; @@ -88,40 +89,45 @@ class StorageRepositoryTest extends UnitTestCase 0 ], 'NoMatchReturnsDefaultStorage' => [ - [1 => 'fileadmin/', 2 => 'fileadmin2/public/'], + array_map([$this, 'asRelativePath'], [1 => 'fileadmin/', 2 => 'fileadmin2/public/']), 'my/dummy/Image.png', 0 ], 'MatchReturnsTheMatch' => [ - [1 => 'fileadmin/', 2 => 'other/public/'], + array_map([$this, 'asRelativePath'], [1 => 'fileadmin/', 2 => 'other/public/']), 'fileadmin/dummy/Image.png', 1 ], 'TwoFoldersWithSameStartReturnsCorrect' => [ - [1 => 'fileadmin/', 2 => 'fileadmin/public/'], + array_map([$this, 'asRelativePath'], [1 => 'fileadmin/', 2 => 'fileadmin/public/']), 'fileadmin/dummy/Image.png', 1 ], 'NestedStorageReallyReturnsTheBestMatching' => [ - [1 => 'fileadmin/', 2 => 'fileadmin/public/'], + array_map([$this, 'asRelativePath'], [1 => 'fileadmin/', 2 => 'fileadmin/public/']), 'fileadmin/public/Image.png', 2 ], 'CommonPrefixButWrongPath' => [ - [1 => 'fileadmin/', 2 => 'uploads/test/'], + array_map([$this, 'asRelativePath'], [1 => 'fileadmin/', 2 => 'uploads/test/']), 'uploads/bogus/dummy.png', 0 ], 'CommonPrefixRightPath' => [ - [1 => 'fileadmin/', 2 => 'uploads/test/'], + array_map([$this, 'asRelativePath'], [1 => 'fileadmin/', 2 => 'uploads/test/']), 'uploads/test/dummy.png', 2 ], 'FindStorageFromWindowsPath' => [ - [1 => 'fileadmin/', 2 => 'uploads/test/'], + array_map([$this, 'asRelativePath'], [1 => 'fileadmin/', 2 => 'uploads/test/']), 'uploads\\test\\dummy.png', 2 ], ]; } + + private function asRelativePath(string $value): LocalPath + { + return new LocalPath($value, LocalPath::TYPE_RELATIVE); + } } diff --git a/typo3/sysext/core/Tests/Unit/Utility/PathUtilityTest.php b/typo3/sysext/core/Tests/Unit/Utility/PathUtilityTest.php index 13a02bfa0f8de8b0550754c9f9d2388312a413fd..5fbd871c7b6ee093f59db348d05e78b3a917d1cd 100644 --- a/typo3/sysext/core/Tests/Unit/Utility/PathUtilityTest.php +++ b/typo3/sysext/core/Tests/Unit/Utility/PathUtilityTest.php @@ -70,6 +70,20 @@ class PathUtilityTest extends UnitTestCase ], '/var/www/myhost.com/' ], + [ + [ + '/var/www/myhost.com/typo3/', + '/var/www/myhost.com/typo3' + ], + '/var/www/myhost.com/typo3/' + ], + [ + [ + '/var/www/myhost.com/typo3', + '/var/www/myhost.com/typo3' + ], + '/var/www/myhost.com/typo3/' + ], [ [ '/var/www/myhost.com/uploads/',