[BUGFIX] ActionController must not register superfluous validators
[Packages/TYPO3.CMS.git] / typo3 / sysext / extbase / Classes / Mvc / Controller / ActionController.php
1 <?php
2 namespace TYPO3\CMS\Extbase\Mvc\Controller;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Core\Messaging\FlashMessage;
18 use TYPO3\CMS\Core\Page\PageRenderer;
19 use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
20 use TYPO3\CMS\Extbase\Mvc\Exception\StopActionException;
21 use TYPO3\CMS\Extbase\Mvc\View\ViewInterface;
22 use TYPO3\CMS\Extbase\Mvc\Web\Request as WebRequest;
23 use TYPO3\CMS\Extbase\Validation\Validator\ConjunctionValidator;
24 use TYPO3\CMS\Extbase\Validation\Validator\ValidatorInterface;
25 use TYPO3Fluid\Fluid\View\TemplateView;
26
27 /**
28 * A multi action controller. This is by far the most common base class for Controllers.
29 *
30 * @api
31 */
32 class ActionController extends AbstractController
33 {
34 /**
35 * @var \TYPO3\CMS\Extbase\Reflection\ReflectionService
36 */
37 protected $reflectionService;
38
39 /**
40 * @var \TYPO3\CMS\Extbase\Service\CacheService
41 */
42 protected $cacheService;
43
44 /**
45 * The current view, as resolved by resolveView()
46 *
47 * @var ViewInterface
48 * @api
49 */
50 protected $view = null;
51
52 /**
53 * @var string
54 * @api
55 */
56 protected $namespacesViewObjectNamePattern = '@vendor\@extension\View\@controller\@action@format';
57
58 /**
59 * A list of formats and object names of the views which should render them.
60 *
61 * Example:
62 *
63 * array('html' => 'Tx_MyExtension_View_MyHtmlView', 'json' => 'F3...
64 *
65 * @var array
66 */
67 protected $viewFormatToObjectNameMap = [];
68
69 /**
70 * The default view object to use if none of the resolved views can render
71 * a response for the current request.
72 *
73 * @var string
74 * @api
75 */
76 protected $defaultViewObjectName = \TYPO3\CMS\Fluid\View\TemplateView::class;
77
78 /**
79 * Name of the action method
80 *
81 * @var string
82 * @api
83 */
84 protected $actionMethodName = 'indexAction';
85
86 /**
87 * Name of the special error action method which is called in case of errors
88 *
89 * @var string
90 * @api
91 */
92 protected $errorMethodName = 'errorAction';
93
94 /**
95 * @var \TYPO3\CMS\Extbase\Mvc\Controller\MvcPropertyMappingConfigurationService
96 * @api
97 */
98 protected $mvcPropertyMappingConfigurationService;
99
100 /**
101 * The current request.
102 *
103 * @var \TYPO3\CMS\Extbase\Mvc\Request
104 * @api
105 */
106 protected $request;
107
108 /**
109 * The response which will be returned by this action controller
110 *
111 * @var \TYPO3\CMS\Extbase\Mvc\Response
112 * @api
113 */
114 protected $response;
115
116 /**
117 * @param \TYPO3\CMS\Extbase\Reflection\ReflectionService $reflectionService
118 */
119 public function injectReflectionService(\TYPO3\CMS\Extbase\Reflection\ReflectionService $reflectionService)
120 {
121 $this->reflectionService = $reflectionService;
122 }
123
124 /**
125 * @param \TYPO3\CMS\Extbase\Service\CacheService $cacheService
126 */
127 public function injectCacheService(\TYPO3\CMS\Extbase\Service\CacheService $cacheService)
128 {
129 $this->cacheService = $cacheService;
130 }
131
132 /**
133 * @param \TYPO3\CMS\Extbase\Mvc\Controller\MvcPropertyMappingConfigurationService $mvcPropertyMappingConfigurationService
134 */
135 public function injectMvcPropertyMappingConfigurationService(\TYPO3\CMS\Extbase\Mvc\Controller\MvcPropertyMappingConfigurationService $mvcPropertyMappingConfigurationService)
136 {
137 $this->mvcPropertyMappingConfigurationService = $mvcPropertyMappingConfigurationService;
138 }
139
140 /**
141 * Handles a request. The result output is returned by altering the given response.
142 *
143 * @param \TYPO3\CMS\Extbase\Mvc\RequestInterface $request The request object
144 * @param \TYPO3\CMS\Extbase\Mvc\ResponseInterface $response The response, modified by this handler
145 *
146 * @throws \TYPO3\CMS\Extbase\Mvc\Exception\UnsupportedRequestTypeException
147 */
148 public function processRequest(\TYPO3\CMS\Extbase\Mvc\RequestInterface $request, \TYPO3\CMS\Extbase\Mvc\ResponseInterface $response)
149 {
150 if (!$this->canProcessRequest($request)) {
151 throw new \TYPO3\CMS\Extbase\Mvc\Exception\UnsupportedRequestTypeException(static::class . ' does not support requests of type "' . get_class($request) . '". Supported types are: ' . implode(' ', $this->supportedRequestTypes), 1187701131);
152 }
153
154 if ($response instanceof \TYPO3\CMS\Extbase\Mvc\Web\Response && $request instanceof WebRequest) {
155 $response->setRequest($request);
156 }
157 $this->request = $request;
158 $this->request->setDispatched(true);
159 $this->response = $response;
160 $this->uriBuilder = $this->objectManager->get(\TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder::class);
161 $this->uriBuilder->setRequest($request);
162 $this->actionMethodName = $this->resolveActionMethodName();
163 $this->initializeActionMethodArguments();
164 $this->initializeActionMethodValidators();
165 $this->mvcPropertyMappingConfigurationService->initializePropertyMappingConfigurationFromRequest($request, $this->arguments);
166 $this->initializeAction();
167 $actionInitializationMethodName = 'initialize' . ucfirst($this->actionMethodName);
168 if (method_exists($this, $actionInitializationMethodName)) {
169 call_user_func([$this, $actionInitializationMethodName]);
170 }
171 $this->mapRequestArgumentsToControllerArguments();
172 $this->controllerContext = $this->buildControllerContext();
173 $this->view = $this->resolveView();
174 if ($this->view !== null) {
175 $this->initializeView($this->view);
176 }
177 $this->callActionMethod();
178 $this->renderAssetsForRequest($request);
179 }
180
181 /**
182 * Method which initializes assets that should be attached to the response
183 * for the given $request, which contains parameters that an override can
184 * use to determine which assets to add via PageRenderer.
185 *
186 * This default implementation will attempt to render the sections "HeaderAssets"
187 * and "FooterAssets" from the template that is being rendered, inserting the
188 * rendered content into either page header or footer, as appropriate. Both
189 * sections are optional and can be used one or both in combination.
190 *
191 * You can add assets with this method without worrying about duplicates, if
192 * for example you do this in a plugin that gets used multiple time on a page.
193 *
194 * @param \TYPO3\CMS\Extbase\Mvc\RequestInterface $request
195 */
196 protected function renderAssetsForRequest($request)
197 {
198 if (!$this->view instanceof TemplateView) {
199 // Only TemplateView (from Fluid engine, so this includes all TYPO3 Views based
200 // on TYPO3's AbstractTemplateView) supports renderSection(). The method is not
201 // declared on ViewInterface - so we must assert a specific class. We silently skip
202 // asset processing if the View doesn't match, so we don't risk breaking custom Views.
203 return;
204 }
205 $pageRenderer = $this->objectManager->get(PageRenderer::class);
206 $variables = ['request' => $request, 'arguments' => $this->arguments];
207 $headerAssets = $this->view->renderSection('HeaderAssets', $variables, true);
208 $footerAssets = $this->view->renderSection('FooterAssets', $variables, true);
209 if (!empty(trim($headerAssets))) {
210 $pageRenderer->addHeaderData($headerAssets);
211 }
212 if (!empty(trim($footerAssets))) {
213 $pageRenderer->addFooterData($footerAssets);
214 }
215 }
216
217 /**
218 * Implementation of the arguments initilization in the action controller:
219 * Automatically registers arguments of the current action
220 *
221 * Don't override this method - use initializeAction() instead.
222 *
223 * @throws \TYPO3\CMS\Extbase\Mvc\Exception\InvalidArgumentTypeException
224 * @see initializeArguments()
225 */
226 protected function initializeActionMethodArguments()
227 {
228 $methodParameters = $this->reflectionService->getMethodParameters(static::class, $this->actionMethodName);
229 foreach ($methodParameters as $parameterName => $parameterInfo) {
230 $dataType = null;
231 if (isset($parameterInfo['type'])) {
232 $dataType = $parameterInfo['type'];
233 } elseif ($parameterInfo['array']) {
234 $dataType = 'array';
235 }
236 if ($dataType === null) {
237 throw new \TYPO3\CMS\Extbase\Mvc\Exception\InvalidArgumentTypeException('The argument type for parameter $' . $parameterName . ' of method ' . static::class . '->' . $this->actionMethodName . '() could not be detected.', 1253175643);
238 }
239 $defaultValue = $parameterInfo['hasDefaultValue'] === true ? $parameterInfo['defaultValue'] : null;
240 $this->arguments->addNewArgument($parameterName, $dataType, $parameterInfo['optional'] === false, $defaultValue);
241 }
242 }
243
244 /**
245 * Adds the needed validators to the Arguments:
246 *
247 * - Validators checking the data type from the @param annotation
248 * - Custom validators specified with validate annotations.
249 * - Model-based validators (validate annotations in the model)
250 * - Custom model validator classes
251 */
252 protected function initializeActionMethodValidators()
253 {
254 if ($this->arguments->count() === 0) {
255 return;
256 }
257
258 $classSchema = $this->reflectionService->getClassSchema(static::class);
259
260 /** @var \TYPO3\CMS\Extbase\Mvc\Controller\Argument $argument */
261 foreach ($this->arguments as $argument) {
262 $validator = $this->objectManager->get(ConjunctionValidator::class);
263 $validatorDefinitions = $classSchema->getMethod($this->actionMethodName)['params'][$argument->getName()]['validators'] ?? [];
264
265 foreach ($validatorDefinitions as $validatorDefinition) {
266 /** @var ValidatorInterface $validatorInstance */
267 $validatorInstance = $this->objectManager->get(
268 $validatorDefinition['className'],
269 $validatorDefinition['options']
270 );
271
272 $validator->addValidator(
273 $validatorInstance
274 );
275 }
276
277 $baseValidatorConjunction = $this->validatorResolver->getBaseValidatorConjunction($argument->getDataType());
278 if ($baseValidatorConjunction->count() > 0) {
279 $validator->addValidator($baseValidatorConjunction);
280 }
281 $argument->setValidator($validator);
282 }
283 }
284
285 /**
286 * Resolves and checks the current action method name
287 *
288 * @return string Method name of the current action
289 * @throws \TYPO3\CMS\Extbase\Mvc\Exception\NoSuchActionException if the action specified in the request object does not exist (and if there's no default action either).
290 */
291 protected function resolveActionMethodName()
292 {
293 $actionMethodName = $this->request->getControllerActionName() . 'Action';
294 if (!method_exists($this, $actionMethodName)) {
295 throw new \TYPO3\CMS\Extbase\Mvc\Exception\NoSuchActionException('An action "' . $actionMethodName . '" does not exist in controller "' . static::class . '".', 1186669086);
296 }
297 return $actionMethodName;
298 }
299
300 /**
301 * Calls the specified action method and passes the arguments.
302 *
303 * If the action returns a string, it is appended to the content in the
304 * response object. If the action doesn't return anything and a valid
305 * view exists, the view is rendered automatically.
306 *
307 * @api
308 */
309 protected function callActionMethod()
310 {
311 $preparedArguments = [];
312 /** @var \TYPO3\CMS\Extbase\Mvc\Controller\Argument $argument */
313 foreach ($this->arguments as $argument) {
314 $preparedArguments[] = $argument->getValue();
315 }
316 $validationResult = $this->arguments->getValidationResults();
317 if (!$validationResult->hasErrors()) {
318 $this->emitBeforeCallActionMethodSignal($preparedArguments);
319 $actionResult = call_user_func_array([$this, $this->actionMethodName], $preparedArguments);
320 } else {
321 $methodTagsValues = $this->reflectionService->getMethodTagsValues(static::class, $this->actionMethodName);
322 $ignoreValidationAnnotations = $methodTagsValues['ignorevalidation'] ?? [];
323
324 // if there exist errors which are not ignored with @TYPO3\CMS\Extbase\Annotation\IgnoreValidation => call error method
325 // else => call action method
326 $shouldCallActionMethod = true;
327 foreach ($validationResult->getSubResults() as $argumentName => $subValidationResult) {
328 if (!$subValidationResult->hasErrors()) {
329 continue;
330 }
331 if (in_array($argumentName, $ignoreValidationAnnotations, true)) {
332 continue;
333 }
334 $shouldCallActionMethod = false;
335 break;
336 }
337 if ($shouldCallActionMethod) {
338 $this->emitBeforeCallActionMethodSignal($preparedArguments);
339 $actionResult = call_user_func_array([$this, $this->actionMethodName], $preparedArguments);
340 } else {
341 $actionResult = call_user_func([$this, $this->errorMethodName]);
342 }
343 }
344
345 if ($actionResult === null && $this->view instanceof ViewInterface) {
346 $this->response->appendContent($this->view->render());
347 } elseif (is_string($actionResult) && $actionResult !== '') {
348 $this->response->appendContent($actionResult);
349 } elseif (is_object($actionResult) && method_exists($actionResult, '__toString')) {
350 $this->response->appendContent((string)$actionResult);
351 }
352 }
353
354 /**
355 * Emits a signal before the current action is called
356 *
357 * @param array $preparedArguments
358 */
359 protected function emitBeforeCallActionMethodSignal(array $preparedArguments)
360 {
361 $this->signalSlotDispatcher->dispatch(__CLASS__, 'beforeCallActionMethod', [static::class, $this->actionMethodName, $preparedArguments]);
362 }
363
364 /**
365 * Prepares a view for the current action.
366 * By default, this method tries to locate a view with a name matching the current action.
367 *
368 * @return ViewInterface
369 * @api
370 */
371 protected function resolveView()
372 {
373 $viewObjectName = $this->resolveViewObjectName();
374 if ($viewObjectName !== false) {
375 /** @var $view ViewInterface */
376 $view = $this->objectManager->get($viewObjectName);
377 $this->setViewConfiguration($view);
378 if ($view->canRender($this->controllerContext) === false) {
379 unset($view);
380 }
381 }
382 if (!isset($view) && $this->defaultViewObjectName != '') {
383 /** @var $view ViewInterface */
384 $view = $this->objectManager->get($this->defaultViewObjectName);
385 $this->setViewConfiguration($view);
386 if ($view->canRender($this->controllerContext) === false) {
387 unset($view);
388 }
389 }
390 if (!isset($view)) {
391 $view = $this->objectManager->get(\TYPO3\CMS\Extbase\Mvc\View\NotFoundView::class);
392 $view->assign('errorMessage', 'No template was found. View could not be resolved for action "'
393 . $this->request->getControllerActionName() . '" in class "' . $this->request->getControllerObjectName() . '"');
394 }
395 $view->setControllerContext($this->controllerContext);
396 if (method_exists($view, 'injectSettings')) {
397 $view->injectSettings($this->settings);
398 }
399 $view->initializeView();
400 // In TYPO3.Flow, solved through Object Lifecycle methods, we need to call it explicitly
401 $view->assign('settings', $this->settings);
402 // same with settings injection.
403 return $view;
404 }
405
406 /**
407 * @param ViewInterface $view
408 */
409 protected function setViewConfiguration(ViewInterface $view)
410 {
411 // Template Path Override
412 $extbaseFrameworkConfiguration = $this->configurationManager->getConfiguration(
413 ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK
414 );
415
416 // set TemplateRootPaths
417 $viewFunctionName = 'setTemplateRootPaths';
418 if (method_exists($view, $viewFunctionName)) {
419 $setting = 'templateRootPaths';
420 $parameter = $this->getViewProperty($extbaseFrameworkConfiguration, $setting);
421 // no need to bother if there is nothing to set
422 if ($parameter) {
423 $view->$viewFunctionName($parameter);
424 }
425 }
426
427 // set LayoutRootPaths
428 $viewFunctionName = 'setLayoutRootPaths';
429 if (method_exists($view, $viewFunctionName)) {
430 $setting = 'layoutRootPaths';
431 $parameter = $this->getViewProperty($extbaseFrameworkConfiguration, $setting);
432 // no need to bother if there is nothing to set
433 if ($parameter) {
434 $view->$viewFunctionName($parameter);
435 }
436 }
437
438 // set PartialRootPaths
439 $viewFunctionName = 'setPartialRootPaths';
440 if (method_exists($view, $viewFunctionName)) {
441 $setting = 'partialRootPaths';
442 $parameter = $this->getViewProperty($extbaseFrameworkConfiguration, $setting);
443 // no need to bother if there is nothing to set
444 if ($parameter) {
445 $view->$viewFunctionName($parameter);
446 }
447 }
448 }
449
450 /**
451 * Handles the path resolving for *rootPath(s)
452 *
453 * numerical arrays get ordered by key ascending
454 *
455 * @param array $extbaseFrameworkConfiguration
456 * @param string $setting parameter name from TypoScript
457 *
458 * @return array
459 */
460 protected function getViewProperty($extbaseFrameworkConfiguration, $setting)
461 {
462 $values = [];
463 if (
464 !empty($extbaseFrameworkConfiguration['view'][$setting])
465 && is_array($extbaseFrameworkConfiguration['view'][$setting])
466 ) {
467 $values = $extbaseFrameworkConfiguration['view'][$setting];
468 }
469
470 return $values;
471 }
472
473 /**
474 * Determines the fully qualified view object name.
475 *
476 * @return mixed The fully qualified view object name or FALSE if no matching view could be found.
477 * @api
478 */
479 protected function resolveViewObjectName()
480 {
481 $vendorName = $this->request->getControllerVendorName();
482 if ($vendorName === null) {
483 return false;
484 }
485
486 $possibleViewName = str_replace(
487 [
488 '@vendor',
489 '@extension',
490 '@controller',
491 '@action'
492 ],
493 [
494 $vendorName,
495 $this->request->getControllerExtensionName(),
496 $this->request->getControllerName(),
497 ucfirst($this->request->getControllerActionName())
498 ],
499 $this->namespacesViewObjectNamePattern
500 );
501 $format = $this->request->getFormat();
502 $viewObjectName = str_replace('@format', ucfirst($format), $possibleViewName);
503 if (class_exists($viewObjectName) === false) {
504 $viewObjectName = str_replace('@format', '', $possibleViewName);
505 }
506 if (isset($this->viewFormatToObjectNameMap[$format]) && class_exists($viewObjectName) === false) {
507 $viewObjectName = $this->viewFormatToObjectNameMap[$format];
508 }
509 return class_exists($viewObjectName) ? $viewObjectName : false;
510 }
511
512 /**
513 * Initializes the view before invoking an action method.
514 *
515 * Override this method to solve assign variables common for all actions
516 * or prepare the view in another way before the action is called.
517 *
518 * @param ViewInterface $view The view to be initialized
519 *
520 * @api
521 */
522 protected function initializeView(ViewInterface $view)
523 {
524 }
525
526 /**
527 * Initializes the controller before invoking an action method.
528 *
529 * Override this method to solve tasks which all actions have in
530 * common.
531 *
532 * @api
533 */
534 protected function initializeAction()
535 {
536 }
537
538 /**
539 * A special action which is called if the originally intended action could
540 * not be called, for example if the arguments were not valid.
541 *
542 * The default implementation sets a flash message, request errors and forwards back
543 * to the originating action. This is suitable for most actions dealing with form input.
544 *
545 * We clear the page cache by default on an error as well, as we need to make sure the
546 * data is re-evaluated when the user changes something.
547 *
548 * @return string
549 * @api
550 */
551 protected function errorAction()
552 {
553 $this->clearCacheOnError();
554 $this->addErrorFlashMessage();
555 $this->forwardToReferringRequest();
556
557 return $this->getFlattenedValidationErrorMessage();
558 }
559
560 /**
561 * Clear cache of current page on error. Needed because we want a re-evaluation of the data.
562 * Better would be just do delete the cache for the error action, but that is not possible right now.
563 */
564 protected function clearCacheOnError()
565 {
566 $extbaseSettings = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK);
567 if (isset($extbaseSettings['persistence']['enableAutomaticCacheClearing']) && $extbaseSettings['persistence']['enableAutomaticCacheClearing'] === '1') {
568 if (isset($GLOBALS['TSFE'])) {
569 $pageUid = $GLOBALS['TSFE']->id;
570 $this->cacheService->clearPageCache([$pageUid]);
571 }
572 }
573 }
574
575 /**
576 * If an error occurred during this request, this adds a flash message describing the error to the flash
577 * message container.
578 */
579 protected function addErrorFlashMessage()
580 {
581 $errorFlashMessage = $this->getErrorFlashMessage();
582 if ($errorFlashMessage !== false) {
583 $this->addFlashMessage($errorFlashMessage, '', FlashMessage::ERROR);
584 }
585 }
586
587 /**
588 * A template method for displaying custom error flash messages, or to
589 * display no flash message at all on errors. Override this to customize
590 * the flash message in your action controller.
591 *
592 * @return string The flash message or FALSE if no flash message should be set
593 * @api
594 */
595 protected function getErrorFlashMessage()
596 {
597 return 'An error occurred while trying to call ' . static::class . '->' . $this->actionMethodName . '()';
598 }
599
600 /**
601 * If information on the request before the current request was sent, this method forwards back
602 * to the originating request. This effectively ends processing of the current request, so do not
603 * call this method before you have finished the necessary business logic!
604 *
605 * @throws StopActionException
606 */
607 protected function forwardToReferringRequest()
608 {
609 $referringRequest = $this->request->getReferringRequest();
610 if ($referringRequest !== null) {
611 $originalRequest = clone $this->request;
612 $this->request->setOriginalRequest($originalRequest);
613 $this->request->setOriginalRequestMappingResults($this->arguments->getValidationResults());
614 $this->forward(
615 $referringRequest->getControllerActionName(),
616 $referringRequest->getControllerName(),
617 $referringRequest->getControllerExtensionName(),
618 $referringRequest->getArguments()
619 );
620 }
621 }
622
623 /**
624 * Returns a string with a basic error message about validation failure.
625 * We may add all validation error messages to a log file in the future,
626 * but for security reasons (@see #54074) we do not return these here.
627 *
628 * @return string
629 */
630 protected function getFlattenedValidationErrorMessage()
631 {
632 $outputMessage = 'Validation failed while trying to call ' . static::class . '->' . $this->actionMethodName . '().' . PHP_EOL;
633 return $outputMessage;
634 }
635
636 /**
637 * Returns a map of action method names and their parameters.
638 *
639 * @param \TYPO3\CMS\Extbase\Object\ObjectManagerInterface $objectManager
640 *
641 * @return array Array of method parameters by action name
642 * @deprecated
643 */
644 public static function getActionMethodParameters($objectManager)
645 {
646 trigger_error(
647 'Method ' . __METHOD__ . ' is deprecated and will be removed in TYPO3 v10.0.',
648 E_USER_DEPRECATED
649 );
650
651 $reflectionService = $objectManager->get(\TYPO3\CMS\Extbase\Reflection\ReflectionService::class);
652
653 $result = [];
654
655 $className = get_called_class();
656 $methodNames = get_class_methods($className);
657 foreach ($methodNames as $methodName) {
658 if (strlen($methodName) > 6 && strpos($methodName, 'Action', strlen($methodName) - 6) !== false) {
659 $result[$methodName] = $reflectionService->getMethodParameters($className, $methodName);
660 }
661 }
662
663 return $result;
664 }
665 }