TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:09 +02:00
commit af8cc155b5
6818 changed files with 642608 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
<?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\Routing\Aspect;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Site\SiteAwareInterface;
use TYPO3\CMS\Core\Site\SiteLanguageAwareInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Factory for creating aspects
*/
class AspectFactory
{
/**
* Create aspects from the given settings.
*
* @return AspectInterface[]
*/
public function createAspects(array $aspects, SiteLanguage $language, Site $site): array
{
$aspects = array_map(
function ($settings) use ($language, $site) {
$type = (string)($settings['type'] ?? '');
$aspect = $this->create($type, $settings);
return $this->enrich($aspect, $language, $site);
},
$aspects
);
uasort($aspects, [$this, 'sortAspects']);
return $aspects;
}
/**
* Creates an aspect
*
* @throws \InvalidArgumentException
* @throws \OutOfRangeException
*/
protected function create(string $type, array $settings): AspectInterface
{
if (empty($type)) {
throw new \InvalidArgumentException('Aspect type cannot be empty', 1538079481);
}
if (!isset($GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['aspects'][$type])) {
throw new \OutOfRangeException(sprintf('No aspect found for %s', $type), 1538079482);
}
unset($settings['type']);
$className = $GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['aspects'][$type];
return GeneralUtility::makeInstance($className, $settings);
}
/**
* Checks for the language aware trait, and adds the site language.
*/
protected function enrich(AspectInterface $aspect, SiteLanguage $language, Site $site): AspectInterface
{
if ($aspect instanceof SiteLanguageAwareInterface) {
$aspect->setSiteLanguage($language);
}
if ($aspect instanceof SiteAwareInterface) {
$aspect->setSite($site);
}
return $aspect;
}
/**
* Sorts aspects with putting persisted aspects to the end, thus
* non-persisted aspects can be executed earlier without invoking database.
*/
protected function sortAspects(AspectInterface $first, AspectInterface $second): int
{
// when first is persisted, move it to the end (>0)
$first = $first instanceof PersistedMappableAspectInterface ? 1 : 0;
// when second is persisted, move it to the beginning (<0)
$second = $second instanceof PersistedMappableAspectInterface ? -1 : 0;
// 0 + 0 = 0 - both are non-persisted
// 1 - 1 = 0 - both are persisted
// 1 + 0 = 1 - only first is persisted
// 0 - 1 = -1 - only second is persisted
return $first + $second;
}
}
@@ -0,0 +1,23 @@
<?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\Routing\Aspect;
/**
* Base interface for all aspects
*/
interface AspectInterface {}
+47
View File
@@ -0,0 +1,47 @@
<?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\Routing\Aspect;
use TYPO3\CMS\Core\DataHandling\TableColumnType;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
trait AspectTrait
{
protected function isSlugUniqueInSite(string $tableName, string $fieldName): bool
{
$schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($tableName);
if (!$schema->hasField($fieldName)) {
return false;
}
$fieldType = $schema->getField($fieldName);
return
$fieldType->isType(TableColumnType::SLUG)
&& GeneralUtility::inList($fieldType->getConfiguration()['eval'] ?? '', 'uniqueInSite');
}
protected function hasSlugUniqueInSite(string $tableName, string ...$fieldNames): bool
{
foreach ($fieldNames as $fieldName) {
if ($this->isSlugUniqueInSite($tableName, $fieldName)) {
return true;
}
}
return false;
}
}
+102
View File
@@ -0,0 +1,102 @@
<?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\Routing\Aspect;
use TYPO3\CMS\Core\Site\SiteLanguageAwareInterface;
use TYPO3\CMS\Core\Site\SiteLanguageAwareTrait;
/**
* Locale modifier to be used to modify routePath directly.
*
* Example:
* routeEnhancers:
* Blog:
* type: Extbase
* extension: BlogExample
* plugin: Pi1
* routes:
* - { routePath: '/{list_label}/{paging_widget}', _controller: 'BlogExample::list', _arguments: {'paging_widget': '@widget_0/currentPage'}}
* defaultController: 'BlogExample::list'
* requirements:
* paging_widget: '\d+'
* aspects:
* list_label:
* type: LocaleModifier
* default: 'list'
* localeMap:
* - locale: 'en_US.*|en_GB.*'
* value: 'overview'
* - locale: 'fr_FR'
* value: 'liste'
* - locale: 'de_.*'
* value: 'übersicht'
*/
class LocaleModifier implements ModifiableAspectInterface, SiteLanguageAwareInterface
{
use SiteLanguageAwareTrait;
/**
* @var array
*/
protected $settings;
/**
* @var array
*/
protected $localeMap;
/**
* @var string|null
*/
protected $default;
/**
* @throws \InvalidArgumentException
*/
public function __construct(array $settings)
{
$localeMap = $settings['localeMap'] ?? null;
$default = $settings['default'] ?? null;
if (!is_array($localeMap)) {
throw new \InvalidArgumentException('localeMap must be array', 1537277153);
}
if (!is_string($default ?? '')) {
throw new \InvalidArgumentException('default must be string', 1537277154);
}
$this->settings = $settings;
$this->localeMap = $localeMap;
$this->default = $default;
}
/**
* {@inheritdoc}
*/
public function modify(): ?string
{
$locale = (string)$this->siteLanguage->getLocale();
foreach ($this->localeMap as $item) {
$pattern = '#^' . str_replace('_', '-', $item['locale']) . '#i';
if (preg_match($pattern, $locale)) {
return (string)$item['value'];
}
}
return $this->default;
}
}
@@ -0,0 +1,28 @@
<?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\Routing\Aspect;
/**
* Aspects that have a mapping table (either static, or in the database).
*/
interface MappableAspectInterface extends AspectInterface
{
public function generate(string $value): ?string;
public function resolve(string $value): ?string;
}
@@ -0,0 +1,86 @@
<?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\Routing\Aspect;
use TYPO3\CMS\Core\Routing\Route;
/**
* Helper class for resolving all aspects that are mappable.
*/
class MappableProcessor
{
public function resolve(Route $route, array &$attributes): bool
{
$mappers = $this->fetchMappers($route, $attributes);
if (empty($mappers)) {
return true;
}
$values = [];
foreach ($mappers as $variableName => $mapper) {
$value = $mapper->resolve(
(string)($attributes[$variableName] ?? '')
);
if ($value === null) {
if (!$mapper instanceof UnresolvedValueInterface || !$mapper->hasFallbackValue()) {
return false;
}
$value = $mapper->getFallbackValue();
}
$values[$variableName] = $value;
}
$attributes = array_merge($attributes, $values);
return true;
}
public function generate(Route $route, array &$attributes): bool
{
$mappers = $this->fetchMappers($route, $attributes);
if (empty($mappers)) {
return true;
}
$values = [];
foreach ($mappers as $variableName => $mapper) {
$value = $mapper->generate(
(string)($attributes[$variableName] ?? '')
);
if ($value === null) {
return false;
}
$values[$variableName] = $value;
}
$attributes = array_merge($attributes, $values);
return true;
}
/**
* @return MappableAspectInterface[]
*/
protected function fetchMappers(Route $route, array $attributes, string $type = MappableAspectInterface::class): array
{
if (empty($attributes)) {
return [];
}
/** @var MappableAspectInterface[] $result */
$result = $route->filterAspects([$type], array_keys($attributes));
return $result;
}
}
@@ -0,0 +1,27 @@
<?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\Routing\Aspect;
/**
* Interface that describes modifiers that provide static modifications
* to route paths based on a given context (current locale, context, ...).
*/
interface ModifiableAspectInterface extends AspectInterface
{
public function modify(): ?string;
}
@@ -0,0 +1,296 @@
<?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\Routing\Aspect;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\LanguageAspectFactory;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendGroupRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Site\SiteAwareInterface;
use TYPO3\CMS\Core\Site\SiteLanguageAwareInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Classic usage when using a "URL segment" (e.g. slug) field within a database table.
*
* Example:
* routeEnhancers:
* EventsPlugin:
* type: Extbase
* extension: Events2
* plugin: Pi1
* routes:
* - { routePath: '/events/{event}', _controller: 'Event::detail', _arguments: {'event': 'event_name'}}
* defaultController: 'Events2::list'
* aspects:
* event:
* type: PersistedAliasMapper
* tableName: 'tx_events2_domain_model_event'
* routeFieldName: 'path_segment'
* routeValuePrefix: '/'
*/
class PersistedAliasMapper implements PersistedMappableAspectInterface, StaticMappableAspectInterface, SiteLanguageAwareInterface, SiteAwareInterface, UnresolvedValueInterface
{
use AspectTrait;
use SiteLanguageAccessorTrait;
use SiteAccessorTrait;
use UnresolvedValueTrait;
/**
* @var array
*/
protected $settings;
/**
* @var string
*/
protected $tableName;
/**
* @var string
*/
protected $routeFieldName;
/**
* @var string
*/
protected $routeValuePrefix;
/**
* @var string[]
*/
protected $persistenceFieldNames;
/**
* @var string|null
*/
protected $languageFieldName;
/**
* @var string|null
*/
protected $languageParentFieldName;
/**
* @var bool
*/
protected $slugUniqueInSite;
/**
* @throws \InvalidArgumentException
*/
public function __construct(array $settings)
{
$tableName = $settings['tableName'] ?? null;
$routeFieldName = $settings['routeFieldName'] ?? null;
$routeValuePrefix = $settings['routeValuePrefix'] ?? '';
if (!is_string($tableName)) {
throw new \InvalidArgumentException(
'tableName must be string',
1537277133
);
}
if (!is_string($routeFieldName)) {
throw new \InvalidArgumentException(
'routeFieldName name must be string',
1537277134
);
}
if (!is_string($routeValuePrefix) || strlen($routeValuePrefix) > 1) {
throw new \InvalidArgumentException(
'$routeValuePrefix must be string with one character',
1537277136
);
}
$this->settings = $settings;
$this->tableName = $tableName;
$this->routeFieldName = $routeFieldName;
$this->routeValuePrefix = $routeValuePrefix;
$schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($this->tableName);
if ($schema->isLanguageAware()) {
$this->languageFieldName = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName();
$this->languageParentFieldName = $schema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName();
} else {
$this->languageFieldName = null;
$this->languageParentFieldName = null;
}
$this->persistenceFieldNames = $this->buildPersistenceFieldNames();
$this->slugUniqueInSite = $this->isSlugUniqueInSite($this->tableName, $this->routeFieldName);
}
/**
* {@inheritdoc}
*/
public function generate(string $value): ?string
{
$result = $this->findByIdentifier($value);
$result = $this->resolveOverlay($result);
if (!isset($result[$this->routeFieldName])) {
return null;
}
return $this->purgeRouteValuePrefix(
(string)$result[$this->routeFieldName]
);
}
/**
* {@inheritdoc}
*/
public function resolve(string $value): ?string
{
$value = $this->routeValuePrefix . $this->purgeRouteValuePrefix($value);
$result = $this->findByRouteFieldValue($value);
$translatedValue = $this->languageParentFieldName ? ($result[$this->languageParentFieldName] ?? null) : null;
if ($translatedValue) {
return (string)$translatedValue;
}
if (isset($result['uid'])) {
return (string)$result['uid'];
}
return null;
}
/**
* @return string[]
*/
protected function buildPersistenceFieldNames(): array
{
return array_filter([
'uid',
'pid',
$this->routeFieldName,
$this->languageFieldName,
$this->languageParentFieldName,
]);
}
/**
* @return string
*/
protected function purgeRouteValuePrefix(?string $value): ?string
{
if (empty($this->routeValuePrefix) || $value === null) {
return $value;
}
return ltrim($value, $this->routeValuePrefix);
}
protected function findByIdentifier(string $value): ?array
{
if (!MathUtility::canBeInterpretedAsInteger($value)) {
return null;
}
$queryBuilder = $this->createQueryBuilder();
$result = $queryBuilder
->select(...$this->persistenceFieldNames)
->where($queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($value, Connection::PARAM_INT)
))
->executeQuery()
->fetchAssociative();
return $result !== false ? $result : null;
}
protected function findByRouteFieldValue(string $value): ?array
{
$languageAware = $this->languageFieldName !== null && $this->languageParentFieldName !== null;
$queryBuilder = $this->createQueryBuilder();
$constraints = [
$queryBuilder->expr()->eq(
$this->routeFieldName,
$queryBuilder->createNamedParameter($value)
),
];
$languageIds = null;
if ($languageAware) {
$languageIds = $this->resolveAllRelevantLanguageIds();
$constraints[] = $queryBuilder->expr()->in(
$this->languageFieldName,
$queryBuilder->createNamedParameter($languageIds, Connection::PARAM_INT_ARRAY)
);
}
$results = $queryBuilder
->select(...$this->persistenceFieldNames)
->where(...$constraints)
->executeQuery()
->fetchAllAssociative();
// limit results to be contained in rootPageId of current Site
// (which is defining the route configuration currently being processed)
if ($this->slugUniqueInSite) {
$results = array_values($this->filterContainedInSite($results));
}
// return first result record in case table is not language aware
if (!$languageAware) {
return $results[0] ?? null;
}
// post-process language fallbacks
return $this->resolveLanguageFallback($results, $this->languageFieldName, $languageIds);
}
protected function createQueryBuilder(): QueryBuilder
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable($this->tableName)
->from($this->tableName);
$queryBuilder->setRestrictions(
GeneralUtility::makeInstance(FrontendRestrictionContainer::class, GeneralUtility::makeInstance(Context::class))
);
// Frontend Groups are not available at this time
// So this must be excluded to allow access restricted records
$queryBuilder->getRestrictions()->removeByType(FrontendGroupRestriction::class);
return $queryBuilder;
}
protected function resolveOverlay(?array $record): ?array
{
$languageId = $this->siteLanguage->getLanguageId();
if ($record === null || $languageId === 0) {
return $record;
}
$pageRepository = $this->createPageRepository();
return $pageRepository->getLanguageOverlay($this->tableName, $record) ?: null;
}
protected function createPageRepository(): PageRepository
{
$context = clone GeneralUtility::makeInstance(Context::class);
$context->setAspect(
'language',
LanguageAspectFactory::createFromSiteLanguage($this->siteLanguage)
);
return GeneralUtility::makeInstance(
PageRepository::class,
$context
);
}
}
@@ -0,0 +1,24 @@
<?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\Routing\Aspect;
/**
* Used for anything that invokes (more expensive) persistence invocations.
* Basically used to improve performance by deferring their execution.
*/
interface PersistedMappableAspectInterface extends MappableAspectInterface {}
@@ -0,0 +1,328 @@
<?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\Routing\Aspect;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\LanguageAspectFactory;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendGroupRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Site\SiteAwareInterface;
use TYPO3\CMS\Core\Site\SiteLanguageAwareInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Very useful for building a path segment from a combined value of the database.
* Please note: title is not prepared for slugs and used raw.
*
* Example:
* routeEnhancers:
* EventsPlugin:
* type: Extbase
* extension: Events2
* plugin: Pi1
* routes:
* - { routePath: '/events/{event}', _controller: 'Event::detail', _arguments: {'event': 'event_name'}}
* defaultController: 'Events2::list'
* aspects:
* event:
* type: PersistedPatternMapper
* tableName: 'tx_events2_domain_model_event'
* routeFieldPattern: '^(?P<title>.+)-(?P<uid>\d+)$'
* routeFieldResult: '{title}-{uid}'
*
* @internal might change its options in the future, be aware that there might be modifications.
*/
class PersistedPatternMapper implements PersistedMappableAspectInterface, StaticMappableAspectInterface, SiteLanguageAwareInterface, SiteAwareInterface, UnresolvedValueInterface
{
use AspectTrait;
use SiteLanguageAccessorTrait;
use SiteAccessorTrait;
use UnresolvedValueTrait;
protected const PATTERN_RESULT = '#\{(?P<fieldName>[^}]+)\}#';
/**
* @var array
*/
protected $settings;
/**
* @var string
*/
protected $tableName;
/**
* @var string
*/
protected $routeFieldPattern;
/**
* @var string
*/
protected $routeFieldResult;
/**
* @var string[]
*/
protected $routeFieldResultNames;
/**
* @var string|null
*/
protected $languageFieldName;
/**
* @var string|null
*/
protected $languageParentFieldName;
/**
* @var bool
*/
protected $slugUniqueInSite;
/**
* @throws \InvalidArgumentException
*/
public function __construct(array $settings)
{
$tableName = $settings['tableName'] ?? null;
$routeFieldPattern = $settings['routeFieldPattern'] ?? null;
$routeFieldResult = $settings['routeFieldResult'] ?? null;
if (!is_string($tableName)) {
throw new \InvalidArgumentException('tableName must be string', 1537277173);
}
if (!is_string($routeFieldPattern)) {
throw new \InvalidArgumentException('routeFieldPattern must be string', 1537277174);
}
if (!is_string($routeFieldResult)) {
throw new \InvalidArgumentException('routeFieldResult must be string', 1537277175);
}
if (!preg_match_all(static::PATTERN_RESULT, $routeFieldResult, $routeFieldResultNames)) {
throw new \InvalidArgumentException(
'routeFieldResult must contain substitutable field names',
1537962752
);
}
$this->settings = $settings;
$this->tableName = $tableName;
$this->routeFieldPattern = $routeFieldPattern;
$this->routeFieldResult = $routeFieldResult;
$this->routeFieldResultNames = $routeFieldResultNames['fieldName'] ?? [];
$schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get($this->tableName);
if ($schema->isLanguageAware()) {
$this->languageFieldName = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName();
$this->languageParentFieldName = $schema->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName();
} else {
$this->languageFieldName = null;
$this->languageParentFieldName = null;
}
$this->slugUniqueInSite = $this->hasSlugUniqueInSite($this->tableName, ...$this->routeFieldResultNames);
}
/**
* {@inheritdoc}
*/
public function generate(string $value): ?string
{
$result = $this->findByIdentifier($value);
$result = $this->resolveOverlay($result);
return $this->createRouteResult($result);
}
/**
* {@inheritdoc}
*/
public function resolve(string $value): ?string
{
if (!preg_match('#' . $this->routeFieldPattern . '#', $value, $matches)) {
return null;
}
$values = $this->filterNamesKeys($matches);
$result = $this->findByRouteFieldValues($values);
if (($result[$this->languageParentFieldName] ?? null) > 0) {
return (string)$result[$this->languageParentFieldName];
}
if (isset($result['uid'])) {
return (string)$result['uid'];
}
return null;
}
/**
* @throws \InvalidArgumentException
*/
protected function createRouteResult(?array $result): ?string
{
if ($result === null) {
return $result;
}
$substitutes = [];
foreach ($this->routeFieldResultNames as $fieldName) {
if (!isset($result[$fieldName])) {
return null;
}
$routeFieldName = '{' . $fieldName . '}';
$substitutes[$routeFieldName] = $result[$fieldName];
}
return str_replace(
array_keys($substitutes),
array_values($substitutes),
$this->routeFieldResult
);
}
protected function filterNamesKeys(array $array): array
{
return array_filter(
$array,
static function ($key) {
return !is_numeric($key);
},
ARRAY_FILTER_USE_KEY
);
}
protected function findByIdentifier(string $value): ?array
{
if (!MathUtility::canBeInterpretedAsInteger($value)) {
return null;
}
$queryBuilder = $this->createQueryBuilder();
$result = $queryBuilder
->select('*')
->where($queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($value, Connection::PARAM_INT)
))
->executeQuery()
->fetchAssociative();
return $result !== false ? $result : null;
}
protected function findByRouteFieldValues(array $values): ?array
{
$languageAware = $this->languageFieldName !== null && $this->languageParentFieldName !== null;
$queryBuilder = $this->createQueryBuilder();
$results = $queryBuilder
->select('*')
->where(...$this->createRouteFieldConstraints($queryBuilder, $values))
->executeQuery()
->fetchAllAssociative();
// limit results to be contained in rootPageId of current Site
// (which is defining the route configuration currently being processed)
if ($this->slugUniqueInSite) {
$results = array_values($this->filterContainedInSite($results));
}
// return first result record in case table is not language aware
if (!$languageAware) {
return $results[0] ?? null;
}
// post-process language fallbacks
$languageIds = $this->resolveAllRelevantLanguageIds();
return $this->resolveLanguageFallback($results, $this->languageFieldName, $languageIds);
}
protected function createQueryBuilder(): QueryBuilder
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable($this->tableName)
->from($this->tableName);
$queryBuilder->setRestrictions(
GeneralUtility::makeInstance(FrontendRestrictionContainer::class, GeneralUtility::makeInstance(Context::class))
);
// Frontend Groups are not available at this time
// So this must be excluded to allow access restricted records
$queryBuilder->getRestrictions()->removeByType(FrontendGroupRestriction::class);
return $queryBuilder;
}
protected function createRouteFieldConstraints(QueryBuilder $queryBuilder, array $values): array
{
$languageAware = $this->languageFieldName !== null && $this->languageParentFieldName !== null;
$languageExpansion = $languageAware && isset($values['uid']);
$constraints = [];
foreach ($values as $fieldName => $fieldValue) {
if ($languageExpansion && $fieldName === 'uid') {
continue;
}
$constraints[] = $queryBuilder->expr()->eq(
$fieldName,
$queryBuilder->createNamedParameter(
$fieldValue
)
);
}
// either match uid or language parent field value (for any language)
if ($languageExpansion) {
$idParameter = $queryBuilder->createNamedParameter(
$values['uid'],
Connection::PARAM_INT
);
$constraints[] = $queryBuilder->expr()->or(
$queryBuilder->expr()->eq('uid', $idParameter),
$queryBuilder->expr()->eq($this->languageParentFieldName, $idParameter)
);
// otherwise - basically uid is not in pattern - restrict to languages and apply fallbacks
} elseif ($languageAware) {
$languageIds = $this->resolveAllRelevantLanguageIds();
$constraints[] = $queryBuilder->expr()->in(
$this->languageFieldName,
$queryBuilder->createNamedParameter($languageIds, Connection::PARAM_INT_ARRAY)
);
}
return $constraints;
}
protected function resolveOverlay(?array $record): ?array
{
$languageId = $this->siteLanguage->getLanguageId();
if ($record === null || $languageId === 0) {
return $record;
}
$pageRepository = $this->createPageRepository();
return $pageRepository->getLanguageOverlay($this->tableName, $record) ?: null;
}
protected function createPageRepository(): PageRepository
{
$context = clone GeneralUtility::makeInstance(Context::class);
$context->setAspect(
'language',
LanguageAspectFactory::createFromSiteLanguage($this->siteLanguage)
);
return GeneralUtility::makeInstance(
PageRepository::class,
$context
);
}
}
@@ -0,0 +1,103 @@
<?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\Routing\Aspect;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Routing\SiteMatcher;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Helper trait to use a site within a class.
*
* @internal this is not public API yet as this might change, and could be changed within TYPO3 Core at any time.
*/
trait SiteAccessorTrait
{
/**
* @var Site
*/
protected $site;
/**
* @var SiteMatcher|null
*/
protected $siteMatcher;
public function setSite(Site $site): void
{
$this->site = $site;
}
public function getSite(): Site
{
return $this->site;
}
/**
* Filters records that are contained in current site
* (resolved from current SiteLanguage).
*
* Results keep original indexes and probably needs to
* be passed through `array_values` for e.g. using the
* first result by `$results[0]`.
*
* @param array $results
*/
protected function filterContainedInSite(array $results): array
{
if (empty($results)) {
return $results;
}
return array_filter(
$results,
function (array $result) {
// default FrontendRestrictionContainer retrieves only live records
// (no specific workspace & move-placeholder resolving required here)
$pageId = (int)$result['pid'];
return $this->isPageIdContainedInSite($pageId);
}
);
}
/**
* Determines whether page is contained in current site
* (resolved from current SiteLanguage).
*/
protected function isPageIdContainedInSite(int $pageId): bool
{
try {
$expectedSite = $this->getSiteMatcher()->matchByPageId($pageId);
return $expectedSite->getRootPageId() === $this->site->getRootPageId();
} catch (SiteNotFoundException $exception) {
// Same as in \TYPO3\CMS\Core\DataHandling\SlugHelper::isUniqueInSite
// where it is assumed that a record, that is not in site context,
// but still configured uniqueInSite is unique. We therefore must assume
// the resolved record to be rightfully part of the current site.
return true;
}
}
protected function getSiteMatcher(): SiteMatcher
{
if (!isset($this->siteMatcher)) {
$this->siteMatcher = GeneralUtility::makeInstance(SiteMatcher::class);
}
return $this->siteMatcher;
}
}
@@ -0,0 +1,94 @@
<?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\Routing\Aspect;
use TYPO3\CMS\Core\Context\LanguageAspect;
use TYPO3\CMS\Core\Context\LanguageAspectFactory;
use TYPO3\CMS\Core\Site\SiteLanguageAwareTrait;
use TYPO3\CMS\Core\Utility\MathUtility;
trait SiteLanguageAccessorTrait
{
use SiteLanguageAwareTrait;
/**
* @var LanguageAspect
*/
protected $languageAspect;
/**
* Resolves one record out of given language fallbacks.
*/
protected function resolveLanguageFallback(array $results, ?string $languageFieldName, ?array $languageIds): ?array
{
if ($results === []) {
return null;
}
if ($languageFieldName === null || $languageIds === null) {
return $results[0];
}
usort(
$results,
// orders records by there occurrence in $languageFallbackIds
static function (array $a, array $b) use ($languageFieldName, $languageIds): int {
$languageA = (int)$a[$languageFieldName];
$languageB = (int)$b[$languageFieldName];
return array_search($languageA, $languageIds, true)
- array_search($languageB, $languageIds, true);
}
);
return $results[0];
}
/**
* Resolves all language ids that are relevant to retrieve the most specific variant of a record.
* The order of these ids defines the processing order concerning language fallback - most specific
* language comes first in this array.
*
* + "all language (-1)", most specific if present since there cannot be any localizations
* + "current language" most specific for the current given request context
* + "language fallbacks" falling back to language alternatives (might include "default language")
*
* @return int[]
*/
protected function resolveAllRelevantLanguageIds()
{
$languageIds = [-1, $this->siteLanguage->getLanguageId()];
foreach ($this->getLanguageAspect()->getFallbackChain() as $item) {
if (in_array($item, $languageIds, true) || !MathUtility::canBeInterpretedAsInteger($item)) {
continue;
}
$languageIds[] = (int)$item;
}
return $languageIds;
}
/**
* Provides LanguageAspect which contains the logic how fallbacks
* for a given context/overlay-mode shall be handled.
*
* @see LanguageAspectFactory::createFromSiteLanguage
*/
protected function getLanguageAspect(): LanguageAspect
{
if ($this->languageAspect === null) {
$this->languageAspect = LanguageAspectFactory::createFromSiteLanguage($this->siteLanguage);
}
return $this->languageAspect;
}
}
@@ -0,0 +1,23 @@
<?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\Routing\Aspect;
/**
* Used for anything that has a fixed list of values mapped against route arguments.
*/
interface StaticMappableAspectInterface extends MappableAspectInterface {}
@@ -0,0 +1,166 @@
<?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\Routing\Aspect;
/**
* Very useful for e.g. pagination or static range like "2011 ... 2030" for years.
*
* Example:
* routeEnhancers:
* MyBlogPlugin:
* type: Extbase
* extension: BlogExample
* plugin: Pi1
* routes:
* - { routePath: '/list/{paging_widget}', _controller: 'BlogExample::list', _arguments: {'paging_widget': '@widget_0/currentPage'}}
* - { routePath: '/glossary/{section}', _controller: 'BlogExample::glossary'}
* defaultController: 'BlogExample::list'
* requirements:
* paging_widget: '\d+'
* aspects:
* paging_widget:
* type: StaticRangeMapper
* start: '1'
* end: '100'
* section:
* type: StaticRangeMapper
* start: 'a'
* end: 'z'
*/
class StaticRangeMapper implements StaticMappableAspectInterface, \Countable
{
/**
* @var array
*/
protected $settings;
/**
* @var string
*/
protected $start;
/**
* @var string
*/
protected $end;
/**
* @var string[]
*/
protected $range;
/**
* @throws \InvalidArgumentException
*/
public function __construct(array $settings)
{
$start = $settings['start'] ?? null;
$end = $settings['end'] ?? null;
if (!is_string($start)) {
throw new \InvalidArgumentException('start must be string', 1537277163);
}
if (!is_string($end)) {
throw new \InvalidArgumentException('end must be string', 1537277164);
}
$this->settings = $settings;
$this->start = $start;
$this->end = $end;
$this->range = $this->applyNumericPrefix($this->buildRange());
}
/**
* {@inheritdoc}
*/
public function count(): int
{
return count($this->range);
}
/**
* {@inheritdoc}
*/
public function generate(string $value): ?string
{
return $this->respondWhenInRange($value);
}
/**
* {@inheritdoc}
*/
public function resolve(string $value): ?string
{
return $this->respondWhenInRange($value);
}
protected function respondWhenInRange(string $value): ?string
{
if (in_array($value, $this->range, true)) {
return $value;
}
return null;
}
/**
* Builds range based on given settings and ensures each item is string.
* The amount of items is limited to 1000 in order to avoid brute-force
* scenarios and the risk of cache-flooding.
*
* In case that is not enough, creating a custom and more specific mapper
* is encouraged. Using high values that are not distinct exposes the site
* to the risk of cache-flooding.
*
* @return string[]
* @throws \LengthException
*/
protected function buildRange(): array
{
$range = array_map('strval', range($this->start, $this->end));
if (count($range) > 1000) {
throw new \LengthException(
'Range is larger than 1000 items',
1537696771
);
}
return $range;
}
/**
* @return string[]
*/
protected function applyNumericPrefix(array $range): array
{
if (!preg_match('#^\d+$#', $this->start)
|| !preg_match('#^\d+$#', $this->end)
|| $this->start === '0' || $this->end === '0'
|| $this->start[0] !== '0' && $this->end[0] !== '0'
) {
return $range;
}
$length = strlen(max($this->start, $this->end));
$range = array_map(
static function ($value) use ($length) {
return str_pad($value, $length, '0', STR_PAD_LEFT);
},
$range
);
return $range;
}
}
@@ -0,0 +1,135 @@
<?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\Routing\Aspect;
use TYPO3\CMS\Core\Site\SiteLanguageAwareInterface;
use TYPO3\CMS\Core\Site\SiteLanguageAwareTrait;
/**
* Mapper for having a static list of mapping them to value properties.
*
* routeEnhancers:
* MyBlogExample:
* type: Extbase
* extension: BlogExample
* plugin: Pi1
* routes:
* - { routePath: '/archive/{year}', _controller: 'Blog::archive' }
* defaultController: 'Blog::list'
* aspects:
* year:
* type: StaticValueMapper
* map:
* 2k17: '2017'
* 2k18: '2018'
* next: '2019'
* # (optional)
* localeMap:
* - locale: 'en_US.*|en_GB.*'
* map:
* twenty-seventeen: '2017'
* twenty-eighteen: '2018'
* next: '2019'
* - locale: 'fr_FR'
* map:
* vingt-dix-sept: '2017'
* vingt-dix-huit: '2018'
* prochain: '2019'
*/
class StaticValueMapper implements StaticMappableAspectInterface, SiteLanguageAwareInterface, UnresolvedValueInterface, \Countable
{
use SiteLanguageAwareTrait;
use UnresolvedValueTrait;
/**
* @var array
*/
protected $settings;
/**
* @var array
*/
protected $map;
/**
* @var array
*/
protected $localeMap;
/**
* @throws \InvalidArgumentException
*/
public function __construct(array $settings)
{
$map = $settings['map'] ?? null;
$localeMap = $settings['localeMap'] ?? [];
if (!is_array($map)) {
throw new \InvalidArgumentException('map must be array', 1537277143);
}
if (!is_array($localeMap)) {
throw new \InvalidArgumentException('localeMap must be array', 1537277144);
}
$this->settings = $settings;
$this->map = array_map('strval', $map);
$this->localeMap = $localeMap;
}
/**
* {@inheritdoc}
*/
public function count(): int
{
return count($this->retrieveLocaleMap() ?? $this->map);
}
/**
* {@inheritdoc}
*/
public function generate(string $value): ?string
{
$map = $this->retrieveLocaleMap() ?? $this->map;
$index = array_search($value, $map, true);
return $index !== false ? (string)$index : null;
}
/**
* {@inheritdoc}
*/
public function resolve(string $value): ?string
{
$map = $this->retrieveLocaleMap() ?? $this->map;
return isset($map[$value]) ? (string)$map[$value] : null;
}
/**
* Fetches the map of with the matching locale.
*/
protected function retrieveLocaleMap(): ?array
{
$locale = (string)$this->siteLanguage->getLocale();
foreach ($this->localeMap as $item) {
$pattern = '#^' . str_replace('_', '-', $item['locale']) . '#i';
if (preg_match($pattern, $locale)) {
return array_map('strval', $item['map']);
}
}
return null;
}
}
@@ -0,0 +1,27 @@
<?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\Routing\Aspect;
/**
* Provides fallback values for unresolved values during processing mappers.
*/
interface UnresolvedValueInterface
{
public function hasFallbackValue(): bool;
public function getFallbackValue(): ?string;
}
@@ -0,0 +1,47 @@
<?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\Routing\Aspect;
/**
* Provides fallback values for unresolved values during processing mappers.
*/
trait UnresolvedValueTrait
{
/**
* @var array{fallbackValue?: ?scalar}
*/
protected $settings;
public function hasFallbackValue(): bool
{
return array_key_exists('fallbackValue', $this->settings);
}
public function getFallbackValue(): ?string
{
if (!$this->hasFallbackValue()) {
throw new \LogicException('Property fallbackValue must be defined', 1668084601);
}
/** @var mixed $fallbackValue */
$fallbackValue = $this->settings['fallbackValue'];
if (is_string($fallbackValue) || is_null($fallbackValue)) {
return $fallbackValue;
}
return (string)$fallbackValue;
}
}
@@ -0,0 +1,135 @@
<?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\Routing;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\Http\Uri;
/**
* This class helps to resolve the virtual path to the main entry point of the TYPO3 Backend.
*/
#[Autoconfigure(public: true)]
class BackendEntryPointResolver
{
protected string $entryPoint = '/typo3';
/**
* Returns a prefix such as /typo3/ or /mysubdir/typo3/ to the TYPO3 Backend with trailing slash.
*/
public function getPathFromRequest(ServerRequestInterface $request): string
{
$entryPoint = $this->getEntryPoint($request);
if (str_contains($entryPoint, '//')) {
$entryPointParts = parse_url($entryPoint);
/* Remove trailing slash unless, the string is '/' itself */
$entryPoint = rtrim('/' . trim($entryPointParts['path'] ?? '', '/'), '/');
}
return $entryPoint . '/';
}
/**
* Returns a full URL to the main URL of the TYPO3 Backend.
*/
public function getUriFromRequest(ServerRequestInterface $request, string $additionalPathPart = ''): UriInterface
{
$entryPoint = $this->getEntryPointConfiguration();
if (str_starts_with($entryPoint, 'https://') || str_starts_with($entryPoint, 'http://')) {
// fqdn, early return as all required information are available.
return new Uri($entryPoint . '/' . ltrim($additionalPathPart, '/'));
}
if ($request->getAttribute('normalizedParams') instanceof NormalizedParams) {
$normalizedParams = $request->getAttribute('normalizedParams');
} else {
$normalizedParams = NormalizedParams::createFromRequest($request);
}
if (str_starts_with($entryPoint, '//')) {
// Browser supports uri starting with `//` and uses the current request scheme for the link. Do avoid issue
// for example checking the url at some point we prefix it with the current request protocol.
return new Uri(($normalizedParams->isHttps() ? 'https:' : 'http:') . $entryPoint . '/' . ltrim($additionalPathPart, '/'));
}
return new Uri($normalizedParams->getSiteUrl() . $entryPoint . '/' . ltrim($additionalPathPart, '/'));
}
public function isBackendRoute(ServerRequestInterface $request): bool
{
return $this->getBackendRoutePath($request) !== null;
}
public function getBackendRoutePath(ServerRequestInterface $request): ?string
{
$uri = $request->getUri();
$path = $uri->getPath();
$entryPoint = $this->getEntryPoint($request);
if (str_contains($entryPoint, '//')) {
$entryPointParts = parse_url($entryPoint);
if ($uri->getHost() !== $entryPointParts['host']) {
return null;
}
/* Remove trailing slash unless, the string is '/' itself */
$entryPoint = rtrim('/' . trim($entryPointParts['path'] ?? '', '/'), '/');
}
if ($path === $entryPoint) {
return '';
}
if (str_starts_with($path, $entryPoint . '/')) {
return substr($path, strlen($entryPoint));
}
return null;
}
/**
* Returns a prefix such as /typo3 or /mysubdir/typo3 to the TYPO3 Backend *without* trailing slash.
*/
protected function getEntryPoint(ServerRequestInterface $request): string
{
$entryPoint = $this->getEntryPointConfiguration();
if (str_contains($entryPoint, '//')) {
return $entryPoint;
}
if ($request->getAttribute('normalizedParams') instanceof NormalizedParams) {
$normalizedParams = $request->getAttribute('normalizedParams');
} else {
$normalizedParams = NormalizedParams::createFromRequest($request);
}
return $normalizedParams->getSitePath() . $entryPoint;
}
protected function getEntryPointConfiguration(): string
{
$entryPoint = $GLOBALS['TYPO3_CONF_VARS']['BE']['entryPoint'] ?? $this->entryPoint;
if (str_starts_with($entryPoint, 'https://')
|| str_starts_with($entryPoint, 'http://')
|| str_starts_with($entryPoint, '//')
) {
$uri = new Uri(rtrim($entryPoint, '/'));
$uri = $uri->withPath($this->removeMultipleSlashes($uri->getPath()));
return (string)$uri;
}
return $this->removeMultipleSlashes(trim($entryPoint, '/'));
}
private function removeMultipleSlashes(string $value): string
{
return preg_replace('/(\/+)/', '/', $value);
}
}
+156
View File
@@ -0,0 +1,156 @@
<?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\Routing;
use Symfony\Component\Routing\Matcher\RedirectableUrlMatcherInterface;
use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RouteCollection as SymfonyRouteCollection;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* @internal
*/
class BestUrlMatcher extends UrlMatcher
{
protected function matchCollection(string $pathinfo, SymfonyRouteCollection $routes): array
{
$matchedRoutes = $this->preMatchCollection($pathinfo, $routes);
$matches = count($matchedRoutes);
if ($matches === 0) {
return [];
}
if ($matches === 1) {
return $matchedRoutes[0]->getRouteResult();
}
usort($matchedRoutes, [$this, 'sortMatchedRoutes']);
return array_shift($matchedRoutes)->getRouteResult();
}
/**
* Tries to match a URL with a set of routes.
* Basically all code has been duplicated from `UrlMatcher::matchCollection`, the difference is
* it does not just return the first match, but return all possible matches for further reduction.
*
* @param string $pathinfo The path info to be parsed
* @return list<MatchedRoute>
*/
protected function preMatchCollection(string $pathinfo, SymfonyRouteCollection $routes): array
{
$matchedRoutes = [];
// HEAD and GET are equivalent as per RFC
$method = $this->context->getMethod();
if ($method === 'HEAD') {
$method = 'GET';
}
$supportsTrailingSlash = $method === 'GET' && $this instanceof RedirectableUrlMatcherInterface;
$trimmedPathinfo = rtrim($pathinfo, '/') ?: '/';
foreach ($routes as $name => $route) {
$compiledRoute = $route->compile();
$staticPrefix = rtrim($compiledRoute->getStaticPrefix(), '/');
$requiredMethods = $route->getMethods();
// check the static prefix of the URL first. Only use the more expensive preg_match when it matches
if ($staticPrefix !== '' && !str_starts_with($trimmedPathinfo, $staticPrefix)) {
continue;
}
$regex = $compiledRoute->getRegex();
$pos = strrpos($regex, '$');
$hasTrailingSlash = $regex[$pos - 1] === '/';
$regex = substr_replace($regex, '/?$', $pos - $hasTrailingSlash, 1 + $hasTrailingSlash);
if (!preg_match($regex, $pathinfo, $matches)) {
continue;
}
$hasTrailingVar = $trimmedPathinfo !== $pathinfo && preg_match('#\{[\w\x80-\xFF]+\}/?$#', $route->getPath());
if ($hasTrailingVar && ($hasTrailingSlash || (null === $m = $matches[count($compiledRoute->getPathVariables())] ?? null) || '/' !== ($m[-1] ?? '/')) && preg_match($regex, $trimmedPathinfo, $m)) {
if ($hasTrailingSlash) {
$matches = $m;
} else {
$hasTrailingVar = false;
}
}
$hostMatches = [];
if ($compiledRoute->getHostRegex() && !preg_match($compiledRoute->getHostRegex(), $this->context->getHost(), $hostMatches)) {
continue;
}
$attributes = $this->getAttributes($route, $name, array_replace($matches, $hostMatches));
$status = $this->handleRouteRequirements($pathinfo, $name, $route, $attributes);
if ($status[0] === self::REQUIREMENT_MISMATCH) {
continue;
}
if ($pathinfo !== '/' && !$hasTrailingVar && $hasTrailingSlash === ($trimmedPathinfo === $pathinfo)) {
if ($supportsTrailingSlash && (!$requiredMethods || in_array('GET', $requiredMethods))) {
return $this->allow = $this->allowSchemes = [];
}
continue;
}
if ($route->getSchemes() && !$route->hasScheme($this->context->getScheme())) {
$this->allowSchemes = array_merge($this->allowSchemes, $route->getSchemes());
continue;
}
if ($requiredMethods && !in_array($method, $requiredMethods)) {
$this->allow = array_merge($this->allow, $requiredMethods);
continue;
}
$matchedRoute = GeneralUtility::makeInstance(
MatchedRoute::class,
$route,
array_replace($attributes, $status[1] ?? [])
);
$matchedRoutes[] = $matchedRoute->withPathMatches($matches)->withHostMatches($hostMatches);
}
return $matchedRoutes;
}
/**
* Sorts the best matching route result to the beginning
*/
protected function sortMatchedRoutes(MatchedRoute $a, MatchedRoute $b): int
{
if ($a->getFallbackScore() !== $b->getFallbackScore()) {
// sort fallbacks to the end
return $a->getFallbackScore() <=> $b->getFallbackScore();
}
if ($b->getHostMatchScore() !== $a->getHostMatchScore()) {
// sort more specific host matches to the beginning
return $b->getHostMatchScore() <=> $a->getHostMatchScore();
}
// index `1` refers to the array index containing the corresponding `tail` match
// @todo not sure, whether `tail` can be defined generic, it's hard coded in `SiteMatcher`
if ($b->getPathMatchScore(1) !== $a->getPathMatchScore(1)) {
return $b->getPathMatchScore(1) <=> $a->getPathMatchScore(1);
}
// fallback for behavior prior to issue #93240, using reverse sorted site identifier
// (side note: site identifier did not contain any URL relevant information)
return $b->getSiteIdentifier() <=> $a->getSiteIdentifier();
}
}
@@ -0,0 +1,229 @@
<?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\Routing\Enhancer;
use TYPO3\CMS\Core\Routing\Aspect\AspectInterface;
use TYPO3\CMS\Core\Routing\Aspect\ModifiableAspectInterface;
use TYPO3\CMS\Core\Routing\Route;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Abstract Enhancer, useful for custom enhancers
*/
abstract class AbstractEnhancer implements EnhancerInterface
{
/**
* @var AspectInterface[]
*/
protected array $aspects = [];
/**
* @var VariableProcessor|null
*/
protected $variableProcessor;
/**
* @param AspectInterface[] $aspects
* @param string|null $namespace
*/
protected function applyRouteAspects(Route $route, array $aspects, ?string $namespace = null)
{
if (empty($aspects)) {
return;
}
$aspects = $this->getVariableProcessor()
->deflateKeys($aspects, $namespace, $route->getArguments());
$route->setAspects($aspects);
}
/**
* @param string|null $namespace
*/
protected function applyRequirements(Route $route, array $requirements, ?string $namespace = null)
{
$requirements = $this->getVariableProcessor()
->deflateKeys($requirements, $namespace, $route->getArguments());
// only keep requirements that are actually part of the current route path
$requirements = $this->filterValuesByPathVariables($route, $requirements);
// Symfony's behavior on applying pattern for parameters just concerns values
// to be passed either to URL or to internal parameters - they are always the
// same, without any transformation.
//
// TYPO3 extends ("enhances") this behavior by making a difference between values
// for generation (resulting in a URL) and matching (resulting in query parameters)
// having the following implications and meaning:
//
// + since requirements in classic Symfony focus on parameters in URLs
// and aspects define a mapping between URL part (e.g. 'some-example-news')
// and the corresponding internal argument (e.g. 'tx_news_pi1[news]=123')
// + thus, the requirement definition cannot be used for resolving and generating
// a route at the same time (it would have to be e.g. `[\w_._]+` AND `\d+`)
//
// Symfony's default regular expression pattern `[^/]+` (see
// `RouteCompiler::compilePattern()`) has to be overridden with `.+` to
// allow URI parameters like `some-example-news/january` as well.
//
// Existing `requirements` for TYPO3 route enhancers are not modified, only those
// that are not defined and would use Symfony's default pattern.
$requirements = $this->defineValuesByAspect($route, $requirements, '.+');
$route->setRequirements($requirements);
}
/**
* Applies variables that are considered static (not having `&cHash=...` applied),
* without having the demand to define a custom `StaticMappableAspectInterface`
* to fake the behavior.
*
* However:
* + in case there's an aspect defined for a variable, it will be skipped (aspects take precedence)
* + in case not requirement is defined for a variable, it will be skipped (avoiding weak definitions)
*
* @param array<non-empty-string, bool> $staticVariables option values
*/
protected function applyStaticVariables(Route $route, array $staticVariables, ?string $namespace = null): void
{
// skip definitions that are not explicitly set to `true`
$staticVariables = array_filter($staticVariables, static fn($definition) => $definition === true);
$staticVariables = $this->getVariableProcessor()
->deflateKeys($staticVariables, $namespace, $route->getArguments());
// only keep static variables that are actually part of the current route path
$staticVariables = $this->filterValuesByPathVariables($route, $staticVariables);
// skip definitions that already have an aspect defined (aspects take precedence)
$staticVariables = array_diff_key($staticVariables, $route->getAspects());
// skip definitions that not have any requirement defined (avoiding weak definitions)
$staticVariables = array_intersect_key($staticVariables, $route->getRequirements());
$route->setOption('_static', $staticVariables);
}
/**
* Only keeps values that actually have been used as variables in route path.
*
* + routePath: '/list/{page}' ('page' used as variable in route path)
* + values: ['entity' => 'entity...', 'page' => 'page...', 'other' => 'other...']
* + result: ['page' => 'page...']
*
* @param Route $route
* @param array $values
*/
protected function filterValuesByPathVariables(Route $route, array $values): array
{
return array_intersect_key(
$values,
array_flip($route->compile()->getPathVariables())
);
}
/**
* Overrides items having an aspect definition with a given
* $overrideValue in target $targetValue array.
*/
protected function overrideValuesByAspect(Route $route, array $values, string $targetValue): array
{
foreach (array_keys($route->getAspects()) as $variableName) {
$values[$variableName] = $targetValue;
}
return $values;
}
/**
* Define items having an aspect definition in case they are not defined
* with a given $targetValue in target $targetValue array.
*/
protected function defineValuesByAspect(Route $route, array $values, string $targetValue): array
{
foreach (array_keys($route->getAspects()) as $variableName) {
if (isset($values[$variableName])) {
continue;
}
$values[$variableName] = $targetValue;
}
return $values;
}
/**
* Modify the route path to add the variable names with the aspects, e.g.
*
* + `/{locale_modifier}/{product_title}` -> `/products/{product_title}`
* + `/{!locale_modifier}/{product_title}` -> `/products/{product_title}`
*
* @param string $routePath
*/
protected function modifyRoutePath(string $routePath): string
{
$substitutes = [];
foreach ($this->aspects as $variableName => $aspect) {
if (!$aspect instanceof ModifiableAspectInterface) {
continue;
}
$value = $aspect->modify();
if ($value !== null) {
$substitutes['{' . $variableName . '}'] = $value;
$substitutes['{!' . $variableName . '}'] = $value;
}
}
return str_replace(
array_keys($substitutes),
array_values($substitutes),
$routePath
);
}
/**
* Retrieves type from processed route and modifies remaining query parameters.
*
* @param array $remainingQueryParameters reference to remaining query parameters
*/
protected function resolveType(Route $route, array &$remainingQueryParameters): string
{
$type = $remainingQueryParameters['type'] ?? 0;
$decoratedParameters = $route->getOption('_decoratedParameters');
if (isset($decoratedParameters['type'])) {
$type = $decoratedParameters['type'];
unset($decoratedParameters['type']);
$remainingQueryParameters = array_replace_recursive(
$remainingQueryParameters,
$decoratedParameters
);
}
return (string)$type;
}
protected function getVariableProcessor(): VariableProcessor
{
if (isset($this->variableProcessor)) {
return $this->variableProcessor;
}
return $this->variableProcessor = GeneralUtility::makeInstance(VariableProcessor::class);
}
/**
* {@inheritdoc}
*/
public function setAspects(array $aspects): void
{
$this->aspects = $aspects;
}
/**
* {@inheritdoc}
*/
public function getAspects(): array
{
return $this->aspects;
}
}
@@ -0,0 +1,56 @@
<?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\Routing\Enhancer;
use TYPO3\CMS\Core\Routing\RouteCollection;
/**
* Decorates a route (or routes within a collection) with additional parameters.
*/
interface DecoratingEnhancerInterface extends EnhancerInterface
{
/**
* Gets pattern that can be used to redecorate (undecorate)
* a potential previously decorated route path.
*
* Example:
* + route path: 'first/second.html'
* + redecoration pattern: '(?:\.html|\.json)$'
* -> 'first/second' might be the redecorated route path after
* applying the redecoration pattern to preg_match/preg_replace
*
* @return string regular expression pattern
*/
public function getRoutePathRedecorationPattern(): string;
/**
* Decorates route collection to be processed during URL resolving.
* Executed before invoking routing enhancers.
*
* @param string $routePath URL path
*/
public function decorateForMatching(RouteCollection $collection, string $routePath): void;
/**
* Decorates route collection during URL URL generation.
* Executed before invoking routing enhancers.
*
* @param array $parameters query parameters
*/
public function decorateForGeneration(RouteCollection $collection, array $parameters): void;
}
@@ -0,0 +1,64 @@
<?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\Routing\Enhancer;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Creates enhancers
*/
class EnhancerFactory
{
/**
* @var array of all class names that need to be EnhancerInterfaces when instantiated.
*/
protected $availableEnhancers;
/**
* EnhancerFactory constructor.
*/
public function __construct()
{
$this->availableEnhancers = $GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['enhancers'] ?? [];
}
/**
* @throws \InvalidArgumentException
* @throws \OutOfRangeException
*/
public function create(string $type, array $settings): EnhancerInterface
{
if (empty($type)) {
throw new \InvalidArgumentException(
'Enhancer type cannot be empty',
1537298284
);
}
if (!isset($this->availableEnhancers[$type])) {
throw new \OutOfRangeException(
sprintf('No enhancer found for %s', $type),
1537277222
);
}
unset($settings['type']);
$className = $this->availableEnhancers[$type];
/** @var EnhancerInterface $enhancer */
$enhancer = GeneralUtility::makeInstance($className, $settings);
return $enhancer;
}
}
@@ -0,0 +1,37 @@
<?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\Routing\Enhancer;
use TYPO3\CMS\Core\Routing\Aspect\AspectInterface;
/**
* Base interface for enhancers, which can be decorators for adding parameters,
* or routing enhancers which adds variants to a page.
*/
interface EnhancerInterface
{
/**
* @param AspectInterface[] $aspects
*/
public function setAspects(array $aspects): void;
/**
* @return AspectInterface[]
*/
public function getAspects(): array;
}
@@ -0,0 +1,26 @@
<?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\Routing\Enhancer;
/**
* Interface asserting that enhancer is capable of inflating parameters.
*/
interface InflatableEnhancerInterface
{
public function inflateParameters(array $parameters, array $internals = []): array;
}
@@ -0,0 +1,237 @@
<?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\Routing\Enhancer;
use TYPO3\CMS\Core\Routing\Route;
use TYPO3\CMS\Core\Routing\RouteCollection;
/**
* Resolves a static list (like page.typeNum) against a file pattern. Usually added on the very last part
* of the URL.
* It is important that the PageType Enhancer is executed at the very end in your configuration, as it modifies
* EXISTING route variants.
*
* routeEnhancers:
* PageTypeSuffix:
* type: PageType
* default: ''
* index: 'index'
* map:
* '.html': 1
* 'menu.json': 13
*/
class PageTypeDecorator extends AbstractEnhancer implements DecoratingEnhancerInterface
{
protected const ROUTE_PATH_DELIMITERS = ['.', '-', '_', '/'];
/**
* @var array
*/
protected $configuration;
/**
* @var string
*/
protected $default;
/**
* @var string
*/
protected $index;
/**
* @var array
*/
protected $map;
public function __construct(array $configuration)
{
$default = $configuration['default'] ?? '';
$index = $configuration['index'] ?? 'index';
$map = $configuration['map'] ?? null;
if (!is_string($default)) {
throw new \InvalidArgumentException('default must be string', 1538327508);
}
if (!is_string($index)) {
throw new \InvalidArgumentException('index must be string', 1538327509);
}
if (!is_array($map)) {
throw new \InvalidArgumentException('map must be array', 1538327510);
}
$this->configuration = $configuration;
$this->default = $default;
$this->index = $index;
$this->map = array_map('strval', $map);
}
public function getRoutePathRedecorationPattern(): string
{
return $this->buildRegularExpressionPattern(false);
}
/**
* {@inheritdoc}
*/
public function decorateForMatching(RouteCollection $collection, string $routePath): void
{
$decoratedRoutePath = null;
$decoratedParameters = null;
$pattern = $this->buildRegularExpressionPattern();
if (preg_match('#(?P<decoration>(?:' . $pattern . '))#', $routePath, $matches, PREG_UNMATCHED_AS_NULL)) {
if (!isset($matches['decoration'])) {
throw new \UnexpectedValueException(
'Unexpected null value at end of URL',
1538335671
);
}
$routePathValue = $matches['decoration'];
$parameterValue = $matches['indexItems'] ?? $matches['slashedItems'] ?? $matches['regularItems'];
$routePathValuePattern = $this->quoteForRegularExpressionPattern($routePathValue) . '$';
$decoratedRoutePath = preg_replace('#' . $routePathValuePattern . '#', '', $routePath);
$mappedType = $this->map[$parameterValue] ?? null;
if ($mappedType !== null) {
$decoratedParameters = ['type' => $mappedType];
} elseif ($this->default === $routePathValue) {
$decoratedParameters = ['type' => 0];
}
}
foreach ($collection->all() as $route) {
if ($decoratedRoutePath !== null) {
$route->setOption(
'_decoratedRoutePath',
'/' . trim($decoratedRoutePath, '/')
);
}
if ($decoratedParameters !== null) {
$route->setOption(
'_decoratedParameters',
$decoratedParameters
);
}
}
}
/**
* {@inheritdoc}
*/
public function decorateForGeneration(RouteCollection $collection, array $parameters): void
{
$type = isset($parameters['type']) ? (string)$parameters['type'] : null;
$value = $this->resolveValue($type);
// If the type is > 0 but the value could not be resolved,
// the type is appended as GET argument, which can be resolved already anyway.
// This happens when the PageTypeDecorator is used, but hasn't been configured for all available types.
if (!empty($type) && ($value === '' || $value === $this->default)) {
return;
}
$considerIndex = $value !== '' && in_array($value[0], static::ROUTE_PATH_DELIMITERS);
if ($value !== '' && !in_array($value[0], static::ROUTE_PATH_DELIMITERS)) {
$value = '/' . $value;
}
/**
* @var Route $existingRoute
*/
foreach ($collection->all() as $existingRoute) {
$existingRoutePath = rtrim($existingRoute->getPath(), '/');
if ($considerIndex && $existingRoutePath === '') {
$existingRoutePath = $this->index;
}
$existingRoute->setPath($existingRoutePath . $value);
$deflatedParameters = $existingRoute->getOption('deflatedParameters') ?? $parameters;
if (isset($deflatedParameters['type'])) {
unset($deflatedParameters['type']);
$existingRoute->setOption(
'deflatedParameters',
$deflatedParameters
);
}
}
}
/**
* Checks if the value exists inside the map.
*/
protected function resolveValue(?string $type): string
{
$index = array_search($type, $this->map, true);
if ($index !== false) {
return $index;
}
return $this->default;
}
/**
* Builds a regexp out of the map.
*/
protected function buildRegularExpressionPattern(bool $useNames = true): string
{
$items = array_keys($this->map);
if ($this->default !== '' && !in_array($this->default, $items, true)) {
$items[] = $this->default;
}
$slashedItems = array_filter($items, [$this, 'needsSlashPrefix']);
$regularItems = array_diff($items, $slashedItems);
$slashedItems = array_map([$this, 'quoteForRegularExpressionPattern'], $slashedItems);
$regularItems = array_map([$this, 'quoteForRegularExpressionPattern'], $regularItems);
$patterns = [];
if (!empty($slashedItems)) {
$name = $useNames ? '?P<slashedItems>' : '';
$patterns[] = '(?:^|/)(' . $name . implode('|', $slashedItems) . ')';
}
if (!empty($regularItems) && !empty($this->index)) {
$name = $useNames ? '?P<indexItems>' : '';
$indexPattern = $this->quoteForRegularExpressionPattern($this->index);
$patterns[] = '^' . $indexPattern . '(' . $name . '(?:' . implode('|', $regularItems) . '))';
}
if (!empty($regularItems)) {
$name = $useNames ? '?P<regularItems>' : '';
$patterns[] = '(' . $name . implode('|', $regularItems) . ')';
}
return '(?:' . implode('|', $patterns) . ')$';
}
/**
* Helper method for regexps.
*/
protected function quoteForRegularExpressionPattern(string $value): string
{
return preg_quote($value, '#');
}
/**
* Checks if a slash should be prefixed.
*/
protected function needsSlashPrefix(string $value): bool
{
return !in_array(
$value[0] ?? '',
static::ROUTE_PATH_DELIMITERS,
true
);
}
}
+174
View File
@@ -0,0 +1,174 @@
<?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\Routing\Enhancer;
use TYPO3\CMS\Core\Routing\Aspect\StaticMappableAspectInterface;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Routing\Route;
use TYPO3\CMS\Core\Routing\RouteCollection;
use TYPO3\CMS\Core\Utility\ArrayUtility;
/**
* Used for plugins like EXT:felogin.
*
* This is usually used for arguments that are built with a `tx_myplugin_pi1` as namespace in GET / POST parameter.
*
* routeEnhancers:
* ForgotPassword:
* type: Plugin
* routePath: '/forgot-pw/{user_id}/{hash}/'
* namespace: 'tx_felogin_pi1'
* _arguments:
* user_id: uid
* requirements:
* user_id: '[a-z]+'
* hash: '[a-z]{0-6}'
*/
class PluginEnhancer extends AbstractEnhancer implements RoutingEnhancerInterface, InflatableEnhancerInterface, ResultingInterface
{
/**
* @var array
*/
protected $configuration;
/**
* @var string
*/
protected $namespace;
public function __construct(array $configuration)
{
$this->configuration = $configuration;
$this->namespace = $this->configuration['namespace'] ?? '';
}
/**
* {@inheritdoc}
*/
public function buildResult(Route $route, array $results, array $remainingQueryParameters = []): PageArguments
{
$variableProcessor = $this->getVariableProcessor();
// determine those parameters that have been processed
$parameters = array_intersect_key(
$results,
array_flip($route->compile()->getPathVariables())
);
// strip of those that where not processed (internals like _route, etc.)
$internals = array_diff_key($results, $parameters);
$matchedVariableNames = array_keys($parameters);
$staticMappers = $route->filterAspects([StaticMappableAspectInterface::class], $matchedVariableNames);
$dynamicCandidates = array_diff_key($parameters, $staticMappers, $route->getOption('_static') ?? []);
// all route arguments
$routeArguments = $this->inflateParameters($parameters, $internals);
// dynamic arguments, that don't have a static mapper
$dynamicArguments = $variableProcessor
->inflateNamespaceParameters($dynamicCandidates, $this->namespace);
// route arguments, that don't appear in dynamic arguments
$staticArguments = ArrayUtility::arrayDiffKeyRecursive($routeArguments, $dynamicArguments);
$page = $route->getOption('_page');
$pageId = (int)(isset($page['t3ver_oid']) && $page['t3ver_oid'] > 0 ? $page['t3ver_oid'] : $page['uid']);
$pageId = (int)($page['l10n_parent'] > 0 ? $page['l10n_parent'] : $pageId);
// See PageSlugCandidateProvider where this is added.
if ($page['MPvar'] ?? '') {
$routeArguments['MP'] = $page['MPvar'];
}
$type = $this->resolveType($route, $remainingQueryParameters);
return new PageArguments($pageId, $type, $routeArguments, $staticArguments, $remainingQueryParameters);
}
/**
* {@inheritdoc}
*/
public function enhanceForMatching(RouteCollection $collection): void
{
/** @var Route $defaultPageRoute */
$defaultPageRoute = $collection->get('default');
$variant = $this->getVariant($defaultPageRoute, $this->configuration);
$collection->add('enhancer_' . $this->namespace . spl_object_hash($variant), $variant);
}
/**
* Builds a variant of a route based on the given configuration.
*/
protected function getVariant(Route $defaultPageRoute, array $configuration): Route
{
$arguments = $configuration['_arguments'] ?? [];
unset($configuration['_arguments']);
$variableProcessor = $this->getVariableProcessor();
$routePath = $this->modifyRoutePath($configuration['routePath']);
$routePath = $variableProcessor->deflateRoutePath($routePath, $this->namespace, $arguments);
$variant = clone $defaultPageRoute;
$variant->setPath(rtrim($variant->getPath(), '/') . '/' . ltrim($routePath, '/'));
$variant->addOptions(['_enhancer' => $this, '_arguments' => $arguments]);
$defaults = $variableProcessor->deflateKeys($this->configuration['defaults'] ?? [], $this->namespace, $arguments);
// only keep `defaults` that are actually used in `routePath`
$variant->setDefaults($this->filterValuesByPathVariables($variant, $defaults));
$this->applyRouteAspects($variant, $this->aspects, $this->namespace);
$this->applyRequirements($variant, $this->configuration['requirements'] ?? [], $this->namespace);
$this->applyStaticVariables($variant, $this->configuration['static'] ?? [], $this->namespace);
return $variant;
}
/**
* {@inheritdoc}
*/
public function enhanceForGeneration(RouteCollection $collection, array $parameters): void
{
// No parameter for this namespace given, so this route does not fit the requirements
if (!is_array($parameters[$this->namespace] ?? null)) {
return;
}
/** @var Route $defaultPageRoute */
$defaultPageRoute = $collection->get('default');
$variant = $this->getVariant($defaultPageRoute, $this->configuration);
$compiledRoute = $variant->compile();
// contains all given parameters, even if not used as variables in route
$deflatedParameters = $this->deflateParameters($variant, $parameters);
$variables = array_flip($compiledRoute->getPathVariables());
$mergedParams = array_replace($variant->getDefaults(), $deflatedParameters);
// all params must be given, otherwise we exclude this variant
if ($variables === [] || array_diff_key($variables, $mergedParams) !== []) {
return;
}
$variant->addOptions(['deflatedParameters' => $deflatedParameters]);
$collection->add('enhancer_' . $this->namespace . spl_object_hash($variant), $variant);
}
protected function deflateParameters(Route $route, array $parameters): array
{
return $this->getVariableProcessor()->deflateNamespaceParameters(
$parameters,
$this->namespace,
$route->getArguments()
);
}
/**
* @param array $parameters Actual parameter payload to be used
* @param array $internals Internal instructions (_route, _controller, ...)
*/
public function inflateParameters(array $parameters, array $internals = []): array
{
return $this->getVariableProcessor()
->inflateNamespaceParameters($parameters, $this->namespace);
}
}
@@ -0,0 +1,30 @@
<?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\Routing\Enhancer;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Routing\Route;
/**
* Extend the Resulting Interface to explain that this route builds the page arguments itself, instead of having
* the PageRouter having to deal with that.
*/
interface ResultingInterface
{
public function buildResult(Route $route, array $results, array $remainingQueryParameters = []): PageArguments;
}
@@ -0,0 +1,37 @@
<?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\Routing\Enhancer;
use TYPO3\CMS\Core\Routing\RouteCollection;
/**
* Interface for enhancers
*/
interface RoutingEnhancerInterface extends EnhancerInterface
{
/**
* Extends route collection with all routes. Used during URL resolving.
*/
public function enhanceForMatching(RouteCollection $collection): void;
/**
* Extends route collection with routes that are relevant for given
* parameters. Used during URL generation.
*/
public function enhanceForGeneration(RouteCollection $collection, array $parameters): void;
}
+143
View File
@@ -0,0 +1,143 @@
<?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\Routing\Enhancer;
use TYPO3\CMS\Core\Routing\Aspect\StaticMappableAspectInterface;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Routing\Route;
use TYPO3\CMS\Core\Routing\RouteCollection;
use TYPO3\CMS\Core\Utility\ArrayUtility;
/**
* This is usually used for simple GET arguments that have no namespace (e.g. not plugins).
*
* routeEnhancers
* Categories:
* type: Simple
* routePath: '/cmd/{category_id}/{scope_id}'
* _arguments:
* category_id: 'category/id'
* scope_id: 'scope/id'
*/
class SimpleEnhancer extends AbstractEnhancer implements RoutingEnhancerInterface, InflatableEnhancerInterface, ResultingInterface
{
/**
* @var array
*/
protected $configuration;
public function __construct(array $configuration)
{
$this->configuration = $configuration;
}
/**
* {@inheritdoc}
*/
public function buildResult(Route $route, array $results, array $remainingQueryParameters = []): PageArguments
{
// determine those parameters that have been processed
$parameters = array_intersect_key(
$results,
array_flip($route->compile()->getPathVariables())
);
// strip of those that where not processed (internals like _route, etc.)
$internals = array_diff_key($results, $parameters);
$matchedVariableNames = array_keys($parameters);
$staticMappers = $route->filterAspects([StaticMappableAspectInterface::class], $matchedVariableNames);
$dynamicCandidates = array_diff_key($parameters, $staticMappers, $route->getOption('_static') ?? []);
// all route arguments
$routeArguments = $this->inflateParameters($parameters, $internals);
// dynamic arguments, that don't have a static mapper
$dynamicArguments = $this->inflateParameters($dynamicCandidates);
// route arguments, that don't appear in dynamic arguments
$staticArguments = ArrayUtility::arrayDiffKeyRecursive($routeArguments, $dynamicArguments);
$page = $route->getOption('_page');
$pageId = (int)(isset($page['t3ver_oid']) && $page['t3ver_oid'] > 0 ? $page['t3ver_oid'] : $page['uid']);
$pageId = (int)($page['l10n_parent'] > 0 ? $page['l10n_parent'] : $pageId);
// See PageSlugCandidateProvider where this is added.
if ($page['MPvar'] ?? '') {
$routeArguments['MP'] = $page['MPvar'];
}
$type = $this->resolveType($route, $remainingQueryParameters);
return new PageArguments($pageId, $type, $routeArguments, $staticArguments, $remainingQueryParameters);
}
/**
* {@inheritdoc}
*/
public function enhanceForMatching(RouteCollection $collection): void
{
/** @var Route $defaultPageRoute */
$defaultPageRoute = $collection->get('default');
$variant = $this->getVariant($defaultPageRoute, $this->configuration);
$collection->add('enhancer_' . spl_object_hash($variant), $variant);
}
/**
* Builds a variant of a route based on the given configuration.
*/
protected function getVariant(Route $defaultPageRoute, array $configuration): Route
{
$arguments = $configuration['_arguments'] ?? [];
unset($configuration['_arguments']);
$variableProcessor = $this->getVariableProcessor();
$routePath = $this->modifyRoutePath($configuration['routePath']);
$routePath = $variableProcessor->deflateRoutePath($routePath, null, $arguments);
$variant = clone $defaultPageRoute;
$variant->setPath(rtrim($variant->getPath(), '/') . '/' . ltrim($routePath, '/'));
$variant->addOptions(['_enhancer' => $this, '_arguments' => $arguments]);
$defaults = $variableProcessor->deflateKeys($this->configuration['defaults'] ?? [], null, $arguments);
// only keep `defaults` that are actually used in `routePath`
$variant->setDefaults($this->filterValuesByPathVariables($variant, $defaults));
$this->applyRouteAspects($variant, $this->aspects);
$this->applyRequirements($variant, $this->configuration['requirements'] ?? []);
$this->applyStaticVariables($variant, $this->configuration['static'] ?? []);
return $variant;
}
/**
* {@inheritdoc}
*/
public function enhanceForGeneration(RouteCollection $collection, array $parameters): void
{
/** @var Route $defaultPageRoute */
$defaultPageRoute = $collection->get('default');
$variant = $this->getVariant($defaultPageRoute, $this->configuration);
$compiledRoute = $variant->compile();
// contains all given parameters, even if not used as variables in route
$deflatedParameters = $this->getVariableProcessor()->deflateParameters($parameters, $variant->getArguments());
$variables = array_flip($compiledRoute->getPathVariables());
$mergedParams = array_replace($variant->getDefaults(), $deflatedParameters);
// all params must be given, otherwise we exclude this variant
if ($variables === [] || array_diff_key($variables, $mergedParams) !== []) {
return;
}
$variant->addOptions(['deflatedParameters' => $deflatedParameters]);
$collection->add('enhancer_' . spl_object_hash($variant), $variant);
}
public function inflateParameters(array $parameters, array $internals = []): array
{
return $this->getVariableProcessor()->inflateParameters($parameters, $internals);
}
}
@@ -0,0 +1,349 @@
<?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\Routing\Enhancer;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
/**
* Helper for processing various variables within a Route Enhancer
*/
#[Autoconfigure(public: true, shared: false)]
class VariableProcessor
{
protected const LEVEL_DELIMITER = '___';
protected const ARGUMENT_SEPARATOR = '/';
protected const VARIABLE_PATTERN = '#\{(?P<modifier>!)?(?P<name>[^}]+)\}#';
protected array $hashes = [];
protected array $nestedValues = [];
public function __construct(private readonly VariableProcessorCache $cache) {}
protected function addHash(string $value): string
{
if (!$this->requiresHashing($value)) {
return $value;
}
// generate hash (fetch from cache, if available)
$hash = $this->generateHash($value);
// store hash locally (indicator, that this value was processed)
$this->hashes[$hash] = $value;
return $hash;
}
/**
* Determines whether a parameter value requires hashing.
* This is the case if the value has 31+ chars (Symfony has a limitation of 32 chars),
* or if the value contains any non-word characters besides `[A-Za-z0-9_]`, such as `@`.
*/
protected function requiresHashing(string $value): bool
{
if (!isset($this->cache->requiresHashing[$value])) {
$this->cache->requiresHashing[$value] = strlen($value) >= 31 || preg_match('#[^\w]#', $value) > 0;
}
return $this->cache->requiresHashing[$value];
}
protected function generateHash(string $value): string
{
if (!isset($this->cache->hashes[$value])) {
// remove one char, which might be used as enforced route prefix `{!value}`
$hash = substr(md5($value), 0, -1);
// Symfony Route Compiler requires the first literal to be non-integer
if ($hash[0] === (string)(int)$hash[0]) {
$hash[0] = str_replace(
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
['o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x'],
$hash[0]
);
}
$this->cache->hashes[$value] = $hash;
}
return $this->cache->hashes[$value];
}
/**
* @throws \OutOfRangeException
*/
protected function resolveHash(string $hash): string
{
if (strlen($hash) < 31) {
return $hash;
}
if (!isset($this->hashes[$hash])) {
throw new \OutOfRangeException(
'Hash not resolvable',
1537633463
);
}
return $this->hashes[$hash];
}
protected function addNestedValue(string $value): string
{
if (!str_contains($value, static::ARGUMENT_SEPARATOR)) {
return $value;
}
$nestedValue = str_replace(
static::ARGUMENT_SEPARATOR,
static::LEVEL_DELIMITER,
$value
);
$this->nestedValues[$nestedValue] = $value;
return $nestedValue;
}
protected function resolveNestedValue(string $value): string
{
if (!str_contains($value, static::LEVEL_DELIMITER)) {
return $value;
}
return $this->nestedValues[$value] ?? $value;
}
public function deflateRoutePath(string $routePath, ?string $namespace = null, array $arguments = []): string
{
if (!preg_match_all(static::VARIABLE_PATTERN, $routePath, $matches)) {
return $routePath;
}
$replace = [];
$search = $matches[0];
$deflatedNames = $this->deflateValues($matches['name'], $namespace, $arguments);
foreach ($deflatedNames as $index => $deflatedName) {
$modifier = $matches['modifier'][$index] ?? '';
$replace[] = '{' . $modifier . $deflatedName . '}';
}
return str_replace($search, $replace, $routePath);
}
public function inflateRoutePath(string $routePath, ?string $namespace = null, array $arguments = []): string
{
if (!preg_match_all(static::VARIABLE_PATTERN, $routePath, $matches)) {
return $routePath;
}
$replace = [];
$search = $matches[0];
$inflatedNames = $this->inflateValues($matches['name'], $namespace, $arguments);
foreach ($inflatedNames as $index => $inflatedName) {
$modifier = $matches['modifier'][$index] ?? '';
$replace[] = '{' . $modifier . $inflatedName . '}';
}
return str_replace($search, $replace, $routePath);
}
/**
* Deflates (flattens) route/request parameters for a given namespace.
*/
public function deflateNamespaceParameters(array $parameters, string $namespace, array $arguments = []): array
{
if (empty($namespace) || empty($parameters[$namespace])) {
return $parameters;
}
// prefix items of namespace parameters and apply argument mapping
$namespaceParameters = $this->deflateKeys($parameters[$namespace], $namespace, $arguments, false);
// deflate those array items
$namespaceParameters = $this->deflateArray($namespaceParameters);
unset($parameters[$namespace]);
// merge with remaining array items
return array_merge($parameters, $namespaceParameters);
}
/**
* Inflates (unflattens) route/request parameters.
*/
public function inflateNamespaceParameters(array $parameters, string $namespace, array $arguments = []): array
{
if (empty($namespace) || empty($parameters)) {
return $parameters;
}
$parameters = $this->inflateArray($parameters, $namespace, $arguments);
// apply argument mapping on items of inflated namespace parameters
if (!empty($parameters[$namespace]) && !empty($arguments)) {
$parameters[$namespace] = $this->inflateKeys($parameters[$namespace], null, $arguments, false);
}
return $parameters;
}
/**
* Deflates (flattens) route/request parameters for a given namespace.
*/
public function deflateParameters(array $parameters, array $arguments = []): array
{
$parameters = $this->deflateKeys($parameters, null, $arguments, false);
return $this->deflateArray($parameters);
}
/**
* Inflates (unflattens) route/request parameters.
*/
public function inflateParameters(array $parameters, array $arguments = []): array
{
$parameters = $this->inflateArray($parameters, null, $arguments);
return $this->inflateKeys($parameters, null, $arguments, false);
}
/**
* Deflates keys names on the first level, now recursion into sub-arrays.
* Can be used to adjust key names of route requirements, mappers, etc.
*/
public function deflateKeys(array $items, ?string $namespace = null, array $arguments = [], bool $hash = true): array
{
if (empty($items) || empty($arguments) && empty($namespace)) {
return $items;
}
$keys = $this->deflateValues(array_keys($items), $namespace, $arguments, $hash);
return array_combine(
$keys,
array_values($items)
);
}
/**
* Inflates keys names on the first level, now recursion into sub-arrays.
* Can be used to adjust key names of route requirements, mappers, etc.
*/
public function inflateKeys(array $items, ?string $namespace = null, array $arguments = [], bool $hash = true): array
{
if (empty($items) || empty($arguments) && empty($namespace)) {
return $items;
}
$keys = $this->inflateValues(array_keys($items), $namespace, $arguments, $hash);
return array_combine(
$keys,
array_values($items)
);
}
/**
* Deflates plain values.
*/
protected function deflateValues(array $values, ?string $namespace = null, array $arguments = [], bool $hash = true): array
{
if (empty($values) || empty($arguments) && empty($namespace)) {
return $values;
}
$namespacePrefix = $namespace ? $namespace . static::LEVEL_DELIMITER : '';
$arguments = array_map('strval', $arguments);
return array_map(
function (string $value) use ($arguments, $namespacePrefix, $hash) {
$value = $arguments[$value] ?? $value;
$value = $this->addNestedValue($value);
$value = $namespacePrefix . $value;
if (!$hash) {
return $value;
}
return $this->addHash($value);
},
$values
);
}
/**
* Inflates plain values.
*/
protected function inflateValues(array $values, ?string $namespace = null, array $arguments = [], bool $hash = true): array
{
if (empty($values) || empty($arguments) && empty($namespace)) {
return $values;
}
$arguments = array_map('strval', $arguments);
$namespacePrefix = $namespace ? $namespace . static::LEVEL_DELIMITER : '';
return array_map(
function (string $value) use ($arguments, $namespacePrefix, $hash) {
if ($hash) {
$value = $this->resolveHash($value);
}
if (!empty($namespacePrefix) && str_starts_with($value, $namespacePrefix)) {
$value = substr($value, strlen($namespacePrefix));
}
$value = $this->resolveNestedValue($value);
$index = array_search($value, $arguments, true);
return $index !== false ? $index : $value;
},
$values
);
}
/**
* Deflates (flattens) array having nested structures.
*/
protected function deflateArray(array $array, string $prefix = ''): array
{
$delimiter = static::LEVEL_DELIMITER;
if ($prefix !== '' && !str_ends_with($prefix, $delimiter)) {
$prefix .= static::LEVEL_DELIMITER;
}
$result = [];
foreach ($array as $key => $value) {
if (is_array($value)) {
$result = array_replace(
$result,
$this->deflateArray(
$value,
$prefix . $key . static::LEVEL_DELIMITER
)
);
} else {
$deflatedKey = $this->addHash($prefix . $key);
$result[$deflatedKey] = $value;
}
}
return $result;
}
/**
* Inflates (unflattens) an array into nested structures.
*
* @param string $namespace
*/
protected function inflateArray(array $array, ?string $namespace, array $arguments): array
{
$result = [];
foreach ($array as $key => $value) {
$inflatedKey = $this->resolveHash((string)$key);
// inflate nested values `namespace__any__nested` -> `namespace__any/nested`
$inflatedKey = $this->inflateNestedValue($inflatedKey, $namespace, $arguments);
$steps = explode(static::LEVEL_DELIMITER, $inflatedKey);
$pointer = &$result;
foreach ($steps as $step) {
$pointer = &$pointer[$step];
}
$pointer = $value;
unset($pointer);
}
return $result;
}
protected function inflateNestedValue(string $value, ?string $namespace, array $arguments): string
{
$namespacePrefix = $namespace ? $namespace . static::LEVEL_DELIMITER : '';
if (!empty($namespace) && !str_starts_with($value, $namespacePrefix)) {
return $value;
}
$arguments = array_map('strval', $arguments);
$possibleNestedValueKey = substr($value, strlen($namespacePrefix));
$possibleNestedValue = $this->nestedValues[$possibleNestedValueKey] ?? null;
if ($possibleNestedValue === null || !in_array($possibleNestedValue, $arguments, true)) {
return $value;
}
return $namespacePrefix . $possibleNestedValue;
}
}
@@ -0,0 +1,36 @@
<?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\Routing\Enhancer;
/**
* Shared cache among multiple `VariableProcessor` instances
*
* @internal
*/
class VariableProcessorCache
{
/**
* @var array<string, bool>
*/
public array $requiresHashing = [];
/**
* @var array<string, string>
*/
public array $hashes = [];
}
@@ -0,0 +1,76 @@
<?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\Routing\Event;
use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Core\Domain\Page;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
final class AfterPageUriGeneratedEvent
{
public function __construct(
private UriInterface $uri,
private readonly array|string|int|Page $route,
private readonly array $parameters,
private readonly string $fragment,
private readonly string $type,
private readonly SiteLanguage $language,
private readonly Site $site,
) {}
public function getUri(): UriInterface
{
return $this->uri;
}
public function setUri(UriInterface $uri): void
{
$this->uri = $uri;
}
public function getRoute(): array|string|int|Page
{
return $this->route;
}
public function getParameters(): array
{
return $this->parameters;
}
public function getFragment(): string
{
return $this->fragment;
}
public function getType(): string
{
return $this->type;
}
public function getLanguage(): SiteLanguage
{
return $this->language;
}
public function getSite(): Site
{
return $this->site;
}
}
@@ -0,0 +1,25 @@
<?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\Routing;
use TYPO3\CMS\Core\Exception;
/**
* Exception thrown when a route does not exist or does not match the Route Arguments
*/
class InvalidRouteArgumentsException extends Exception {}
+85
View File
@@ -0,0 +1,85 @@
<?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\Routing;
use Symfony\Component\Routing\Route as SymfonyRoute;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
/**
* @internal
*/
class MatchedRoute
{
protected array $hostMatches = [];
protected array $pathMatches = [];
public function __construct(protected SymfonyRoute $route, protected array $routeResult) {}
public function withPathMatches(array $pathMatches): self
{
$target = clone $this;
$target->pathMatches = $pathMatches;
return $target;
}
public function withHostMatches(array $hostMatches): self
{
$target = clone $this;
$target->hostMatches = $hostMatches;
return $target;
}
public function getRoute(): SymfonyRoute
{
return $this->route;
}
public function getRouteResult(): array
{
return $this->routeResult;
}
public function getFallbackScore(): int
{
return $this->route->getOption('fallback') === true ? 1 : 0;
}
public function getHostMatchScore(): int
{
return empty($this->hostMatches[0]) ? 0 : 1;
}
public function getPathMatchScore(int $index): int
{
$completeMatch = $this->pathMatches[0];
$tailMatch = $this->pathMatches[$index] ?? '';
// no tail, it's a complete match
if ($tailMatch === '') {
return strlen($completeMatch);
}
// otherwise, find length of complete match that does not contain tail
// example: complete: `/french/other`, tail: `/other` -> `strlen` of `/french`
return strrpos($completeMatch, $tailMatch);
}
public function getSiteIdentifier(): string
{
$site = $this->route->getDefault('site');
return $site instanceof SiteInterface ? $site->getIdentifier() : '';
}
}
+248
View File
@@ -0,0 +1,248 @@
<?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\Routing;
use TYPO3\CMS\Core\Utility\ArrayUtility;
/**
* Contains all resolved parameters when a page is resolved from a page path segment plus all fragments.
*/
class PageArguments implements RouteResultInterface
{
protected int $pageId;
protected string $pageType;
protected bool $dirty = false;
/**
* All (merged) arguments of this URI (routeArguments + dynamicArguments)
*
* @var array<string, string|array>
*/
protected array $arguments;
/**
* Route arguments mapped by static mappers
* "static" means the provided values in a URI maps to a finite number of values
* (routeArguments - "arguments mapped by non static mapper")
*
* @var array<string, string|array>
*/
protected array $staticArguments;
/**
* Route arguments, that have an infinite number of possible values
* AND query string arguments. These arguments require a cHash.
*
* @var array<string, string|array>
*/
protected array $dynamicArguments;
/**
* Arguments defined in and mapped by a route enhancer
*
* @var array<string, string|array>
*/
protected array $routeArguments;
/**
* Query arguments in the generated URI
*
* @var array<string, string|array>
*/
protected array $queryArguments = [];
public function __construct(int $pageId, string $pageType, array $routeArguments, array $staticArguments = [], array $remainingArguments = [])
{
$this->pageId = $pageId;
$this->pageType = $pageType;
$this->routeArguments = $this->sort($routeArguments);
$this->staticArguments = $this->sort($staticArguments);
$this->arguments = $this->routeArguments;
$this->updateDynamicArguments();
if (!empty($remainingArguments)) {
$this->updateQueryArguments($remainingArguments);
}
}
public function areDirty(): bool
{
return $this->dirty;
}
/**
* @return array<string, string|array>
*/
public function getRouteArguments(): array
{
return $this->routeArguments;
}
public function getPageId(): int
{
return $this->pageId;
}
public function getPageType(): string
{
return $this->pageType;
}
/**
* @return string|array<string, string|array>|null
*/
public function get(string $name): mixed
{
return $this->arguments[$name] ?? null;
}
/**
* @return array<string, string|array>
*/
public function getArguments(): array
{
return $this->arguments;
}
/**
* @return array<string, string|array>
*/
public function getStaticArguments(): array
{
return $this->staticArguments;
}
/**
* @return array<string, string|array>
*/
public function getDynamicArguments(): array
{
return $this->dynamicArguments;
}
/**
* @return array<string, string|array>
*/
public function getQueryArguments(): array
{
return $this->queryArguments;
}
/**
* @param array<string, string|array> $queryArguments
*/
protected function updateQueryArguments(array $queryArguments)
{
$queryArguments = $this->sort($queryArguments);
if ($this->queryArguments === $queryArguments) {
return;
}
// in case query arguments would override route arguments,
// the state is considered as dirty (since it's not distinct)
// thus, route arguments take precedence over query arguments
$additionalQueryArguments = $this->diff($queryArguments, $this->routeArguments);
$dirty = $additionalQueryArguments !== $queryArguments;
$this->dirty = $this->dirty || $dirty;
$this->queryArguments = $queryArguments;
$this->arguments = array_replace_recursive($this->arguments, $additionalQueryArguments);
$this->updateDynamicArguments();
}
/**
* Updates dynamic arguments based on definitions for static arguments.
*/
protected function updateDynamicArguments(): void
{
$this->dynamicArguments = $this->diff(
$this->arguments,
$this->staticArguments
);
}
/**
* Cleans empty array recursively.
*
* @param array<string, string|array> $array
*/
protected function clean(array $array): array
{
foreach ($array as $key => &$item) {
if (!is_array($item)) {
continue;
}
if (!empty($item)) {
$item = $this->clean($item);
}
if (empty($item)) {
unset($array[$key]);
}
}
return $array;
}
/**
* Sorts array keys recursively.
*
* @param array<string, string|array> $array
*/
protected function sort(array $array): array
{
$array = $this->clean($array);
ArrayUtility::naturalKeySortRecursive($array);
return $array;
}
/**
* Removes keys that are defined in $second from $first recursively.
*
* @param array<string, string|array> $first
* @param array<string, string|array> $second
*/
protected function diff(array $first, array $second): array
{
return ArrayUtility::arrayDiffKeyRecursive($first, $second);
}
public function offsetExists(mixed $offset): bool
{
return $offset === 'pageId' || $offset === 'pageType' || isset($this->arguments[$offset]);
}
/**
* @return int|string|array<string, string|array>|null
*/
public function offsetGet(mixed $offset): mixed
{
if ($offset === 'pageId') {
return $this->getPageId();
}
if ($offset === 'pageType') {
return $this->getPageType();
}
return $this->arguments[$offset] ?? null;
}
public function offsetSet(mixed $offset, mixed $value): void
{
throw new \InvalidArgumentException('PageArguments cannot be modified.', 1538152266);
}
public function offsetUnset(mixed $offset): void
{
throw new \InvalidArgumentException('PageArguments cannot be modified.', 1538152269);
}
}
+710
View File
@@ -0,0 +1,710 @@
<?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\Routing;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\Routing\Exception\MissingMandatoryParametersException;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\LanguageAspectFactory;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Domain\Page;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\ExpressionLanguage\Resolver;
use TYPO3\CMS\Core\Http\Uri;
use TYPO3\CMS\Core\Routing\Aspect\AspectFactory;
use TYPO3\CMS\Core\Routing\Aspect\MappableProcessor;
use TYPO3\CMS\Core\Routing\Aspect\StaticMappableAspectInterface;
use TYPO3\CMS\Core\Routing\Enhancer\DecoratingEnhancerInterface;
use TYPO3\CMS\Core\Routing\Enhancer\EnhancerFactory;
use TYPO3\CMS\Core\Routing\Enhancer\EnhancerInterface;
use TYPO3\CMS\Core\Routing\Enhancer\InflatableEnhancerInterface;
use TYPO3\CMS\Core\Routing\Enhancer\ResultingInterface;
use TYPO3\CMS\Core\Routing\Enhancer\RoutingEnhancerInterface;
use TYPO3\CMS\Core\Routing\Event\AfterPageUriGeneratedEvent;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Frontend\Page\CacheHashCalculator;
use TYPO3\CMS\Frontend\Page\CacheHashConfiguration;
/**
* Page Router - responsible for a page based on a request, by looking up the slug of the page path.
* Is also used for generating URLs for pages.
*
* Resolving is done via the "Route Candidate" pattern.
*
* Example:
* - /about-us/team/management/
*
* will look for all pages that have
* - /about-us
* - /about-us/
* - /about-us/team
* - /about-us/team/
* - /about-us/team/management
* - /about-us/team/management/
*
* And create route candidates for that.
*
* Please note: PageRouter does not restrict the HTTP method or is bound to any domain constraints,
* as the SiteMatcher has done that already.
*
* The concept of the PageRouter is to *resolve*, and to *generate* URIs. On top, it is a facade to hide the
* dependency to symfony and to not expose its logic.
*/
class PageRouter implements RouterInterface
{
protected Site $site;
protected EnhancerFactory $enhancerFactory;
protected AspectFactory $aspectFactory;
protected CacheHashCalculator $cacheHashCalculator;
protected Context $context;
protected RequestContextFactory $requestContextFactory;
protected EventDispatcherInterface $eventDispatcher;
/**
* A page router is always bound to a specific site.
*/
public function __construct(Site $site, ?Context $context = null)
{
$this->site = $site;
$this->context = $context ?? GeneralUtility::makeInstance(Context::class);
$this->enhancerFactory = GeneralUtility::makeInstance(EnhancerFactory::class);
$this->aspectFactory = GeneralUtility::makeInstance(AspectFactory::class, $this->context);
$this->cacheHashCalculator = GeneralUtility::makeInstance(
CacheHashCalculator::class,
GeneralUtility::makeInstance(CacheHashConfiguration::class),
GeneralUtility::makeInstance(HashService::class)
);
$this->requestContextFactory = GeneralUtility::makeInstance(RequestContextFactory::class);
$this->eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class);
}
/**
* Finds a RouteResult based on the given request.
*/
public function matchRequest(ServerRequestInterface $request, ?RouteResultInterface $previousResult = null): RouteResultInterface|PageArguments
{
if (!$previousResult instanceof SiteRouteResult) {
throw new RouteNotFoundException('No previous result given. Cannot find a page for an empty route part', 1555303496);
}
$candidateProvider = $this->getSlugCandidateProvider($this->context);
// Legacy URIs (?id=12345) takes precedence, no matter if a route is given
$requestId = ($request->getQueryParams()['id'] ?? null);
$type = '0';
if (isset($request->getQueryParams()['type']) && is_scalar($request->getQueryParams()['type'])) {
$type = (string)$request->getQueryParams()['type'];
}
if ($requestId !== null) {
if (MathUtility::canBeInterpretedAsInteger($requestId)
&& (int)$requestId > 0
&& !empty($pageId = $candidateProvider->getRealPageIdForPageIdAsPossibleCandidate((int)$requestId))
) {
return new PageArguments((int)$pageId, $type, [], [], $request->getQueryParams());
}
throw new RouteNotFoundException('The requested page does not exist.', 1557839801);
}
$urlPath = $previousResult->getTail();
$language = $previousResult->getLanguage();
// Keep possible existing "/" at the end (no trim, just ltrim), even though the page slug might not
// contain a "/" at the end. This way we find page candidates where pages MIGHT have a trailing slash
// and pages with slugs that do not have a trailing slash
// $pageCandidates will contain more records than expected, which is important here, as the ->match() method
// will handle this then.
// The prepended slash will ensure that the root page of the site tree will also be fetched
$prefixedUrlPath = '/' . ltrim($urlPath, '/');
$pageCandidates = $candidateProvider->getCandidatesForPath($prefixedUrlPath, $language);
// Stop if there are no candidates
if (empty($pageCandidates)) {
throw new RouteNotFoundException('No page candidates found for path "' . $prefixedUrlPath . '"', 1538389999);
}
/** @var RouteCollection<string, Route> $fullCollection */
$fullCollection = new RouteCollection();
foreach ($pageCandidates as $page) {
$pageIdForDefaultLanguage = (int)($page['l10n_parent'] ?: $page['uid']);
$pagePath = $page['slug'];
$pageCollection = new RouteCollection();
$defaultRouteForPage = new Route(
$pagePath,
[],
[],
['utf8' => true, '_page' => $page]
);
$pageCollection->add('default', $defaultRouteForPage);
$enhancers = $this->getEnhancersForPage($pageIdForDefaultLanguage, $language, $page);
foreach ($enhancers as $enhancer) {
if ($enhancer instanceof DecoratingEnhancerInterface) {
$enhancer->decorateForMatching($pageCollection, $urlPath);
}
}
foreach ($enhancers as $enhancer) {
if ($enhancer instanceof RoutingEnhancerInterface) {
$enhancer->enhanceForMatching($pageCollection);
}
}
$collectionPrefix = 'page_' . $page['uid'];
// Pages with a MountPoint Parameter means that they have a different context, and should be treated
// as a separate instance
if (isset($page['MPvar'])) {
$collectionPrefix .= '_MP_' . str_replace(',', '', $page['MPvar']);
}
$pageCollection->addNamePrefix($collectionPrefix . '_');
$fullCollection->addCollection($pageCollection);
// set default route flag after all routes have been processed
$defaultRouteForPage->setOption('_isDefault', true);
}
$matcher = new PageUriMatcher($fullCollection);
try {
$result = $matcher->match($prefixedUrlPath);
/** @var Route $matchedRoute */
$matchedRoute = $fullCollection->get($result['_route']);
// Only use route if page language variant matches current language, otherwise handle it as route not found.
if ($this->isRouteReallyValidForLanguage($matchedRoute, $language)) {
return $this->buildPageArguments($matchedRoute, $result, $request->getQueryParams());
}
} catch (ResourceNotFoundException $e) {
if (str_ends_with($prefixedUrlPath, '/')) {
// Second try, look for /my-page even though the request was called via /my-page/ and the slash
// was not part of the slug, but let's then check again
try {
$result = $matcher->match(rtrim($prefixedUrlPath, '/'));
/** @var Route $matchedRoute */
$matchedRoute = $fullCollection->get($result['_route']);
// Only use route if page language variant matches current language, otherwise
// handle it as route not found.
if ($this->isRouteReallyValidForLanguage($matchedRoute, $language)) {
return $this->buildPageArguments($matchedRoute, $result, $request->getQueryParams());
}
} catch (ResourceNotFoundException $e) {
// Do nothing
}
} else {
// Second try, look for /my-page/ even though the request was called via /my-page and the slash
// was part of the slug, but let's then check again
try {
$result = $matcher->match($prefixedUrlPath . '/');
/** @var Route $matchedRoute */
$matchedRoute = $fullCollection->get($result['_route']);
// Only use route if page language variant matches current language, otherwise
// handle it as route not found.
if ($this->isRouteReallyValidForLanguage($matchedRoute, $language)) {
return $this->buildPageArguments($matchedRoute, $result, $request->getQueryParams());
}
} catch (ResourceNotFoundException $e) {
// Do nothing
}
}
}
throw new RouteNotFoundException('No route found for path "' . $urlPath . '"', 1538389998);
}
/**
* API for generating a page uri where the $route parameter is typically an array (a page record) or the page ID
*
* @param array|string|int|Page $route
* @param array $parameters an array of query parameters which can be built into the URI path, also consider the special handling of "_language"
* @param string $fragment additional #my-fragment part
* @param string $type see the RouterInterface for possible types
* @throws InvalidRouteArgumentsException
*/
public function generateUri($route, array $parameters = [], string $fragment = '', string $type = ''): UriInterface
{
// sanitize superfluous page-id from additional parameters
// (even if `$parameters['id']` is different to `$pageId`, it will be removed)
unset($parameters['id']);
// Resolve language
$language = null;
$languageOption = $parameters['_language'] ?? null;
unset($parameters['_language']);
if ($languageOption instanceof SiteLanguage) {
$language = $languageOption;
} elseif ($languageOption !== null) {
$language = $this->site->getLanguageById((int)$languageOption);
}
if ($language === null) {
$language = $this->site->getDefaultLanguage();
}
$pageId = 0;
if ($route instanceof Page) {
$pageId = $route->getPageId();
} elseif (is_array($route)) {
$pageId = (int)$route['uid'];
} elseif (is_scalar($route)) {
$pageId = (int)$route;
}
$context = clone $this->context;
$context->setAspect('language', LanguageAspectFactory::createFromSiteLanguage($language));
$pageRepository = GeneralUtility::makeInstance(PageRepository::class, $context);
if ($route instanceof Page) {
$page = $route->toArray(true);
} elseif (is_array($route)
// Check 3rd party input $route for basic requirements
&& isset($route['uid'], $route['language_tag'], $route['l10n_parent'], $route['slug'])
&& (int)$route['language_tag'] === $language->getLanguageId()
&& ((int)$route['l10n_parent'] === 0 || isset($route['_LOCALIZED_UID']))
) {
$page = $route;
} else {
$page = $pageRepository->getPage($pageId, true);
}
$pagePath = $page['slug'] ?? '';
if ($parameters['MP'] ?? '') {
$mountPointPairs = explode(',', $parameters['MP']);
$pagePath = $this->resolveMountPointParameterIntoPageSlug(
$pageId,
$pagePath,
$mountPointPairs,
$pageRepository
);
// If the MountPoint page has a different site, the link needs to be generated
// with the base of the MountPoint page, this is especially relevant for cross-domain linking
// Because the language contains the full base, it is retrieved in this case.
try {
[, $mountPointPage] = explode('-', (string)reset($mountPointPairs));
$site = GeneralUtility::makeInstance(SiteMatcher::class)
->matchByPageId((int)$mountPointPage);
$language = $site->getLanguageById($language->getLanguageId());
} catch (SiteNotFoundException $e) {
// No alternative site found, use the existing one
}
// Store the MP parameter in the page record, so it could be used for any enhancers
$page['MPvar'] = $parameters['MP'];
unset($parameters['MP']);
}
$originalParameters = $parameters;
$collection = new RouteCollection();
$defaultRouteForPage = new Route(
'/' . ltrim($pagePath, '/'),
[],
[],
['utf8' => true, '_page' => $page]
);
$collection->add('default', $defaultRouteForPage);
// cHash is never considered because cHash is built by this very method.
unset($originalParameters['cHash']);
$enhancers = $this->getEnhancersForPage($pageId, $language, $page);
foreach ($enhancers as $enhancer) {
if ($enhancer instanceof RoutingEnhancerInterface) {
$enhancer->enhanceForGeneration($collection, $originalParameters);
}
}
foreach ($enhancers as $enhancer) {
if ($enhancer instanceof DecoratingEnhancerInterface) {
$enhancer->decorateForGeneration($collection, $originalParameters);
}
}
$mappableProcessor = new MappableProcessor();
$requestContext = $this->requestContextFactory->fromSiteLanguage($language);
$generator = new UrlGenerator($collection, $requestContext);
$generator->injectMappableProcessor($mappableProcessor);
// set default route flag after all routes have been processed
$defaultRouteForPage->setOption('_isDefault', true);
$allRoutes = GeneralUtility::makeInstance(RouteSorter::class)
->withRoutes($collection->all())
->withOriginalParameters($originalParameters)
->sortRoutesForGeneration()
->getRoutes();
$matchedRoute = null;
$pageRouteResult = null;
$uri = null;
// map our reference type to symfony's custom paths
$referenceType = $type === static::ABSOLUTE_PATH ? UrlGenerator::ABSOLUTE_PATH : UrlGenerator::ABSOLUTE_URL;
/**
* @var string $routeName
* @var Route $routeCandidate
*/
foreach ($allRoutes as $routeName => $routeCandidate) {
try {
$parameters = $originalParameters;
if ($routeCandidate->hasOption('deflatedParameters')) {
$parameters = $routeCandidate->getOption('deflatedParameters');
}
// skip the route, in case any aspect of it could not be mapped to a value
if ($mappableProcessor->generate($routeCandidate, $parameters) === false) {
continue;
}
// ABSOLUTE_URL is used as default fallback
$urlAsString = $generator->generate($routeName, $parameters, $referenceType);
$uri = new Uri($urlAsString);
/** @var Route $matchedRoute */
$matchedRoute = $collection->get($routeName);
// fetch potential applied defaults for later cHash generation
// (even if not applied in route, it will be exposed during resolving)
$appliedDefaults = $matchedRoute->getOption('_appliedDefaults') ?? [];
parse_str($uri->getQuery(), $remainingQueryParameters);
$enhancer = $routeCandidate->getEnhancer();
if ($enhancer instanceof InflatableEnhancerInterface) {
$remainingQueryParameters = $enhancer->inflateParameters($remainingQueryParameters);
}
$pageRouteResult = $this->buildPageArguments($routeCandidate, array_merge($appliedDefaults, $parameters), $remainingQueryParameters);
break;
} catch (MissingMandatoryParametersException $e) {
// no match
}
}
if (!$uri instanceof UriInterface) {
throw new InvalidRouteArgumentsException('Uri could not be built for page "' . $pageId . '"', 1538390230);
}
if ($pageRouteResult && $pageRouteResult->areDirty()) {
// for generating URLs this should(!) never happen
// if it does happen, generator logic has flaws
throw new InvalidRouteArgumentsException('Route arguments are dirty', 1537613247);
}
if ($matchedRoute && $pageRouteResult && !empty($pageRouteResult->getDynamicArguments())) {
$cacheHash = $this->generateCacheHash($pageId, $pageRouteResult);
$queryArguments = $pageRouteResult->getQueryArguments();
if (!empty($cacheHash)) {
$queryArguments['cHash'] = $cacheHash;
}
$uri = $uri->withQuery(http_build_query($queryArguments, '', '&', PHP_QUERY_RFC3986));
}
if ($fragment) {
$uri = $uri->withFragment($fragment);
}
$event = new AfterPageUriGeneratedEvent($uri, $route, $originalParameters, $fragment, $type, $language, $this->site);
$this->eventDispatcher->dispatch($event);
return $event->getUri();
}
/**
* When a MP parameter is given, the mount point parameter is resolved, and the slug of the new page
* is added while the same parts of the original pagePath is removed (before).
* This way, the subpage to a mounted page has now a different "base" (= prefixed with the slug of the
* mount point).
*
* This is done recursively when multiple mount point parameter pairs
*
* @param string $pagePath the original path of the page
* @param array $mountPointPairs an array with MP pairs (like ['13-3', '4-2'] for recursive mount points)
*/
protected function resolveMountPointParameterIntoPageSlug(
int $pageId,
string $pagePath,
array $mountPointPairs,
PageRepository $pageRepository
): string {
// Handle recursive mount points
$prefixesToRemove = [];
$slugPrefixesToAdd = [];
foreach ($mountPointPairs as $mountPointPair) {
[$mountRoot, $mountedPage] = GeneralUtility::intExplode('-', (string)$mountPointPair);
$mountPageInformation = $pageRepository->getMountPointInfo($mountedPage);
if ($mountPageInformation) {
if ($pageId === $mountedPage) {
continue;
}
// Get slugs in the translated page
$mountedPage = $pageRepository->getPage($mountedPage);
$mountRoot = $pageRepository->getPage($mountRoot);
$slugPrefix = $mountedPage['slug'] ?? '';
if ($slugPrefix === '/') {
$slugPrefix = '';
}
$prefixToRemove = $mountRoot['slug'] ?? '';
if ($prefixToRemove === '/') {
$prefixToRemove = '';
}
$prefixesToRemove[] = $prefixToRemove;
$slugPrefixesToAdd[] = $slugPrefix;
}
}
$slugPrefixesToAdd = array_reverse($slugPrefixesToAdd);
$prefixesToRemove = array_reverse($prefixesToRemove);
foreach ($prefixesToRemove as $prefixToRemove) {
// Slug prefixes are taken from the beginning of the array, where as the parts to be removed
// Are taken from the end.
$replacement = array_shift($slugPrefixesToAdd);
if ($prefixToRemove !== '' && str_starts_with($pagePath, $prefixToRemove)) {
$pagePath = substr($pagePath, strlen($prefixToRemove));
}
$pagePath = $replacement . ($pagePath !== '/' ? '/' . ltrim($pagePath, '/') : '');
}
return $pagePath;
}
/**
* Fetch possible enhancers + aspects based on the current page configuration and the site configuration put
* into "routeEnhancers"
*
* @return EnhancerInterface[]
*/
protected function getEnhancersForPage(int $pageId, SiteLanguage $language, array $page = []): array
{
$enhancers = [];
$resolver = null;
foreach ($this->site->getConfiguration()['routeEnhancers'] ?? [] as $enhancerConfiguration) {
if (is_array($enhancerConfiguration['limitToPages'] ?? null)
&& !$this->matchesPageLimitation($enhancerConfiguration['limitToPages'], $pageId, $page, $language, $resolver)
) {
continue;
}
$enhancerType = $enhancerConfiguration['type'] ?? '';
$enhancer = $this->enhancerFactory->create($enhancerType, $enhancerConfiguration);
if (!empty($enhancerConfiguration['aspects'] ?? null)) {
$aspects = $this->aspectFactory->createAspects(
$enhancerConfiguration['aspects'],
$language,
$this->site
);
$enhancer->setAspects($aspects);
}
$enhancers[] = $enhancer;
}
return $enhancers;
}
/**
* Checks whether the current page matches any of the limitToPages conditions.
* Each entry in the array is OR-combined:
* - Integer values are matched against the page ID (existing behavior)
* - String values are evaluated as Symfony ExpressionLanguage expressions
* with access to the `page`, `site` and `siteLanguage` variables
*/
protected function matchesPageLimitation(array $limitToPages, int $pageId, array $page, SiteLanguage $language, ?Resolver &$resolver): bool
{
foreach ($limitToPages as $limitation) {
if (is_int($limitation)) {
if ($limitation === $pageId) {
return true;
}
continue;
}
if (is_string($limitation) && $limitation !== '') {
if ($page === []) {
continue;
}
$resolver ??= GeneralUtility::makeInstance(
Resolver::class,
'routing',
[
'page' => $page,
'site' => $this->site,
'siteLanguage' => $language,
]
);
try {
if ($resolver->evaluate($limitation)) {
return true;
}
} catch (\Exception) {
continue;
}
}
}
return false;
}
protected function generateCacheHash(int $pageId, PageArguments $arguments): string
{
return $this->cacheHashCalculator->calculateCacheHash(
$this->getCacheHashParameters($pageId, $arguments)
);
}
protected function getCacheHashParameters(int $pageId, PageArguments $arguments): array
{
$hashParameters = $arguments->getDynamicArguments();
$hashParameters['id'] = $pageId;
$uri = http_build_query($hashParameters, '', '&', PHP_QUERY_RFC3986);
return $this->cacheHashCalculator->getRelevantParameters($uri);
}
/**
* Builds route arguments. The important part here is to distinguish between
* static and dynamic arguments. Per default all arguments are dynamic until
* aspects can be used to really consider them as static (= 1:1 mapping between
* route value and resulting arguments).
*
* Besides that, internal arguments (_route, _controller, _custom, ..) have
* to be separated since those values are not meant to be used for later
* processing. Not separating those values might result in invalid cHash.
*
* This method is used during resolving and generation of URLs.
*
* @param Route $route
* @param array $results
* @param array $remainingQueryParameters
*/
protected function buildPageArguments(Route $route, array $results, array $remainingQueryParameters = []): PageArguments
{
// only use parameters that actually have been processed
// (thus stripping internals like _route, _controller, ...)
$routeArguments = $this->filterProcessedParameters($route, $results);
// assert amount of "static" mappers is not too "dynamic"
$this->assertMaximumStaticMappableAmount($route, array_keys($routeArguments));
// delegate result handling to enhancer
$enhancer = $route->getEnhancer();
if ($enhancer instanceof ResultingInterface) {
// forward complete(!) results, not just filtered parameters
return $enhancer->buildResult($route, $results, $remainingQueryParameters);
}
$page = $route->getOption('_page');
if ((int)($page['l10n_parent'] ?? 0) > 0) {
$pageId = (int)$page['l10n_parent'];
} elseif ((int)($page['t3ver_oid'] ?? 0) > 0) {
$pageId = (int)$page['t3ver_oid'];
} else {
$pageId = (int)($page['uid'] ?? 0);
}
$type = $this->resolveType($route, $remainingQueryParameters);
// See PageSlugCandidateProvider where this is added.
if ($page['MPvar'] ?? '') {
$routeArguments['MP'] = $page['MPvar'];
}
return new PageArguments($pageId, $type, $routeArguments, [], $remainingQueryParameters);
}
/**
* Retrieves type from processed route and modifies remaining query parameters.
*
* @param array $remainingQueryParameters reference to remaining query parameters
*/
protected function resolveType(Route $route, array &$remainingQueryParameters): string
{
$type = $remainingQueryParameters['type'] ?? 0;
$decoratedParameters = $route->getOption('_decoratedParameters');
if (isset($decoratedParameters['type'])) {
$type = $decoratedParameters['type'];
unset($decoratedParameters['type']);
$remainingQueryParameters = array_replace_recursive(
$remainingQueryParameters,
$decoratedParameters
);
}
if (is_scalar($type)) {
return (string)$type;
}
return '0';
}
/**
* Asserts that possible amount of items in all static and countable mappers
* (such as StaticRangeMapper) is limited to 10000 in order to avoid
* brute-force scenarios and the risk of cache-flooding.
*
* @throws \OverflowException
* @todo with having `static` route variables, this restriction should be configurable & optional
*/
protected function assertMaximumStaticMappableAmount(Route $route, array $variableNames = [])
{
// empty when only values of route defaults where used
if ($variableNames === []) {
return;
}
$mappers = $route->filterAspects(
[StaticMappableAspectInterface::class, \Countable::class],
$variableNames
);
if ($mappers === []) {
return;
}
$multipliers = array_map(count(...), $mappers);
$product = array_product($multipliers);
if ($product > 10000) {
throw new \OverflowException(
'Possible range of all mappers is larger than 10000 items',
1537696772
);
}
}
/**
* Determine parameters that have been processed.
*/
protected function filterProcessedParameters(Route $route, array $results): array
{
return array_intersect_key(
$results,
array_flip($route->compile()->getPathVariables())
);
}
protected function getSlugCandidateProvider(Context $context): PageSlugCandidateProvider
{
return GeneralUtility::makeInstance(
PageSlugCandidateProvider::class,
$context,
$this->site,
$this->enhancerFactory
);
}
/**
* Request may have been made with default page slug, also we are dealing with a site language variant. To avoid
* duplicate content, we need to revalidate that the eventually matched language route is really the available
* page language variant for the current lange. We do this at this late point to minimize the needed database
* queries instead of checking it for all build page candidates.
*
* This is safe, as we can simply drop the route and having a correct page not found action delivered.
*/
protected function isRouteReallyValidForLanguage(Route $route, SiteLanguage $siteLanguage): bool
{
$page = $route->getOption('_page');
$schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get('pages');
if (!$schema->isLanguageAware()) {
return true;
}
$languageIdField = $schema->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName();
$languageId = (int)($page[$languageIdField] ?? 0);
if ($siteLanguage->getLanguageId() === 0 || $siteLanguage->getLanguageId() === $languageId) {
// default language site request or if page record is same language then siteLanguage, page record
// is valid to use as page resolving candidate and need no further overlay checks.
return true;
}
$pageIdInDefaultLanguage = (int)($languageId > 0 ? $page['l10n_parent'] : $page['uid']);
$pageRepository = GeneralUtility::makeInstance(PageRepository::class, $this->context);
$localizedPage = $pageRepository->getPageOverlay($pageIdInDefaultLanguage, $siteLanguage->getLanguageId());
if (!$localizedPage) {
// no page language overlay found, which means that either language page is not published and no logged
// in backend user OR there is no language overlay for that page at all. Thus using page record to build
// as page resolving candidate is valid.
return true;
}
// we found a valid page overlay, which means that current record is not the valid page for the current
// siteLanguage. To avoid resolving page with multiple slugs for a siteLanguage path, we flag this invalid.
return false;
}
}
@@ -0,0 +1,456 @@
<?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\Routing;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\LanguageAspect;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Routing\Enhancer\DecoratingEnhancerInterface;
use TYPO3\CMS\Core\Routing\Enhancer\EnhancerFactory;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\RootlineUtility;
/**
* Provides possible pages (from the database) that _could_ match a certain URL path,
* but also works for fetching the best "slug" value for multi-lingual pages with a specific language requested.
*
* @internal as this API might change and a possible interface is given at some point.
*/
class PageSlugCandidateProvider
{
protected Site $site;
protected Context $context;
protected EnhancerFactory $enhancerFactory;
public function __construct(Context $context, Site $site, ?EnhancerFactory $enhancerFactory)
{
$this->context = $context;
$this->site = $site;
$this->enhancerFactory = $enhancerFactory ?? GeneralUtility::makeInstance(EnhancerFactory::class);
}
/**
* Fetches an array of possible URLs that match the current site + language (incl. fallbacks)
*
* @return array<int,array<string,mixed>>
*/
public function getCandidatesForPath(string $urlPath, SiteLanguage $language): array
{
$slugCandidates = $this->getCandidateSlugsFromRoutePath($urlPath ?: '/');
$pageCandidates = [];
$languages = [$language->getLanguageId()];
if (!empty($language->getFallbackLanguageIds())) {
$languages = array_merge($languages, $language->getFallbackLanguageIds());
}
// Iterate all defined languages in their configured order to get matching page candidates somewhere in the language fallback chain
foreach ($languages as $languageId) {
$pageCandidatesFromSlugsAndLanguage = $this->getPagesFromDatabaseForCandidates($slugCandidates, $languageId);
// Determine whether fetched page candidates qualify for the request. The incoming URL is checked against all
// pages found for the current URL and language.
foreach ($pageCandidatesFromSlugsAndLanguage as $candidate) {
$slugCandidate = '/' . trim($candidate['slug'], '/');
if ($slugCandidate === '/' || str_starts_with($urlPath, $slugCandidate)) {
// The slug is a subpart of the requested URL, so it's a possible candidate
if ($urlPath === $slugCandidate) {
// The requested URL matches exactly the found slug. We can't find a better match,
// so use that page candidate and stop any further querying.
$pageCandidates = [$candidate];
break 2;
}
$pageCandidates[] = $candidate;
}
}
}
return $pageCandidates;
}
/**
* Fetches the page without any language or other hidden/enable fields, but only takes
* "deleted" and "workspace" into account, as all other things will be evaluated later.
*
* This is only needed for resolving the ACTUAL Page Id when index.php?id=13 was given
*
* Should be rebuilt to return the actual Page ID considering the online ID of the page.
*
* @param int $pageId
*/
public function getRealPageIdForPageIdAsPossibleCandidate(int $pageId): ?int
{
$workspaceId = (int)$this->context->getPropertyFromAspect('workspace', 'id');
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('pages');
$queryBuilder
->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $workspaceId));
$statement = $queryBuilder
->select('uid', 'l10n_parent')
->from('pages')
->where(
$queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)
)
)
->executeQuery();
$page = $statement->fetchAssociative();
if (empty($page)) {
return null;
}
return (int)($page['l10n_parent'] ?: $page['uid']);
}
/**
* Gets all patterns that can be used to redecorate (undecorate) a
* potential previously decorated route path.
*
* @return string regular expression pattern capable of redecorating
*/
protected function getRoutePathRedecorationPattern(): string
{
$decoratingEnhancers = $this->getDecoratingEnhancers();
if (empty($decoratingEnhancers)) {
return '';
}
$redecorationPatterns = array_map(
static function (DecoratingEnhancerInterface $decorationEnhancers) {
$pattern = $decorationEnhancers->getRoutePathRedecorationPattern();
return '(?:' . $pattern . ')';
},
$decoratingEnhancers
);
return '(?P<decoration>' . implode('|', $redecorationPatterns) . ')';
}
/**
* Resolves decorating enhancers without having aspects assigned. These
* instances are used to pre-process URL path and MUST NOT be used for
* actually resolving or generating URL parameters.
*
* @return DecoratingEnhancerInterface[]
*/
protected function getDecoratingEnhancers(): array
{
$enhancers = [];
foreach ($this->site->getConfiguration()['routeEnhancers'] ?? [] as $enhancerConfiguration) {
$enhancerType = $enhancerConfiguration['type'] ?? '';
$enhancer = $this->enhancerFactory->create($enhancerType, $enhancerConfiguration);
if ($enhancer instanceof DecoratingEnhancerInterface) {
$enhancers[] = $enhancer;
}
}
return $enhancers;
}
/**
* Check for records in the database which matches one of the slug candidates.
*
* @param array $excludeUids when called recursively this is the mountpoint parameter of the original prefix
* @return array[]|array
* @throws SiteNotFoundException
*/
protected function getPagesFromDatabaseForCandidates(array $slugCandidates, int $languageId, array $excludeUids = []): array
{
$workspaceId = (int)$this->context->getPropertyFromAspect('workspace', 'id');
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('pages');
$queryBuilder
->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $workspaceId, true));
$statement = $queryBuilder
->select('*')
->from('pages')
->where(
$queryBuilder->expr()->eq(
'sys_language_uid',
$queryBuilder->createNamedParameter($languageId, Connection::PARAM_INT)
),
$queryBuilder->expr()->in(
'slug',
$queryBuilder->createNamedParameter(
$slugCandidates,
Connection::PARAM_STR_ARRAY
)
)
)
// Exact match will be first, that's important
->orderBy('slug', 'desc')
// versioned records should be rendered before the live records
->addOrderBy('t3ver_wsid', 'desc')
// Sort pages that are not MountPoint pages before mount points
->addOrderBy('mount_pid_ol', 'asc')
->addOrderBy('mount_pid', 'asc')
->executeQuery();
$pages = [];
$siteFinder = GeneralUtility::makeInstance(SiteFinder::class);
$pageRepository = GeneralUtility::makeInstance(PageRepository::class, $this->context);
$isRecursiveCall = !empty($excludeUids);
while ($row = $statement->fetchAssociative()) {
$mountPageInformation = null;
$pageIdInDefaultLanguage = (int)($languageId > 0 ? $row['l10n_parent'] : ($row['t3ver_oid'] ?: $row['uid']));
// When this page was added before via recursion, this page should be skipped
if (in_array($pageIdInDefaultLanguage, $excludeUids, true)) {
continue;
}
try {
$isOnSameSite = $siteFinder->getSiteByPageId($pageIdInDefaultLanguage)->getRootPageId() === $this->site->getRootPageId();
} catch (SiteNotFoundException $e) {
// Page is not in a site, so it's not considered
$isOnSameSite = false;
}
// If a MountPoint is found on the current site, and it hasn't been added yet by some other iteration
// (see below "findPageCandidatesOfMountPoint"), then let's resolve the MountPoint information now
if (!$isOnSameSite && $isRecursiveCall) {
// Not in the same site, and called recursive, should be skipped
continue;
}
$mountPageInformation = $pageRepository->getMountPointInfo($pageIdInDefaultLanguage, $row);
// Mount Point Pages which are not on the same site (when not called on the first level) should be skipped
// As they just clutter up the queries.
if (!$isOnSameSite && !$isRecursiveCall && $mountPageInformation) {
continue;
}
$mountedPage = null;
if (is_array($mountPageInformation)) {
// Add the MPvar to the row, so it can be used later-on in the PageRouter / PageArguments
$row['MPvar'] = $mountPageInformation['MPvar'];
$mountedPage = $pageRepository->getPage_noCheck((int)$mountPageInformation['mount_pid_rec']['uid']);
// Ensure to fetch the slug in the translated page
$mountedPage = $pageRepository->getLanguageOverlay('pages', $mountedPage, new LanguageAspect($languageId, $languageId));
// Mount wasn't connected properly, so it is skipped
if (!$mountedPage) {
continue;
}
// If the page is a MountPoint which should be overlaid with the contents of the mounted page,
// it must never be accessible directly, but only in the MountPoint context. Therefore we change
// the current ID and slug.
// This needs to happen before the regular case, as the $pageToAdd contains the MPvar information
if ((int)$row['doktype'] === PageRepository::DOKTYPE_MOUNTPOINT && $row['mount_pid_ol']) {
// If the mounted page was already added from above, this should not be added again (to include
// the mount point parameter).
if (in_array((int)$mountedPage['uid'], $excludeUids, true)) {
continue;
}
$pageToAdd = $mountedPage;
// Make sure target page "/about-us" is replaced by "/global-site/about-us" so router works
$pageToAdd['MPvar'] = $mountPageInformation['MPvar'];
$pageToAdd['slug'] = $row['slug'];
$pages[] = $pageToAdd;
$excludeUids[] = (int)$pageToAdd['uid'];
$excludeUids[] = $pageIdInDefaultLanguage;
}
}
// This is the regular "non-MountPoint page" case (must happen after the if condition so MountPoint
// pages that have been replaced by the Mounted Page will not be added again.
if ($isOnSameSite && !in_array($pageIdInDefaultLanguage, $excludeUids, true)) {
$pages[] = $row;
$excludeUids[] = $pageIdInDefaultLanguage;
}
// Add possible sub-pages prepended with the MountPoint page slug
if (is_array($mountPageInformation)) {
/** @var array $mountedPage */
$siteOfMountedPage = $siteFinder->getSiteByPageId((int)$mountedPage['uid']);
$morePageCandidates = $this->findPageCandidatesOfMountPoint(
$row,
$mountedPage,
$siteOfMountedPage,
$languageId,
$slugCandidates
);
foreach ($morePageCandidates as $candidate) {
// When called previously this MountPoint page should be skipped
if (in_array((int)$candidate['uid'], $excludeUids, true)) {
continue;
}
$pages[] = $candidate;
}
}
}
return $pages;
}
/**
* Check if the page candidate is a mount point, if so, we need to
* re-start the slug candidates procedure with the mount point as a prefix (= context of the subpage).
*
* Before doing the slugCandidates are adapted to remove the slug of the mount point (actively moving the pointer
* of the path to strip away the existing prefix), then checking for more pages.
*
* Once possible candidates are found, the slug prefix needs to be re-added so the PageRouter finds the page,
* with an additional 'MPvar' attribute.
* However, all page candidates needs to be checked if they are connected in the proper mount page.
*
* @param array $mountPointPage the page with doktype=7
* @param array $mountedPage the target page where the mountpoint is pointing to
* @param Site $siteOfMountedPage the site of the target page, which could be different from the current page
* @param int $languageId the current language id
* @param array $slugCandidates the existing slug candidates that were looked for previously
* @return array more candidates
*/
protected function findPageCandidatesOfMountPoint(
array $mountPointPage,
array $mountedPage,
Site $siteOfMountedPage,
int $languageId,
array $slugCandidates
): array {
$pages = [];
$slugOfMountPoint = $mountPointPage['slug'] ?? '';
$commonSlugPrefixOfMountedPage = rtrim($mountedPage['slug'] ?? '', '/');
$narrowedDownSlugPrefixes = [];
foreach ($slugCandidates as $slugCandidate) {
// Remove the mount point prefix (that we just found) from the slug candidates
if (str_starts_with($slugCandidate, $slugOfMountPoint)) {
// Find pages without the common prefix
$narrowedDownSlugPrefix = '/' . trim(substr($slugCandidate, strlen($slugOfMountPoint)), '/');
$narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix;
$narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix . '/';
// Find pages with the prefix of the mounted page as well
if ($commonSlugPrefixOfMountedPage) {
$narrowedDownSlugPrefix = $commonSlugPrefixOfMountedPage . $narrowedDownSlugPrefix;
$narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix;
$narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix . '/';
}
}
}
$trimmedSlugPrefixes = [];
$narrowedDownSlugPrefixes = array_unique($narrowedDownSlugPrefixes);
foreach ($narrowedDownSlugPrefixes as $narrowedDownSlugPrefix) {
$narrowedDownSlugPrefix = trim($narrowedDownSlugPrefix, '/');
$trimmedSlugPrefixes[] = '/' . $narrowedDownSlugPrefix;
if (!empty($narrowedDownSlugPrefix)) {
$trimmedSlugPrefixes[] = '/' . $narrowedDownSlugPrefix . '/';
}
}
$trimmedSlugPrefixes = array_unique($trimmedSlugPrefixes);
rsort($trimmedSlugPrefixes);
$slugProviderForMountPage = GeneralUtility::makeInstance(static::class, $this->context, $siteOfMountedPage, $this->enhancerFactory);
// Find the right pages for which have been matched
$excludedPageIds = [(int)$mountPointPage['uid']];
$pageCandidates = $slugProviderForMountPage->getPagesFromDatabaseForCandidates(
$trimmedSlugPrefixes,
$languageId,
$excludedPageIds
);
// The rootline is built with page IDs of the default language, so the mount point page ID
// of the default language must be used for the connection check below
$mountPointPageIdInDefaultLanguage = (int)($languageId > 0 ? $mountPointPage['l10n_parent'] : ($mountPointPage['t3ver_oid'] ?: $mountPointPage['uid']));
// Depending on the "mount_pid_ol" parameter, the mountedPage or the mounted page is in the rootline
$pageWhichMustBeInRootLine = (int)($mountPointPage['mount_pid_ol'] ? $mountedPage['uid'] : $mountPointPageIdInDefaultLanguage);
foreach ($pageCandidates as $pageCandidate) {
if (!$pageCandidate['mount_pid_ol']) {
$pageCandidate['MPvar'] = !empty($pageCandidate['MPvar'])
? $mountPointPage['MPvar'] . ',' . $pageCandidate['MPvar']
: $mountPointPage['MPvar'];
}
// In order to avoid the possibility that any random page like /about-us which is not connected to the mount
// point is not possible to be called via /my-mount-point/about-us, let's check the
$pageCandidateIsConnectedInMountPoint = false;
$rootLine = GeneralUtility::makeInstance(
RootlineUtility::class,
$pageCandidate['uid'],
(string)($pageCandidate['MPvar'] ?? $pageCandidate['mount_pid_ol']),
$this->context
)->get();
foreach ($rootLine as $pageInRootLine) {
if ((int)$pageInRootLine['uid'] === $pageWhichMustBeInRootLine) {
$pageCandidateIsConnectedInMountPoint = true;
break;
}
}
if ($pageCandidateIsConnectedInMountPoint === false) {
continue;
}
// Rewrite the slug of the subpage to match the PageRouter matching again
// This is done by first removing the "common" prefix possibly provided by the Mounted Page
// But more importantly adding the $slugOfMountPoint of the MountPoint Page
$slugOfSubpage = $pageCandidate['slug'];
if ($commonSlugPrefixOfMountedPage && str_starts_with($slugOfSubpage, $commonSlugPrefixOfMountedPage)) {
$slugOfSubpage = substr($slugOfSubpage, strlen($commonSlugPrefixOfMountedPage));
}
$pageCandidate['slug'] = $slugOfMountPoint . (($slugOfSubpage && $slugOfSubpage !== '/') ? '/' . trim($slugOfSubpage, '/') : '');
$pages[] = $pageCandidate;
}
return $pages;
}
/**
* Returns possible URL parts for a string like /home/about-us/offices/ or /home/about-us/offices.json
* to return.
*
* /home/about-us/offices/
* /home/about-us/offices.json
* /home/about-us/offices
* /home/about-us/
* /home/about-us
* /home/
* /home
* /
*
* @param string $routePath
* @return string[]
*/
protected function getCandidateSlugsFromRoutePath(string $routePath): array
{
$redecorationPattern = $this->getRoutePathRedecorationPattern();
if (!empty($redecorationPattern) && preg_match('#' . $redecorationPattern . '#', $routePath, $matches)) {
$decoration = $matches['decoration'];
$decorationPattern = preg_quote($decoration, '#');
$routePath = preg_replace('#' . $decorationPattern . '$#', '', $routePath) ?? '';
}
$candidatePathParts = [];
$pathParts = GeneralUtility::trimExplode('/', $routePath, true);
if (empty($pathParts)) {
return ['/'];
}
while (!empty($pathParts)) {
$prefix = '/' . implode('/', $pathParts);
$candidatePathParts[] = $prefix . '/';
$candidatePathParts[] = $prefix;
array_pop($pathParts);
}
$candidatePathParts[] = '/';
return $candidatePathParts;
}
}
+160
View File
@@ -0,0 +1,160 @@
<?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\Routing;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use TYPO3\CMS\Core\Routing\Aspect\MappableProcessor;
/**
* Internal class, which is similar to Symfony's Urlmatcher but without validating
* - conditions / expression language
* - host matches
* - method checks
* because this method only works in conjunction with PageRouter.
*
* @internal
*/
class PageUriMatcher
{
/**
* @var RouteCollection<string, Route>
*/
protected $routes;
/**
* @var MappableProcessor
*/
protected $mappableProcessor;
/**
* @param RouteCollection<string, Route> $routes
*/
public function __construct(RouteCollection $routes)
{
$this->routes = $routes;
$this->mappableProcessor = new MappableProcessor();
}
/**
* Matches a path segment against the route collection
*
* @return array
* @throws ResourceNotFoundException
*/
public function match(string $urlPath)
{
if ($ret = $this->matchCollection(rawurldecode($urlPath), $this->routes)) {
return $ret;
}
throw new ResourceNotFoundException(
sprintf('No routes found for "%s".', $urlPath),
1538156220
);
}
/**
* Tries to match a URL with a set of routes.
*
* @param string $urlPath The path info to be parsed
* @param RouteCollection<string,Route> $routes The set of routes
* @return array An array of parameters
*/
protected function matchCollection(string $urlPath, RouteCollection $routes): ?array
{
foreach ($routes as $name => $route) {
$urlPath = $this->getDecoratedRoutePath($route) ?? $urlPath;
$compiledRoute = $route->compile();
// check the static prefix of the URL first. Only use the more expensive preg_match when it matches
if ($compiledRoute->getStaticPrefix() !== '' && !str_starts_with($urlPath, $compiledRoute->getStaticPrefix())) {
continue;
}
if (!preg_match($compiledRoute->getRegex(), $urlPath, $matches)) {
continue;
}
// custom handling of Mappable instances
if (!$this->mappableProcessor->resolve($route, $matches)) {
continue;
}
return $this->getAttributes($route, $name, $matches);
}
return null;
}
/**
* Resolves an optional route specific decorated route path that has been
* assigned by DecoratingEnhancerInterface instances.
*/
protected function getDecoratedRoutePath(Route $route): ?string
{
if (!$route->hasOption('_decoratedRoutePath')) {
return null;
}
$urlPath = $route->getOption('_decoratedRoutePath');
return rawurldecode($urlPath);
}
/**
* Returns an array of values to use as request attributes.
*
* As this method requires the Route object, it is not available
* in matchers that do not have access to the matched Route instance
* (like the PHP and Apache matcher dumpers).
*
* @param Route $route The route we are matching against
* @param string $name The name of the route
* @param array $attributes An array of attributes from the matcher
* @return array An array of parameters
*/
protected function getAttributes(Route $route, string $name, array $attributes): array
{
$defaults = $route->getDefaults();
if (isset($defaults['_canonical_route'])) {
$name = $defaults['_canonical_route'];
unset($defaults['_canonical_route']);
}
$attributes['_route'] = $name;
// store applied default values in route options
$relevantDefaults = array_intersect_key($defaults, array_flip($route->compile()->getPathVariables()));
// option '_appliedDefaults' contains internal(!) values (default values are not mapped when resolving)
// (keys used are deflated and need to be inflated later using VariableProcessor)
$route->setOption('_appliedDefaults', array_diff_key($relevantDefaults, $attributes));
// side note: $defaults can contain e.g. '_controller'
return $this->mergeDefaults($attributes, $defaults);
}
/**
* Get merged default parameters.
*
* @param array $params The parameters
* @param array $defaults The defaults
* @return array Merged default parameters
*/
protected function mergeDefaults(array $params, array $defaults): array
{
foreach ($params as $key => $value) {
if (!is_int($key) && $value !== null) {
$defaults[$key] = $value;
}
}
return $defaults;
}
}
+77
View File
@@ -0,0 +1,77 @@
<?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\Routing;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\Routing\RequestContext;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
/**
* @internal this is not part of the TYPO3 Core public API, as it serves as an internal
* bridge between symfony/routing component and PSR-7 requests
*/
#[Autoconfigure(public: true)]
readonly class RequestContextFactory
{
public function __construct(
protected BackendEntryPointResolver $backendEntryPointResolver
) {}
public function fromBackendRequest(ServerRequestInterface $request): RequestContext
{
$scheme = $request->getUri()->getScheme();
return new RequestContext(
$this->backendEntryPointResolver->getPathFromRequest($request),
$request->getMethod(),
$request->getUri()->getHost() ? (string)idn_to_ascii($request->getUri()->getHost()) : '',
$request->getUri()->getScheme(),
$scheme === 'http' ? $request->getUri()->getPort() ?? 80 : 80,
$scheme === 'https' ? $request->getUri()->getPort() ?? 443 : 443,
);
}
public function fromUri(UriInterface $uri, string $method = 'GET'): RequestContext
{
return new RequestContext(
'',
$method,
(string)idn_to_ascii($uri->getHost()),
$uri->getScheme(),
// Ports are only necessary for URL generation in Symfony which is not used by TYPO3
80,
443,
$uri->getPath()
);
}
public function fromSiteLanguage(SiteLanguage $language): RequestContext
{
$scheme = $language->getBase()->getScheme();
return new RequestContext(
// page segment (slug & enhanced part) is supposed to start with '/'
rtrim($language->getBase()->getPath(), '/'),
'GET',
$language->getBase()->getHost(),
$scheme ?: 'https',
$scheme === 'http' ? $language->getBase()->getPort() ?? 80 : 80,
$scheme === 'https' ? $language->getBase()->getPort() ?? 443 : 443
);
}
}
+187
View File
@@ -0,0 +1,187 @@
<?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\Routing;
use Symfony\Component\Routing\CompiledRoute;
use Symfony\Component\Routing\Route as SymfonyRoute;
use TYPO3\CMS\Core\Routing\Aspect\AspectInterface;
use TYPO3\CMS\Core\Routing\Enhancer\EnhancerInterface;
/**
* TYPO3's route is built on top of Symfony's route with some special handling
* of "Aspects" built on top of a route
*
* @internal as this is tightly coupled to Symfony's Routing and we try to encapsulate this, please note that this might change if we change the under-the-hood implementation.
*/
class Route extends SymfonyRoute
{
/**
* @var CompiledRoute|null
*/
protected $compiled;
/**
* @var array<string, AspectInterface>
*/
protected $aspects = [];
public function __construct(
string $path,
array $defaults = [],
array $requirements = [],
array $options = [],
?string $host = '',
$schemes = [],
$methods = [],
?string $condition = '',
array $aspects = []
) {
parent::__construct($path, $defaults, $requirements, $options, $host, $schemes, $methods, $condition);
$this->setAspects($aspects);
}
/**
* @todo '_arguments' are added implicitly, make it explicit in enhancers
*/
public function getArguments(): array
{
return $this->getOption('_arguments') ?? [];
}
public function getEnhancer(): ?EnhancerInterface
{
return $this->getOption('_enhancer') ?? null;
}
/**
* @return array<string, AspectInterface>
*/
public function getAspects(): array
{
return $this->aspects;
}
/**
* Sets the aspects and removes existing ones.
*
* This method implements a fluent interface.
*
* @param array<string, AspectInterface> $aspects
* @return $this
*/
public function setAspects(array $aspects): self
{
$this->aspects = [];
return $this->addAspects($aspects);
}
/**
* Adds aspects to the existing maps.
*
* This method implements a fluent interface.
*
* @param array<string, AspectInterface> $aspects
* @return $this
*/
public function addAspects(array $aspects): self
{
foreach ($aspects as $key => $aspect) {
if (isset($this->aspects[$key])) {
throw new \OverflowException(
sprintf('Cannot override aspect %s', $key),
1538326791
);
}
$this->aspects[$key] = $aspect;
}
$this->compiled = null;
return $this;
}
/**
* Returns the aspect for the given key.
*
* @param string $key The key
* @return AspectInterface|null The regex or null when not given
*/
public function getAspect(string $key): ?AspectInterface
{
return $this->aspects[$key] ?? null;
}
/**
* Checks if an aspect is set for the given key.
*
* @param string $key A variable name
* @return bool true if an aspect is specified, false otherwise
*/
public function hasAspect(string $key): bool
{
return array_key_exists($key, $this->aspects);
}
/**
* Sets an aspect for the given key.
*
* @param string $key The key
* @return $this
*/
public function setAspect(string $key, AspectInterface $aspect): self
{
$this->aspects[$key] = $aspect;
$this->compiled = null;
return $this;
}
/**
* @param string[] $classNames All (logical AND) class names that must match
* (including interfaces, abstract classes and traits)
* @param string[] $variableNames Variable names to be filtered
* @return AspectInterface[]
*/
public function filterAspects(array $classNames, array $variableNames = []): array
{
$aspects = $this->aspects;
if (empty($classNames) && empty($variableNames)) {
return $aspects;
}
if (!empty($variableNames)) {
$aspects = array_filter(
$this->aspects,
static function (string $variableName) use ($variableNames) {
return in_array($variableName, $variableNames, true);
},
ARRAY_FILTER_USE_KEY
);
}
return array_filter(
$aspects,
static function (AspectInterface $aspect) use ($classNames) {
$uses = class_uses($aspect) ?: [];
foreach ($classNames as $className) {
if (!is_a($aspect, $className)
&& !in_array($className, $uses, true)
) {
return false;
}
}
return true;
}
);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?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\Routing;
use Symfony\Component\Routing\Route as SymfonyRoute;
use Symfony\Component\Routing\RouteCollection as SymfonyRouteCollection;
use TYPO3\CMS\Backend\Routing\Route as Typo3Route;
/**
* Extensible container based on Symfony's Route Collection
*
* @internal as this is tightly coupled to Symfony's Routing and we try to encapsulate this, please note that this might change
*/
class RouteCollection extends SymfonyRouteCollection
{
public function add(string $name, Typo3Route|SymfonyRoute $route, int $priority = 0): void
{
if ($route instanceof Typo3Route) {
$symfonyRoute = new SymfonyRoute($route->getPath(), [], [], $route->getOptions());
$symfonyRoute->setMethods($route->getMethods());
parent::add($name, $symfonyRoute, $priority);
} else {
parent::add($name, $route, $priority);
}
}
}
@@ -0,0 +1,25 @@
<?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\Routing;
use TYPO3\CMS\Core\Exception;
/**
* Exception thrown when a route does not exist
*/
class RouteNotFoundException extends Exception {}
+23
View File
@@ -0,0 +1,23 @@
<?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\Routing;
/**
* An object that is usually returned by a Router to contain all results.
*/
interface RouteResultInterface extends \ArrayAccess {}
+229
View File
@@ -0,0 +1,229 @@
<?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\Routing;
/**
* Pre-processing of given routes based on their actual disposal concerning given parameters.
* @internal as this is tightly coupled to Symfony's Routing and we try to encapsulate this, please note that this might change
*/
class RouteSorter
{
protected const EARLIER = -1;
protected const LATER = 1;
/**
* @var Route[]
*/
protected array $routes = [];
/**
* @var array<string, string>
*/
protected array $originalParameters = [];
/**
* @return Route[]
*/
public function getRoutes(): array
{
return $this->routes;
}
public function withRoutes(array $routes): self
{
$target = clone $this;
$target->routes = $routes;
return $target;
}
public function withOriginalParameters(array $originalParameters): self
{
$target = clone $this;
$target->originalParameters = $originalParameters;
return $target;
}
public function sortRoutesForGeneration(): self
{
uasort($this->routes, [$this, 'compareForGeneration']);
return $this;
}
protected function compareForGeneration(Route $self, Route $other): int
{
// default routes (e.g `/my-page`) -> process later
return $this->compareDefaultRoutes($self, $other, self::LATER)
// no variables (e.g. `/my-page/list`) -> process later
?? $this->compareStaticRoutes($self, $other, self::LATER)
// all variables complete -> process earlier
?? $this->compareAllVariablesPresence($self, $other, self::EARLIER)
// mandatory variables complete -> process earlier
?? $this->compareMandatoryVariablesPresence($self, $other, self::EARLIER)
// more missing variable defaults -> process later
?? $this->compareMissingDefaultsAmount($self, $other, self::LATER)
// more variable defaults -> process later
?? $this->compareDefaultsAmount($self, $other, self::LATER)
// hm, dunno -> keep position
?? 0;
}
protected function compareDefaultRoutes(Route $self, Route $other, int $action = self::LATER): ?int
{
$selfIsDefaultRoute = (bool)$self->getOption('_isDefault');
$otherIsDefaultRoute = (bool)$other->getOption('_isDefault');
// both are default routes, keep order
if ($selfIsDefaultRoute && $otherIsDefaultRoute) {
return 0;
}
// $self is default route, sort $self after $other
if ($selfIsDefaultRoute) {
return $action;
}
// $other is default route, sort $self before $other
if ($otherIsDefaultRoute) {
return -$action;
}
return null;
}
protected function compareStaticRoutes(Route $self, Route $other, int $action = self::LATER): ?int
{
$selfVariableNames = $self->compile()->getPathVariables();
$otherVariableNames = $other->compile()->getPathVariables();
if ($selfVariableNames === [] && $otherVariableNames === []) {
return 0;
}
if ($selfVariableNames === []) {
return $action;
}
if ($otherVariableNames === []) {
return -$action;
}
return null;
}
protected function compareAllVariablesPresence(Route $self, Route $other, int $action = self::EARLIER): ?int
{
$selfVariables = $this->getAllRouteVariables($self);
$otherVariables = $this->getAllRouteVariables($other);
$missingSelfVariables = array_diff_key(
$selfVariables,
$this->getRouteParameters($self)
);
$missingOtherVariables = array_diff_key(
$otherVariables,
$this->getRouteParameters($other)
);
if ($missingSelfVariables === [] && $missingOtherVariables === []) {
$difference = count($selfVariables) - count($otherVariables);
return $difference * $action;
}
if ($missingSelfVariables === [] && $missingOtherVariables !== []) {
return $action;
}
if ($missingOtherVariables === []) {
return -$action;
}
return null;
}
protected function compareMandatoryVariablesPresence(Route $self, Route $other, int $action = self::EARLIER): ?int
{
$missingSelfVariables = array_diff_key(
$this->getMandatoryRouteVariables($self),
$this->getRouteParameters($self)
);
$missingOtherVariables = array_diff_key(
$this->getMandatoryRouteVariables($other),
$this->getRouteParameters($other)
);
if ($missingSelfVariables === [] && $missingOtherVariables !== []) {
return $action;
}
if ($missingSelfVariables !== [] && $missingOtherVariables === []) {
return -$action;
}
return null;
}
protected function compareMissingDefaultsAmount(Route $self, Route $other, int $action = self::LATER): ?int
{
$missingSelfDefaults = array_diff_key(
$this->getActualRouteDefaults($self),
$this->getRouteParameters($self)
);
$missingOtherDefaults = array_diff_key(
$this->getActualRouteDefaults($other),
$this->getRouteParameters($other)
);
$difference = count($missingSelfDefaults) - count($missingOtherDefaults);
// return `null` in case of equality (`0`)
return $difference === 0 ? null : $difference * $action;
}
protected function compareDefaultsAmount(Route $self, Route $other, int $action = self::LATER): ?int
{
$selfDefaults = $this->getActualRouteDefaults($self);
$otherDefaults = $this->getActualRouteDefaults($other);
$difference = count($selfDefaults) - count($otherDefaults);
// return `null` in case of equality (`0`)
return $difference === 0 ? null : $difference * $action;
}
/**
* Filters route variable defaults that are actually used in route path.
*
* @return array<string, string>
*/
protected function getActualRouteDefaults(Route $route): array
{
return array_intersect_key(
$route->getDefaults(),
array_flip($route->compile()->getPathVariables())
);
}
/**
* @return array<string, int>
*/
protected function getAllRouteVariables(Route $route): array
{
return array_flip($route->compile()->getPathVariables());
}
/**
* @return array<string, int>
*/
protected function getMandatoryRouteVariables(Route $route): array
{
return array_diff_key(
$this->getAllRouteVariables($route),
$route->getDefaults()
);
}
/**
* @return array<string, string>
*/
protected function getRouteParameters(Route $route): array
{
// $originalParameters is used as fallback
// (custom enhancers should have processed and deflated parameters)
return $route->getOption('deflatedParameters') ?? $this->originalParameters;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?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\Routing;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Core\Domain\RecordInterface;
/**
* Base Router to be used all over the TYPO3 Core. Its base lies around PSR-7 requests + URIs, and special "RouteResult"
* objects.
*/
interface RouterInterface
{
/**
* Generates an absolute URL
*/
public const ABSOLUTE_URL = 'url';
/**
* Generates an absolute path
*/
public const ABSOLUTE_PATH = 'absolute';
/**
* @param RouteResultInterface|null $previousResult
* @throws RouteNotFoundException
*/
public function matchRequest(ServerRequestInterface $request, ?RouteResultInterface $previousResult = null): RouteResultInterface;
/**
* Builds a URI based on the $route and the given parameters.
*
* @param string|array|int|\ArrayAccess|RecordInterface $route either the route name, or for pages it is usually the array of a page record, or the page ID
* @param array $parameters query parameters, specially reserved parameters are usually prefixed with "_"
* @param string $fragment the section/fragment www.example.com/page/#fragment, WITHOUT the hash
* @param string $type see the constants above.
* @throws InvalidRouteArgumentsException
*/
public function generateUri($route, array $parameters = [], string $fragment = '', string $type = self::ABSOLUTE_URL): UriInterface;
}
+275
View File
@@ -0,0 +1,275 @@
<?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\Routing;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\Routing\Exception\NoConfigurationException;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Configuration\Features;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Site\Entity\NullSite;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Utility\RootlineUtility;
/**
* Returns a site based on a given request.
*
* The main usage is the ->matchRequest() functionality, which receives a request object and boots up
* Symfony Routing to find the proper route with its defaults / attributes.
*
* On top, this is also commonly used throughout TYPO3 to fetch a site by a given pageId.
* ->matchPageId().
*
* The concept of the SiteMatcher is to *resolve*, and not build URIs. On top, it is a facade to hide the
* dependency to symfony and to not expose its logic.
*
* @internal Please note that the site matcher will be probably cease to exist and adapted to the SiteFinder concept when Pseudo-Site handling will be removed.
*/
readonly class SiteMatcher implements SingletonInterface
{
public function __construct(
protected Features $features,
protected SiteFinder $finder,
protected RequestContextFactory $requestContextFactory
) {}
/**
* Only used when a page is moved but the pseudo site caches has this information hard-coded, so the caches
* need to be flushed.
*
* @internal
* @throws \TYPO3\CMS\Core\Cache\Exception\NoSuchCacheException
*/
public function refresh()
{
/** Ensure root line caches are flushed */
$cacheManager = GeneralUtility::makeInstance(CacheManager::class);
$cacheManager->getCache('runtime')->flushByTag(RootlineUtility::RUNTIME_CACHE_TAG);
$cacheManager->getCache('rootline')->flush();
}
/**
* First, it is checked, if a "id" GET/POST parameter is found.
* If it is, we check for a valid site mounted there.
*
* If it isn't the quest continues by validating the whole request URL and validating against
* all available site records (and their language prefixes).
*
* @param ServerRequestInterface $request
*/
public function matchRequest(ServerRequestInterface $request): RouteResultInterface
{
// Remove script file name (index.php) from request uri
$uri = $this->canonicalizeUri($request->getUri(), $request);
$pageId = $this->resolvePageIdQueryParam($request);
$languageId = $this->resolveLanguageIdQueryParam($request);
$routeResult = $this->matchSiteByUri($uri, $request);
// Allow insecure pageId based site resolution if explicitly enabled and only if both, ?id= and ?L= are defined
// (pageId based site resolution without L parameter has always been prohibited, so we do not support that)
if (
$this->features->isFeatureEnabled('security.frontend.allowInsecureSiteResolutionByQueryParameters')
&& $pageId !== null && $languageId !== null
) {
return $this->matchSiteByQueryParams($pageId, $languageId, $routeResult, $uri);
}
// Allow the default language to be resolved in case all languages use a prefix
// and therefore did not match based on path if an explicit pageId is given,
// (example "https://www.example.com/?id=.." was entered, but all languages have "https://www.example.com/lang-key/")
// @todo remove this fallback, in order for SiteBaseRedirectResolver to produce a redirect instead (requires functionals to be adapted)
if ($pageId !== null && $routeResult->getLanguage() === null) {
$routeResult = $routeResult->withLanguage($routeResult->getSite()->getDefaultLanguage());
}
// adjust the language aspect if it was given by query param `&L` (and ?id is given)
// @todo remove, this is added for backwards (and functional tests) compatibility reasons
if ($languageId !== null && $pageId !== null) {
try {
// override/set language by `&L=` query param
$routeResult = $routeResult->withLanguage($routeResult->getSite()->getLanguageById($languageId));
} catch (\InvalidArgumentException) {
// ignore; language id not available
}
}
return $routeResult;
}
/**
* If a given page ID is handed in, a Site/NullSite is returned.
*
* @param int $pageId uid of a page in default language
* @param array|null $rootLine an alternative root line, if already at and.
*/
public function matchByPageId(int $pageId, ?array $rootLine = null): SiteInterface
{
try {
return $this->finder->getSiteByPageId($pageId, $rootLine);
} catch (SiteNotFoundException) {
return new NullSite();
}
}
/**
* Returns a Symfony RouteCollection containing all routes to all sites.
*/
protected function getRouteCollectionForAllSites(): RouteCollection
{
$collection = new RouteCollection();
foreach ($this->finder->getAllSites() as $site) {
// Add the site as entrypoint
// @todo Find a way to test only this basic route against chinese characters, as site languages kicking
// always in. Do the rawurldecode() here to to be consistent with language preparations.
$uri = $site->getBase();
$route = new Route(
(rawurldecode($uri->getPath()) ?: '/') . '{tail}',
['site' => $site, 'language' => null, 'tail' => ''],
array_filter(['tail' => '.*', 'port' => (string)$uri->getPort()]),
['utf8' => true, 'fallback' => true],
// @todo Verify if host should here covered with idn_to_ascii() to be consistent with preparation for languages.
$uri->getHost() ?: '',
$uri->getScheme() === '' ? [] : [$uri->getScheme()]
);
$identifier = 'site_' . $site->getIdentifier();
$collection->add($identifier, $route);
// Add all languages
foreach ($site->getAllLanguages() as $siteLanguage) {
$uri = $siteLanguage->getBase();
$route = new Route(
(rawurldecode($uri->getPath()) ?: '/') . '{tail}',
['site' => $site, 'language' => $siteLanguage, 'tail' => ''],
array_filter(['tail' => '.*', 'port' => (string)$uri->getPort()]),
['utf8' => true],
$uri->getHost() ? (string)idn_to_ascii($uri->getHost()) : '',
$uri->getScheme() === '' ? [] : [$uri->getScheme()]
);
$identifier = 'site_' . $site->getIdentifier() . '_' . $siteLanguage->getLanguageId();
$collection->add($identifier, $route);
}
}
return $collection;
}
/**
* @return ?positive-int
*/
protected function resolvePageIdQueryParam(ServerRequestInterface $request): ?int
{
$pageId = $request->getQueryParams()['id'] ?? $request->getParsedBody()['id'] ?? null;
if ($pageId === null) {
return null;
}
if (!MathUtility::canBeInterpretedAsInteger($pageId)) {
return null;
}
return (int)$pageId <= 0 ? null : (int)$pageId;
}
/**
* @return ?positive-int
*/
protected function resolveLanguageIdQueryParam(ServerRequestInterface $request): ?int
{
$languageId = $request->getQueryParams()['L'] ?? $request->getParsedBody()['L'] ?? null;
if ($languageId === null) {
return null;
}
if (!MathUtility::canBeInterpretedAsInteger($languageId)) {
return null;
}
return (int)$languageId < 0 ? null : (int)$languageId;
}
/**
* Remove script file name (index.php) from request uri
*/
protected function canonicalizeUri(UriInterface $uri, ServerRequestInterface $request): UriInterface
{
if ($uri->getPath() === '') {
return $uri;
}
$normalizedParams = $request->getAttribute('normalizedParams');
if (!$normalizedParams instanceof NormalizedParams) {
return $uri;
}
$urlPath = ltrim($uri->getPath(), '/');
$scriptName = ltrim($normalizedParams->getScriptName(), '/');
$scriptPath = ltrim($normalizedParams->getSitePath(), '/');
if ($scriptName !== '' && str_starts_with($urlPath, $scriptName)) {
$urlPath = '/' . $scriptPath . substr($urlPath, mb_strlen($scriptName));
$uri = $uri->withPath($urlPath);
}
return $uri;
}
protected function matchSiteByUri(UriInterface $uri, ServerRequestInterface $request): SiteRouteResult
{
$collection = $this->getRouteCollectionForAllSites();
$requestContext = $this->requestContextFactory->fromUri($uri, $request->getMethod());
$matcher = new BestUrlMatcher($collection, $requestContext);
try {
/** @var array{site: SiteInterface, language: ?SiteLanguage, tail: string} $match */
$match = $matcher->match($uri->getPath());
return new SiteRouteResult(
$uri,
$match['site'],
$match['language'],
$match['tail']
);
} catch (NoConfigurationException|ResourceNotFoundException) {
return new SiteRouteResult($uri, new NullSite(), null, '');
}
}
protected function matchSiteByQueryParams(
int $pageId,
int $languageId,
SiteRouteResult $fallback,
UriInterface $uri,
): SiteRouteResult {
try {
$site = $this->finder->getSiteByPageId($pageId);
} catch (SiteNotFoundException) {
return $fallback;
}
try {
// override/set language by `&L=` query param
$language = $site->getLanguageById($languageId);
} catch (\InvalidArgumentException) {
return $fallback;
}
return new SiteRouteResult($uri, $site, $language);
}
}
+169
View File
@@ -0,0 +1,169 @@
<?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\Routing;
use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
/**
* Class, usually available within request attribute "routing"
* containing all the findings of the Routers.
* When doing page-based routing the SiteRouteResult will get replaced with the PageArguments object.
*/
class SiteRouteResult implements RouteResultInterface
{
/**
* @var array
*/
protected $validProperties = ['uri', 'site', 'language', 'tail'];
/**
* Incoming URI which was processed.
* @var UriInterface
*/
protected $uri;
/**
* @var SiteInterface
*/
protected $site;
/**
* @var SiteLanguage|null
*/
protected $language;
/**
* data bag with additional attributes
* @var array
*/
protected $data;
/**
* The leftover string of the path from the uri
* @var string
*/
protected $tail;
public function __construct(UriInterface $uri, SiteInterface $site, ?SiteLanguage $language = null, string $tail = '', array $data = [])
{
$this->uri = $uri;
$this->site = $site;
$this->language = $language;
$this->tail = $tail;
$this->data = $data;
}
public function getUri(): UriInterface
{
return $this->uri;
}
public function getSite(): SiteInterface
{
return $this->site;
}
public function getLanguage(): ?SiteLanguage
{
return $this->language;
}
public function getTail(): string
{
return $this->tail;
}
public function offsetExists($offset): bool
{
return in_array($offset, $this->validProperties, true) || isset($this->data[$offset]);
}
/**
* @internal
*/
public function withLanguage(SiteLanguage $language): self
{
$clone = clone $this;
$clone->language = $language;
return $clone;
}
/**
* @param mixed $offset
*/
public function offsetGet($offset): mixed
{
switch ($offset) {
case 'uri':
return $this->uri;
case 'site':
return $this->site;
case 'language':
return $this->language;
case 'tail':
return $this->tail;
default:
return $this->data[$offset];
}
}
/**
* @param mixed $offset
* @param mixed $value
*/
public function offsetSet($offset, $value): void
{
switch ($offset) {
case 'uri':
throw new \InvalidArgumentException('You can never replace the URI in a route result', 1535462423);
case 'site':
throw new \InvalidArgumentException('You can never replace the Site object in a route result', 1535462454);
case 'language':
throw new \InvalidArgumentException('You can never replace the Language object in a route result', 1535462452);
case 'tail':
$this->tail = $value;
break;
default:
$this->data[$offset] = $value;
}
}
/**
* @param mixed $offset
*/
public function offsetUnset($offset): void
{
switch ($offset) {
case 'uri':
throw new \InvalidArgumentException('You can never replace the URI in a route result', 1535462429);
case 'site':
throw new \InvalidArgumentException('You can never replace the Site object in a route result', 1535462458);
case 'language':
$this->language = null;
break;
case 'tail':
$this->tail = '';
break;
default:
unset($this->data[$offset]);
}
}
}
+97
View File
@@ -0,0 +1,97 @@
<?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\Routing;
use TYPO3\CMS\Core\Http\ServerRequest;
use TYPO3\CMS\Core\Site\Entity\Site;
/**
* Utilizes the SiteMatcher to resolve a URL and return its UID.
*
* @internal
*/
final readonly class SiteUrlResolver
{
public function __construct(
private SiteMatcher $siteMatcher,
) {}
/**
* Searches the page UID by the full URI
*/
public function resolvePageUidBySiteUrl(string $fullUri): ?int
{
$request = new ServerRequest($fullUri);
/** @var SiteRouteResult $siteMatch */
$siteMatch = $this->siteMatcher->matchRequest($request);
$site = $siteMatch->getSite();
$siteId = $site->getIdentifier();
$languageId = $siteMatch->getLanguage()?->getLanguageId();
if ($siteId === '' || $languageId === null || !($site instanceof Site)) {
// Not a valid site.
return null;
}
try {
$route = $site->getRouter()->matchRequest($request, $siteMatch);
} catch (RouteNotFoundException) {
return null;
}
if ($route instanceof PageArguments && !$route->areDirty()) {
return $route->getPageId();
}
return null;
}
/**
* Searches the page UID by the full URI and returns the page UID including its language.
*
* @return ?array{uid: int, languageUid: int, languageName: string}
*/
public function resolvePageUidAndLanguageBySiteUrl(string $fullUri): ?array
{
$request = new ServerRequest($fullUri);
/** @var SiteRouteResult $siteMatch */
$siteMatch = $this->siteMatcher->matchRequest($request);
$site = $siteMatch->getSite();
$siteId = $site->getIdentifier();
$languageId = $siteMatch->getLanguage()?->getLanguageId();
if ($siteId === '' || $languageId === null || !($site instanceof Site)) {
// Not a valid site.
return null;
}
try {
$route = $site->getRouter()->matchRequest($request, $siteMatch);
} catch (RouteNotFoundException) {
return null;
}
if ($route instanceof PageArguments && !$route->areDirty()) {
return [
'uid' => $route->getPageId(),
'languageUid' => $languageId,
'languageName' => $site->getLanguageById($languageId)->getTitle(),
];
}
return null;
}
}
@@ -0,0 +1,25 @@
<?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\Routing;
use TYPO3\CMS\Core\Exception;
/**
* Exception thrown when a link to a page (or page in a specific translation) cannot be built.
*/
class UnableToLinkToPageException extends Exception {}
+57
View File
@@ -0,0 +1,57 @@
<?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\Routing;
use Symfony\Component\Routing\Generator\UrlGenerator as SymfonyUrlGenerator;
use TYPO3\CMS\Core\Routing\Aspect\MappableProcessor;
/**
* @internal
*/
class UrlGenerator extends SymfonyUrlGenerator
{
/**
* @var MappableProcessor|null
*/
protected $mappableProcessor;
public function injectMappableProcessor(MappableProcessor $mappableProcessor): void
{
$this->mappableProcessor = $mappableProcessor;
}
/**
* Processes aspect mapping on default values and delegates route generation to parent class.
*
* {@inheritdoc}
*/
protected function doGenerate(array $variables, array $defaults, array $requirements, array $tokens, array $parameters, string $name, int $referenceType, array $hostTokens, array $requiredSchemes = []): string
{
/** @var Route $route */
$route = $this->routes->get($name);
// _appliedDefaults contains internal(!) values (mapped default values are not generated yet)
// (keys used are deflated and need to be inflated later using VariableProcessor)
$relevantDefaults = array_intersect_key($defaults, array_flip($route->compile()->getPathVariables()));
$route->setOption('_appliedDefaults', array_diff_key($relevantDefaults, $parameters));
// map default values for URL generation (e.g. '1' becomes 'one' if defined in aspect)
$mappableProcessor = $this->mappableProcessor ?? new MappableProcessor();
$mappableProcessor->generate($route, $defaults);
return parent::doGenerate($variables, $defaults, $requirements, $tokens, $parameters, $name, $referenceType, $hostTokens, $requiredSchemes);
}
}