Files

434 lines
18 KiB
PHP

<?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\Utility;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\SystemResource\Exception\CanNotResolvePublicResourceException;
use TYPO3\CMS\Core\SystemResource\Exception\CanNotResolveSystemResourceException;
use TYPO3\CMS\Core\SystemResource\Publishing\SystemResourcePublisherInterface;
use TYPO3\CMS\Core\SystemResource\Publishing\UriGenerationOptions;
use TYPO3\CMS\Core\SystemResource\SystemResourceFactory;
/**
* Class with helper functions for file paths.
*/
readonly class PathUtility
{
/**
* Creates an absolute URL out of really any input path, removes '../' parts for the targetPath
*
* @todo: And this exactly is a big issue as it mixes file system paths with (relative) URLs.
* Additionally, it depends on the current request and can not do its job on CLI.
* Deprecate entirely and replace with stricter API.
*
* @param string $targetPath can be "../typo3conf/ext/myext/myfile.js" or "/myfile.js"
* @param bool $prefixWithSitePath Don't use this argument. It is only used by TYPO3 in one place, which are subject to removal.
* @return string something like "/mysite/typo3conf/ext/myext/myfile.js"
*/
public static function getAbsoluteWebPath(string $targetPath, bool $prefixWithSitePath = true): string
{
if (static::hasProtocolAndScheme($targetPath)) {
return $targetPath;
}
$prefixWithSitePath = $prefixWithSitePath && !Environment::isCli();
if (self::isAbsolutePath($targetPath)) {
if (str_starts_with($targetPath, Environment::getPublicPath())) {
// It is an absolute file system path with file/folder inside document root,
// therefore we can strip the full file system path to the document root to obtain the URI
$targetPath = self::stripPathSitePrefix($targetPath);
} elseif (Environment::isComposerMode() && str_contains($targetPath, 'Resources/Public') && str_starts_with($targetPath, Environment::getProjectPath())) {
// TYPO3 is in managed by Composer and it is an absolute file system path inside composer root path,
// and a public resource is referenced, therefore we can calculate the path to the published assets
// This is true for all Composer packages that are installed in vendor folder by Composer, but still recognized by TYPO3
$relativePath = substr($targetPath, strlen(Environment::getProjectPath()));
// The $relativePath might contain multiple occurrences of 'Resources/Public', so only search for first one
[$relativePrefix, $relativeAssetPath] = explode('Resources/Public', $relativePath, 2);
$targetPath = '_assets/' . md5($relativePrefix) . $relativeAssetPath;
} else {
// At this point it can be ANY path, even an invalid or non existent and it is totally unclear,
// whether this is a mistake or accidentally working as intended.
// The only conclusion here is, that this API has to be deprecated altogether an be replaced with API
// that clearly distinguishes between creating a URL from a static resource and ensuring an URL is absolute and not relative to current script.
$prefixWithSitePath = false;
}
} else {
// Make an absolute path out of it
$targetPath = self::dirname(Environment::getCurrentScript()) . '/' . $targetPath;
$targetPath = self::stripPathSitePrefix($targetPath);
}
if ($prefixWithSitePath) {
// @todo: Another reason this method must fall.
$targetPath = NormalizedParams::createFromServerParams($_SERVER)->getSitePath() . $targetPath;
}
return $targetPath;
}
/**
* @internal Will be removed (or made private) before v14 LTS release
*
* @throws CanNotResolvePublicResourceException
* @throws CanNotResolveSystemResourceException
*/
public static function getSystemResourceUri(string $resourceIdentifier, ?ServerRequestInterface $request = null, ?UriGenerationOptions $options = null): UriInterface
{
$resourceFactory = GeneralUtility::makeInstance(SystemResourceFactory::class);
$resource = $resourceFactory->createPublicResource($resourceIdentifier);
$resourcePublisher = GeneralUtility::makeInstance(SystemResourcePublisherInterface::class);
return $resourcePublisher->generateUri($resource, $request, $options);
}
/**
* Checks whether the given path is an extension resource
*/
public static function isExtensionPath(string $path, bool $includePackagePaths = false): bool
{
return
str_starts_with($path, 'EXT:')
|| ($includePackagePaths && str_starts_with($path, 'PKG:'));
}
/**
* Gets the common path prefix out of many paths.
* + /var/www/domain.com/typo3/sysext/frontend/
* + /var/www/domain.com/typo3/sysext/em/
* + /var/www/domain.com/typo3/sysext/file/
* = /var/www/domain.com/typo3/sysext/
*
* @param array<string> $paths Paths to be processed
*/
public static function getCommonPrefix(array $paths): ?string
{
$paths = array_map(GeneralUtility::fixWindowsFilePath(...), $paths);
$commonPath = null;
if (count($paths) === 1) {
$commonPath = array_shift($paths);
} elseif (count($paths) > 1) {
$parts = explode('/', (string)array_shift($paths));
$comparePath = '';
$break = false;
foreach ($parts as $part) {
$comparePath .= $part . '/';
foreach ($paths as $path) {
if (!str_starts_with($path . '/', $comparePath)) {
$break = true;
break;
}
}
if ($break) {
break;
}
$commonPath = $comparePath;
}
}
if ($commonPath !== null) {
$commonPath = self::sanitizeTrailingSeparator($commonPath, '/');
}
return $commonPath;
}
/**
* Normalizes a trailing separator.
*
* (e.g. 'some/path' -> 'some/path/')
*
* @param string $path The path to be sanitized
* @param string $separator The separator to be used
*/
public static function sanitizeTrailingSeparator(string $path, string $separator = '/'): string
{
return rtrim($path, $separator) . $separator;
}
/**
* Returns trailing name component of path
*
* Since basename() is locale dependent we need to access
* the filesystem with the same locale of the system, not
* the rendering context.
*
* @see http://www.php.net/manual/en/function.basename.php
*
* @param string $path
*/
public static function basename(string $path): string
{
$targetLocale = $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemLocale'] ?? '';
if (empty($targetLocale)) {
return basename($path);
}
$currentLocale = (string)setlocale(LC_CTYPE, '0');
setlocale(LC_CTYPE, $targetLocale);
$basename = basename($path);
setlocale(LC_CTYPE, $currentLocale);
return $basename;
}
/**
* Returns parent directory's path
*
* Since dirname() is locale dependent we need to access
* the filesystem with the same locale of the system, not
* the rendering context.
*
* @see http://www.php.net/manual/en/function.dirname.php
*
* @param string $path
*/
public static function dirname(string $path): string
{
$targetLocale = $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemLocale'] ?? '';
if (empty($targetLocale)) {
return dirname($path);
}
$currentLocale = (string)setlocale(LC_CTYPE, '0');
setlocale(LC_CTYPE, $targetLocale);
$dirname = dirname($path);
setlocale(LC_CTYPE, $currentLocale);
return $dirname;
}
/**
* Returns parent directory's path
*
* Since pathinfo() is locale dependent we need to access
* the filesystem with the same locale of the system, not
* the rendering context.
*
* The valid flags for $options are the same as for the built-in
* phpinfo() function.
*
* @see http://www.php.net/manual/en/function.pathinfo.php
*
* @return ($options is PATHINFO_ALL ? array{dirname?: string, basename?: string, extension?: string, filename?: string} : string)
*/
public static function pathinfo(string $path, int $options = PATHINFO_ALL): string|array
{
$targetLocale = $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemLocale'] ?? '';
if (empty($targetLocale)) {
return pathinfo($path, $options);
}
$currentLocale = (string)setlocale(LC_CTYPE, '0');
setlocale(LC_CTYPE, $targetLocale);
$pathinfo = pathinfo($path, $options);
setlocale(LC_CTYPE, $currentLocale);
return $pathinfo;
}
/**
* Checks if the $path is absolute or relative (detecting either '/' or 'x:/' as first part of string) and returns TRUE if so.
*/
public static function isAbsolutePath(string $path): bool
{
// On Windows also a path starting with a drive letter is absolute: X:/
if (Environment::isWindows() && (substr($path, 1, 2) === ':/' || substr($path, 1, 2) === ':\\')) {
return true;
}
// Path starting with a / is always absolute, on every system
return str_starts_with($path, '/');
}
/**
* Gets the (absolute) path of an include file based on the (absolute) path of a base file
*
* Does NOT do any sanity checks. This is a task for the calling function, e.g.
* call GeneralUtility::getFileAbsFileName() on the result.
* @see \TYPO3\CMS\Core\Utility\GeneralUtility::getFileAbsFileName()
*
* Resolves all dots and slashes between that paths of both files.
* Whether the result is absolute or not, depends on the base file name.
*
* If the include file goes higher than a relative base file, then the result
* will contain dots as a relative part.
* <pre>
* base: abc/one.txt
* include: ../../two.txt
* result: ../two.txt
* </pre>
* The exact behavior, refer to getCanonicalPath().
*
* @param string $baseFilenameOrPath The name of the file or a path that serves as a base; a path will need to have a '/' at the end
* @param string $includeFileName The name of the file that is included in the file
* @return string The (absolute) path of the include file
*/
public static function getAbsolutePathOfRelativeReferencedFileOrPath(string $baseFilenameOrPath, string $includeFileName): string
{
$fileName = static::basename($includeFileName);
$basePath = str_ends_with($baseFilenameOrPath, '/') ? $baseFilenameOrPath : static::dirname($baseFilenameOrPath);
$newDir = static::getCanonicalPath($basePath . '/' . static::dirname($includeFileName));
// Avoid double slash on empty path
return (($newDir !== '/') ? $newDir : '') . '/' . $fileName;
}
/**
* Returns parent directory's path
* Early during bootstrap there is no TYPO3_CONF_VARS yet so the setting for the system locale
* is also unavailable. The path of the parent directory is determined with a regular expression
* to avoid issues with locales.
*
*
* @return string Path without trailing slash
*/
public static function dirnameDuringBootstrap(string $path): string
{
return preg_replace('#(.*)(/|\\\\)([^\\\\/]+)$#', '$1', $path);
}
/**
* Returns filename part of a path
* Early during bootstrap there is no TYPO3_CONF_VARS yet so the setting for the system locale
* is also unavailable. The filename part is determined with a regular expression to avoid issues
* with locales.
*/
public static function basenameDuringBootstrap(string $path): string
{
return preg_replace('#.*[/\\\\]([^\\\\/]+)$#', '$1', $path);
}
/*********************
*
* Cleaning methods
*
*********************/
/**
* Resolves all dots, slashes and removes spaces after or before a path...
*
* @param string $path Input string
* @return string Canonical path, always without trailing slash
*/
public static function getCanonicalPath(string $path): string
{
// Replace backslashes with slashes to work with Windows paths if given
$path = trim(str_replace('\\', '/', $path));
// @todo do we really need this? Probably only in testing context for vfs?
$protocol = '';
if (str_contains($path, '://')) {
[$protocol, $path] = explode('://', $path);
$protocol .= '://';
}
$absolutePathPrefix = '';
if (static::isAbsolutePath($path)) {
if (Environment::isWindows() && substr($path, 1, 2) === ':/') {
$absolutePathPrefix = substr($path, 0, 3);
$path = substr($path, 3);
} else {
$path = ltrim($path, '/');
$absolutePathPrefix = '/';
}
}
$theDirParts = explode('/', $path);
$theDirPartsCount = count($theDirParts);
// This cannot use a foreach() as some steps skip ahead multiple elements.
for ($partCount = 0; $partCount < $theDirPartsCount; $partCount++) {
// double-slashes in path: remove element
if ($theDirParts[$partCount] === '') {
array_splice($theDirParts, $partCount, 1);
$partCount--;
$theDirPartsCount--;
}
// "." in path: remove element
if (($theDirParts[$partCount] ?? '') === '.') {
array_splice($theDirParts, $partCount, 1);
$partCount--;
$theDirPartsCount--;
}
// ".." in path:
if (($theDirParts[$partCount] ?? '') === '..') {
if ($partCount >= 1) {
// Remove this and previous element
array_splice($theDirParts, $partCount - 1, 2);
$partCount -= 2;
$theDirPartsCount -= 2;
} elseif ($absolutePathPrefix) {
// can't go higher than root dir
// simply remove this part and continue
array_splice($theDirParts, $partCount, 1);
$partCount--;
$theDirPartsCount--;
}
}
}
return $protocol . $absolutePathPrefix . implode('/', $theDirParts);
}
/**
* Strip first part of a path, equal to the length of public web path including trailing slash
*
* @internal
*/
public static function stripPathSitePrefix(string $path): string
{
return substr($path, strlen(Environment::getPublicPath() . '/'));
}
/**
* Tries to guess whether a given URL hast protocol and (optional) scheme.
* Scheme relative URLs match as well.
* Current implementation is two simple string operations.
*
* This is just a guess. For a more detailed validation and parsing,
* use \TYPO3\CMS\Core\Utility\GeneralUtility::isValidUrl()
*
* @param string $path
*
* @internal
*/
public static function hasProtocolAndScheme(string $path): bool
{
return str_starts_with($path, '//') || strpos($path, '://') > 0;
}
/**
* Evaluates a given path against the optional settings in `$GLOBALS['TYPO3_CONF_VARS']['BE']['lockRootPath']`.
* Albeit the name `BE/lockRootPath` is misleading, this setting was and is used in general and is not limited
* to the backend-scope. The setting actually allows defining additional paths, besides the project root path.
*
* @param string $path Absolute path to a file or directory
*/
public static function isAllowedAdditionalPath(string $path): bool
{
// ensure the submitted path ends with a string, even for a file
$path = self::sanitizeTrailingSeparator($path);
$allowedPaths = $GLOBALS['TYPO3_CONF_VARS']['BE']['lockRootPath'] ?? [];
if (is_string($allowedPaths)) {
// The setting was a string before and is now an array
// For compatibility reasons, we cast a string to an array here for now
$allowedPaths = [$allowedPaths];
}
if (!is_array($allowedPaths)) {
throw new \RuntimeException('$GLOBALS[\'TYPO3_CONF_VARS\'][\'BE\'][\'lockRootPath\'] is expected to be an array.', 1707408379);
}
foreach ($allowedPaths as $allowedPath) {
$allowedPath = trim($allowedPath);
if ($allowedPath !== '' && str_starts_with($path, self::sanitizeTrailingSeparator($allowedPath))) {
return true;
}
}
return false;
}
}