[BUGFIX] Allow custom namespace in ExtbasePluginEnhancer
[Packages/TYPO3.CMS.git] / typo3 / sysext / extbase / Classes / Routing / ExtbasePluginEnhancer.php
1 <?php
2 declare(strict_types = 1);
3
4 namespace TYPO3\CMS\Extbase\Routing;
5
6 /*
7 * This file is part of the TYPO3 CMS project.
8 *
9 * It is free software; you can redistribute it and/or modify it under
10 * the terms of the GNU General Public License, either version 2
11 * of the License, or any later version.
12 *
13 * For the full copyright and license information, please read the
14 * LICENSE.txt file that was distributed with this source code.
15 *
16 * The TYPO3 project - inspiring people to share!
17 */
18
19 use TYPO3\CMS\Core\Routing\Enhancer\PluginEnhancer;
20 use TYPO3\CMS\Core\Routing\Route;
21 use TYPO3\CMS\Core\Routing\RouteCollection;
22
23 /**
24 * Allows to have a plugin with multiple controllers + actions for one specific plugin that has a namespace.
25 *
26 * A typical configuration looks like this:
27 *
28 * routeEnhancers:
29 * BlogExample:
30 * type: Extbase
31 * extension: BlogExample
32 * plugin: Pi1
33 * routes:
34 * - { routePath: '/blog/{page}', _controller: 'Blog::list', _arguments: {'page': '@widget_0/currentPage'} }
35 * - { routePath: '/blog/{slug}', _controller: 'Blog::detail' }
36 * requirements:
37 * page: '[0-9]+'
38 * slug: '.*'
39 */
40 class ExtbasePluginEnhancer extends PluginEnhancer
41 {
42 /**
43 * @var array
44 */
45 protected $routesOfPlugin;
46
47 public function __construct(array $configuration)
48 {
49 parent::__construct($configuration);
50 $this->routesOfPlugin = $this->configuration['routes'] ?? [];
51 // Only set the namespace if the plugin+extension keys are given. This allows to also use "namespace" property
52 // instead from the parent constructor.
53 if (isset($this->configuration['extension']) && isset($this->configuration['plugin'])) {
54 $extensionName = $this->configuration['extension'];
55 $pluginName = $this->configuration['plugin'];
56 $extensionName = str_replace(' ', '', ucwords(str_replace('_', ' ', $extensionName)));
57 $pluginSignature = strtolower($extensionName . '_' . $pluginName);
58 $this->namespace = 'tx_' . $pluginSignature;
59 }
60 return;
61 }
62
63 /**
64 * {@inheritdoc}
65 */
66 public function enhanceForMatching(RouteCollection $collection): void
67 {
68 $i = 0;
69 /** @var Route $defaultPageRoute */
70 $defaultPageRoute = $collection->get('default');
71 foreach ($this->routesOfPlugin as $configuration) {
72 $route = $this->getVariant($defaultPageRoute, $configuration);
73 $collection->add($this->namespace . '_' . $i++, $route);
74 }
75 }
76
77 /**
78 * {@inheritdoc}
79 */
80 protected function getVariant(Route $defaultPageRoute, array $configuration): Route
81 {
82 $arguments = $configuration['_arguments'] ?? [];
83 unset($configuration['_arguments']);
84
85 $namespacedRequirements = $this->getNamespacedRequirements();
86 $routePath = $this->modifyRoutePath($configuration['routePath']);
87 $routePath = $this->getVariableProcessor()->deflateRoutePath($routePath, $this->namespace, $arguments);
88 unset($configuration['routePath']);
89 $defaults = array_merge_recursive($defaultPageRoute->getDefaults(), $configuration);
90 $options = array_merge($defaultPageRoute->getOptions(), ['_enhancer' => $this, 'utf8' => true, '_arguments' => $arguments]);
91 $route = new Route(rtrim($defaultPageRoute->getPath(), '/') . '/' . ltrim($routePath, '/'), $defaults, [], $options);
92 $this->applyRouteAspects($route, $this->aspects ?? [], $this->namespace);
93 if ($namespacedRequirements) {
94 $compiledRoute = $route->compile();
95 $variables = $compiledRoute->getPathVariables();
96 $variables = array_flip($variables);
97 $requirements = array_filter($namespacedRequirements, function ($key) use ($variables) {
98 return isset($variables[$key]);
99 }, ARRAY_FILTER_USE_KEY);
100 $route->setRequirements($requirements);
101 }
102 return $route;
103 }
104
105 /**
106 * {@inheritdoc}
107 */
108 public function enhanceForGeneration(RouteCollection $collection, array $originalParameters): void
109 {
110 if (!is_array($originalParameters[$this->namespace] ?? null)) {
111 return;
112 }
113 // apply default controller and action names if not set in parameters
114 if (!$this->hasControllerActionValues($originalParameters[$this->namespace])
115 && !empty($this->configuration['defaultController'])
116 ) {
117 $this->applyControllerActionValues(
118 $this->configuration['defaultController'],
119 $originalParameters[$this->namespace]
120 );
121 }
122
123 $i = 0;
124 /** @var Route $defaultPageRoute */
125 $defaultPageRoute = $collection->get('default');
126 foreach ($this->routesOfPlugin as $configuration) {
127 $variant = $this->getVariant($defaultPageRoute, $configuration);
128 // The enhancer tells us: This given route does not match the parameters
129 if (!$this->verifyRequiredParameters($variant, $originalParameters)) {
130 continue;
131 }
132 $parameters = $originalParameters;
133 unset($parameters[$this->namespace]['action']);
134 unset($parameters[$this->namespace]['controller']);
135 $compiledRoute = $variant->compile();
136 $deflatedParameters = $this->deflateParameters($variant, $parameters);
137 $variables = array_flip($compiledRoute->getPathVariables());
138 $mergedParams = array_replace($variant->getDefaults(), $deflatedParameters);
139 // all params must be given, otherwise we exclude this variant
140 if ($diff = array_diff_key($variables, $mergedParams)) {
141 continue;
142 }
143 $variant->addOptions(['deflatedParameters' => $deflatedParameters]);
144 $collection->add($this->namespace . '_' . $i++, $variant);
145 }
146 }
147
148 /**
149 * A route has matched the controller/action combination, so ensure that these properties
150 * are set to tx_blogexample_pi1[controller] and tx_blogexample_pi1[action].
151 *
152 * @param array $parameters Actual parameter payload to be used
153 * @param array $internals Internal instructions (_route, _controller, ...)
154 * @return array
155 */
156 protected function inflateParameters(array $parameters, array $internals = []): array
157 {
158 $parameters = $this->getVariableProcessor()
159 ->inflateNamespaceParameters($parameters, $this->namespace);
160 $parameters[$this->namespace] = $parameters[$this->namespace] ?? [];
161
162 // Invalid if there is no controller given, so this enhancers does not do anything
163 if (empty($internals['_controller'] ?? null)) {
164 return $parameters;
165 }
166 $this->applyControllerActionValues(
167 $internals['_controller'],
168 $parameters[$this->namespace]
169 );
170 return $parameters;
171 }
172
173 /**
174 * Check if controller+action combination matches
175 *
176 * @param Route $route
177 * @param array $parameters
178 * @return bool
179 */
180 protected function verifyRequiredParameters(Route $route, array $parameters): bool
181 {
182 if (!is_array($parameters[$this->namespace])) {
183 return false;
184 }
185 if (!$route->hasDefault('_controller')) {
186 return false;
187 }
188 $controller = $route->getDefault('_controller');
189 list($controllerName, $actionName) = explode('::', $controller);
190 if ($controllerName !== $parameters[$this->namespace]['controller']) {
191 return false;
192 }
193 if ($actionName !== $parameters[$this->namespace]['action']) {
194 return false;
195 }
196 return true;
197 }
198
199 /**
200 * Check if action and controller are not empty.
201 *
202 * @param array $target
203 * @return bool
204 */
205 protected function hasControllerActionValues(array $target): bool
206 {
207 return !empty($target['controller']) && !empty($target['action']);
208 }
209
210 /**
211 * Add controller and action parameters so they can be used later-on.
212 *
213 * @param string $controllerActionValue
214 * @param array $target
215 */
216 protected function applyControllerActionValues(string $controllerActionValue, array &$target)
217 {
218 if (strpos($controllerActionValue, '::') === false) {
219 return;
220 }
221 list($controllerName, $actionName) = explode('::', $controllerActionValue, 2);
222 $target['controller'] = $controllerName;
223 $target['action'] = $actionName;
224 }
225 }