TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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() : '';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user