TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:15 +02:00
commit 6830982e7d
295 changed files with 31995 additions and 0 deletions
+967
View File
@@ -0,0 +1,967 @@
<?php
/*
* 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\Extbase\Mvc\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Core\Crypto\HashAlgo;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Http\PropagateResponseException;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\View\ViewFactoryData;
use TYPO3\CMS\Core\View\ViewFactoryInterface;
use TYPO3\CMS\Core\View\ViewInterface;
use TYPO3\CMS\Extbase\Authorization\AuthorizationFailureReason;
use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
use TYPO3\CMS\Extbase\Event\Mvc\BeforeActionAuthorizationDeniedEvent;
use TYPO3\CMS\Extbase\Event\Mvc\BeforeActionCallEvent;
use TYPO3\CMS\Extbase\Event\Mvc\BeforeActionRateLimitResponseEvent;
use TYPO3\CMS\Extbase\Http\ForwardResponse;
use TYPO3\CMS\Extbase\Mvc\Controller\Exception\RequiredArgumentMissingException;
use TYPO3\CMS\Extbase\Mvc\Exception\InvalidArgumentNameException;
use TYPO3\CMS\Extbase\Mvc\Exception\InvalidArgumentTypeException;
use TYPO3\CMS\Extbase\Mvc\Exception\NoSuchActionException;
use TYPO3\CMS\Extbase\Mvc\ExtbaseRequestParameters;
use TYPO3\CMS\Extbase\Mvc\Request;
use TYPO3\CMS\Extbase\Mvc\RequestInterface;
use TYPO3\CMS\Extbase\Mvc\View\JsonView;
use TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder;
use TYPO3\CMS\Extbase\Property\Exception\TargetNotFoundException;
use TYPO3\CMS\Extbase\Property\PropertyMapper;
use TYPO3\CMS\Extbase\Reflection\ReflectionService;
use TYPO3\CMS\Extbase\Security\HashScope;
use TYPO3\CMS\Extbase\Service\ExtensionService;
use TYPO3\CMS\Extbase\Service\FileHandlingService;
use TYPO3\CMS\Extbase\Utility\LocalizationUtility;
use TYPO3\CMS\Extbase\Validation\Validator\ConjunctionValidator;
use TYPO3\CMS\Extbase\Validation\ValidatorResolver;
use TYPO3\CMS\Fluid\View\FluidViewAdapter;
use TYPO3\CMS\Frontend\Controller\ErrorController;
use TYPO3\CMS\Frontend\Page\PageAccessFailureReasons;
/**
* A multi action controller. This is by far the most common base class for Controllers.
*/
abstract class ActionController implements ControllerInterface
{
protected ResponseFactoryInterface $responseFactory;
protected StreamFactoryInterface $streamFactory;
protected HashService $hashService;
/**
* @internal
*/
protected ReflectionService $reflectionService;
/**
* The current view, as resolved by resolveView()
*/
protected ViewInterface $view;
/**
* The default view class to use. Keep this 'null' for default fluid
* view, or set to 'JsonView::class' or some inheriting class.
*
* @var class-string|null
*/
protected ?string $defaultViewObjectName = null;
/**
* Name of the action method
* @var non-empty-string
* @internal
*/
protected string $actionMethodName = 'indexAction';
/**
* Name of the special error action method which is called in case of errors
*/
protected string $errorMethodName = 'errorAction';
protected MvcPropertyMappingConfigurationService $mvcPropertyMappingConfigurationService;
protected EventDispatcherInterface $eventDispatcher;
protected FileHandlingService $fileHandlingService;
protected RequestInterface $request;
protected UriBuilder $uriBuilder;
protected RateLimitRegistry $rateLimitRegistry;
protected AuthorizeRegistry $authorizeRegistry;
/**
* Contains the settings of the current extension
*/
protected array $settings;
/**
* @internal
*/
protected ValidatorResolver $validatorResolver;
private ViewFactoryInterface $viewFactory;
protected Arguments $arguments;
/**
* @internal
*/
protected ConfigurationManagerInterface $configurationManager;
/**
* @internal
*/
private PropertyMapper $propertyMapper;
/**
* @internal
*/
private FlashMessageService $internalFlashMessageService;
/**
* @internal
*/
private ExtensionService $internalExtensionService;
final public function injectResponseFactory(ResponseFactoryInterface $responseFactory): void
{
$this->responseFactory = $responseFactory;
}
final public function injectStreamFactory(StreamFactoryInterface $streamFactory): void
{
$this->streamFactory = $streamFactory;
}
/**
* @internal
*/
public function injectConfigurationManager(ConfigurationManagerInterface $configurationManager): void
{
$this->configurationManager = $configurationManager;
$this->settings = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_SETTINGS);
$this->arguments = GeneralUtility::makeInstance(Arguments::class);
}
/**
* @internal
*/
public function injectValidatorResolver(ValidatorResolver $validatorResolver): void
{
$this->validatorResolver = $validatorResolver;
}
final public function injectViewFactory(ViewFactoryInterface $viewFactory): void
{
$this->viewFactory = $viewFactory;
}
/**
* @internal
*/
public function injectReflectionService(ReflectionService $reflectionService): void
{
$this->reflectionService = $reflectionService;
}
/**
* @internal
*/
public function injectHashService(HashService $hashService): void
{
$this->hashService = $hashService;
}
public function injectMvcPropertyMappingConfigurationService(MvcPropertyMappingConfigurationService $mvcPropertyMappingConfigurationService): void
{
$this->mvcPropertyMappingConfigurationService = $mvcPropertyMappingConfigurationService;
}
public function injectEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->eventDispatcher = $eventDispatcher;
}
public function injectFileHandlingService(FileHandlingService $fileHandlingService): void
{
$this->fileHandlingService = $fileHandlingService;
}
public function injectRateLimitRegistry(RateLimitRegistry $rateLimitRegistry): void
{
$this->rateLimitRegistry = $rateLimitRegistry;
}
public function injectAuthorizeRegistry(AuthorizeRegistry $authorizeRegistry): void
{
$this->authorizeRegistry = $authorizeRegistry;
}
/**
* @internal
*/
public function injectPropertyMapper(PropertyMapper $propertyMapper): void
{
$this->propertyMapper = $propertyMapper;
}
/**
* @internal
*/
final public function injectInternalFlashMessageService(FlashMessageService $flashMessageService): void
{
$this->internalFlashMessageService = $flashMessageService;
}
/**
* @internal
*/
final public function injectInternalExtensionService(ExtensionService $extensionService): void
{
$this->internalExtensionService = $extensionService;
}
/**
* Initializes the controller before invoking an action method.
*
* Override this method to solve tasks which all actions have in
* common.
*/
protected function initializeAction(): void {}
/**
* Implementation of the arguments initialization in the action controller:
* Automatically registers arguments of the current action
*
* Don't override this method - use initializeAction() instead.
*
* @throws InvalidArgumentTypeException
* @see initializeArguments()
*
* @internal
*/
protected function initializeActionMethodArguments(): void
{
$methodParameters = $this->reflectionService
->getClassSchema(static::class)
->getMethod($this->actionMethodName)->getParameters();
foreach ($methodParameters as $parameterName => $parameter) {
$dataType = null;
if ($parameter->getType() !== null) {
$dataType = $parameter->getType();
} elseif ($parameter->isArray()) {
$dataType = 'array';
}
if ($dataType === null) {
throw new InvalidArgumentTypeException('The argument type for parameter $' . $parameterName . ' of method ' . static::class . '->' . $this->actionMethodName . '() could not be detected.', 1253175643);
}
$defaultValue = $parameter->hasDefaultValue() ? $parameter->getDefaultValue() : null;
$this->arguments->addNewArgument($parameterName, $dataType, !$parameter->isOptional(), $defaultValue);
}
}
/**
* Adds the needed validators to the Arguments:
*
* - Validators checking the data type from the param annotation
* - Custom validators specified with #[Validate] attributes.
* - Model-based validators (#[Validate] attributes in the model)
* - Custom model validator classes
*
* @internal
*/
protected function initializeActionMethodValidators(): void
{
if ($this->arguments->count() === 0) {
return;
}
$classSchemaMethod = $this->reflectionService->getClassSchema(static::class)->getMethod($this->actionMethodName);
/** @var Argument $argument */
foreach ($this->arguments as $argument) {
$classSchemaMethodParameter = $classSchemaMethod->getParameter($argument->getName());
// At this point validation is skipped if there is an #[IgnoreValidation] attribute.
// @todo: IgnoreValidation attributes could be evaluated in the ClassSchema and result in
// no validators being applied to the method parameter.
if ($classSchemaMethodParameter->ignoreValidation()) {
continue;
}
/** @var ConjunctionValidator $validator */
$validator = $this->validatorResolver->createValidator(ConjunctionValidator::class);
foreach ($classSchemaMethodParameter->getValidators() as $validatorDefinition) {
if (isset($validatorDefinition['constraint'])) {
$validatorInstance = $validatorDefinition['constraint'];
} else {
$validatorInstance = $this->validatorResolver->createValidator(
$validatorDefinition['className'],
$validatorDefinition['options'],
$this->request,
);
}
if ($validatorInstance !== null) {
$validator->addValidator($validatorInstance);
}
}
$baseValidatorConjunction = $this->validatorResolver->getBaseValidatorConjunction(
$argument->getDataType(),
$this->request
);
if ($baseValidatorConjunction->count() > 0) {
$validator->addValidator($baseValidatorConjunction);
}
$argument->setValidator($validator);
}
}
protected function initializeStateFromExtbaseRequestParameters(): void
{
$extbaseRequestParameters = $this->request->getAttribute('extbase');
if (!$extbaseRequestParameters instanceof ExtbaseRequestParameters) {
return;
}
$flashMessageQueue = $this->getFlashMessageQueue();
foreach ($extbaseRequestParameters->getOriginalFlashMessages() as $flashMessage) {
$flashMessage->setStoreInSession(false);
$flashMessageQueue->enqueue($flashMessage);
}
}
/**
* Handles an incoming request and returns a response object
*
* @internal
*/
public function processRequest(RequestInterface $request): ResponseInterface
{
/** @var Request $request */
$this->request = $request;
$this->uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
$this->uriBuilder->setRequest($request);
$this->actionMethodName = $this->resolveActionMethodName();
$this->initializeActionMethodArguments();
$this->initializeActionMethodValidators();
$this->initializeStateFromExtbaseRequestParameters();
$this->mvcPropertyMappingConfigurationService->initializePropertyMappingConfigurationFromRequest($request, $this->arguments);
$this->fileHandlingService->initializeFileUploadConfigurationsFromRequest($request, $this->arguments);
$this->initializeAction();
$actionInitializationMethodName = 'initialize' . ucfirst($this->actionMethodName);
/** @var callable|null $callable */
$callable = [$this, $actionInitializationMethodName];
if (is_callable($callable)) {
$callable();
}
$this->mapRequestArgumentsToControllerArguments();
$this->view = $this->resolveView();
if (method_exists($this, 'initializeView')) {
// @todo: We may want to get rid of this and declare actions should actively create own
// views using ViewFactoryInterface instead. See comment on resolveView() below.
// Currently, this method is pretty much only helpful in 'xclass' scenarios,
// since actions can already do whatever happens here within their action body.
$this->initializeView($this->view);
}
$response = $this->callActionMethod($request);
return $response;
}
/**
* Resolves and checks the current action method name
*
* @throws NoSuchActionException if the action specified in the request object does not exist (and if there's no default action either).
*
* @internal
*/
protected function resolveActionMethodName(): string
{
$actionMethodName = $this->request->getControllerActionName() . 'Action';
if (!method_exists($this, $actionMethodName)) {
throw new NoSuchActionException('An action "' . $actionMethodName . '" does not exist in controller "' . static::class . '".', 1186669086);
}
return $actionMethodName;
}
/**
* Calls the specified action method and passes the arguments.
*
* If the action returns a string, it is appended to the content in the
* response object. If the action doesn't return anything and a valid
* view exists, the view is rendered automatically.
*
* @internal
*/
protected function callActionMethod(RequestInterface $request): ResponseInterface
{
// incoming request is not needed yet but can be passed into the action in the future like in symfony
// todo: support this via method-reflection
$this->fileHandlingService->initializeFileUploadDeletionConfigurationsFromRequest($request, $this->arguments);
$validationResult = $this->arguments->validate();
if (!$validationResult->hasErrors()) {
$preparedArguments = [];
/** @var Argument $argument */
foreach ($this->arguments as $argument) {
$this->fileHandlingService->applyDeletionsToArgument($argument);
$this->fileHandlingService->mapUploadedFilesToArgument($argument);
$preparedArguments[] = $argument->getValue();
}
if (($authorizeResponse = $this->performAuthorizationChecks($request, $preparedArguments)) !== null) {
return $authorizeResponse;
}
if (($rateLimitResponse = $this->handleRateLimit($request)) !== null) {
return $rateLimitResponse;
}
$this->eventDispatcher->dispatch(new BeforeActionCallEvent(static::class, $this->actionMethodName, $preparedArguments, $this->request));
$actionResult = $this->{$this->actionMethodName}(...$preparedArguments);
} else {
$actionResult = $this->{$this->errorMethodName}();
}
if ($actionResult instanceof ResponseInterface) {
return $actionResult;
}
throw new \RuntimeException(
sprintf(
'Controller action %s did not return an instance of %s.',
static::class . '::' . $this->actionMethodName,
ResponseInterface::class
),
1638554283
);
}
/**
* Prepares a view for the current action.
*
* @internal
* @todo We may want to decide in extbase to go away from the automatic view preparation via
* processRequest() and this method for actions. We could very well postulate actions
* should take care of creating "their" view on their own using a ViewFactoryInterface
* implementation, similar to what is done with request creation already (which needs
* further work, too), and have a helper in this class to easily create a standard view.
* This would dissolve the ugly $this->defaultViewObjectName property, which is more
* a burden than helpful since controllers then need to have an initializeFooAction()
* just to set this property when different actions want different views. Also, it does
* not allow actions to have no view prepared at all, for instance when they just want to
* create a json response by json_encode()'ing stuff. We should look at this in v14, which
* renders property defaultViewObjectName even more useless.
*/
protected function resolveView(): ViewInterface
{
if ($this->defaultViewObjectName !== null && is_a($this->defaultViewObjectName, JsonView::class, true)) {
// @todo: JsonView is a very extbase specific thing. It comes with setVariablesToRender() and
// setConfiguration(). We don't let it run through a factory here, since consumers need
// to deal with these specialities anyways. Often, one would rather want to either have
// an own view prepared in a controller (or action), or have a custom factory that deals
// with stuff and returns a ViewInterface, or directly json_encode() data in an action.
// This is related to the comment above, too.
$view = new JsonView();
$view->assign('settings', $this->settings);
return $view;
}
$configuration = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK);
$extensionKey = $this->request->getControllerExtensionKey();
$templateRootPaths = $this->addDefaultPathToPaths($configuration['view']['templateRootPaths'] ?? [], 'EXT:' . $extensionKey . '/Resources/Private/Templates/');
$layoutRootPaths = $this->addDefaultPathToPaths($configuration['view']['layoutRootPaths'] ?? [], 'EXT:' . $extensionKey . '/Resources/Private/Layouts/');
$partialRootPaths = $this->addDefaultPathToPaths($configuration['view']['partialRootPaths'] ?? [], 'EXT:' . $extensionKey . '/Resources/Private/Partials/');
if ($this->defaultViewObjectName === null) {
$viewFactoryData = new ViewFactoryData(
templateRootPaths: $templateRootPaths,
partialRootPaths: $partialRootPaths,
layoutRootPaths: $layoutRootPaths,
request: $this->request,
format: $this->request->getFormat(),
);
$view = $this->viewFactory->create($viewFactoryData);
if ($view instanceof FluidViewAdapter) {
// This specific magic is tailored to Fluid. Ignore if we're not dealing with a fluid view here.
$renderingContext = $view->getRenderingContext();
$renderingContext->setControllerName($this->request->getControllerName());
$renderingContext->setControllerAction($this->request->getControllerActionName());
}
$view->assign('settings', $this->settings);
return $view;
}
throw new \RuntimeException(
'The only allowed values for $this->defaultViewObjectName are null or extbase JsonView::class.'
. ' Please create an own view in your action if that is not sufficient, or inject a different'
. ' ViewFactoryInterface',
1729780151
);
}
/**
* Adds extbase's default template path to the configured list of
* template paths. The default path is usually used as a fallback if
* no paths are specified or if the template cannot be found in any
* of the configured paths. However, if the default path is already
* present in the configured paths, the specified position takes
* precedence. This allows the default path to be "moved" within
* the list of paths via configuration.
*
* @return string[]
* @internal
*/
protected function addDefaultPathToPaths(mixed $paths, string $defaultPath): array
{
if (!is_array($paths) || empty($paths)) {
$paths = [$defaultPath];
} else {
$paths = ArrayUtility::sortArrayWithIntegerKeys($paths);
if (!in_array($defaultPath, $paths)) {
$paths = array_merge([$defaultPath], $paths);
}
}
return $paths;
}
/**
* A special action which is called if the originally intended action could
* not be called, for example if the arguments were not valid.
*
* The default implementation sets a flash message, request errors and forwards back
* to the originating action. This is suitable for most actions dealing with form input.
*/
protected function errorAction(): ResponseInterface
{
if (($response = $this->forwardToReferringRequest()) !== null) {
if ($response instanceof ForwardResponse) {
// Add flash messages to queue
$this->addErrorFlashMessage();
// Extract all pending flash messages out of th queue and ensure they
// are passed along the response but without invoking the session.
$flashMessages = $this->getFlashMessageQueue()->getAllMessagesAndFlush();
$response = $response->withFlashMessages(...$flashMessages);
}
return $response->withStatus(400);
}
$response = $this->htmlResponse($this->getFlattenedValidationErrorMessage());
return $response->withStatus(400);
}
/**
* If an error occurred during this request, this adds a flash message describing the error to the flash
* message container.
*
* @internal
*/
protected function addErrorFlashMessage(): void
{
$errorFlashMessage = $this->getErrorFlashMessage();
if (is_string($errorFlashMessage)) {
$this->addFlashMessage($errorFlashMessage, '', ContextualFeedbackSeverity::ERROR, false);
}
}
/**
* A template method for displaying custom error flash messages, or to
* display no flash message at all on errors. Override this to customize
* the flash message in your action controller.
*
* Returns either the flash message or "false" if no flash message should be set
*/
protected function getErrorFlashMessage(): bool|string
{
return 'An error occurred while trying to call ' . static::class . '->' . $this->actionMethodName . '()';
}
/**
* If information on the request before the current request was sent, this method forwards back
* to the originating request. This effectively ends processing of the current request, so do not
* call this method before you have finished the necessary business logic!
*
* @internal
*/
protected function forwardToReferringRequest(): ?ResponseInterface
{
/** @var ExtbaseRequestParameters $extbaseRequestParameters */
$extbaseRequestParameters = $this->request->getAttribute('extbase');
$referringRequestArguments = $extbaseRequestParameters->getInternalArgument('__referrer') ?? null;
if (is_string($referringRequestArguments['@request'] ?? null)) {
$referrerArray = json_decode(
$this->hashService->validateAndStripHmac($referringRequestArguments['@request'], HashScope::ReferringRequest->prefix(), HashAlgo::SHA3_256),
true
);
$arguments = [];
if (is_string($referringRequestArguments['arguments'] ?? null)) {
/* @phpstan-ignore unserialize.allowedClasses.insecure (Integrity check already happens via HMAC validation) */
$arguments = unserialize(
base64_decode($this->hashService->validateAndStripHmac(
$referringRequestArguments['arguments'],
HashScope::ReferringArguments->prefix(),
HashAlgo::SHA3_256
)),
['allowed_classes' => true]
);
}
$replacedArguments = array_replace_recursive($arguments, $referrerArray);
$nonExtbaseBaseArguments = [];
foreach ($replacedArguments as $argumentName => $argumentValue) {
if (!is_string($argumentName) || $argumentName === '') {
throw new InvalidArgumentNameException('Invalid argument name.', 1623940985);
}
if (str_starts_with($argumentName, '__')
|| in_array($argumentName, ['@extension', '@subpackage', '@controller', '@action', '@format'], true)
) {
// Don't handle internalArguments here, not needed for forwardResponse()
continue;
}
$nonExtbaseBaseArguments[$argumentName] = $argumentValue;
}
return (new ForwardResponse((string)($replacedArguments['@action'] ?? 'index')))
->withControllerName((string)($replacedArguments['@controller'] ?? 'Standard'))
->withExtensionName((string)($replacedArguments['@extension'] ?? ''))
->withArguments($nonExtbaseBaseArguments)
->withArgumentsValidationResult($this->arguments->validate());
}
return null;
}
/**
* Returns a string with a basic error message about validation failure.
* We may add all validation error messages to a log file in the future,
* but for security reasons (@see #54074) we do not return these here.
*
* @internal
*/
protected function getFlattenedValidationErrorMessage(): string
{
return 'Validation failed while trying to call ' . static::class . '->' . $this->actionMethodName . '().' . PHP_EOL;
}
/**
* Creates a Message object and adds it to the FlashMessageQueue.
*
* @throws \InvalidArgumentException if the message body is no string
* @see FlashMessage
*/
public function addFlashMessage(
string $messageBody,
string $messageTitle = '',
ContextualFeedbackSeverity $severity = ContextualFeedbackSeverity::OK,
bool $storeInSession = true
): void {
$flashMessage = new FlashMessage(
$messageBody,
$messageTitle,
$severity,
$storeInSession
);
$this->getFlashMessageQueue()->enqueue($flashMessage);
}
/**
* todo: As soon as the incoming request contains the compiled plugin namespace, extbase will offer a trait to
* create a flash message identifier from the current request. Users then should inject the flash message
* service themselves if needed.
*
* @internal
*/
protected function getFlashMessageQueue(?string $identifier = null): FlashMessageQueue
{
if ($identifier === null) {
$pluginNamespace = $this->internalExtensionService->getPluginNamespace(
$this->request->getControllerExtensionName(),
$this->request->getPluginName()
);
$identifier = 'extbase.flashmessages.' . $pluginNamespace;
}
return $this->internalFlashMessageService->getMessageQueueByIdentifier($identifier);
}
/**
* Redirects the request to another action and / or controller.
*
* Redirect will be sent to the client which then performs another request to the new URI.
*
* @param string|null $actionName Name of the action to forward to
* @param string|null $controllerName Unqualified object name of the controller to forward to. If not specified, the current controller is used.
* @param string|null $extensionName Name of the extension containing the controller to forward to. If not specified, the current extension is assumed.
* @param array|null $arguments Arguments to pass to the target action
* @param int|null $pageUid Target page uid. If NULL, the current page uid is used
* @param null $_ (optional) Unused
* @param int $statusCode (optional) The HTTP status code for the redirect. Default is "303 See Other
*/
protected function redirect(
?string $actionName,
?string $controllerName = null,
?string $extensionName = null,
?array $arguments = null,
?int $pageUid = null,
$_ = null,
int $statusCode = 303
): ResponseInterface {
if ($controllerName === null) {
$controllerName = $this->request->getControllerName();
}
$this->uriBuilder->reset()->setCreateAbsoluteUri(true);
if (MathUtility::canBeInterpretedAsInteger($pageUid)) {
$this->uriBuilder->setTargetPageUid((int)$pageUid);
}
if ($this->request->getAttribute('normalizedParams')->isHttps()) {
$this->uriBuilder->setAbsoluteUriScheme('https');
}
$uri = $this->uriBuilder->uriFor($actionName, $arguments, $controllerName, $extensionName);
return $this->redirectToUri($uri, null, $statusCode);
}
/**
* Redirects the web request to another uri.
*
* @param string|UriInterface $uri A string representation of a URI
* @param null $_ (optional) Unused
* @param int $statusCode (optional) The HTTP status code for the redirect. Default is "303 See Other"
*/
protected function redirectToUri(string|UriInterface $uri, $_ = null, int $statusCode = 303): ResponseInterface
{
$uri = $this->addBaseUriIfNecessary((string)$uri);
return new RedirectResponse($uri, $statusCode);
}
/**
* Adds the base uri if not already in place.
*
* @internal
*/
protected function addBaseUriIfNecessary(string $uri): string
{
return GeneralUtility::locationHeaderUrl($uri, $this->request);
}
/**
* Sends the specified HTTP status immediately and only stops to run back through the middleware stack.
* Note: If any other plugin or content or hook is used within a frontend request, this is skipped by design.
*
* @param int $statusCode The HTTP status code
* @param string $statusMessage A custom HTTP status message
* @param string|null $content Body content which further explains the status
* @throws PropagateResponseException
*/
public function throwStatus(int $statusCode, string $statusMessage = '', ?string $content = null): never
{
if ($content === null) {
$content = $statusCode . ' ' . $statusMessage;
}
$response = $this->responseFactory
->createResponse($statusCode, $statusMessage)
->withBody($this->streamFactory->createStream((string)$content));
throw new PropagateResponseException($response, 1476045871);
}
/**
* This method processes exceptions that occur due to missing or not found targets or arguments during argument
* mapping. Based on configuration settings, either a "page not found" response is triggered or the original
* exception is propagated.
*
* Extension authors can override this function to implement additional/custom argument mapping exception handling
*/
protected function handleArgumentMappingExceptions(\Exception $exception): void
{
$configuration = $this->configurationManager->getConfiguration(
ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK
);
$handleTargetNotFoundException = $exception instanceof TargetNotFoundException
&& (bool)($configuration['mvc']['showPageNotFoundIfTargetNotFoundException'] ?? false);
$handleRequiredArgumentMissingException = $exception instanceof RequiredArgumentMissingException
&& (bool)($configuration['mvc']['showPageNotFoundIfRequiredArgumentIsMissingException'] ?? false);
if ($handleTargetNotFoundException || $handleRequiredArgumentMissingException) {
$response = GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
$this->request,
$exception->getMessage()
);
throw new PropagateResponseException($response, 1720242346);
}
throw $exception;
}
/**
* Maps arguments delivered by the request object to the local controller arguments.
*
* @internal
*/
protected function mapRequestArgumentsToControllerArguments(): void
{
try {
/** @var Argument $argument */
foreach ($this->arguments as $argument) {
$argumentName = $argument->getName();
if ($this->request->hasArgument($argumentName)) {
$this->setArgumentValue($argument, $this->request->getArgument($argumentName));
} elseif ($argument->isRequired()) {
throw new RequiredArgumentMissingException('Required argument "' . $argumentName . '" is not set for ' . $this->request->getControllerObjectName() . '->' . $this->request->getControllerActionName() . '.', 1298012500);
}
if ($this->request->getMethod() === 'POST') {
$uploadedFiles = $this->request->getUploadedFiles()[$argumentName] ?? [];
$argument->setUploadedFiles($uploadedFiles);
}
}
} catch (\Exception $exception) {
$this->handleArgumentMappingExceptions($exception);
}
}
private function setArgumentValue(Argument $argument, mixed $rawValue): void
{
if ($rawValue === null) {
$argument->setValue(null);
return;
}
$dataType = $argument->getDataType();
if ($rawValue instanceof $dataType) {
$argument->setValue($rawValue);
return;
}
$this->propertyMapper->resetMessages();
try {
$argument->setValue(
$this->propertyMapper->convert(
$rawValue,
$dataType,
$argument->getPropertyMappingConfiguration()
)
);
} catch (TargetNotFoundException $e) {
// for optional arguments no exception is thrown.
if ($argument->isRequired()) {
throw $e;
}
}
$argument->getValidationResults()->merge($this->propertyMapper->getMessages());
}
/**
* Returns a response object with either the given html string or the current rendered view as content.
*/
protected function htmlResponse(?string $html = null): ResponseInterface
{
return $this->responseFactory->createResponse()
->withHeader('Content-Type', 'text/html; charset=utf-8')
->withBody($this->streamFactory->createStream(($html ?? $this->view->render())));
}
/**
* Returns a response object with either the given json string or the current rendered
* view as content. Mainly to be used for actions / controllers using the JsonView.
*/
protected function jsonResponse(?string $json = null): ResponseInterface
{
return $this->responseFactory->createResponse()
->withHeader('Content-Type', 'application/json; charset=utf-8')
->withBody($this->streamFactory->createStream(($json ?? $this->view->render())));
}
/**
* Handles rate-limiting for the given action request. Checks if the current request exceeds
* a possible defined rate limit for the action method and generates an appropriate response
* if the limit is reached.
*
* @internal
* @return ResponseInterface|null The rate-limited response if the limit is exceeded, or null if no rate-limiting applies.
*/
protected function handleRateLimit(RequestInterface $request): ?ResponseInterface
{
$rateLimiter = $this->rateLimitRegistry->createLimiter(static::class, $this->actionMethodName, $this->request);
if ($rateLimiter === null) {
return null;
}
$rateLimit = $this->rateLimitRegistry->getRateLimit(static::class, $this->actionMethodName);
$limit = $rateLimiter->consume();
if ($limit->isAccepted()) {
return null;
}
$customMessage = null;
if ($rateLimit->message !== '') {
$customMessage = LocalizationUtility::translate($rateLimit->message, $this->request->getControllerExtensionName());
}
$message = $customMessage ?? LocalizationUtility::translate('ratelimit.action.defaultmessage', 'extbase');
$response = $this->responseFactory->createResponse()
->withHeader('Content-Type', 'text/html; charset=utf-8')
->withStatus(429)
->withBody($this->streamFactory->createStream($message));
$event = $this->eventDispatcher->dispatch(
new BeforeActionRateLimitResponseEvent($request, static::class, $this->actionMethodName, $rateLimit, $response)
);
return $event->getResponse();
}
/**
* Performs authorization checks for actions with the #[Authorize] attribute. If access is denied, a HTTP 403
* response is propagated. This behavior can be customized by implementing a event listener for the
* {@see BeforeActionAuthorizationDeniedEvent}.
*
* @internal
*/
protected function performAuthorizationChecks(RequestInterface $request, array $preparedArguments): ?ResponseInterface
{
$result = $this->authorizeRegistry->checkAuthorization($this, $this->actionMethodName, $preparedArguments);
if ($result === null || $result->isAllowed()) {
return null;
}
$message = match ($result->failureReason) {
AuthorizationFailureReason::NOT_LOGGED_IN => 'Access denied: Login required',
AuthorizationFailureReason::MISSING_GROUP => 'Access denied: Insufficient permissions',
AuthorizationFailureReason::CALLBACK_DENIED, null => 'Access denied',
};
$event = $this->eventDispatcher->dispatch(
new BeforeActionAuthorizationDeniedEvent(
$request,
static::class,
$this->actionMethodName,
$result->failedAttribute,
$result->failureReason,
)
);
if (!$event->getResponse()) {
$response = GeneralUtility::makeInstance(ErrorController::class)->accessDeniedAction(
$this->request,
$message,
[
'code' => PageAccessFailureReasons::ACCESS_DENIED_GENERAL,
]
);
throw new PropagateResponseException($response, 1761287264);
}
return $event->getResponse();
}
}
+253
View File
@@ -0,0 +1,253 @@
<?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\Extbase\Mvc\Controller;
use Psr\Http\Message\UploadedFileInterface;
use TYPO3\CMS\Core\Http\UploadedFile;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\Exception\MissingArrayPathException;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Error\Result;
use TYPO3\CMS\Extbase\Utility\TypeHandlingUtility;
use TYPO3\CMS\Extbase\Validation\Validator\ValidatorInterface;
/**
* A controller argument
*/
class Argument
{
protected MvcPropertyMappingConfiguration $propertyMappingConfiguration;
protected FileHandlingServiceConfiguration $fileHandlingServiceConfiguration;
protected string $name = '';
protected string $shortName = '';
protected string $dataType = '';
protected bool $isRequired = false;
protected mixed $value = null;
private bool $hasBeenValidated = false;
/**
* Uploaded files for the argument
* @var array<string, UploadedFileInterface|list<UploadedFileInterface>>
*/
protected array $uploadedFiles = [];
/**
* Default value. Used if argument is optional.
*/
protected mixed $defaultValue = null;
/**
* A custom validator, used supplementary to the base validation
*/
protected ?ValidatorInterface $validator = null;
/**
* The validation results. This can be asked if the argument has errors.
*/
protected Result $validationResults;
/**
* Constructs this controller argument
*
* @throws \InvalidArgumentException if $name is empty string
*/
public function __construct(string $name, string $dataType)
{
if ($name === '') {
throw new \InvalidArgumentException('$name must be a non-empty string.', 1232551853);
}
$this->name = $name;
$this->dataType = TypeHandlingUtility::normalizeType($dataType);
$this->validationResults = new Result();
$this->propertyMappingConfiguration = GeneralUtility::makeInstance(MvcPropertyMappingConfiguration::class);
$this->fileHandlingServiceConfiguration = GeneralUtility::makeInstance(FileHandlingServiceConfiguration::class);
}
public function getName(): string
{
return $this->name;
}
/**
* @throws \InvalidArgumentException if $shortName is not a character
*/
public function setShortName(string $shortName): Argument
{
if (strlen($shortName) !== 1) {
throw new \InvalidArgumentException('$shortName must be a single character or NULL', 1195824959);
}
$this->shortName = $shortName;
return $this;
}
public function getShortName(): string
{
return $this->shortName;
}
public function getDataType(): string
{
return $this->dataType;
}
public function setRequired(bool $required): Argument
{
$this->isRequired = $required;
return $this;
}
public function isRequired(): bool
{
return $this->isRequired;
}
public function setDefaultValue(mixed $defaultValue): Argument
{
$this->defaultValue = $defaultValue;
return $this;
}
public function getDefaultValue(): mixed
{
return $this->defaultValue;
}
/**
* Sets a custom validator which is used supplementary to the base validation
*/
public function setValidator(ValidatorInterface $validator): Argument
{
$this->validator = $validator;
return $this;
}
public function getValidator(): ?ValidatorInterface
{
return $this->validator;
}
public function setValue(mixed $rawValue): Argument
{
$this->value = $rawValue;
return $this;
}
public function getValue(): mixed
{
if ($this->value === null) {
return $this->defaultValue;
}
return $this->value;
}
/**
* Return the Property Mapping Configuration used for this argument; can be used by the initialize*action to modify the Property Mapping.
*/
public function getPropertyMappingConfiguration(): MvcPropertyMappingConfiguration
{
return $this->propertyMappingConfiguration;
}
/**
* Return the FileHandlingServiceConfiguration used for this argument; can be used by the
* initialize*action to modify the file upload configuration for properties.
*/
public function getFileHandlingServiceConfiguration(): FileHandlingServiceConfiguration
{
return $this->fileHandlingServiceConfiguration;
}
public function getUploadedFiles(): array
{
return $this->uploadedFiles;
}
public function setUploadedFiles(array $uploadedFiles): void
{
$this->uploadedFiles = $uploadedFiles;
}
/**
* @return bool TRUE if the argument is valid, FALSE otherwise
*/
public function isValid(): bool
{
return !$this->validate()->hasErrors();
}
/**
* Returns a string representation of this argument's value
*/
public function __toString(): string
{
return (string)$this->value;
}
public function validate(): Result
{
if ($this->hasBeenValidated) {
return $this->validationResults;
}
if ($this->validator !== null) {
$validationMessages = $this->validator->validate($this->value);
$this->validationResults->merge($validationMessages);
}
if ($this->fileHandlingServiceConfiguration->hasfileUploadConfigurations()) {
$fileOperationValidationResults = $this->fileHandlingServiceConfiguration->validateFileOperations($this);
$this->validationResults->merge($fileOperationValidationResults);
}
$this->hasBeenValidated = true;
return $this->validationResults;
}
/**
* Returns an array of possible UploadedFile objects for the given property
* @return list<UploadedFileInterface>
*/
public function getUploadedFilesForProperty(string $propertyName): array
{
$result = [];
try {
$uploadedFiles = ArrayUtility::getValueByPath($this->uploadedFiles, $propertyName, '.');
if ($uploadedFiles instanceof UploadedFile) {
$result = [$uploadedFiles];
} elseif (is_iterable($uploadedFiles)) {
foreach ($uploadedFiles as $uploadedFile) {
$result[] = $uploadedFile;
}
}
} catch (MissingArrayPathException) {
// Do nothing, empty array will be returned
}
return $result;
}
/**
* @internal only to be used within Extbase, not part of TYPO3 Core API.
*/
public function getValidationResults(): Result
{
return $this->validationResults;
}
}
+235
View File
@@ -0,0 +1,235 @@
<?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\Extbase\Mvc\Controller;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Error\Result;
use TYPO3\CMS\Extbase\Mvc\Exception\NoSuchArgumentException;
/**
* A composite of controller arguments
* @internal only to be used within Extbase, not part of TYPO3 Core API.
*/
class Arguments extends \ArrayObject
{
protected array $argumentNames = [];
protected array $argumentShortNames = [];
/**
* Constructor. If this one is removed, reflection breaks.
*/
public function __construct()
{
parent::__construct();
}
/**
* Adds or replaces the argument specified by $value. The argument's name is taken from the
* argument object itself, therefore the $offset does not have any meaning in this context.
*
* @param mixed $offset Offset - not used here
* @throws \InvalidArgumentException if the argument is not a valid Controller Argument object
*/
public function offsetSet(mixed $offset, mixed $value): void
{
if (!$value instanceof Argument) {
throw new \InvalidArgumentException('Controller arguments must be valid TYPO3\\CMS\\Extbase\\Mvc\\Controller\\Argument objects.', 1187953786);
}
$argumentName = $value->getName();
parent::offsetSet($argumentName, $value);
$this->argumentNames[$argumentName] = true;
}
/**
* Sets an argument, aliased to offsetSet()
*
* @throws \InvalidArgumentException if the argument is not a valid Controller Argument object
*/
public function append(mixed $value): void
{
if (!$value instanceof Argument) {
throw new \InvalidArgumentException('Controller arguments must be valid TYPO3\\CMS\\Extbase\\Mvc\\Controller\\Argument objects.', 1187953787);
}
$this->offsetSet(null, $value);
}
public function offsetUnset(mixed $offset): void
{
$translatedOffset = $this->translateToLongArgumentName($offset);
parent::offsetUnset($translatedOffset);
unset($this->argumentNames[$translatedOffset]);
if ($offset != $translatedOffset) {
unset($this->argumentShortNames[$offset]);
}
}
public function offsetExists(mixed $offset): bool
{
$translatedOffset = $this->translateToLongArgumentName($offset);
return parent::offsetExists($translatedOffset);
}
/**
* Returns the value at the specified index
*
* @throws NoSuchArgumentException if the argument does not exist
*/
public function offsetGet(mixed $offset): Argument
{
$translatedOffset = $this->translateToLongArgumentName($offset);
if ($translatedOffset === '') {
throw new NoSuchArgumentException('The argument "' . $offset . '" does not exist.', 1216909923);
}
return parent::offsetGet($translatedOffset);
}
/**
* Creates, adds and returns a new controller argument to this composite object.
* If an argument with the same name exists already, it will be replaced by the
* new argument object.
*/
public function addNewArgument(string $name, string $dataType = 'Text', bool $isRequired = false, mixed $defaultValue = null): Argument
{
$argument = GeneralUtility::makeInstance(Argument::class, $name, $dataType);
$argument->setRequired($isRequired);
$argument->setDefaultValue($defaultValue);
$this->addArgument($argument);
return $argument;
}
/**
* Adds the specified controller argument to this composite object.
* If an argument with the same name exists already, it will be replaced by the
* new argument object.
*
* Note that the argument will be cloned, not referenced.
*/
public function addArgument(Argument $argument): void
{
$this->offsetSet(null, $argument);
}
/**
* Returns an argument specified by name
*
* @throws NoSuchArgumentException
*/
public function getArgument(string $argumentName): Argument
{
if (!$this->offsetExists($argumentName)) {
throw new NoSuchArgumentException('An argument "' . $argumentName . '" does not exist.', 1195815178);
}
return $this->offsetGet($argumentName);
}
/**
* Checks if an argument with the specified name exists
*
* @see offsetExists()
*/
public function hasArgument(string $argumentName): bool
{
return $this->offsetExists($argumentName);
}
/**
* Returns the names of all arguments contained in this object
*/
public function getArgumentNames(): array
{
return array_keys($this->argumentNames);
}
/**
* Returns the short names of all arguments contained in this object that have one.
*/
public function getArgumentShortNames(): array
{
$argumentShortNames = [];
/** @var Argument $argument */
foreach ($this as $argument) {
$argumentShortNames[$argument->getShortName()] = true;
}
return array_keys($argumentShortNames);
}
/**
* Magic setter method for the argument values. Each argument
* value can be set by just calling the setArgumentName() method.
*
* @throws \LogicException
*/
public function __call(string $methodName, array $arguments): void
{
if (!str_starts_with($methodName, 'set')) {
throw new \LogicException('Unknown method "' . $methodName . '".', 1210858451);
}
$firstLowerCaseArgumentName = $this->translateToLongArgumentName(strtolower($methodName[3]) . substr($methodName, 4));
$firstUpperCaseArgumentName = $this->translateToLongArgumentName(ucfirst(substr($methodName, 3)));
if (in_array($firstLowerCaseArgumentName, $this->getArgumentNames())) {
$argument = parent::offsetGet($firstLowerCaseArgumentName);
$argument->setValue($arguments[0]);
} elseif (in_array($firstUpperCaseArgumentName, $this->getArgumentNames())) {
$argument = parent::offsetGet($firstUpperCaseArgumentName);
$argument->setValue($arguments[0]);
}
}
/**
* Translates a short argument name to its corresponding long name. If the
* specified argument name is a real argument name already, it will be returned again.
*
* If an argument with the specified name or short name does not exist, an empty
* string is returned.
*/
protected function translateToLongArgumentName(string $argumentName): string
{
if (in_array($argumentName, $this->getArgumentNames())) {
return $argumentName;
}
/** @var Argument $argument */
foreach ($this as $argument) {
if ($argumentName === $argument->getShortName()) {
return $argument->getName();
}
}
return '';
}
/**
* Remove all arguments and resets this object
*/
public function removeAll(): void
{
foreach ($this->argumentNames as $argumentName => $booleanValue) {
parent::offsetUnset($argumentName);
}
$this->argumentNames = [];
}
public function validate(): Result
{
$results = new Result();
/** @var Argument $argument */
foreach ($this as $argument) {
$argumentValidationResults = $argument->validate();
$results->forProperty($argument->getName())->merge($argumentValidationResults);
}
return $results;
}
}
@@ -0,0 +1,61 @@
<?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\Extbase\Mvc\Controller;
use TYPO3\CMS\Extbase\Attribute\Authorize;
use TYPO3\CMS\Extbase\Authorization\AuthorizationResult;
use TYPO3\CMS\Extbase\Service\ActionAuthorizationService;
/**
* Registry for authorization configurations of extbase controller actions,
* populated at compile time via {@see \TYPO3\CMS\Extbase\DependencyInjection\AuthorizePass}.
*
* @internal
*/
final class AuthorizeRegistry
{
/** @var array<string, array<string, list<Authorize>>> */
private array $authorizations = [];
public function __construct(
private readonly ActionAuthorizationService $authorizationService,
) {}
public function add(string $controllerClass, string $actionMethod, string|array|null $callback, bool $requireLogin, array $requireGroups): void
{
$this->authorizations[$controllerClass][$actionMethod][] = new Authorize($callback, $requireLogin, $requireGroups);
}
/**
* @return list<Authorize>
*/
public function getAuthorizeAttributes(string $controllerClass, string $actionMethod): array
{
return $this->authorizations[$controllerClass][$actionMethod] ?? [];
}
public function checkAuthorization(ActionController $controller, string $actionMethod, array $preparedArguments): ?AuthorizationResult
{
$authorizeAttributes = $this->getAuthorizeAttributes($controller::class, $actionMethod);
if ($authorizeAttributes === []) {
return null;
}
return $this->authorizationService->checkAuthorization($controller, $authorizeAttributes, $preparedArguments);
}
}
@@ -0,0 +1,32 @@
<?php
/*
* 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\Extbase\Mvc\Controller;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\RequestInterface;
/**
* Interface for controllers
*/
interface ControllerInterface
{
/**
* Processes a general request. The result can be returned by altering the given response.
*
* @param \TYPO3\CMS\Extbase\Mvc\RequestInterface $request The request object
*/
public function processRequest(RequestInterface $request): ResponseInterface;
}
@@ -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\Extbase\Mvc\Controller\Exception;
use TYPO3\CMS\Extbase\Mvc\Exception;
/**
* An "Invalid Argument Name" exception
*/
class RequiredArgumentMissingException extends Exception {}
@@ -0,0 +1,277 @@
<?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\Extbase\Mvc\Controller;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Extbase\Error\Error;
use TYPO3\CMS\Extbase\Error\Result;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
use TYPO3\CMS\Extbase\Reflection\ObjectAccess;
use TYPO3\CMS\Extbase\Utility\LocalizationUtility;
use TYPO3\CMS\Extbase\Validation\Validator\FileExtensionMimeTypeConsistencyValidator;
use TYPO3\CMS\Extbase\Validation\Validator\FileNameValidator;
use TYPO3\CMS\Extbase\Validation\Validator\ValidatorInterface;
/**
* @internal Only to be used within Extbase, not part of TYPO3 Core API.
*/
class FileHandlingServiceConfiguration
{
/**
* @var ObjectStorage<FileUploadConfiguration>
*/
protected ObjectStorage $fileUploadConfigurations;
/**
* @var ObjectStorage<FileUploadDeletionConfiguration>
*/
protected ObjectStorage $fileUploadDeletionConfigurations;
public function __construct()
{
$this->fileUploadConfigurations = new ObjectStorage();
$this->fileUploadDeletionConfigurations = new ObjectStorage();
}
public function addFileUploadConfiguration(FileUploadConfiguration $configuration): void
{
$this->fileUploadConfigurations->attach($configuration);
}
public function getFileUploadConfigurations(): ObjectStorage
{
return $this->fileUploadConfigurations;
}
public function hasfileUploadConfigurations(): bool
{
return $this->fileUploadConfigurations->count() > 0;
}
/**
* Returns the FileUploadConfiguration for the given propertyName
*/
public function getFileUploadConfigurationForProperty(string $propertyName): ?FileUploadConfiguration
{
foreach ($this->fileUploadConfigurations as $configuration) {
if ($configuration->getPropertyName() === $propertyName) {
return $configuration;
}
}
return null;
}
/**
* Registers a file deletion for the given property and file reference uid
*/
public function registerFileDeletion(string $property, int $fileReferenceUid): void
{
$fileUploadDeletionConfiguration = $this->getFileUploadDeletionConfigurationForProperty($property);
if (!$fileUploadDeletionConfiguration) {
$fileUploadDeletionConfiguration = GeneralUtility::makeInstance(FileUploadDeletionConfiguration::class, $property);
$this->fileUploadDeletionConfigurations->attach($fileUploadDeletionConfiguration);
}
$fileUploadDeletionConfiguration->addFileReferenceUid($fileReferenceUid);
}
/**
* Returns all file deletion configurations
*/
public function getFileUploadDeletionConfigurations(): ObjectStorage
{
return $this->fileUploadDeletionConfigurations;
}
/**
* Returns the FileUploadDeletionConfiguration for the given propertyName
*/
public function getFileUploadDeletionConfigurationForProperty(string $propertyName): ?FileUploadDeletionConfiguration
{
foreach ($this->fileUploadDeletionConfigurations as $configuration) {
if ($configuration->getPropertyName() === $propertyName) {
return $configuration;
}
}
return null;
}
/**
* Returns the amount of configured file deletions for the given property
*/
private function getFileUploadDeletionCountForProperty(string $propertyName): int
{
$fileUploadDeletionConfiguration = $this->getFileUploadDeletionConfigurationForProperty($propertyName);
if ($fileUploadDeletionConfiguration) {
return count($fileUploadDeletionConfiguration->getFileReferenceUids());
}
return 0;
}
/**
* Validates file operations for the given argument by checking file upload and file deletion configurations and
* returning the validation result.
*/
public function validateFileOperations(Argument $argument): Result
{
$validationResults = new Result();
$value = $argument->getValue();
foreach ($this->fileUploadConfigurations as $configuration) {
$uploadedFilesForProperty = $argument->getUploadedFilesForProperty(
$configuration->getPropertyName()
);
$fileDeletionCount = $this->getFileUploadDeletionCountForProperty($configuration->getPropertyName());
$currentPropertyValue = null;
if ($value) {
$currentPropertyValue = ObjectAccess::getPropertyPath($value, $configuration->getPropertyName());
}
$validationResult = $this->getValidationResultsForProperty(
$configuration,
$configuration->getPropertyName(),
$currentPropertyValue,
$uploadedFilesForProperty,
$fileDeletionCount
);
$validationResults->merge($validationResult);
}
return $validationResults;
}
/**
* Validates file uploads and file deletions for the given propertyPath and currentPropertyValue and returns
* the validation result.
*/
private function getValidationResultsForProperty(
FileUploadConfiguration $configuration,
string $propertyPath,
mixed $currentPropertyValue,
array $uploadedFiles,
int $fileDeletionCount
): Result {
$validationResults = new Result();
if ($currentPropertyValue instanceof FileReference) {
$currentAmount = 1;
} elseif ($currentPropertyValue instanceof ObjectStorage) {
$currentAmount = $currentPropertyValue->count();
} else {
$currentAmount = 0;
}
// Validate, that minimum files requirement is valid after file deletion(s)
if ($fileDeletionCount > 0
&& ($currentPropertyValue instanceof FileReference || $currentPropertyValue instanceof ObjectStorage)
) {
$newAmount = $currentAmount - $fileDeletionCount + count($uploadedFiles);
if ($newAmount < $configuration->getMinFiles()) {
$minFilesError = new Error(
$this->translateErrorMessage(
'filehandlingserviceconfiguration.minfiles.delete.notvalid',
'extbase',
),
1714557062
);
$validationResults->forProperty($propertyPath)
->addError($minFilesError);
}
}
// If the given $currentPropertyValue (which is the target property for file upload) is either a FileReference
// or a non empty ObjectStorage and no uploaded files are available, the rest of the validation can be skipped.
if ($uploadedFiles === []
&& ($currentPropertyValue instanceof FileReference
|| ($currentPropertyValue instanceof ObjectStorage && $currentPropertyValue->count() > 0))
) {
return $validationResults;
}
if (count($uploadedFiles) < $configuration->getMinFiles()) {
$minFilesError = new Error(
$this->translateErrorMessage(
'filehandlingserviceconfiguration.minfiles.notvalid',
'extbase',
[$configuration->getMinFiles()]
),
1708596527
);
$validationResults->forProperty($propertyPath)
->addError($minFilesError);
}
if ((count($uploadedFiles) + $currentAmount - $fileDeletionCount) > $configuration->getMaxFiles()) {
$minFilesError = new Error(
$this->translateErrorMessage(
'filehandlingserviceconfiguration.maxfiles.notvalid',
'extbase',
[$configuration->getMaxFiles()]
),
1708596528
);
$validationResults->forProperty($propertyPath)
->addError($minFilesError);
}
$validators = $this->enforceDefaultValidators(
...$configuration->getValidators()
);
foreach ($validators as $validator) {
foreach ($uploadedFiles as $uploadedFile) {
$validatorResult = $validator->validate($uploadedFile);
if ($validatorResult->hasErrors()) {
$validationResults->forProperty($propertyPath)->merge($validatorResult);
}
}
}
return $validationResults;
}
/**
* @return list<ValidatorInterface>
*/
private function enforceDefaultValidators(ValidatorInterface ...$validators): array
{
$enforceValidators = [
FileNameValidator::class,
FileExtensionMimeTypeConsistencyValidator::class,
];
$existingValidators = array_map(get_class(...), $validators);
$missingValidators = array_diff($enforceValidators, $existingValidators);
foreach ($missingValidators as $missingValidator) {
$validators[] = GeneralUtility::makeInstance($missingValidator);
}
return $validators;
}
/**
* Wrapper to translate error messages
*/
private function translateErrorMessage(string $translateKey, string $extensionName, array $arguments = []): string
{
return LocalizationUtility::translate(
$translateKey,
$extensionName,
$arguments
) ?? '';
}
}
@@ -0,0 +1,266 @@
<?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\Extbase\Mvc\Controller;
use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Extbase\Validation\Validator\FileExtensionValidator;
use TYPO3\CMS\Extbase\Validation\Validator\FileSizeValidator;
use TYPO3\CMS\Extbase\Validation\Validator\ImageDimensionsValidator;
use TYPO3\CMS\Extbase\Validation\Validator\MimeTypeValidator;
use TYPO3\CMS\Extbase\Validation\Validator\ValidatorInterface;
/**
* @internal Only to be used within Extbase, not part of TYPO3 Core API.
*/
class FileUploadConfiguration
{
protected string $uploadFolder = '';
protected int $minFiles = 0;
protected int $maxFiles = PHP_INT_MAX;
protected bool $addRandomSuffix = true;
protected bool $createUploadFolderIfNotExist = true;
protected DuplicationBehavior $duplicationBehavior = DuplicationBehavior::RENAME;
/**
* @var array<ValidatorInterface>
*/
protected array $validators = [];
public function __construct(protected readonly string $propertyName) {}
/**
* Initializes the object with the given configuration array. Typically used with configuration from
* #[FileUpload] attribute.
*/
public function initializeWithConfiguration(array $configuration): self
{
if (!isset($configuration['validation']) || $configuration['validation'] === []) {
throw new \RuntimeException('Extbase file upload must at least define one validation rule.', 1711947120);
}
$this->initializeUploadValidation($configuration['validation']);
if (isset($configuration['uploadFolder']) && $configuration['uploadFolder'] !== '') {
$this->uploadFolder = $configuration['uploadFolder'];
}
if (isset($configuration['addRandomSuffix'])) {
$this->addRandomSuffix = (bool)$configuration['addRandomSuffix'];
}
if (isset($configuration['duplicationBehavior'])) {
$this->duplicationBehavior = $configuration['duplicationBehavior'];
}
if (isset($configuration['createUploadFolderIfNotExist'])) {
$this->createUploadFolderIfNotExist = $configuration['createUploadFolderIfNotExist'];
}
return $this;
}
public function addValidator(ValidatorInterface $validator): self
{
$this->validators[] = $validator;
return $this;
}
public function getValidators(): array
{
return $this->validators;
}
public function resetValidators(): self
{
$this->validators = [];
return $this;
}
public function getPropertyName(): string
{
return $this->propertyName;
}
public function setRequired(): self
{
$this->minFiles = 1;
return $this;
}
public function getMinFiles(): int
{
return $this->minFiles;
}
public function setMinFiles(int $minFiles): self
{
$this->minFiles = $minFiles;
return $this;
}
public function getMaxFiles(): int
{
return $this->maxFiles;
}
public function setMaxFiles(int $maxFiles): self
{
$this->maxFiles = $maxFiles;
return $this;
}
public function getUploadFolder(): string
{
return $this->uploadFolder;
}
public function setUploadFolder(string $uploadFolder): self
{
$this->uploadFolder = $uploadFolder;
return $this;
}
public function isAddRandomSuffix(): bool
{
return $this->addRandomSuffix;
}
public function setAddRandomSuffix(bool $addRandomSuffix): self
{
$this->addRandomSuffix = $addRandomSuffix;
return $this;
}
public function isCreateUploadFolderIfNotExist(): bool
{
return $this->createUploadFolderIfNotExist;
}
public function setCreateUploadFolderIfNotExist(bool $createUploadFolderIfNotExist): self
{
$this->createUploadFolderIfNotExist = $createUploadFolderIfNotExist;
return $this;
}
public function getDuplicationBehavior(): DuplicationBehavior
{
return $this->duplicationBehavior;
}
public function setDuplicationBehavior(DuplicationBehavior $duplicationBehavior): void
{
$this->duplicationBehavior = $duplicationBehavior;
}
/**
* Checks if the current configuration is considered valid for the given target type and throws
* an exception, if the configuration is invalid.
*/
public function ensureValidConfiguration(string $targetType): void
{
if ($targetType !== FileReference::class) {
throw new \RuntimeException('The FileUploadConfiguration can only be used for properties of type FileReference.', 1721623184);
}
if (str_contains($this->getPropertyName(), '.')) {
throw new \RuntimeException('The property name for the FileUploadConfiguration must not contain any dot.', 1724585391);
}
if ($this->getUploadFolder() === '') {
throw new \RuntimeException('An upload folder must be defined for the FileUploadConfiguration.', 1711799735);
}
if (!$this->isCombinedStoragePathIdentifier($this->getUploadFolder())) {
throw new \RuntimeException('The upload folder must be a combined identifier - e.g. 1:/user_upload/', 1711801071);
}
if ($this->getMaxFiles() < $this->getMinFiles()) {
throw new \RuntimeException('Maximum number of files cannot be less than minimum number of files.', 1711799765);
}
}
private function isCombinedStoragePathIdentifier(string $identifier): bool
{
return str_contains($identifier, ':')
&& !str_starts_with($identifier, ':')
&& !str_ends_with($identifier, ':')
&& MathUtility::canBeInterpretedAsInteger(substr($identifier, 0, strpos($identifier, ':')));
}
/**
* Initializes validators based on the given array of validation configuration
*/
private function initializeUploadValidation(array $validationConfiguration): void
{
if ($validationConfiguration['required'] ?? false) {
$this->minFiles = 1;
}
if ((int)($validationConfiguration['minFiles'] ?? PHP_INT_MAX) < PHP_INT_MAX) {
$this->minFiles = (int)($validationConfiguration['minFiles']);
}
if ((int)($validationConfiguration['maxFiles'] ?? PHP_INT_MAX) < PHP_INT_MAX) {
$this->maxFiles = (int)($validationConfiguration['maxFiles']);
}
// Migrate allowedMimeTypes to mimeType configuration, if mimeType configuration is not defined
if (($validationConfiguration['allowedMimeTypes'] ?? false)
&& is_array($validationConfiguration['allowedMimeTypes'])
&& !isset($validationConfiguration['mimeType'])
) {
$validationConfiguration['mimeType'] = ['allowedMimeTypes' => $validationConfiguration['allowedMimeTypes']];
unset($validationConfiguration['allowedMimeTypes']);
}
if (($validationConfiguration['mimeType'] ?? false)
&& is_array($validationConfiguration['mimeType'])
) {
$mimeTypeValidator = GeneralUtility::makeInstance(MimeTypeValidator::class);
$mimeTypeValidator->setOptions($validationConfiguration['mimeType']);
$this->addValidator($mimeTypeValidator);
}
if (($validationConfiguration['fileExtension'] ?? false)
&& is_array($validationConfiguration['fileExtension'])
) {
$fileExtensionValidator = GeneralUtility::makeInstance(FileExtensionValidator::class);
$fileExtensionValidator->setOptions($validationConfiguration['fileExtension']);
$this->addValidator($fileExtensionValidator);
}
if (($validationConfiguration['fileSize'] ?? false)
&& is_array($validationConfiguration['fileSize'])
) {
$fileSizeValidator = GeneralUtility::makeInstance(FileSizeValidator::class);
$fileSizeValidator->setOptions($validationConfiguration['fileSize']);
$this->addValidator($fileSizeValidator);
}
if (($validationConfiguration['imageDimensions'] ?? false)
&& is_array($validationConfiguration['imageDimensions'])
) {
$imageDimensionsValidator = GeneralUtility::makeInstance(ImageDimensionsValidator::class);
$imageDimensionsValidator->setOptions($validationConfiguration['imageDimensions']);
$this->addValidator($imageDimensionsValidator);
}
}
}
@@ -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\Extbase\Mvc\Controller;
/**
* @internal Only to be used within Extbase, not part of TYPO3 Core API.
*/
class FileUploadDeletionConfiguration
{
public function __construct(protected readonly string $propertyName, protected array $fileReferenceUids = []) {}
public function getPropertyName(): string
{
return $this->propertyName;
}
public function addFileReferenceUid(int $fileReferenceUid): void
{
$this->fileReferenceUids[] = $fileReferenceUid;
}
public function getFileReferenceUids(): array
{
return $this->fileReferenceUids;
}
}
@@ -0,0 +1,59 @@
<?php
/*
* 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\Extbase\Mvc\Controller;
use TYPO3\CMS\Extbase\Property\PropertyMappingConfiguration;
use TYPO3\CMS\Extbase\Property\TypeConverter\PersistentObjectConverter;
/**
* The default property mapping configuration is available
* inside the Argument-object.
*/
class MvcPropertyMappingConfiguration extends PropertyMappingConfiguration
{
/**
* Allow creation of a certain sub property
*
* @param string $propertyPath
*/
public function allowCreationForSubProperty($propertyPath)
{
$this->forProperty($propertyPath)->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_CREATION_ALLOWED, true);
}
/**
* Allow modification for a given property path
*
* @param string $propertyPath
*/
public function allowModificationForSubProperty($propertyPath)
{
$this->forProperty($propertyPath)->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_MODIFICATION_ALLOWED, true);
}
/**
* Set the target type for a certain property. Especially useful
* if there is an object which has a nested object which is abstract,
* and you want to instantiate a concrete object instead.
*
* @param string $propertyPath
* @param string $targetType
*/
public function setTargetTypeForSubProperty($propertyPath, $targetType)
{
$this->forProperty($propertyPath)->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_TARGET_TYPE, $targetType);
}
}
@@ -0,0 +1,177 @@
<?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\Extbase\Mvc\Controller;
use TYPO3\CMS\Core\Crypto\HashAlgo;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Error\Http\BadRequestException;
use TYPO3\CMS\Core\Exception\Crypto\InvalidHashStringException;
use TYPO3\CMS\Extbase\Mvc\ExtbaseRequestParameters;
use TYPO3\CMS\Extbase\Mvc\Request;
use TYPO3\CMS\Extbase\Property\PropertyMappingConfigurationInterface;
use TYPO3\CMS\Extbase\Property\TypeConverter\PersistentObjectConverter;
use TYPO3\CMS\Extbase\Security\Exception\InvalidArgumentForHashGenerationException;
use TYPO3\CMS\Extbase\Security\HashScope;
/**
* This is a service which can generate a request hash and check whether the currently given arguments
* fit to the request hash.
*
* It is used when forms are generated and submitted:
* After a form has been generated, the method "generateTrustedPropertiesToken" is called with the names of all form fields.
* It cleans up the array of form fields and creates another representation of it, which is then json encoded and a hmac
* is appended. This is called the request hash.
*
* The json encoded form field list and the appended hmac will be submitted with the form (as attribute __trustedProperties).
*
* On the validation side, the validation happens in two steps:
* 1) Check if the request hash is consistent (the hmac value fits to the json encoded field list string)
* 2) Check that _all_ GET/POST parameters submitted occur inside the form field list of the request hash.
*
* Note: It is crucially important that a private key is computed into the hash value! This is done inside the HashService.
*
* @internal only to be used within Extbase, not part of TYPO3 Core API.
*/
class MvcPropertyMappingConfigurationService
{
protected HashService $hashService;
public function injectHashService(HashService $hashService): void
{
$this->hashService = $hashService;
}
/**
* Generate a request hash for a list of form fields
*/
public function generateTrustedPropertiesToken(array $formFieldNames, string $fieldNamePrefix = ''): string
{
$formFieldArray = [];
foreach ($formFieldNames as $formField) {
$formFieldParts = explode('[', $formField);
$currentPosition = &$formFieldArray;
$formFieldPartsCount = count($formFieldParts);
for ($i = 0; $i < $formFieldPartsCount; $i++) {
$formFieldPart = $formFieldParts[$i];
$formFieldPart = rtrim($formFieldPart, ']');
if (!is_array($currentPosition)) {
throw new InvalidArgumentForHashGenerationException('The form field "' . $formField . '" is declared as array, but it collides with a previous form field of the same name which declared the field as string. This is an inconsistency you need to fix inside your Fluid form. (String overridden by Array)', 1255072196);
}
if ($i === $formFieldPartsCount - 1) {
if (isset($currentPosition[$formFieldPart]) && is_array($currentPosition[$formFieldPart])) {
throw new InvalidArgumentForHashGenerationException('The form field "' . $formField . '" is declared as string, but it collides with a previous form field of the same name which declared the field as array. This is an inconsistency you need to fix inside your Fluid form. (Array overridden by String)', 1255072587);
}
// Last iteration - add a string
if ($formFieldPart === '') {
$currentPosition[] = 1;
} else {
$currentPosition[$formFieldPart] = 1;
}
} else {
if ($formFieldPart === '') {
throw new InvalidArgumentForHashGenerationException('The form field "' . $formField . '" is invalid. Reason: "[]" used not as last argument, but somewhere in the middle (like foo[][bar]).', 1255072832);
}
if (!isset($currentPosition[$formFieldPart])) {
$currentPosition[$formFieldPart] = [];
}
$currentPosition = &$currentPosition[$formFieldPart];
}
}
}
if ($fieldNamePrefix !== '') {
$formFieldArray = ($formFieldArray[$fieldNamePrefix] ?? []);
}
return $this->encodeAndHashFormFieldArray($formFieldArray);
}
/**
* Encode and hash the form field array
*/
protected function encodeAndHashFormFieldArray(array $formFieldArray): string
{
$encodedFormFieldArray = json_encode($formFieldArray);
return $this->hashService->appendHmac($encodedFormFieldArray, HashScope::TrustedProperties->prefix(), HashAlgo::SHA3_256);
}
/**
* Initialize the property mapping configuration in $controllerArguments if
* the trusted properties are set inside the request.
*
* @throws BadRequestException
*/
public function initializePropertyMappingConfigurationFromRequest(Request $request, Arguments $controllerArguments): void
{
/** @var ExtbaseRequestParameters $extbaseRequestParameters */
$extbaseRequestParameters = $request->getAttribute('extbase');
$trustedPropertiesToken = $extbaseRequestParameters->getInternalArgument('__trustedProperties');
if (!is_string($trustedPropertiesToken)) {
return;
}
try {
$encodedTrustedProperties = $this->hashService->validateAndStripHmac($trustedPropertiesToken, HashScope::TrustedProperties->prefix(), HashAlgo::SHA3_256);
} catch (InvalidHashStringException $e) {
throw new BadRequestException('The HMAC of the form could not be validated.', 1581862822);
}
$trustedProperties = json_decode($encodedTrustedProperties, true);
if (!is_array($trustedProperties)) {
if (str_starts_with($encodedTrustedProperties, 'a:')) {
throw new BadRequestException('Trusted properties used outdated serialization format instead json.', 1699604555);
}
throw new BadRequestException('The HMAC of the form could not be utilized.', 1691267306);
}
foreach ($trustedProperties as $propertyName => $propertyConfiguration) {
$propertyName = (string)$propertyName;
if (!$controllerArguments->hasArgument($propertyName) || !is_array($propertyConfiguration)) {
continue;
}
$propertyMappingConfiguration = $controllerArguments->getArgument($propertyName)->getPropertyMappingConfiguration();
$this->modifyPropertyMappingConfiguration($propertyConfiguration, $propertyMappingConfiguration);
}
}
/**
* Modify the passed $propertyMappingConfiguration according to the $propertyConfiguration which
* has been generated by Fluid. In detail, if the $propertyConfiguration contains
* an __identity field, we allow modification of objects; else we allow creation.
*
* All other properties are specified as allowed properties.
*/
protected function modifyPropertyMappingConfiguration(
array $propertyConfiguration,
PropertyMappingConfigurationInterface $propertyMappingConfiguration
): void {
if (isset($propertyConfiguration['__identity'])) {
$propertyMappingConfiguration->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_MODIFICATION_ALLOWED, true);
unset($propertyConfiguration['__identity']);
} else {
$propertyMappingConfiguration->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_CREATION_ALLOWED, true);
}
foreach ($propertyConfiguration as $innerKey => $innerValue) {
if (is_array($innerValue)) {
$this->modifyPropertyMappingConfiguration(
$innerValue,
$propertyMappingConfiguration->forProperty((string)$innerKey)
);
}
$propertyMappingConfiguration->allowProperties($innerKey);
}
}
}
@@ -0,0 +1,60 @@
<?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\Extbase\Mvc\Controller;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\RateLimiter\LimiterInterface;
use TYPO3\CMS\Core\RateLimiter\RateLimiterFactoryInterface;
use TYPO3\CMS\Extbase\Attribute\RateLimit;
/**
* Registry for rate limit configurations of extbase controller actions,
* populated at compile time via {@see \TYPO3\CMS\Extbase\DependencyInjection\RateLimitPass}.
*
* @internal
*/
final class RateLimitRegistry
{
/** @var array<string, array<string, RateLimit>> */
private array $rateLimits = [];
public function __construct(
private readonly RateLimiterFactoryInterface $rateLimiterFactory,
) {}
public function add(string $controllerClass, string $actionMethod, int $limit, string $interval, string $policy, string $message): void
{
$this->rateLimits[$controllerClass][$actionMethod] = new RateLimit($limit, $interval, $policy, $message);
}
public function getRateLimit(string $controllerClass, string $actionMethod): ?RateLimit
{
return $this->rateLimits[$controllerClass][$actionMethod] ?? null;
}
public function createLimiter(string $controllerClass, string $actionMethod, ServerRequestInterface $request): ?LimiterInterface
{
$rateLimit = $this->getRateLimit($controllerClass, $actionMethod);
if ($rateLimit === null) {
return null;
}
$identifier = strtolower(str_replace('\\', '-', $controllerClass) . '-' . $actionMethod);
return $this->rateLimiterFactory->createRequestBasedLimiter($request, $rateLimit->getConfiguration($identifier));
}
}
+127
View File
@@ -0,0 +1,127 @@
<?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\Extbase\Mvc;
use Psr\Container\ContainerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Attribute\IgnoreValidation;
use TYPO3\CMS\Extbase\Event\Mvc\AfterRequestDispatchedEvent;
use TYPO3\CMS\Extbase\Http\ForwardResponse;
use TYPO3\CMS\Extbase\Mvc\Controller\ControllerInterface;
use TYPO3\CMS\Extbase\Mvc\Exception\InfiniteLoopException;
use TYPO3\CMS\Extbase\Mvc\Exception\InvalidControllerException;
/**
* Dispatches requests to the controller which was specified by the request and
* returns the response the controller generated.
* @internal only to be used within Extbase, not part of TYPO3 Core API.
*/
class Dispatcher
{
private ContainerInterface $container;
protected EventDispatcherInterface $eventDispatcher;
public function __construct(
ContainerInterface $container,
EventDispatcherInterface $eventDispatcher
) {
$this->container = $container;
$this->eventDispatcher = $eventDispatcher;
}
/**
* Dispatches a request to a controller and initializes the security framework.
*
* @param RequestInterface $request The request to dispatch
* @throws Exception\InfiniteLoopException
*/
public function dispatch(RequestInterface $request): ResponseInterface
{
$dispatchLoopCount = 0;
$isDispatched = false;
while (!$isDispatched) {
if ($dispatchLoopCount++ > 99) {
throw new InfiniteLoopException(
'Could not ultimately dispatch the request after ' . $dispatchLoopCount
. ' iterations. Most probably, an #[' . IgnoreValidation::class . ']'
. ' attribute is missing on re-displaying a form with validation errors.',
1217839467
);
}
$controller = $this->resolveController($request);
$response = $controller->processRequest($request);
if ($response instanceof ForwardResponse) {
// The controller action returned an extbase internal Forward response:
// Another action should be dispatched.
$request = static::buildRequestFromCurrentRequestAndForwardResponse($request, $response);
} else {
// The controller action returned a casual or a HTTP redirect response.
// Dispatching ends here and response is sent to client.
$isDispatched = true;
}
}
$this->eventDispatcher->dispatch(new AfterRequestDispatchedEvent($request, $response));
return $response;
}
/**
* Finds and instantiates a controller that matches the current request.
* If no controller can be found, an instance of NotFoundControllerInterface is returned.
*
* @param RequestInterface $request The request to dispatch
* @return Controller\ControllerInterface
* @throws Exception\InvalidControllerException
*/
protected function resolveController(RequestInterface $request)
{
$controllerObjectName = $request->getControllerObjectName();
$controller = $this->container->get($controllerObjectName);
if (!$controller instanceof ControllerInterface) {
throw new InvalidControllerException(
'Invalid controller "' . $request->getControllerObjectName() . '". The controller must implement the TYPO3\\CMS\\Extbase\\Mvc\\Controller\\ControllerInterface.',
1476109646
);
}
return $controller;
}
/**
* @internal only to be used within Extbase, not part of TYPO3 Core API.
* @todo: make this a private method again as soon as the tests, that fake the dispatching of requests, are refactored.
*/
public static function buildRequestFromCurrentRequestAndForwardResponse(RequestInterface $currentRequest, ForwardResponse $forwardResponse): RequestInterface
{
$request = $currentRequest->withControllerActionName($forwardResponse->getActionName());
if ($forwardResponse->getControllerName() !== null) {
$request = $request->withControllerName($forwardResponse->getControllerName());
}
if ($forwardResponse->getExtensionName() !== null) {
$request = $request->withControllerExtensionName($forwardResponse->getExtensionName());
}
if ($forwardResponse->getArguments() !== null) {
$request = $request->withArguments($forwardResponse->getArguments());
}
/** @var ExtbaseRequestParameters $extbaseRequestParameters */
$extbaseRequestParameters = clone $request->getAttribute('extbase');
$extbaseRequestParameters->setOriginalRequest($currentRequest);
$extbaseRequestParameters->setOriginalRequestMappingResults($forwardResponse->getArgumentsValidationResult());
$extbaseRequestParameters->setOriginalFlashMessages(...$forwardResponse->getFlashMessages());
return $request->withAttribute('extbase', $extbaseRequestParameters);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?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\Extbase\Mvc;
use TYPO3\CMS\Extbase\Exception as ExtbaseException;
/**
* A generic MVC exception
*/
class Exception extends ExtbaseException
{
/**
* @internal only to be used within Extbase, not part of TYPO3 Core API.
*/
public static function fromPrevious(\Throwable $e): self
{
return new self($e->getMessage(), $e->getCode(), $e);
}
}
@@ -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\Extbase\Mvc\Exception;
use TYPO3\CMS\Extbase\Mvc\Exception;
/**
* An "Infinite Loop" exception
*/
class InfiniteLoopException extends Exception {}
@@ -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\Extbase\Mvc\Exception;
use TYPO3\CMS\Extbase\Mvc\Exception;
/**
* An "invalid action name" exception
*/
class InvalidActionNameException extends Exception {}
@@ -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\Extbase\Mvc\Exception;
use TYPO3\CMS\Extbase\Mvc\Exception;
/**
* An "Invalid Argument Name" exception
*/
class InvalidArgumentMixingException extends Exception {}
@@ -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\Extbase\Mvc\Exception;
use TYPO3\CMS\Extbase\Mvc\Exception;
/**
* An "Invalid Argument Name" exception
*/
class InvalidArgumentNameException extends Exception {}
@@ -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\Extbase\Mvc\Exception;
use TYPO3\CMS\Extbase\Mvc\Exception;
/**
* An "Invalid Argument Type" exception
*/
class InvalidArgumentTypeException extends Exception {}
@@ -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\Extbase\Mvc\Exception;
use TYPO3\CMS\Extbase\Mvc\Exception;
/**
* An "Invalid Argument Value" exception
*/
class InvalidArgumentValueException extends Exception {}
@@ -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\Extbase\Mvc\Exception;
use TYPO3\CMS\Extbase\Mvc\Exception;
/**
* An "Invalid Controller" exception
*/
class InvalidControllerException extends Exception {}
@@ -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\Extbase\Mvc\Exception;
use TYPO3\CMS\Extbase\Mvc\Exception;
/**
* An "Invalid Controller Name" exception
*/
class InvalidControllerNameException extends Exception {}
@@ -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\Extbase\Mvc\Exception;
use TYPO3\CMS\Extbase\Mvc\Exception;
/**
* An "Invalid Extension Name" exception
*/
class InvalidExtensionNameException extends Exception {}
@@ -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\Extbase\Mvc\Exception;
use TYPO3\CMS\Extbase\Mvc\Exception;
/**
* A "No Such Action" exception
*/
class NoSuchActionException extends Exception {}
@@ -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\Extbase\Mvc\Exception;
use TYPO3\CMS\Extbase\Mvc\Exception;
/**
* A "No Such Argument" exception
*/
class NoSuchArgumentException extends Exception {}
@@ -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\Extbase\Mvc\Exception;
use TYPO3\CMS\Extbase\Mvc\Exception;
/**
* A "No Such Controller" exception
*/
class NoSuchControllerException extends Exception {}
+349
View File
@@ -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\Extbase\Mvc;
use Psr\Http\Message\UploadedFileInterface;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Utility\ClassNamingUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Error\Result;
use TYPO3\CMS\Extbase\Mvc\Exception\InvalidArgumentNameException;
use TYPO3\CMS\Extbase\Mvc\Exception\NoSuchArgumentException;
/**
* Extbase request related state.
* Attached as 'extbase' attribute to PSR-7 ServerRequestInterface.
*
* @internal Sets up extbase internally, use TYPO3\CMS\Extbase\Mvc\Request instead.
*/
class ExtbaseRequestParameters
{
/**
* Key of the plugin which identifies the plugin.
* In frontend, it is the second argument of ExtensionUtility::configurePlugin(), example: "FormFramework" in ext:form.
* In backend, it is the module identifier from the corresponding module configuration, for example
* "web_FormFormbuilder" for the ext:form backend module.
*/
protected string $pluginName = '';
/**
* Name of the extension which is supposed to handle this request. This is the extension key in UpperCamelCase.
* This is typically defined by ExtensionUtility::configurePlugin() and friends as first argument.
* Example: "IndexedSearch", when the extension key "directory name of extension" is indexed_search.
*/
protected string $controllerExtensionName = '';
/**
* This is the FQDN of a controller, example: "TYPO3\CMS\Form\Controller\FormManagerController"
* for ext:form backend module.
*/
protected string $controllerObjectName = '';
/**
* Object name of the controller which is supposed to handle this request. This is the non-FQDN
* version of $controllerObjectName, without the word "Controller", example: "FormManager".
*/
protected string $controllerName = 'Standard';
/**
* A map $controllerName => $controllerObjectName
*/
protected array $controllerAliasToClassNameMapping = [];
/**
* Name of the action the controller is supposed to execute. For example "create" with the
* controller method name being "createAction()".
* Action name must start with a lower case letter and is case-sensitive.
*/
protected string $controllerActionName = 'index';
/**
* The arguments for this request. This receives only those arguments relevant and
* prefixed for this extension/controller/plugin combination.
*/
protected array $arguments = [];
/**
* Framework-internal arguments for this request, such as __referrer.
* All framework-internal arguments start with double underscore (__),
* and are only used from within the framework. Not for user consumption.
* Internal Arguments can be objects, in contrast to public arguments
*/
protected array $internalArguments = [];
/**
* The requested representation format, "html", "xml", "png", "json" or the like.
* Can even be something like "rss.xml".
*/
protected string $format = 'html';
/**
* If this request is a forward because of an error, the original request gets filled.
*/
protected ?RequestInterface $originalRequest = null;
/**
* If the request is a forward because of an error, these mapping results get filled here.
*/
protected ?Result $originalRequestMappingResults = null;
/**
* @var list<FlashMessage>
*/
protected array $originalFlashMessages = [];
/**
* If files were uploaded, this array holds the files
* prefixed for this extension/controller/plugin combination.
*/
protected array $uploadedFiles = [];
public function __construct(string $controllerClassName = '')
{
$this->controllerObjectName = $controllerClassName;
}
public function getControllerObjectName(): string
{
return $this->controllerObjectName;
}
public function setControllerObjectName(string $controllerObjectName): self
{
$nameParts = ClassNamingUtility::explodeObjectControllerName($controllerObjectName);
$this->controllerExtensionName = $nameParts['extensionName'];
$this->controllerName = $nameParts['controllerName'];
return $this;
}
public function setPluginName(string $pluginName): self
{
$this->pluginName = $pluginName;
return $this;
}
public function getPluginName(): string
{
return $this->pluginName;
}
public function setControllerExtensionName(string $controllerExtensionName): self
{
$this->controllerExtensionName = $controllerExtensionName;
return $this;
}
public function getControllerExtensionName(): string
{
return $this->controllerExtensionName;
}
public function getControllerExtensionKey(): string
{
return GeneralUtility::camelCaseToLowerCaseUnderscored($this->controllerExtensionName);
}
public function setControllerAliasToClassNameMapping(array $controllerAliasToClassNameMapping): self
{
// this is only needed as long as forwarded requests are altered and unless there
// is no new request object created by the request builder.
$this->controllerAliasToClassNameMapping = $controllerAliasToClassNameMapping;
return $this;
}
public function setControllerName(string $controllerName): self
{
$this->controllerName = $controllerName;
// There might be no Controller Class, for example for Fluid Templates.
$this->controllerObjectName = $this->controllerAliasToClassNameMapping[$controllerName] ?? '';
return $this;
}
public function getControllerName(): string
{
return $this->controllerName;
}
public function setControllerActionName(string $actionName): self
{
$this->controllerActionName = $actionName;
return $this;
}
public function getControllerActionName(): string
{
return $this->controllerActionName;
}
/**
* @param mixed $value The new value
* @throws InvalidArgumentNameException
*/
public function setArgument(string $argumentName, mixed $value): self
{
if ($argumentName === '') {
throw new InvalidArgumentNameException('Invalid argument name.', 1210858767);
}
if (str_starts_with($argumentName, '__')) {
$this->internalArguments[$argumentName] = $value;
return $this;
}
if (!in_array($argumentName, ['@extension', '@subpackage', '@controller', '@action', '@format'], true)) {
$this->arguments[$argumentName] = $value;
}
return $this;
}
/**
* Sets the whole arguments array and therefore replaces any arguments which existed before.
*
* @param array<string, mixed> $arguments
* @throws InvalidArgumentNameException
*/
public function setArguments(array $arguments): self
{
$this->arguments = [];
foreach ($arguments as $argumentName => $argumentValue) {
$this->setArgument($argumentName, $argumentValue);
}
return $this;
}
public function getArguments(): array
{
return $this->arguments;
}
/**
* Returns the value of the specified argument.
*
* @return mixed Value of the argument
* @throws NoSuchArgumentException if such an argument does not exist
*/
public function getArgument(string $argumentName): mixed
{
if (!isset($this->arguments[$argumentName])) {
throw new NoSuchArgumentException('An argument "' . $argumentName . '" does not exist for this request.', 1176558158);
}
return $this->arguments[$argumentName];
}
/**
* Checks if an argument of the given name exists (is set)
*/
public function hasArgument(string $argumentName = ''): bool
{
return isset($this->arguments[$argumentName]);
}
public function setFormat(string $format): self
{
$this->format = $format;
return $this;
}
public function getFormat(): string
{
return $this->format;
}
/**
* Returns the original request. Filled only if a property mapping error occurred.
*/
public function getOriginalRequest(): ?RequestInterface
{
return $this->originalRequest;
}
public function setOriginalRequest(RequestInterface $originalRequest): self
{
$this->originalRequest = $originalRequest;
return $this;
}
public function getOriginalRequestMappingResults(): Result
{
if ($this->originalRequestMappingResults === null) {
return new Result();
}
return $this->originalRequestMappingResults;
}
public function setOriginalRequestMappingResults(Result $originalRequestMappingResults): self
{
$this->originalRequestMappingResults = $originalRequestMappingResults;
return $this;
}
/**
* @return list<FlashMessage>
*/
public function getOriginalFlashMessages(): array
{
return $this->originalFlashMessages;
}
public function setOriginalFlashMessages(FlashMessage ...$originalFlashMessages): self
{
$this->originalFlashMessages = $originalFlashMessages;
return $this;
}
/**
* Returns the value of the specified argument
*
* @return mixed Value of the argument, or NULL if not set.
*/
public function getInternalArgument($argumentName): mixed
{
if (!isset($this->internalArguments[$argumentName])) {
return null;
}
return $this->internalArguments[$argumentName];
}
public function getUploadedFiles(): array
{
return $this->uploadedFiles;
}
public function setUploadedFiles(array $files): self
{
$this->validateUploadedFiles($files);
$this->uploadedFiles = $files;
return $this;
}
/**
* Recursively validate the structure in an uploaded files array.
*
* @throws \InvalidArgumentException if any leaf is not an UploadedFileInterface instance.
*/
protected function validateUploadedFiles(array $uploadedFiles): void
{
foreach ($uploadedFiles as $file) {
if (is_array($file)) {
$this->validateUploadedFiles($file);
continue;
}
if (!$file instanceof UploadedFileInterface) {
throw new \InvalidArgumentException('Invalid file in uploaded files structure.', 1647338470);
}
}
}
}
+391
View File
@@ -0,0 +1,391 @@
<?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\Extbase\Mvc;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
/**
* The extbase request.
*
* This is a decorator: The core PSR-7 request is hand over as constructor
* argument, this class implements ServerRequestInterface, too.
* Additionally, the extbase request details are attached as 'extbase'
* attribute to the PSR-7 request and this class implements extbase RequestInterface.
* This class has no state except the PSR-7 request, all operations are
* hand down to the PSR-7 request.
*/
class Request implements RequestInterface
{
protected ServerRequestInterface $request;
final public function __construct(ServerRequestInterface $request)
{
if (!$request->getAttribute('extbase') instanceof ExtbaseRequestParameters) {
throw new \InvalidArgumentException(
'Given request must have an attribute "extbase" of type ExtbaseAttribute',
1624452070
);
}
$this->request = $request;
}
/**
* ExtbaseAttribute attached as attribute 'extbase' to $request carries extbase
* specific request values. This helper method type hints this attribute.
*/
protected function getExtbaseAttribute(): ExtbaseRequestParameters
{
return $this->request->getAttribute('extbase');
}
public function getControllerObjectName(): string
{
return $this->getExtbaseAttribute()->getControllerObjectName();
}
/**
* Return an instance with the specified controller object name set.
*/
public function withControllerObjectName(string $controllerObjectName): static
{
$attribute = clone $this->getExtbaseAttribute();
$attribute->setControllerObjectName($controllerObjectName);
return $this->withAttribute('extbase', $attribute);
}
/**
* Returns the plugin key.
*/
public function getPluginName(): string
{
return $this->getExtbaseAttribute()->getPluginName();
}
/**
* Return an instance with the specified plugin name set.
*/
public function withPluginName(string $pluginName): static
{
$attribute = clone $this->getExtbaseAttribute();
$attribute->setPluginName($pluginName);
return $this->withAttribute('extbase', $attribute);
}
/**
* Returns the extension name of the specified controller.
*/
public function getControllerExtensionName(): string
{
return $this->getExtbaseAttribute()->getControllerExtensionName();
}
/**
* Return an instance with the specified controller extension name set.
*/
public function withControllerExtensionName(string $controllerExtensionName): static
{
$attribute = clone $this->getExtbaseAttribute();
$attribute->setControllerExtensionName($controllerExtensionName);
return $this->withAttribute('extbase', $attribute);
}
/**
* Returns the extension key of the specified controller.
*/
public function getControllerExtensionKey(): string
{
return $this->getExtbaseAttribute()->getControllerExtensionKey();
}
/**
* Returns the controller name supposed to handle this request, if one
* was set already (if not, the name of the default controller is returned)
*/
public function getControllerName(): string
{
return $this->getExtbaseAttribute()->getControllerName();
}
/**
* Return an instance with the specified controller name set.
*/
public function withControllerName(string $controllerName): static
{
$attribute = clone $this->getExtbaseAttribute();
$attribute->setControllerName($controllerName);
return $this->withAttribute('extbase', $attribute);
}
/**
* Returns the name of the action the controller is supposed to execute.
*/
public function getControllerActionName(): string
{
return $this->getExtbaseAttribute()->getControllerActionName();
}
/**
* Return an instance with the specified controller action name set.
*/
public function withControllerActionName(string $actionName): static
{
$attribute = clone $this->getExtbaseAttribute();
$attribute->setControllerActionName($actionName);
return $this->withAttribute('extbase', $attribute);
}
public function getArguments(): array
{
return $this->getExtbaseAttribute()->getArguments();
}
/**
* Return an instance with the specified extbase arguments, replacing
* any arguments which existed before.
*/
public function withArguments(array $arguments): static
{
$attribute = clone $this->getExtbaseAttribute();
$attribute->setArguments($arguments);
return $this->withAttribute('extbase', $attribute);
}
public function getArgument(string $argumentName): mixed
{
return $this->getExtbaseAttribute()->getArgument($argumentName);
}
public function hasArgument(string $argumentName): bool
{
return $this->getExtbaseAttribute()->hasArgument($argumentName);
}
/**
* Return an instance with the specified argument set.
*/
public function withArgument(string $argumentName, mixed $value): static
{
$attribute = clone $this->getExtbaseAttribute();
$attribute->setArgument($argumentName, $value);
return $this->withAttribute('extbase', $attribute);
}
/**
* Returns the requested representation format, something
* like "html", "xml", "png", "json" or the like.
*/
public function getFormat(): string
{
return $this->getExtbaseAttribute()->getFormat();
}
/**
* Return an instance with the specified derived request attribute.
*
* This method allows setting a single derived request attribute as
* described in getFormat().
*/
public function withFormat(string $format): static
{
$attribute = clone $this->getExtbaseAttribute();
$attribute->setFormat($format);
return $this->withAttribute('extbase', $attribute);
}
/**
* Methods implementing ServerRequestInterface
*/
public function getServerParams(): array
{
return $this->request->getServerParams();
}
public function getCookieParams(): array
{
return $this->request->getCookieParams();
}
public function withCookieParams(array $cookies): static
{
$request = $this->request->withCookieParams($cookies);
return new static($request);
}
public function getQueryParams(): array
{
return $this->request->getQueryParams();
}
public function withQueryParams(array $query): static
{
$request = $this->request->withQueryParams($query);
return new static($request);
}
public function getUploadedFiles(): array
{
return $this->getExtbaseAttribute()->getUploadedFiles();
}
public function withUploadedFiles(array $uploadedFiles): static
{
$attribute = clone $this->getExtbaseAttribute();
$attribute->setUploadedFiles($uploadedFiles);
return $this->withAttribute('extbase', $attribute);
}
public function getParsedBody()
{
return $this->request->getParsedBody();
}
public function withParsedBody($data): static
{
$request = $this->request->withParsedBody($data);
return new static($request);
}
public function getAttributes(): array
{
return $this->request->getAttributes();
}
public function getAttribute($name, $default = null)
{
return $this->request->getAttribute($name, $default);
}
public function withAttribute($name, $value): static
{
$request = $this->request->withAttribute($name, $value);
return new static($request);
}
/**
* @return ($name is 'extbase' ? ServerRequestInterface : static)
*/
public function withoutAttribute($name): ServerRequestInterface|static
{
$request = $this->request->withoutAttribute($name);
if ($name === 'extbase') {
return $request;
}
return new static($request);
}
/**
* Methods implementing RequestInterface
*/
public function getRequestTarget(): string
{
return $this->request->getRequestTarget();
}
public function withRequestTarget($requestTarget): static
{
$request = $this->request->withRequestTarget($requestTarget);
return new static($request);
}
public function getMethod(): string
{
return $this->request->getMethod();
}
public function withMethod($method): static
{
$request = $this->request->withMethod($method);
return new static($request);
}
public function getUri(): UriInterface
{
return $this->request->getUri();
}
public function withUri(UriInterface $uri, $preserveHost = false): static
{
$request = $this->request->withUri($uri, $preserveHost);
return new static($request);
}
/**
* Methods implementing MessageInterface
*/
public function getProtocolVersion(): string
{
return $this->request->getProtocolVersion();
}
public function withProtocolVersion($version): static
{
$request = $this->request->withProtocolVersion($version);
return new static($request);
}
public function getHeaders(): array
{
return $this->request->getHeaders();
}
public function hasHeader($name): bool
{
return $this->request->hasHeader($name);
}
public function getHeader($name): array
{
return $this->request->getHeader($name);
}
public function getHeaderLine($name): string
{
return $this->request->getHeaderLine($name);
}
public function withHeader($name, $value): static
{
$request = $this->request->withHeader($name, $value);
return new static($request);
}
public function withAddedHeader($name, $value): static
{
$request = $this->request->withAddedHeader($name, $value);
return new static($request);
}
public function withoutHeader($name): static
{
$request = $this->request->withoutHeader($name);
return new static($request);
}
public function getBody(): StreamInterface
{
return $this->request->getBody();
}
public function withBody(StreamInterface $body): static
{
$request = $this->request->withBody($body);
return new static($request);
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
/*
* 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\Extbase\Mvc;
use Psr\Http\Message\ServerRequestInterface;
/**
* Contract for an extbase request.
*/
interface RequestInterface extends ServerRequestInterface
{
/**
* Returns the plugin key.
*/
public function getPluginName(): string;
/**
* Return an instance with the specified plugin name set.
*/
public function withPluginName(string $pluginName): RequestInterface;
/**
* Returns the extension name of the specified controller.
*/
public function getControllerExtensionName(): string;
/**
* Return an instance with the specified controller extension name set.
*/
public function withControllerExtensionName(string $controllerExtensionName): RequestInterface;
/**
* Returns the extension key of the specified controller.
*/
public function getControllerExtensionKey(): string;
/**
* Returns the object name of the controller defined by the package
* key and controller name.
*/
public function getControllerObjectName(): string;
/**
* Return an instance with the specified controller object name set.
*/
public function withControllerObjectName(string $controllerObjectName): RequestInterface;
/**
* Returns the object name of the controller supposed to handle this request, if one
* was specified already (if not, the name of the default controller is returned)
*/
public function getControllerName(): string;
/**
* Return an instance with the specified controller name set.
*/
public function withControllerName(string $controllerName): RequestInterface;
/**
* Returns the name of the action the controller is supposed to execute.
*/
public function getControllerActionName(): string;
/**
* Return an instance with the specified controller action name set.
*
* Note that the action name must start with a lower case letter and is case-sensitive.
*/
public function withControllerActionName(string $actionName): RequestInterface;
/**
* Returns the value of the specified argument.
*/
public function getArgument(string $argumentName): mixed;
/**
* Checks if an argument of the given name exists (is set).
*/
public function hasArgument(string $argumentName): bool;
/**
* Return an instance with the specified argument set.
*/
public function withArgument(string $argumentName, mixed $value): RequestInterface;
/**
* Returns an array of extbase arguments and their values.
*/
public function getArguments(): array;
/**
* Return an instance with the specified extbase arguments, replacing
* any arguments which existed before.
*/
public function withArguments(array $arguments): RequestInterface;
/**
* Returns the requested representation format, something
* like "html", "xml", "png", "json" or the like.
*/
public function getFormat(): string;
/**
* Return an instance with the specified format.
* This method allows setting the format as described in getFormat().
*/
public function withFormat(string $format): RequestInterface;
}
+337
View File
@@ -0,0 +1,337 @@
<?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\Extbase\Mvc\View;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\View\ViewInterface;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
use TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface;
use TYPO3\CMS\Extbase\Reflection\ObjectAccess;
/**
* A JSON view
*/
#[Autoconfigure(public: true, shared: false)]
class JsonView implements ViewInterface
{
/**
* Definition for the class name exposure configuration,
* that is, if the class name of an object should also be
* part of the output JSON, if configured.
*
* Setting this value, the object's class name is fully
* put out, including the namespace.
*/
public const EXPOSE_CLASSNAME_FULLY_QUALIFIED = 1;
/**
* Puts out only the actual class name without namespace.
* See EXPOSE_CLASSNAME_FULL for the meaning of the constant at all.
*/
public const EXPOSE_CLASSNAME_UNQUALIFIED = 2;
/**
* Only variables whose name is contained in this array will be rendered
*
* @var string[]
*/
protected array $variablesToRender = ['value'];
protected string $currentVariable = '';
/**
* The rendering configuration for this JSON view which
* determines which properties of each variable to render.
*
* The configuration array must have the following structure:
*
* Example 1:
*
* [
* 'variable1' => [
* '_only' => ['property1', 'property2', ...]
* ],
* 'variable2' => [
* '_exclude' => ['property3', 'property4, ...]
* ],
* 'variable3' => [
* '_exclude' => ['secretTitle'],
* '_descend' => [
* 'customer' => [
* '_only' => ['firstName', 'lastName']
* ]
* ]
* ],
* 'somearrayvalue' => [
* '_descendAll' => [
* '_only' => ['property1']
* ]
* ]
* ]
*
* Of variable1 only property1 and property2 will be included.
* Of variable2 all properties except property3 and property4
* are used.
* Of variable3 all properties except secretTitle are included.
*
* If a property value is an array or object, it is not included
* by default. If, however, such a property is listed in a "_descend"
* section, the renderer will descend into this sub structure and
* include all its properties (of the next level).
*
* The configuration of each property in "_descend" has the same syntax
* as the top level. Therefore - theoretically - infinitely nested
* structures can be configured.
*
* To export indexed arrays the "_descendAll" section can be used to
* include all array keys for the output. The configuration inside a
* "_descendAll" will be applied to each array element.
*
*
* Example 2: exposing object identifier
*
* [
* 'variableFoo' => [
* '_exclude' => ['secretTitle'],
* '_descend' => [
* 'customer' => [ // consider 'customer' being a persisted entity
* '_only' => ['firstName'],
* '_exposeObjectIdentifier' => TRUE,
* '_exposedObjectIdentifierKey' => 'guid'
* ]
* ]
* ]
* ]
*
* Note for entity objects you are able to expose the object's identifier
* also, just add an "_exposeObjectIdentifier" directive set to TRUE and
* an additional property '__identity' will appear keeping the persistence
* identifier. Renaming that property name instead of '__identity' is also
* possible with the directive "_exposedObjectIdentifierKey".
* Example 2 above would output (summarized):
* {"customer":{"firstName":"John","guid":"892693e4-b570-46fe-af71-1ad32918fb64"}}
*
*
* Example 3: exposing object's class name
*
* [
* 'variableFoo' => [
* '_exclude' => ['secretTitle'],
* '_descend' => [
* 'customer' => [ // consider 'customer' being an object
* '_only' => ['firstName'],
* '_exposeClassName' => \TYPO3\CMS\Extbase\Mvc\View\JsonView::EXPOSE_CLASSNAME_FULLY_QUALIFIED
* ]
* ]
* ]
* ]
*
* The ``_exposeClassName`` is similar to the objectIdentifier one, but the class name is added to the
* JSON object output, for example (summarized):
* {"customer":{"firstName":"John","__class":"Acme\Foo\Domain\Model\Customer"}}
*
* The other option is EXPOSE_CLASSNAME_UNQUALIFIED which only will give the last part of the class
* without the namespace, for example (summarized):
* {"customer":{"firstName":"John","__class":"Customer"}}
* This might be of interest to not provide information about the package or domain structure behind.
*/
protected array $configuration = [];
protected PersistenceManagerInterface $persistenceManager;
/**
* View variables and their values
*/
protected array $variables = [];
/**
* @internal
*/
public function injectPersistenceManager(PersistenceManagerInterface $persistenceManager): void
{
$this->persistenceManager = $persistenceManager;
}
/**
* Add a variable to $this->viewData.
* Can be chained, so $this->view->assign(..., ...)->assign(..., ...); is possible
*
* @param string $key Key of variable
* @param mixed $value Value of object
* @return self an instance of $this, to enable chaining
*/
public function assign(string $key, mixed $value): ViewInterface
{
$this->variables[$key] = $value;
return $this;
}
/**
* Add multiple variables to $this->viewData.
*
* @param array $values array in the format array(key1 => value1, key2 => value2).
* @return self an instance of $this, to enable chaining
*/
public function assignMultiple(array $values): ViewInterface
{
foreach ($values as $key => $value) {
$this->assign($key, $value);
}
return $this;
}
/**
* Specifies which variables this JsonView should render
* By default only the variable 'value' will be rendered
*
* @param string[] $variablesToRender
*/
public function setVariablesToRender(array $variablesToRender): void
{
$this->variablesToRender = $variablesToRender;
}
/**
* @param array $configuration The rendering configuration for this JSON view
*/
public function setConfiguration(array $configuration): void
{
$this->configuration = $configuration;
}
/**
* Transforms the value view variable to a serializable
* array representation using a YAML view configuration and JSON encodes
* the result.
*
* @return string The JSON encoded variables
*/
public function render(string $templateFileName = ''): string
{
$propertiesToRender = $this->renderArray();
return json_encode($propertiesToRender, JSON_UNESCAPED_UNICODE);
}
/**
* Loads the configuration and transforms the value to a serializable array.
*/
protected function renderArray(): mixed
{
if (count($this->variablesToRender) === 1) {
$firstLevel = false;
$variableName = current($this->variablesToRender);
$this->currentVariable = $variableName;
$valueToRender = $this->variables[$variableName] ?? null;
$configuration = $this->configuration[$variableName] ?? [];
} else {
$firstLevel = true;
$valueToRender = [];
foreach ($this->variablesToRender as $variableName) {
$valueToRender[$variableName] = $this->variables[$variableName] ?? null;
}
$configuration = $this->configuration;
}
return $this->transformValue($valueToRender, $configuration, $firstLevel);
}
/**
* Transforms a value depending on type recursively using the
* supplied configuration.
*
* @param mixed $value The value to transform
* @param array $configuration Configuration for transforming the value
* @return mixed The transformed value
*/
protected function transformValue(mixed $value, array $configuration, bool $firstLevel = false): mixed
{
// ObjectStorage returns $key as string, which causes the resulting JSON to be an object instead of the expected array
if ($value instanceof ObjectStorage) {
$value = $value->toArray();
}
if (is_array($value) || $value instanceof \ArrayAccess) {
$array = [];
foreach ($value as $key => $element) {
if ($firstLevel) {
$this->currentVariable = $key;
}
if (isset($configuration['_descendAll']) && is_array($configuration['_descendAll'])) {
$array[$key] = $this->transformValue($element, $configuration['_descendAll']);
} else {
if (isset($configuration['_only']) && is_array($configuration['_only']) && !in_array($key, $configuration['_only'], true)) {
continue;
}
if (isset($configuration['_exclude']) && is_array($configuration['_exclude']) && in_array($key, $configuration['_exclude'], true)) {
continue;
}
$array[$key] = $this->transformValue($element, $configuration[$key] ?? []);
}
}
return $array;
}
if (is_object($value)) {
return $this->transformObject($value, $configuration);
}
return $value;
}
/**
* Traverses the given object structure in order to transform it into an array structure.
*
* @param object $object Object to traverse
* @param array $configuration Configuration for transforming the given object or NULL
* @return array|string Object structure as an array or as a rendered string (for a DateTime instance)
*/
protected function transformObject(object $object, array $configuration): array|string
{
if ($object instanceof \DateTimeInterface) {
return $object->format(\DateTimeInterface::ATOM);
}
$propertyNames = ObjectAccess::getGettablePropertyNames($object);
$propertiesToRender = [];
foreach ($propertyNames as $propertyName) {
if (isset($configuration['_only']) && is_array($configuration['_only']) && !in_array($propertyName, $configuration['_only'], true)) {
continue;
}
if (isset($configuration['_exclude']) && is_array($configuration['_exclude']) && in_array($propertyName, $configuration['_exclude'], true)) {
continue;
}
$propertyValue = ObjectAccess::getProperty($object, $propertyName);
if (!is_array($propertyValue) && !is_object($propertyValue)) {
$propertiesToRender[$propertyName] = $propertyValue;
} elseif (isset($configuration['_descend']) && array_key_exists($propertyName, $configuration['_descend'])) {
$propertiesToRender[$propertyName] = $this->transformValue($propertyValue, $configuration['_descend'][$propertyName]);
} elseif (isset($configuration['_recursive']) && in_array($propertyName, $configuration['_recursive'])) {
$propertiesToRender[$propertyName] = $this->transformValue($propertyValue, $this->configuration[$this->currentVariable]);
}
}
if (isset($configuration['_exposeObjectIdentifier']) && $configuration['_exposeObjectIdentifier'] === true) {
if (isset($configuration['_exposedObjectIdentifierKey']) && strlen($configuration['_exposedObjectIdentifierKey']) > 0) {
$identityKey = $configuration['_exposedObjectIdentifierKey'];
} else {
$identityKey = '__identity';
}
$propertiesToRender[$identityKey] = $this->persistenceManager->getIdentifierByObject($object);
}
if (isset($configuration['_exposeClassName']) && ($configuration['_exposeClassName'] === self::EXPOSE_CLASSNAME_FULLY_QUALIFIED || $configuration['_exposeClassName'] === self::EXPOSE_CLASSNAME_UNQUALIFIED)) {
$className = get_class($object);
$classNameParts = explode('\\', $className);
$propertiesToRender['__class'] = ($configuration['_exposeClassName'] === self::EXPOSE_CLASSNAME_FULLY_QUALIFIED ? $className : array_pop($classNameParts));
}
return $propertiesToRender;
}
}
+222
View File
@@ -0,0 +1,222 @@
<?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\Extbase\Mvc\Web;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Module\ExtbaseModule;
use TYPO3\CMS\Core\Error\Http\PageNotFoundException;
use TYPO3\CMS\Core\Http\UploadedFile;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
use TYPO3\CMS\Extbase\Mvc\Exception as MvcException;
use TYPO3\CMS\Extbase\Mvc\Exception\InvalidActionNameException;
use TYPO3\CMS\Extbase\Mvc\Exception\InvalidArgumentNameException;
use TYPO3\CMS\Extbase\Mvc\Exception\InvalidControllerNameException;
use TYPO3\CMS\Extbase\Mvc\ExtbaseRequestParameters;
use TYPO3\CMS\Extbase\Mvc\Request;
use TYPO3\CMS\Extbase\Mvc\RequestInterface;
use TYPO3\CMS\Extbase\Service\ExtensionService;
/**
* Builds an extbase web request.
*
* @internal only to be used within Extbase, not part of TYPO3 Core API.
*/
#[Autoconfigure(public: true)]
readonly class RequestBuilder
{
public function __construct(
protected ConfigurationManagerInterface $configurationManager,
protected ExtensionService $extensionService,
) {}
/**
* Decorate a PSR-7 request as extbase web Request with the extbase attribute.
*/
public function build(ServerRequestInterface $mainRequest): RequestInterface
{
$configuration = [];
// Parameters, which are not part of the request URL (e.g. due to "useArgumentsWithoutNamespace"), which however
// need to be taken into account on building the extbase request. Usually those are "controller" and "action".
$fallbackParameters = [];
// To be used in TYPO3 Backend for Extbase modules that do not need the "namespaces" GET and POST parameters anymore.
$useArgumentsWithoutNamespace = false;
// Fetch requested module from the main request. This is only used for TYPO3 Backend Modules.
$module = $mainRequest->getAttribute('module');
if ($module instanceof ExtbaseModule) {
$configuration = [
'controllerConfiguration' => $module->getControllerActions(),
];
$useArgumentsWithoutNamespace = true;
// Ensure the "controller" and "action" information are added as fallback parameters.
if ($routeOptions = $mainRequest->getAttribute('route')?->getOptions()) {
$fallbackParameters['controller'] = $routeOptions['controller'] ?? null;
$fallbackParameters['action'] = $routeOptions['action'];
}
}
$defaultValues = $this->loadDefaultValues($configuration);
$pluginNamespace = $this->extensionService->getPluginNamespace(
$defaultValues->getExtensionName(),
$defaultValues->getPluginName()
);
$queryArguments = $mainRequest->getAttribute('routing');
if ($useArgumentsWithoutNamespace) {
$parameters = $mainRequest->getQueryParams();
} elseif ($queryArguments instanceof PageArguments) {
$parameters = $queryArguments->get($pluginNamespace) ?? [];
} else {
$parameters = $mainRequest->getQueryParams()[$pluginNamespace] ?? [];
}
$parameters = is_array($parameters) ? $parameters : [];
if ($fallbackParameters !== []) {
// Enhance with fallback parameters, such as "controller" and "action"
$parameters = array_replace_recursive($fallbackParameters, $parameters);
}
if ($mainRequest->getMethod() === 'POST') {
if ($useArgumentsWithoutNamespace) {
$postParameters = $mainRequest->getParsedBody();
} else {
$postParameters = $mainRequest->getParsedBody()[$pluginNamespace] ?? [];
}
$postParameters = is_array($postParameters) ? $postParameters : [];
$parameters = array_replace_recursive($parameters, $postParameters);
}
$files = $mainRequest->getUploadedFiles();
if (!$useArgumentsWithoutNamespace) {
$files = $files[$pluginNamespace] ?? [];
if ($files instanceof UploadedFile) {
throw new InvalidArgumentNameException(
'Using only the plugin namespace as argument name is not allowed for uploaded files. Please use plugin_namespace[argument_name] instead.',
1722542546
);
}
}
// Merge UploadedFiles into request parameters, so that they are available as arguments
// for property mapping (e.g. in ext:form or a custom file upload TypeConverter).
$parameters = array_replace_recursive($parameters, $files);
$controllerClassName = $this->resolveControllerClassName($defaultValues, $parameters);
$actionName = $this->resolveActionName($defaultValues, $controllerClassName, $parameters);
$extbaseAttribute = new ExtbaseRequestParameters();
$extbaseAttribute->setPluginName($defaultValues->getPluginName());
$extbaseAttribute->setControllerExtensionName($defaultValues->getExtensionName());
$extbaseAttribute->setControllerAliasToClassNameMapping($defaultValues->getControllerAliasToClassMapping());
$extbaseAttribute->setControllerName($defaultValues->getControllerAliasForControllerClassName($controllerClassName));
$extbaseAttribute->setControllerActionName($actionName);
$extbaseAttribute->setUploadedFiles($files);
if (isset($parameters['format']) && is_string($parameters['format']) && $parameters['format'] !== '') {
$extbaseAttribute->setFormat(preg_replace('/[^a-zA-Z0-9]+/', '', $parameters['format']));
} else {
$extbaseAttribute->setFormat($defaultValues->getDefaultFormat());
}
foreach ($parameters as $argumentName => $argumentValue) {
$extbaseAttribute->setArgument($argumentName, $argumentValue);
}
return new Request($mainRequest->withAttribute('extbase', $extbaseAttribute));
}
/**
* @throws MvcException
*/
protected function loadDefaultValues(array $configuration = []): RequestBuilderDefaultValues
{
// todo: See comment in \TYPO3\CMS\Extbase\Core\Bootstrap::initializeConfiguration for further explanation
// todo: on why we shouldn't use the configuration manager here.
$configuration = array_replace_recursive($this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK), $configuration);
try {
return RequestBuilderDefaultValues::fromConfiguration($configuration);
} catch (\InvalidArgumentException $e) {
throw MvcException::fromPrevious($e);
}
}
/**
* Returns the current ControllerName extracted from given $parameters.
* If no controller is specified, the defaultControllerName will be returned.
* If that's not available, an exception is thrown.
*
* @throws InvalidControllerNameException
* @throws MvcException if the controller could not be resolved
* @throws PageNotFoundException
* @return class-string
*/
protected function resolveControllerClassName(RequestBuilderDefaultValues $defaultValues, array $parameters): string
{
if (!isset($parameters['controller']) || $parameters['controller'] === '') {
return $defaultValues->getDefaultControllerClassName();
}
$controllerClassName = $defaultValues->getControllerClassNameForAlias($parameters['controller']) ?? '';
if ($defaultValues->getAllowedControllerActionsOfController($controllerClassName) === []) {
$configuration = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK);
if (isset($configuration['mvc']['throwPageNotFoundExceptionIfActionCantBeResolved']) && (bool)$configuration['mvc']['throwPageNotFoundExceptionIfActionCantBeResolved']) {
throw new PageNotFoundException('The requested resource was not found', 1313857897);
}
if (isset($configuration['mvc']['callDefaultActionIfActionCantBeResolved']) && (bool)$configuration['mvc']['callDefaultActionIfActionCantBeResolved']) {
return $defaultValues->getDefaultControllerClassName();
}
throw new InvalidControllerNameException(
'The controller "' . $parameters['controller'] . '" is not allowed by plugin "' . $defaultValues->getPluginName() . '". Please check for TYPO3\\CMS\\Extbase\\Utility\\ExtensionUtility::configurePlugin() in your ext_localconf.php.',
1313855173
);
}
return preg_replace('/[^a-zA-Z0-9\\\\]+/', '', $controllerClassName);
}
/**
* Returns the current actionName extracted from given $parameters.
* If no action is specified, the defaultActionName will be returned.
* If that's not available or the specified action is not defined in the current plugin, an exception is thrown.
*
* @param class-string $controllerClassName
* @throws InvalidActionNameException
* @throws MvcException
* @throws PageNotFoundException
* @return non-empty-string
*/
protected function resolveActionName(RequestBuilderDefaultValues $defaultValues, string $controllerClassName, array $parameters): string
{
$defaultActionName = $defaultValues->getDefaultActionName($controllerClassName);
if (!isset($parameters['action']) || $parameters['action'] === '') {
if ($defaultActionName === '') {
throw new MvcException('The default action can not be determined for controller "' . $controllerClassName . '". Please check TYPO3\\CMS\\Extbase\\Utility\\ExtensionUtility::configurePlugin() in your ext_localconf.php.', 1295479651);
}
return $defaultActionName;
}
$actionName = $parameters['action'];
$allowedActionNames = $defaultValues->getAllowedControllerActionsOfController($controllerClassName);
if (!in_array($actionName, $allowedActionNames)) {
$configuration = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK);
if (isset($configuration['mvc']['throwPageNotFoundExceptionIfActionCantBeResolved']) && (bool)$configuration['mvc']['throwPageNotFoundExceptionIfActionCantBeResolved']) {
throw new PageNotFoundException('The requested resource was not found', 1313857898);
}
if (isset($configuration['mvc']['callDefaultActionIfActionCantBeResolved']) && (bool)$configuration['mvc']['callDefaultActionIfActionCantBeResolved']) {
if ($defaultActionName === '') {
throw new MvcException('The default action can not be determined for controller "' . $controllerClassName . '". Please check TYPO3\\CMS\\Extbase\\Utility\\ExtensionUtility::configurePlugin() in your ext_localconf.php.', 1679048627);
}
return $defaultActionName;
}
throw new InvalidActionNameException('The action "' . $actionName . '" (controller "' . $controllerClassName . '") is not allowed by this plugin / module. Please check TYPO3\\CMS\\Extbase\\Utility\\ExtensionUtility::configurePlugin() in your ext_localconf.php / array key "controllerActions" defined in your Configuration/Backend/Modules.php.', 1313855175);
}
return preg_replace('/[^a-zA-Z0-9]+/', '', $actionName);
}
}
@@ -0,0 +1,247 @@
<?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\Extbase\Mvc\Web;
/**
* @internal only to be used within Extbase, not part of TYPO3 Core API.
*/
final class RequestBuilderDefaultValues
{
/**
* @param non-empty-string $extensionName
* @param non-empty-string $pluginName
* @param class-string $defaultControllerClassName
* @param non-empty-string $defaultControllerAlias
* @param non-empty-string $defaultFormat
*/
private function __construct(
private readonly string $extensionName,
private readonly string $pluginName,
private readonly string $defaultControllerClassName,
private readonly string $defaultControllerAlias,
private readonly string $defaultFormat,
private readonly array $allowedControllerActions,
private readonly array $controllerAliasToClassMapping,
private readonly array $controllerClassToAliasMapping,
) {}
public static function fromConfiguration(array $configuration): self
{
$extensionName = $configuration['extensionName'] ?? null;
$extensionName = is_string($extensionName) && $extensionName !== '' ? $extensionName : null;
$pluginName = $configuration['pluginName'] ?? null;
$pluginName = is_string($pluginName) && $pluginName !== '' ? $pluginName : null;
$controllerConfigurations = $configuration['controllerConfiguration'] ?? [];
$controllerConfigurations = is_array($controllerConfigurations) ? $controllerConfigurations : [];
if (!is_string($extensionName)) {
throw new \InvalidArgumentException('"extensionName" is not properly configured. Request can\'t be dispatched!', 1289843275);
}
if (!is_string($pluginName)) {
throw new \InvalidArgumentException('"pluginName" is not properly configured. Request can\'t be dispatched!', 1289843277);
}
if ($controllerConfigurations === []) {
throw new \InvalidArgumentException(
sprintf(
'The default controller for extension "%s" and plugin "%s" can not be determined. '
. 'Please check for TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin() in your ext_localconf.php.',
$extensionName,
$pluginName
),
1316104317
);
}
$defaultFormat = $configuration['format'] ?? null;
$defaultFormat = is_string($defaultFormat) && $defaultFormat !== '' ? $defaultFormat : 'html';
$defaultControllerClassName = null;
$defaultControllerAlias = null;
$allowedControllerActions = [];
$controllerClassToAliasMapping = [];
$controllerAliasToClassMapping = [];
$firstItem = true;
foreach ($controllerConfigurations as $controllerClassName => $controllerConfiguration) {
if (!is_string($controllerClassName) || $controllerClassName === '') {
continue;
}
if (!is_array($controllerConfiguration)) {
continue;
}
$actions = $controllerConfiguration['actions'] ?? [];
$actions = is_array($actions) ? $actions : [];
if ($actions === []) {
continue;
}
$controllerClassName = $controllerConfiguration['className'] ?? null;
$controllerClassName = is_string($controllerClassName) && $controllerClassName !== '' ? $controllerClassName : null;
if ($controllerClassName === null) {
continue;
}
$controllerAlias = $controllerConfiguration['alias'] ?? null;
$controllerAlias = is_string($controllerAlias) && $controllerAlias !== '' ? $controllerAlias : null;
if ($controllerAlias === null) {
continue;
}
$allowedControllerActions[$controllerClassName] = $actions;
$controllerClassToAliasMapping[$controllerClassName] = $controllerAlias;
$controllerAliasToClassMapping[$controllerAlias] = $controllerClassName;
if ($firstItem) {
$defaultControllerClassName = $controllerClassName;
$defaultControllerAlias = $controllerAlias;
}
$firstItem = false;
}
if ($defaultControllerClassName === null || $defaultControllerAlias === null) {
throw new \LogicException(
'Either $defaultControllerClassName or $defaultControllerAlias are unexpectedly null',
1679051921
);
}
if ($allowedControllerActions === []) {
throw new \LengthException(
'$allowedControllerActions is expected to not be empty',
1679051891
);
}
return new self(
$extensionName,
$pluginName,
$defaultControllerClassName,
$defaultControllerAlias,
$defaultFormat,
$allowedControllerActions,
$controllerAliasToClassMapping,
$controllerClassToAliasMapping,
);
}
/**
* @return non-empty-string
*/
public function getExtensionName(): string
{
return $this->extensionName;
}
/**
* @return non-empty-string
*/
public function getPluginName(): string
{
return $this->pluginName;
}
/**
* @return class-string
*/
public function getDefaultControllerClassName(): string
{
return $this->defaultControllerClassName;
}
/**
* @return non-empty-string
*/
public function getDefaultControllerAlias(): string
{
return $this->defaultControllerAlias;
}
/**
* @return non-empty-string
*/
public function getDefaultFormat(): string
{
return $this->defaultFormat;
}
/**
* @return array<class-string, list<string>>
*/
public function getAllowedControllerActions(): array
{
return $this->allowedControllerActions;
}
/**
* @return list<string>
*/
public function getAllowedControllerActionsOfController(string $controllerClassName): array
{
return $this->allowedControllerActions[$controllerClassName] ?? [];
}
/**
* @return array<non-empty-string, class-string>
*/
public function getControllerAliasToClassMapping(): array
{
return $this->controllerAliasToClassMapping;
}
/**
* @return array<class-string, non-empty-string>
*/
public function getControllerClassToAliasMapping(): array
{
return $this->controllerClassToAliasMapping;
}
/**
* @param non-empty-string $controllerAlias
* @return class-string|null
*/
public function getControllerClassNameForAlias(string $controllerAlias): ?string
{
return $this->controllerAliasToClassMapping[$controllerAlias] ?? null;
}
/**
* @param class-string $controllerClassName
* @return non-empty-string|null
*/
public function getControllerAliasForControllerClassName(string $controllerClassName): ?string
{
return $this->controllerClassToAliasMapping[$controllerClassName] ?? null;
}
public function getDefaultActionName(string $controllerClassName): ?string
{
$actions = $this->allowedControllerActions[$controllerClassName] ?? [];
return $actions[0] ?? null;
}
}
+647
View File
@@ -0,0 +1,647 @@
<?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\Extbase\Mvc\Web\Routing;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Routing\Exception\RouteNotFoundException;
use TYPO3\CMS\Backend\Routing\Route;
use TYPO3\CMS\Core\Http\ApplicationType;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\DomainObject\AbstractValueObject;
use TYPO3\CMS\Extbase\DomainObject\DomainObjectInterface;
use TYPO3\CMS\Extbase\Mvc\Exception\InvalidArgumentValueException;
use TYPO3\CMS\Extbase\Mvc\RequestInterface;
use TYPO3\CMS\Extbase\Persistence\Generic\LazyLoadingProxy;
use TYPO3\CMS\Extbase\Service\ExtensionService;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* URI Builder for extbase requests.
*/
#[Autoconfigure(public: true, shared: false)]
class UriBuilder
{
protected RequestInterface $request;
protected array $arguments = [];
protected array $lastArguments = [];
protected string $section = '';
protected bool $createAbsoluteUri = false;
protected ?string $absoluteUriScheme = null;
protected bool|string|int $addQueryString = false;
protected array $argumentsToBeExcludedFromQueryString = [];
protected bool $linkAccessRestrictedPages = false;
protected ?int $targetPageUid = null;
protected int $targetPageType = 0;
protected ?string $language = null;
protected bool $noCache = false;
protected string $format = '';
protected ?string $argumentPrefix = null;
public function __construct(
protected readonly ExtensionService $extensionService,
) {}
/**
* Sets the current request
*
* @return static the current UriBuilder to allow method chaining
*/
public function setRequest(RequestInterface $request): UriBuilder
{
$this->request = $request;
return $this;
}
/**
* Additional query parameters.
* If you want to "prefix" arguments, you can pass in multidimensional arrays:
* array('prefix1' => array('foo' => 'bar')) gets "&prefix1[foo]=bar"
*
* @return static the current UriBuilder to allow method chaining
*/
public function setArguments(array $arguments): UriBuilder
{
$this->arguments = $arguments;
return $this;
}
/**
* @internal
*/
public function getArguments(): array
{
return $this->arguments;
}
/**
* If specified, adds a given HTML anchor to the URI (#...)
*
* @return static the current UriBuilder to allow method chaining
*/
public function setSection(string $section): UriBuilder
{
$this->section = $section;
return $this;
}
/**
* @internal
*/
public function getSection(): string
{
return $this->section;
}
/**
* Specifies the format of the target (e.g. "html" or "xml")
*
* @return static the current UriBuilder to allow method chaining
*/
public function setFormat(string $format): UriBuilder
{
$this->format = $format;
return $this;
}
/**
* @internal
*/
public function getFormat(): string
{
return $this->format;
}
/**
* If set, the URI is prepended with the current base URI. Defaults to FALSE.
*
* @return static the current UriBuilder to allow method chaining
*/
public function setCreateAbsoluteUri(bool $createAbsoluteUri): UriBuilder
{
$this->createAbsoluteUri = $createAbsoluteUri;
return $this;
}
/**
* @internal
*/
public function getCreateAbsoluteUri(): bool
{
return $this->createAbsoluteUri;
}
/**
* @internal only to be used within Extbase, not part of TYPO3 Core API.
*/
public function getAbsoluteUriScheme(): ?string
{
return $this->absoluteUriScheme;
}
/**
* Sets the scheme that should be used for absolute URIs in FE mode
*
* @param string $absoluteUriScheme the scheme to be used for absolute URIs
* @return static the current UriBuilder to allow method chaining
*/
public function setAbsoluteUriScheme(string $absoluteUriScheme): UriBuilder
{
$this->absoluteUriScheme = $absoluteUriScheme;
return $this;
}
/**
* Enforces a URI / link to a page to a specific language (or use "current")
*/
public function setLanguage(?string $language): UriBuilder
{
$this->language = $language;
return $this;
}
/**
* @internal only to be used within Extbase, not part of TYPO3 Core API.
*/
public function getLanguage(): ?string
{
return $this->language;
}
/**
* If set, the current query parameters will be merged with $this->arguments in backend context.
* In frontend context, setting this property will only include mapped query arguments from the
* Page Routing. To include any - possible "unsafe" - GET parameters, the property has to be set
* to "untrusted". Defaults to FALSE.
*
* @param bool|string|int $addQueryString is set to "1", "true", "0", "false" or "untrusted"
* @return static the current UriBuilder to allow method chaining
* @see https://docs.typo3.org/m/typo3/reference-typoscript/main/en-us/Functions/Typolink.html#addquerystring
*/
public function setAddQueryString(bool|string|int $addQueryString): UriBuilder
{
$this->addQueryString = $addQueryString;
return $this;
}
/**
* @internal
*/
public function getAddQueryString(): bool|string|int
{
return $this->addQueryString;
}
/**
* A list of arguments to be excluded from the query parameters
* Only active if addQueryString is set
*
* @return static the current UriBuilder to allow method chaining
* @see https://docs.typo3.org/m/typo3/reference-typoscript/main/en-us/Functions/Typolink.html#addquerystring
* @see setAddQueryString()
*/
public function setArgumentsToBeExcludedFromQueryString(array $argumentsToBeExcludedFromQueryString): UriBuilder
{
$this->argumentsToBeExcludedFromQueryString = $argumentsToBeExcludedFromQueryString;
return $this;
}
/**
* @internal
*/
public function getArgumentsToBeExcludedFromQueryString(): array
{
return $this->argumentsToBeExcludedFromQueryString;
}
/**
* Specifies the prefix to be used for all arguments.
*
* @return static the current UriBuilder to allow method chaining
*/
public function setArgumentPrefix(string $argumentPrefix): UriBuilder
{
$this->argumentPrefix = $argumentPrefix;
return $this;
}
/**
* @internal only to be used within Extbase, not part of TYPO3 Core API.
*/
public function getArgumentPrefix(): ?string
{
return $this->argumentPrefix;
}
/**
* If set, URIs for pages without access permissions will be created
*
* @return static the current UriBuilder to allow method chaining
*/
public function setLinkAccessRestrictedPages(bool $linkAccessRestrictedPages): UriBuilder
{
$this->linkAccessRestrictedPages = $linkAccessRestrictedPages;
return $this;
}
/**
* @internal
*/
public function getLinkAccessRestrictedPages(): bool
{
return $this->linkAccessRestrictedPages;
}
/**
* Uid of the target page
*
* @return static the current UriBuilder to allow method chaining
*/
public function setTargetPageUid(int $targetPageUid): UriBuilder
{
$this->targetPageUid = $targetPageUid;
return $this;
}
/**
* @internal only to be used within Extbase, not part of TYPO3 Core API.
*/
public function getTargetPageUid(): ?int
{
return $this->targetPageUid;
}
/**
* Sets the page type of the target URI. Defaults to 0
*
* @return static the current UriBuilder to allow method chaining
*/
public function setTargetPageType(int $targetPageType): UriBuilder
{
$this->targetPageType = $targetPageType;
return $this;
}
/**
* @internal only to be used within Extbase, not part of TYPO3 Core API.
*/
public function getTargetPageType(): int
{
return $this->targetPageType;
}
/**
* by default FALSE; if TRUE, &no_cache=1 will be appended to the URI
*
* @return static the current UriBuilder to allow method chaining
*/
public function setNoCache(bool $noCache): UriBuilder
{
$this->noCache = $noCache;
return $this;
}
/**
* @internal
*/
public function getNoCache(): bool
{
return $this->noCache;
}
/**
* Returns the arguments being used for the last URI being built.
* This is only set after build() / uriFor() has been called.
*
* @return array The last arguments
* @internal only to be used within Extbase, not part of TYPO3 Core API.
*/
public function getLastArguments(): array
{
return $this->lastArguments;
}
/**
* Resets all UriBuilder options to their default value
*
* @return static the current UriBuilder to allow method chaining
*/
public function reset(): UriBuilder
{
$this->arguments = [];
$this->section = '';
$this->format = '';
$this->language = null;
$this->createAbsoluteUri = false;
$this->addQueryString = false;
$this->argumentsToBeExcludedFromQueryString = [];
$this->linkAccessRestrictedPages = false;
$this->targetPageUid = null;
$this->targetPageType = 0;
$this->noCache = false;
$this->argumentPrefix = null;
$this->absoluteUriScheme = null;
// $this->request MUST NOT be reset here because the request is actually a hard dependency
// and not part of the internal state of this object.
return $this;
}
/**
* Creates a URI used for linking to an Extbase action.
* Works in Frontend and Backend mode of TYPO3.
*
* @param string|null $actionName Name of the action to be called
* @param array|null $controllerArguments Additional query parameters. Will be "namespaced" and merged with $this->arguments.
* @param string|null $controllerName Name of the target controller. If not set, current ControllerName is used.
* @param string|null $extensionName Name of the target extension, without underscores. If not set, current ExtensionName is used.
* @param string|null $pluginName Name of the target plugin. If not set, current PluginName is used.
* @return string the rendered URI
* @see build()
*/
public function uriFor(
?string $actionName = null,
?array $controllerArguments = null,
?string $controllerName = null,
?string $extensionName = null,
?string $pluginName = null
): string {
$controllerArguments = $controllerArguments ?? [];
if ($actionName !== null) {
$controllerArguments['action'] = $actionName;
}
if ($controllerName !== null) {
$controllerArguments['controller'] = $controllerName;
} else {
$controllerArguments['controller'] = $this->request->getControllerName();
}
if ($extensionName === null) {
$extensionName = $this->request->getControllerExtensionName();
}
$isFrontend = ApplicationType::fromRequest($this->request)->isFrontend();
if ($pluginName === null && $isFrontend) {
$pluginName = $this->extensionService->getPluginNameByAction($extensionName, $controllerArguments['controller'], $controllerArguments['action'] ?? null);
}
if ($pluginName === null) {
$pluginName = $this->request->getPluginName();
}
if ($this->targetPageUid === null && $isFrontend) {
$this->targetPageUid = $this->extensionService->getTargetPidByPlugin($extensionName, $pluginName);
}
if ($this->format !== '') {
$controllerArguments['format'] = $this->format;
}
if ($this->argumentPrefix !== null) {
$prefixedControllerArguments = [$this->argumentPrefix => $controllerArguments];
} elseif (!$isFrontend) {
$prefixedControllerArguments = $controllerArguments;
// Backend UriBuilder needs the route, which usually maps to the "route" parameter, which can be
// found in "Configuration/Backend/Modules.php" as the main key - that is the actual base route
// for the backend module, which in Extbase-speak is called a "pluginName"
$prefixedControllerArguments['route'] = $pluginName;
} else {
$pluginNamespace = $this->extensionService->getPluginNamespace($extensionName, $pluginName);
$prefixedControllerArguments = [$pluginNamespace => $controllerArguments];
}
ArrayUtility::mergeRecursiveWithOverrule($this->arguments, $prefixedControllerArguments);
return $this->build();
}
/**
* Builds the URI
* Depending on the current context this calls buildBackendUri() or buildFrontendUri()
*
* @return string The URI
* @see buildBackendUri()
* @see buildFrontendUri()
*/
public function build(): string
{
if (ApplicationType::fromRequest($this->request)->isBackend()) {
return $this->buildBackendUri();
}
return $this->buildFrontendUri();
}
/**
* Builds the URI, backend flavour
* The settings pageUid, pageType, noCache & linkAccessRestrictedPages
* will be ignored in the backend.
*
* @return string The URI
* @internal only to be used within Extbase, not part of TYPO3 Core API.
*/
public function buildBackendUri(): string
{
$arguments = [];
if ($this->addQueryString && $this->addQueryString !== 'false') {
$arguments = $this->request->getQueryParams();
foreach ($this->argumentsToBeExcludedFromQueryString as $argumentToBeExcluded) {
$argumentArrayToBeExcluded = [];
parse_str($argumentToBeExcluded, $argumentArrayToBeExcluded);
$arguments = ArrayUtility::arrayDiffKeyRecursive($arguments, $argumentArrayToBeExcluded);
}
} else {
$id = $this->request->getParsedBody()['id'] ?? $this->request->getQueryParams()['id'] ?? null;
if ($id !== null) {
$arguments['id'] = $id;
}
}
if (($route = $this->request->getAttribute('route')) instanceof Route) {
/** @var Route $route */
$arguments['route'] = $route->getOption('_identifier');
}
$arguments = array_replace_recursive($arguments, $this->arguments);
$arguments = $this->convertDomainObjectsToIdentityArrays($arguments);
$this->lastArguments = $arguments;
$routeIdentifier = $arguments['route'] ?? null;
unset($arguments['route'], $arguments['token']);
// In case the current route identifier is an identifier of a sub route, remove the sub route
// part to be able to add the actually requested sub route based on the current arguments.
if ($routeIdentifier && str_contains($routeIdentifier, '.')) {
[$routeIdentifier] = explode('.', $routeIdentifier);
}
// Build route identifier to the actually requested sub route (controller / action pair) - if any -
// and unset corresponding arguments.
if ($routeIdentifier && isset($arguments['controller'], $arguments['action'])) {
$routeIdentifier .= '.' . $arguments['controller'] . '_' . $arguments['action'];
unset($arguments['controller'], $arguments['action']);
}
$uri = '';
if ($routeIdentifier) {
$backendUriBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Backend\Routing\UriBuilder::class);
try {
if ($this->createAbsoluteUri) {
$uri = (string)$backendUriBuilder->buildUriFromRoute($routeIdentifier, $arguments, \TYPO3\CMS\Backend\Routing\UriBuilder::ABSOLUTE_URL);
} else {
$uri = (string)$backendUriBuilder->buildUriFromRoute($routeIdentifier, $arguments);
}
} catch (RouteNotFoundException) {
// empty URL
}
}
if ($this->section !== '') {
$uri .= '#' . $this->section;
}
return $uri;
}
/**
* Builds the URI, frontend flavour
*
* @return string The URI
* @see buildTypolinkConfiguration()
* @internal only to be used within Extbase, not part of TYPO3 Core API.
*/
public function buildFrontendUri(): string
{
$typolinkConfiguration = $this->buildTypolinkConfiguration();
if ($this->createAbsoluteUri === true) {
$typolinkConfiguration['forceAbsoluteUrl'] = true;
if ($this->absoluteUriScheme !== null) {
$typolinkConfiguration['forceAbsoluteUrl.']['scheme'] = $this->absoluteUriScheme;
}
}
/** @var ?ContentObjectRenderer $currentContentObject */
$currentContentObject = $this->request->getAttribute('currentContentObject');
return $currentContentObject?->createUrl($typolinkConfiguration) ?? '';
}
/**
* Builds a TypoLink configuration array from the current settings
*
* @return array typolink configuration array
* @see https://docs.typo3.org/m/typo3/reference-typoscript/main/en-us/Functions/Typolink.html
*/
protected function buildTypolinkConfiguration(): array
{
$typolinkConfiguration = [];
$typolinkConfiguration['parameter'] = $this->targetPageUid ?? $this->request->getAttribute('frontend.page.information')?->getId() ?? '';
if ($this->targetPageType !== 0) {
$typolinkConfiguration['parameter'] .= ',' . $this->targetPageType;
} elseif ($this->format !== '') {
$targetPageType = $this->extensionService->getTargetPageTypeByFormat($this->request->getControllerExtensionName(), $this->format);
$typolinkConfiguration['parameter'] .= ',' . $targetPageType;
}
if (!empty($this->arguments)) {
$arguments = $this->convertDomainObjectsToIdentityArrays($this->arguments);
$this->lastArguments = $arguments;
$typolinkConfiguration['queryParameters'] = $arguments;
}
if ($this->addQueryString && $this->addQueryString !== 'false') {
$typolinkConfiguration['addQueryString'] = $this->addQueryString;
if (!empty($this->argumentsToBeExcludedFromQueryString)) {
$typolinkConfiguration['addQueryString.'] = [
'exclude' => implode(',', $this->argumentsToBeExcludedFromQueryString),
];
}
}
if ($this->language !== null) {
$typolinkConfiguration['language'] = $this->language;
}
if ($this->noCache === true) {
$typolinkConfiguration['no_cache'] = 1;
}
if ($this->section !== '') {
$typolinkConfiguration['section'] = $this->section;
}
if ($this->linkAccessRestrictedPages === true) {
$typolinkConfiguration['linkAccessRestrictedPages'] = 1;
}
return $typolinkConfiguration;
}
/**
* Recursively iterates through the specified arguments and turns instances of type \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
* into an arrays containing the uid of the domain object.
*
* @param array $arguments The arguments to be iterated
* @throws InvalidArgumentValueException
* @return array The modified arguments array
*/
protected function convertDomainObjectsToIdentityArrays(array $arguments): array
{
foreach ($arguments as $argumentKey => $argumentValue) {
// if we have a LazyLoadingProxy here, make sure to get the real instance for further processing
if ($argumentValue instanceof LazyLoadingProxy) {
$argumentValue = $argumentValue->_loadRealInstance();
// also update the value in the arguments array, because the lazyLoaded object could be
// hidden and thus the $argumentValue would be NULL.
$arguments[$argumentKey] = $argumentValue;
}
if ($argumentValue instanceof \Iterator) {
$argumentValue = $this->convertIteratorToArray($argumentValue);
}
if ($argumentValue instanceof DomainObjectInterface) {
if ($argumentValue->getUid() !== null) {
$arguments[$argumentKey] = $argumentValue->getUid();
} elseif ($argumentValue instanceof AbstractValueObject) {
$arguments[$argumentKey] = $this->convertTransientObjectToArray($argumentValue);
} else {
throw new InvalidArgumentValueException('Could not serialize Domain Object ' . get_class($argumentValue) . '. It is neither an Entity with identity properties set, nor a Value Object.', 1260881688);
}
} elseif (is_array($argumentValue)) {
$arguments[$argumentKey] = $this->convertDomainObjectsToIdentityArrays($argumentValue);
} elseif ($argumentValue instanceof \UnitEnum) {
$arguments[$argumentKey] = $argumentValue->value ?? $argumentValue->name;
} elseif ($argumentValue instanceof \Stringable) {
$arguments[$argumentKey] = (string)$argumentValue;
}
}
return $arguments;
}
protected function convertIteratorToArray(\Iterator $iterator): array
{
if (method_exists($iterator, 'toArray')) {
$array = $iterator->toArray();
} else {
$array = iterator_to_array($iterator);
}
return $array;
}
/**
* Converts a given object recursively into an array.
*
* @todo Refactor this into convertDomainObjectsToIdentityArrays()
*/
protected function convertTransientObjectToArray(DomainObjectInterface $object): array
{
$result = [];
foreach ($object->_getProperties() as $propertyName => $propertyValue) {
if ($propertyValue instanceof \Iterator) {
$propertyValue = $this->convertIteratorToArray($propertyValue);
}
if ($propertyValue instanceof DomainObjectInterface) {
if ($propertyValue->getUid() !== null) {
$result[$propertyName] = $propertyValue->getUid();
} else {
$result[$propertyName] = $this->convertTransientObjectToArray($propertyValue);
}
} elseif (is_array($propertyValue)) {
$result[$propertyName] = $this->convertDomainObjectsToIdentityArrays($propertyValue);
} else {
$result[$propertyName] = $propertyValue;
}
}
return $result;
}
}