TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:09 +02:00
commit af8cc155b5
6818 changed files with 642608 additions and 0 deletions
@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Routing\Enhancer;
use TYPO3\CMS\Core\Routing\Aspect\AspectInterface;
use TYPO3\CMS\Core\Routing\Aspect\ModifiableAspectInterface;
use TYPO3\CMS\Core\Routing\Route;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Abstract Enhancer, useful for custom enhancers
*/
abstract class AbstractEnhancer implements EnhancerInterface
{
/**
* @var AspectInterface[]
*/
protected array $aspects = [];
/**
* @var VariableProcessor|null
*/
protected $variableProcessor;
/**
* @param AspectInterface[] $aspects
* @param string|null $namespace
*/
protected function applyRouteAspects(Route $route, array $aspects, ?string $namespace = null)
{
if (empty($aspects)) {
return;
}
$aspects = $this->getVariableProcessor()
->deflateKeys($aspects, $namespace, $route->getArguments());
$route->setAspects($aspects);
}
/**
* @param string|null $namespace
*/
protected function applyRequirements(Route $route, array $requirements, ?string $namespace = null)
{
$requirements = $this->getVariableProcessor()
->deflateKeys($requirements, $namespace, $route->getArguments());
// only keep requirements that are actually part of the current route path
$requirements = $this->filterValuesByPathVariables($route, $requirements);
// Symfony's behavior on applying pattern for parameters just concerns values
// to be passed either to URL or to internal parameters - they are always the
// same, without any transformation.
//
// TYPO3 extends ("enhances") this behavior by making a difference between values
// for generation (resulting in a URL) and matching (resulting in query parameters)
// having the following implications and meaning:
//
// + since requirements in classic Symfony focus on parameters in URLs
// and aspects define a mapping between URL part (e.g. 'some-example-news')
// and the corresponding internal argument (e.g. 'tx_news_pi1[news]=123')
// + thus, the requirement definition cannot be used for resolving and generating
// a route at the same time (it would have to be e.g. `[\w_._]+` AND `\d+`)
//
// Symfony's default regular expression pattern `[^/]+` (see
// `RouteCompiler::compilePattern()`) has to be overridden with `.+` to
// allow URI parameters like `some-example-news/january` as well.
//
// Existing `requirements` for TYPO3 route enhancers are not modified, only those
// that are not defined and would use Symfony's default pattern.
$requirements = $this->defineValuesByAspect($route, $requirements, '.+');
$route->setRequirements($requirements);
}
/**
* Applies variables that are considered static (not having `&cHash=...` applied),
* without having the demand to define a custom `StaticMappableAspectInterface`
* to fake the behavior.
*
* However:
* + in case there's an aspect defined for a variable, it will be skipped (aspects take precedence)
* + in case not requirement is defined for a variable, it will be skipped (avoiding weak definitions)
*
* @param array<non-empty-string, bool> $staticVariables option values
*/
protected function applyStaticVariables(Route $route, array $staticVariables, ?string $namespace = null): void
{
// skip definitions that are not explicitly set to `true`
$staticVariables = array_filter($staticVariables, static fn($definition) => $definition === true);
$staticVariables = $this->getVariableProcessor()
->deflateKeys($staticVariables, $namespace, $route->getArguments());
// only keep static variables that are actually part of the current route path
$staticVariables = $this->filterValuesByPathVariables($route, $staticVariables);
// skip definitions that already have an aspect defined (aspects take precedence)
$staticVariables = array_diff_key($staticVariables, $route->getAspects());
// skip definitions that not have any requirement defined (avoiding weak definitions)
$staticVariables = array_intersect_key($staticVariables, $route->getRequirements());
$route->setOption('_static', $staticVariables);
}
/**
* Only keeps values that actually have been used as variables in route path.
*
* + routePath: '/list/{page}' ('page' used as variable in route path)
* + values: ['entity' => 'entity...', 'page' => 'page...', 'other' => 'other...']
* + result: ['page' => 'page...']
*
* @param Route $route
* @param array $values
*/
protected function filterValuesByPathVariables(Route $route, array $values): array
{
return array_intersect_key(
$values,
array_flip($route->compile()->getPathVariables())
);
}
/**
* Overrides items having an aspect definition with a given
* $overrideValue in target $targetValue array.
*/
protected function overrideValuesByAspect(Route $route, array $values, string $targetValue): array
{
foreach (array_keys($route->getAspects()) as $variableName) {
$values[$variableName] = $targetValue;
}
return $values;
}
/**
* Define items having an aspect definition in case they are not defined
* with a given $targetValue in target $targetValue array.
*/
protected function defineValuesByAspect(Route $route, array $values, string $targetValue): array
{
foreach (array_keys($route->getAspects()) as $variableName) {
if (isset($values[$variableName])) {
continue;
}
$values[$variableName] = $targetValue;
}
return $values;
}
/**
* Modify the route path to add the variable names with the aspects, e.g.
*
* + `/{locale_modifier}/{product_title}` -> `/products/{product_title}`
* + `/{!locale_modifier}/{product_title}` -> `/products/{product_title}`
*
* @param string $routePath
*/
protected function modifyRoutePath(string $routePath): string
{
$substitutes = [];
foreach ($this->aspects as $variableName => $aspect) {
if (!$aspect instanceof ModifiableAspectInterface) {
continue;
}
$value = $aspect->modify();
if ($value !== null) {
$substitutes['{' . $variableName . '}'] = $value;
$substitutes['{!' . $variableName . '}'] = $value;
}
}
return str_replace(
array_keys($substitutes),
array_values($substitutes),
$routePath
);
}
/**
* Retrieves type from processed route and modifies remaining query parameters.
*
* @param array $remainingQueryParameters reference to remaining query parameters
*/
protected function resolveType(Route $route, array &$remainingQueryParameters): string
{
$type = $remainingQueryParameters['type'] ?? 0;
$decoratedParameters = $route->getOption('_decoratedParameters');
if (isset($decoratedParameters['type'])) {
$type = $decoratedParameters['type'];
unset($decoratedParameters['type']);
$remainingQueryParameters = array_replace_recursive(
$remainingQueryParameters,
$decoratedParameters
);
}
return (string)$type;
}
protected function getVariableProcessor(): VariableProcessor
{
if (isset($this->variableProcessor)) {
return $this->variableProcessor;
}
return $this->variableProcessor = GeneralUtility::makeInstance(VariableProcessor::class);
}
/**
* {@inheritdoc}
*/
public function setAspects(array $aspects): void
{
$this->aspects = $aspects;
}
/**
* {@inheritdoc}
*/
public function getAspects(): array
{
return $this->aspects;
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Routing\Enhancer;
use TYPO3\CMS\Core\Routing\RouteCollection;
/**
* Decorates a route (or routes within a collection) with additional parameters.
*/
interface DecoratingEnhancerInterface extends EnhancerInterface
{
/**
* Gets pattern that can be used to redecorate (undecorate)
* a potential previously decorated route path.
*
* Example:
* + route path: 'first/second.html'
* + redecoration pattern: '(?:\.html|\.json)$'
* -> 'first/second' might be the redecorated route path after
* applying the redecoration pattern to preg_match/preg_replace
*
* @return string regular expression pattern
*/
public function getRoutePathRedecorationPattern(): string;
/**
* Decorates route collection to be processed during URL resolving.
* Executed before invoking routing enhancers.
*
* @param string $routePath URL path
*/
public function decorateForMatching(RouteCollection $collection, string $routePath): void;
/**
* Decorates route collection during URL URL generation.
* Executed before invoking routing enhancers.
*
* @param array $parameters query parameters
*/
public function decorateForGeneration(RouteCollection $collection, array $parameters): void;
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Routing\Enhancer;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Creates enhancers
*/
class EnhancerFactory
{
/**
* @var array of all class names that need to be EnhancerInterfaces when instantiated.
*/
protected $availableEnhancers;
/**
* EnhancerFactory constructor.
*/
public function __construct()
{
$this->availableEnhancers = $GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['enhancers'] ?? [];
}
/**
* @throws \InvalidArgumentException
* @throws \OutOfRangeException
*/
public function create(string $type, array $settings): EnhancerInterface
{
if (empty($type)) {
throw new \InvalidArgumentException(
'Enhancer type cannot be empty',
1537298284
);
}
if (!isset($this->availableEnhancers[$type])) {
throw new \OutOfRangeException(
sprintf('No enhancer found for %s', $type),
1537277222
);
}
unset($settings['type']);
$className = $this->availableEnhancers[$type];
/** @var EnhancerInterface $enhancer */
$enhancer = GeneralUtility::makeInstance($className, $settings);
return $enhancer;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Routing\Enhancer;
use TYPO3\CMS\Core\Routing\Aspect\AspectInterface;
/**
* Base interface for enhancers, which can be decorators for adding parameters,
* or routing enhancers which adds variants to a page.
*/
interface EnhancerInterface
{
/**
* @param AspectInterface[] $aspects
*/
public function setAspects(array $aspects): void;
/**
* @return AspectInterface[]
*/
public function getAspects(): array;
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Routing\Enhancer;
/**
* Interface asserting that enhancer is capable of inflating parameters.
*/
interface InflatableEnhancerInterface
{
public function inflateParameters(array $parameters, array $internals = []): array;
}
@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Routing\Enhancer;
use TYPO3\CMS\Core\Routing\Route;
use TYPO3\CMS\Core\Routing\RouteCollection;
/**
* Resolves a static list (like page.typeNum) against a file pattern. Usually added on the very last part
* of the URL.
* It is important that the PageType Enhancer is executed at the very end in your configuration, as it modifies
* EXISTING route variants.
*
* routeEnhancers:
* PageTypeSuffix:
* type: PageType
* default: ''
* index: 'index'
* map:
* '.html': 1
* 'menu.json': 13
*/
class PageTypeDecorator extends AbstractEnhancer implements DecoratingEnhancerInterface
{
protected const ROUTE_PATH_DELIMITERS = ['.', '-', '_', '/'];
/**
* @var array
*/
protected $configuration;
/**
* @var string
*/
protected $default;
/**
* @var string
*/
protected $index;
/**
* @var array
*/
protected $map;
public function __construct(array $configuration)
{
$default = $configuration['default'] ?? '';
$index = $configuration['index'] ?? 'index';
$map = $configuration['map'] ?? null;
if (!is_string($default)) {
throw new \InvalidArgumentException('default must be string', 1538327508);
}
if (!is_string($index)) {
throw new \InvalidArgumentException('index must be string', 1538327509);
}
if (!is_array($map)) {
throw new \InvalidArgumentException('map must be array', 1538327510);
}
$this->configuration = $configuration;
$this->default = $default;
$this->index = $index;
$this->map = array_map('strval', $map);
}
public function getRoutePathRedecorationPattern(): string
{
return $this->buildRegularExpressionPattern(false);
}
/**
* {@inheritdoc}
*/
public function decorateForMatching(RouteCollection $collection, string $routePath): void
{
$decoratedRoutePath = null;
$decoratedParameters = null;
$pattern = $this->buildRegularExpressionPattern();
if (preg_match('#(?P<decoration>(?:' . $pattern . '))#', $routePath, $matches, PREG_UNMATCHED_AS_NULL)) {
if (!isset($matches['decoration'])) {
throw new \UnexpectedValueException(
'Unexpected null value at end of URL',
1538335671
);
}
$routePathValue = $matches['decoration'];
$parameterValue = $matches['indexItems'] ?? $matches['slashedItems'] ?? $matches['regularItems'];
$routePathValuePattern = $this->quoteForRegularExpressionPattern($routePathValue) . '$';
$decoratedRoutePath = preg_replace('#' . $routePathValuePattern . '#', '', $routePath);
$mappedType = $this->map[$parameterValue] ?? null;
if ($mappedType !== null) {
$decoratedParameters = ['type' => $mappedType];
} elseif ($this->default === $routePathValue) {
$decoratedParameters = ['type' => 0];
}
}
foreach ($collection->all() as $route) {
if ($decoratedRoutePath !== null) {
$route->setOption(
'_decoratedRoutePath',
'/' . trim($decoratedRoutePath, '/')
);
}
if ($decoratedParameters !== null) {
$route->setOption(
'_decoratedParameters',
$decoratedParameters
);
}
}
}
/**
* {@inheritdoc}
*/
public function decorateForGeneration(RouteCollection $collection, array $parameters): void
{
$type = isset($parameters['type']) ? (string)$parameters['type'] : null;
$value = $this->resolveValue($type);
// If the type is > 0 but the value could not be resolved,
// the type is appended as GET argument, which can be resolved already anyway.
// This happens when the PageTypeDecorator is used, but hasn't been configured for all available types.
if (!empty($type) && ($value === '' || $value === $this->default)) {
return;
}
$considerIndex = $value !== '' && in_array($value[0], static::ROUTE_PATH_DELIMITERS);
if ($value !== '' && !in_array($value[0], static::ROUTE_PATH_DELIMITERS)) {
$value = '/' . $value;
}
/**
* @var Route $existingRoute
*/
foreach ($collection->all() as $existingRoute) {
$existingRoutePath = rtrim($existingRoute->getPath(), '/');
if ($considerIndex && $existingRoutePath === '') {
$existingRoutePath = $this->index;
}
$existingRoute->setPath($existingRoutePath . $value);
$deflatedParameters = $existingRoute->getOption('deflatedParameters') ?? $parameters;
if (isset($deflatedParameters['type'])) {
unset($deflatedParameters['type']);
$existingRoute->setOption(
'deflatedParameters',
$deflatedParameters
);
}
}
}
/**
* Checks if the value exists inside the map.
*/
protected function resolveValue(?string $type): string
{
$index = array_search($type, $this->map, true);
if ($index !== false) {
return $index;
}
return $this->default;
}
/**
* Builds a regexp out of the map.
*/
protected function buildRegularExpressionPattern(bool $useNames = true): string
{
$items = array_keys($this->map);
if ($this->default !== '' && !in_array($this->default, $items, true)) {
$items[] = $this->default;
}
$slashedItems = array_filter($items, [$this, 'needsSlashPrefix']);
$regularItems = array_diff($items, $slashedItems);
$slashedItems = array_map([$this, 'quoteForRegularExpressionPattern'], $slashedItems);
$regularItems = array_map([$this, 'quoteForRegularExpressionPattern'], $regularItems);
$patterns = [];
if (!empty($slashedItems)) {
$name = $useNames ? '?P<slashedItems>' : '';
$patterns[] = '(?:^|/)(' . $name . implode('|', $slashedItems) . ')';
}
if (!empty($regularItems) && !empty($this->index)) {
$name = $useNames ? '?P<indexItems>' : '';
$indexPattern = $this->quoteForRegularExpressionPattern($this->index);
$patterns[] = '^' . $indexPattern . '(' . $name . '(?:' . implode('|', $regularItems) . '))';
}
if (!empty($regularItems)) {
$name = $useNames ? '?P<regularItems>' : '';
$patterns[] = '(' . $name . implode('|', $regularItems) . ')';
}
return '(?:' . implode('|', $patterns) . ')$';
}
/**
* Helper method for regexps.
*/
protected function quoteForRegularExpressionPattern(string $value): string
{
return preg_quote($value, '#');
}
/**
* Checks if a slash should be prefixed.
*/
protected function needsSlashPrefix(string $value): bool
{
return !in_array(
$value[0] ?? '',
static::ROUTE_PATH_DELIMITERS,
true
);
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Routing\Enhancer;
use TYPO3\CMS\Core\Routing\Aspect\StaticMappableAspectInterface;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Routing\Route;
use TYPO3\CMS\Core\Routing\RouteCollection;
use TYPO3\CMS\Core\Utility\ArrayUtility;
/**
* Used for plugins like EXT:felogin.
*
* This is usually used for arguments that are built with a `tx_myplugin_pi1` as namespace in GET / POST parameter.
*
* routeEnhancers:
* ForgotPassword:
* type: Plugin
* routePath: '/forgot-pw/{user_id}/{hash}/'
* namespace: 'tx_felogin_pi1'
* _arguments:
* user_id: uid
* requirements:
* user_id: '[a-z]+'
* hash: '[a-z]{0-6}'
*/
class PluginEnhancer extends AbstractEnhancer implements RoutingEnhancerInterface, InflatableEnhancerInterface, ResultingInterface
{
/**
* @var array
*/
protected $configuration;
/**
* @var string
*/
protected $namespace;
public function __construct(array $configuration)
{
$this->configuration = $configuration;
$this->namespace = $this->configuration['namespace'] ?? '';
}
/**
* {@inheritdoc}
*/
public function buildResult(Route $route, array $results, array $remainingQueryParameters = []): PageArguments
{
$variableProcessor = $this->getVariableProcessor();
// determine those parameters that have been processed
$parameters = array_intersect_key(
$results,
array_flip($route->compile()->getPathVariables())
);
// strip of those that where not processed (internals like _route, etc.)
$internals = array_diff_key($results, $parameters);
$matchedVariableNames = array_keys($parameters);
$staticMappers = $route->filterAspects([StaticMappableAspectInterface::class], $matchedVariableNames);
$dynamicCandidates = array_diff_key($parameters, $staticMappers, $route->getOption('_static') ?? []);
// all route arguments
$routeArguments = $this->inflateParameters($parameters, $internals);
// dynamic arguments, that don't have a static mapper
$dynamicArguments = $variableProcessor
->inflateNamespaceParameters($dynamicCandidates, $this->namespace);
// route arguments, that don't appear in dynamic arguments
$staticArguments = ArrayUtility::arrayDiffKeyRecursive($routeArguments, $dynamicArguments);
$page = $route->getOption('_page');
$pageId = (int)(isset($page['t3ver_oid']) && $page['t3ver_oid'] > 0 ? $page['t3ver_oid'] : $page['uid']);
$pageId = (int)($page['l10n_parent'] > 0 ? $page['l10n_parent'] : $pageId);
// See PageSlugCandidateProvider where this is added.
if ($page['MPvar'] ?? '') {
$routeArguments['MP'] = $page['MPvar'];
}
$type = $this->resolveType($route, $remainingQueryParameters);
return new PageArguments($pageId, $type, $routeArguments, $staticArguments, $remainingQueryParameters);
}
/**
* {@inheritdoc}
*/
public function enhanceForMatching(RouteCollection $collection): void
{
/** @var Route $defaultPageRoute */
$defaultPageRoute = $collection->get('default');
$variant = $this->getVariant($defaultPageRoute, $this->configuration);
$collection->add('enhancer_' . $this->namespace . spl_object_hash($variant), $variant);
}
/**
* Builds a variant of a route based on the given configuration.
*/
protected function getVariant(Route $defaultPageRoute, array $configuration): Route
{
$arguments = $configuration['_arguments'] ?? [];
unset($configuration['_arguments']);
$variableProcessor = $this->getVariableProcessor();
$routePath = $this->modifyRoutePath($configuration['routePath']);
$routePath = $variableProcessor->deflateRoutePath($routePath, $this->namespace, $arguments);
$variant = clone $defaultPageRoute;
$variant->setPath(rtrim($variant->getPath(), '/') . '/' . ltrim($routePath, '/'));
$variant->addOptions(['_enhancer' => $this, '_arguments' => $arguments]);
$defaults = $variableProcessor->deflateKeys($this->configuration['defaults'] ?? [], $this->namespace, $arguments);
// only keep `defaults` that are actually used in `routePath`
$variant->setDefaults($this->filterValuesByPathVariables($variant, $defaults));
$this->applyRouteAspects($variant, $this->aspects, $this->namespace);
$this->applyRequirements($variant, $this->configuration['requirements'] ?? [], $this->namespace);
$this->applyStaticVariables($variant, $this->configuration['static'] ?? [], $this->namespace);
return $variant;
}
/**
* {@inheritdoc}
*/
public function enhanceForGeneration(RouteCollection $collection, array $parameters): void
{
// No parameter for this namespace given, so this route does not fit the requirements
if (!is_array($parameters[$this->namespace] ?? null)) {
return;
}
/** @var Route $defaultPageRoute */
$defaultPageRoute = $collection->get('default');
$variant = $this->getVariant($defaultPageRoute, $this->configuration);
$compiledRoute = $variant->compile();
// contains all given parameters, even if not used as variables in route
$deflatedParameters = $this->deflateParameters($variant, $parameters);
$variables = array_flip($compiledRoute->getPathVariables());
$mergedParams = array_replace($variant->getDefaults(), $deflatedParameters);
// all params must be given, otherwise we exclude this variant
if ($variables === [] || array_diff_key($variables, $mergedParams) !== []) {
return;
}
$variant->addOptions(['deflatedParameters' => $deflatedParameters]);
$collection->add('enhancer_' . $this->namespace . spl_object_hash($variant), $variant);
}
protected function deflateParameters(Route $route, array $parameters): array
{
return $this->getVariableProcessor()->deflateNamespaceParameters(
$parameters,
$this->namespace,
$route->getArguments()
);
}
/**
* @param array $parameters Actual parameter payload to be used
* @param array $internals Internal instructions (_route, _controller, ...)
*/
public function inflateParameters(array $parameters, array $internals = []): array
{
return $this->getVariableProcessor()
->inflateNamespaceParameters($parameters, $this->namespace);
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Routing\Enhancer;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Routing\Route;
/**
* Extend the Resulting Interface to explain that this route builds the page arguments itself, instead of having
* the PageRouter having to deal with that.
*/
interface ResultingInterface
{
public function buildResult(Route $route, array $results, array $remainingQueryParameters = []): PageArguments;
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Routing\Enhancer;
use TYPO3\CMS\Core\Routing\RouteCollection;
/**
* Interface for enhancers
*/
interface RoutingEnhancerInterface extends EnhancerInterface
{
/**
* Extends route collection with all routes. Used during URL resolving.
*/
public function enhanceForMatching(RouteCollection $collection): void;
/**
* Extends route collection with routes that are relevant for given
* parameters. Used during URL generation.
*/
public function enhanceForGeneration(RouteCollection $collection, array $parameters): void;
}
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Routing\Enhancer;
use TYPO3\CMS\Core\Routing\Aspect\StaticMappableAspectInterface;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Routing\Route;
use TYPO3\CMS\Core\Routing\RouteCollection;
use TYPO3\CMS\Core\Utility\ArrayUtility;
/**
* This is usually used for simple GET arguments that have no namespace (e.g. not plugins).
*
* routeEnhancers
* Categories:
* type: Simple
* routePath: '/cmd/{category_id}/{scope_id}'
* _arguments:
* category_id: 'category/id'
* scope_id: 'scope/id'
*/
class SimpleEnhancer extends AbstractEnhancer implements RoutingEnhancerInterface, InflatableEnhancerInterface, ResultingInterface
{
/**
* @var array
*/
protected $configuration;
public function __construct(array $configuration)
{
$this->configuration = $configuration;
}
/**
* {@inheritdoc}
*/
public function buildResult(Route $route, array $results, array $remainingQueryParameters = []): PageArguments
{
// determine those parameters that have been processed
$parameters = array_intersect_key(
$results,
array_flip($route->compile()->getPathVariables())
);
// strip of those that where not processed (internals like _route, etc.)
$internals = array_diff_key($results, $parameters);
$matchedVariableNames = array_keys($parameters);
$staticMappers = $route->filterAspects([StaticMappableAspectInterface::class], $matchedVariableNames);
$dynamicCandidates = array_diff_key($parameters, $staticMappers, $route->getOption('_static') ?? []);
// all route arguments
$routeArguments = $this->inflateParameters($parameters, $internals);
// dynamic arguments, that don't have a static mapper
$dynamicArguments = $this->inflateParameters($dynamicCandidates);
// route arguments, that don't appear in dynamic arguments
$staticArguments = ArrayUtility::arrayDiffKeyRecursive($routeArguments, $dynamicArguments);
$page = $route->getOption('_page');
$pageId = (int)(isset($page['t3ver_oid']) && $page['t3ver_oid'] > 0 ? $page['t3ver_oid'] : $page['uid']);
$pageId = (int)($page['l10n_parent'] > 0 ? $page['l10n_parent'] : $pageId);
// See PageSlugCandidateProvider where this is added.
if ($page['MPvar'] ?? '') {
$routeArguments['MP'] = $page['MPvar'];
}
$type = $this->resolveType($route, $remainingQueryParameters);
return new PageArguments($pageId, $type, $routeArguments, $staticArguments, $remainingQueryParameters);
}
/**
* {@inheritdoc}
*/
public function enhanceForMatching(RouteCollection $collection): void
{
/** @var Route $defaultPageRoute */
$defaultPageRoute = $collection->get('default');
$variant = $this->getVariant($defaultPageRoute, $this->configuration);
$collection->add('enhancer_' . spl_object_hash($variant), $variant);
}
/**
* Builds a variant of a route based on the given configuration.
*/
protected function getVariant(Route $defaultPageRoute, array $configuration): Route
{
$arguments = $configuration['_arguments'] ?? [];
unset($configuration['_arguments']);
$variableProcessor = $this->getVariableProcessor();
$routePath = $this->modifyRoutePath($configuration['routePath']);
$routePath = $variableProcessor->deflateRoutePath($routePath, null, $arguments);
$variant = clone $defaultPageRoute;
$variant->setPath(rtrim($variant->getPath(), '/') . '/' . ltrim($routePath, '/'));
$variant->addOptions(['_enhancer' => $this, '_arguments' => $arguments]);
$defaults = $variableProcessor->deflateKeys($this->configuration['defaults'] ?? [], null, $arguments);
// only keep `defaults` that are actually used in `routePath`
$variant->setDefaults($this->filterValuesByPathVariables($variant, $defaults));
$this->applyRouteAspects($variant, $this->aspects);
$this->applyRequirements($variant, $this->configuration['requirements'] ?? []);
$this->applyStaticVariables($variant, $this->configuration['static'] ?? []);
return $variant;
}
/**
* {@inheritdoc}
*/
public function enhanceForGeneration(RouteCollection $collection, array $parameters): void
{
/** @var Route $defaultPageRoute */
$defaultPageRoute = $collection->get('default');
$variant = $this->getVariant($defaultPageRoute, $this->configuration);
$compiledRoute = $variant->compile();
// contains all given parameters, even if not used as variables in route
$deflatedParameters = $this->getVariableProcessor()->deflateParameters($parameters, $variant->getArguments());
$variables = array_flip($compiledRoute->getPathVariables());
$mergedParams = array_replace($variant->getDefaults(), $deflatedParameters);
// all params must be given, otherwise we exclude this variant
if ($variables === [] || array_diff_key($variables, $mergedParams) !== []) {
return;
}
$variant->addOptions(['deflatedParameters' => $deflatedParameters]);
$collection->add('enhancer_' . spl_object_hash($variant), $variant);
}
public function inflateParameters(array $parameters, array $internals = []): array
{
return $this->getVariableProcessor()->inflateParameters($parameters, $internals);
}
}
@@ -0,0 +1,349 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Routing\Enhancer;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
/**
* Helper for processing various variables within a Route Enhancer
*/
#[Autoconfigure(public: true, shared: false)]
class VariableProcessor
{
protected const LEVEL_DELIMITER = '___';
protected const ARGUMENT_SEPARATOR = '/';
protected const VARIABLE_PATTERN = '#\{(?P<modifier>!)?(?P<name>[^}]+)\}#';
protected array $hashes = [];
protected array $nestedValues = [];
public function __construct(private readonly VariableProcessorCache $cache) {}
protected function addHash(string $value): string
{
if (!$this->requiresHashing($value)) {
return $value;
}
// generate hash (fetch from cache, if available)
$hash = $this->generateHash($value);
// store hash locally (indicator, that this value was processed)
$this->hashes[$hash] = $value;
return $hash;
}
/**
* Determines whether a parameter value requires hashing.
* This is the case if the value has 31+ chars (Symfony has a limitation of 32 chars),
* or if the value contains any non-word characters besides `[A-Za-z0-9_]`, such as `@`.
*/
protected function requiresHashing(string $value): bool
{
if (!isset($this->cache->requiresHashing[$value])) {
$this->cache->requiresHashing[$value] = strlen($value) >= 31 || preg_match('#[^\w]#', $value) > 0;
}
return $this->cache->requiresHashing[$value];
}
protected function generateHash(string $value): string
{
if (!isset($this->cache->hashes[$value])) {
// remove one char, which might be used as enforced route prefix `{!value}`
$hash = substr(md5($value), 0, -1);
// Symfony Route Compiler requires the first literal to be non-integer
if ($hash[0] === (string)(int)$hash[0]) {
$hash[0] = str_replace(
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
['o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x'],
$hash[0]
);
}
$this->cache->hashes[$value] = $hash;
}
return $this->cache->hashes[$value];
}
/**
* @throws \OutOfRangeException
*/
protected function resolveHash(string $hash): string
{
if (strlen($hash) < 31) {
return $hash;
}
if (!isset($this->hashes[$hash])) {
throw new \OutOfRangeException(
'Hash not resolvable',
1537633463
);
}
return $this->hashes[$hash];
}
protected function addNestedValue(string $value): string
{
if (!str_contains($value, static::ARGUMENT_SEPARATOR)) {
return $value;
}
$nestedValue = str_replace(
static::ARGUMENT_SEPARATOR,
static::LEVEL_DELIMITER,
$value
);
$this->nestedValues[$nestedValue] = $value;
return $nestedValue;
}
protected function resolveNestedValue(string $value): string
{
if (!str_contains($value, static::LEVEL_DELIMITER)) {
return $value;
}
return $this->nestedValues[$value] ?? $value;
}
public function deflateRoutePath(string $routePath, ?string $namespace = null, array $arguments = []): string
{
if (!preg_match_all(static::VARIABLE_PATTERN, $routePath, $matches)) {
return $routePath;
}
$replace = [];
$search = $matches[0];
$deflatedNames = $this->deflateValues($matches['name'], $namespace, $arguments);
foreach ($deflatedNames as $index => $deflatedName) {
$modifier = $matches['modifier'][$index] ?? '';
$replace[] = '{' . $modifier . $deflatedName . '}';
}
return str_replace($search, $replace, $routePath);
}
public function inflateRoutePath(string $routePath, ?string $namespace = null, array $arguments = []): string
{
if (!preg_match_all(static::VARIABLE_PATTERN, $routePath, $matches)) {
return $routePath;
}
$replace = [];
$search = $matches[0];
$inflatedNames = $this->inflateValues($matches['name'], $namespace, $arguments);
foreach ($inflatedNames as $index => $inflatedName) {
$modifier = $matches['modifier'][$index] ?? '';
$replace[] = '{' . $modifier . $inflatedName . '}';
}
return str_replace($search, $replace, $routePath);
}
/**
* Deflates (flattens) route/request parameters for a given namespace.
*/
public function deflateNamespaceParameters(array $parameters, string $namespace, array $arguments = []): array
{
if (empty($namespace) || empty($parameters[$namespace])) {
return $parameters;
}
// prefix items of namespace parameters and apply argument mapping
$namespaceParameters = $this->deflateKeys($parameters[$namespace], $namespace, $arguments, false);
// deflate those array items
$namespaceParameters = $this->deflateArray($namespaceParameters);
unset($parameters[$namespace]);
// merge with remaining array items
return array_merge($parameters, $namespaceParameters);
}
/**
* Inflates (unflattens) route/request parameters.
*/
public function inflateNamespaceParameters(array $parameters, string $namespace, array $arguments = []): array
{
if (empty($namespace) || empty($parameters)) {
return $parameters;
}
$parameters = $this->inflateArray($parameters, $namespace, $arguments);
// apply argument mapping on items of inflated namespace parameters
if (!empty($parameters[$namespace]) && !empty($arguments)) {
$parameters[$namespace] = $this->inflateKeys($parameters[$namespace], null, $arguments, false);
}
return $parameters;
}
/**
* Deflates (flattens) route/request parameters for a given namespace.
*/
public function deflateParameters(array $parameters, array $arguments = []): array
{
$parameters = $this->deflateKeys($parameters, null, $arguments, false);
return $this->deflateArray($parameters);
}
/**
* Inflates (unflattens) route/request parameters.
*/
public function inflateParameters(array $parameters, array $arguments = []): array
{
$parameters = $this->inflateArray($parameters, null, $arguments);
return $this->inflateKeys($parameters, null, $arguments, false);
}
/**
* Deflates keys names on the first level, now recursion into sub-arrays.
* Can be used to adjust key names of route requirements, mappers, etc.
*/
public function deflateKeys(array $items, ?string $namespace = null, array $arguments = [], bool $hash = true): array
{
if (empty($items) || empty($arguments) && empty($namespace)) {
return $items;
}
$keys = $this->deflateValues(array_keys($items), $namespace, $arguments, $hash);
return array_combine(
$keys,
array_values($items)
);
}
/**
* Inflates keys names on the first level, now recursion into sub-arrays.
* Can be used to adjust key names of route requirements, mappers, etc.
*/
public function inflateKeys(array $items, ?string $namespace = null, array $arguments = [], bool $hash = true): array
{
if (empty($items) || empty($arguments) && empty($namespace)) {
return $items;
}
$keys = $this->inflateValues(array_keys($items), $namespace, $arguments, $hash);
return array_combine(
$keys,
array_values($items)
);
}
/**
* Deflates plain values.
*/
protected function deflateValues(array $values, ?string $namespace = null, array $arguments = [], bool $hash = true): array
{
if (empty($values) || empty($arguments) && empty($namespace)) {
return $values;
}
$namespacePrefix = $namespace ? $namespace . static::LEVEL_DELIMITER : '';
$arguments = array_map('strval', $arguments);
return array_map(
function (string $value) use ($arguments, $namespacePrefix, $hash) {
$value = $arguments[$value] ?? $value;
$value = $this->addNestedValue($value);
$value = $namespacePrefix . $value;
if (!$hash) {
return $value;
}
return $this->addHash($value);
},
$values
);
}
/**
* Inflates plain values.
*/
protected function inflateValues(array $values, ?string $namespace = null, array $arguments = [], bool $hash = true): array
{
if (empty($values) || empty($arguments) && empty($namespace)) {
return $values;
}
$arguments = array_map('strval', $arguments);
$namespacePrefix = $namespace ? $namespace . static::LEVEL_DELIMITER : '';
return array_map(
function (string $value) use ($arguments, $namespacePrefix, $hash) {
if ($hash) {
$value = $this->resolveHash($value);
}
if (!empty($namespacePrefix) && str_starts_with($value, $namespacePrefix)) {
$value = substr($value, strlen($namespacePrefix));
}
$value = $this->resolveNestedValue($value);
$index = array_search($value, $arguments, true);
return $index !== false ? $index : $value;
},
$values
);
}
/**
* Deflates (flattens) array having nested structures.
*/
protected function deflateArray(array $array, string $prefix = ''): array
{
$delimiter = static::LEVEL_DELIMITER;
if ($prefix !== '' && !str_ends_with($prefix, $delimiter)) {
$prefix .= static::LEVEL_DELIMITER;
}
$result = [];
foreach ($array as $key => $value) {
if (is_array($value)) {
$result = array_replace(
$result,
$this->deflateArray(
$value,
$prefix . $key . static::LEVEL_DELIMITER
)
);
} else {
$deflatedKey = $this->addHash($prefix . $key);
$result[$deflatedKey] = $value;
}
}
return $result;
}
/**
* Inflates (unflattens) an array into nested structures.
*
* @param string $namespace
*/
protected function inflateArray(array $array, ?string $namespace, array $arguments): array
{
$result = [];
foreach ($array as $key => $value) {
$inflatedKey = $this->resolveHash((string)$key);
// inflate nested values `namespace__any__nested` -> `namespace__any/nested`
$inflatedKey = $this->inflateNestedValue($inflatedKey, $namespace, $arguments);
$steps = explode(static::LEVEL_DELIMITER, $inflatedKey);
$pointer = &$result;
foreach ($steps as $step) {
$pointer = &$pointer[$step];
}
$pointer = $value;
unset($pointer);
}
return $result;
}
protected function inflateNestedValue(string $value, ?string $namespace, array $arguments): string
{
$namespacePrefix = $namespace ? $namespace . static::LEVEL_DELIMITER : '';
if (!empty($namespace) && !str_starts_with($value, $namespacePrefix)) {
return $value;
}
$arguments = array_map('strval', $arguments);
$possibleNestedValueKey = substr($value, strlen($namespacePrefix));
$possibleNestedValue = $this->nestedValues[$possibleNestedValueKey] ?? null;
if ($possibleNestedValue === null || !in_array($possibleNestedValue, $arguments, true)) {
return $value;
}
return $namespacePrefix . $possibleNestedValue;
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Routing\Enhancer;
/**
* Shared cache among multiple `VariableProcessor` instances
*
* @internal
*/
class VariableProcessorCache
{
/**
* @var array<string, bool>
*/
public array $requiresHashing = [];
/**
* @var array<string, string>
*/
public array $hashes = [];
}