TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:27 +02:00
commit 44c503b2d1
233 changed files with 31556 additions and 0 deletions
@@ -0,0 +1,131 @@
<?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\Frontend\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Authentication\Mfa\MfaRequiredException;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Authentication\FrontendBackendUserAuthentication;
use TYPO3\CMS\Frontend\Cache\CacheInstruction;
/**
* This middleware authenticates a Backend User (be_user) (pre)-viewing a frontend page.
*
* This middleware also ensures that $GLOBALS['LANG'] is available, however it is possible that
* a different middleware later-on might unset the BE_USER as he/she is not allowed to preview a certain
* page due to rights management. As this can only happen once the page ID is resolved, this will happen
* after the routing middleware.
*/
class BackendUserAuthenticator extends \TYPO3\CMS\Core\Middleware\BackendUserAuthenticator
{
public function __construct(
Context $context,
protected readonly LanguageServiceFactory $languageServiceFactory
) {
parent::__construct($context);
}
/**
* Creates a backend user authentication object, tries to authenticate a user
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
// Initializing a possible logged-in Backend User
// If the backend cookie is set,
// we proceed and check if a backend user is logged in.
$backendUserObject = null;
if (isset($request->getCookieParams()[BackendUserAuthentication::getCookieName()])) {
$backendUserObject = $this->initializeBackendUser($request);
}
$GLOBALS['BE_USER'] = $backendUserObject;
// Load specific dependencies which are necessary for a valid Backend User
// like $GLOBALS['LANG'] for labels in the language of the BE User (so AdminPanel works)
if ($backendUserObject !== null) {
$GLOBALS['LANG'] = $this->languageServiceFactory->createFromUserPreferences($GLOBALS['BE_USER']);
$this->setBackendUserAspect($GLOBALS['BE_USER']);
if ($this->context->getPropertyFromAspect('backend.user', 'isLoggedIn', false)
&& (strtolower($request->getServerParams()['HTTP_CACHE_CONTROL'] ?? '') === 'no-cache'
|| strtolower($request->getServerParams()['HTTP_PRAGMA'] ?? '') === 'no-cache')
) {
// Detecting if shift-reload has been clicked to disable caching if so.
// This is only done if a backend user is logged in to prevent DoS-attacks for "casual" requests.
$cacheInstruction = $request->getAttribute('frontend.cache.instruction', new CacheInstruction());
$cacheInstruction->disableCache('EXT:frontend: Logged in backend user forced reload disabled cache.');
$request = $request->withAttribute('frontend.cache.instruction', $cacheInstruction);
}
}
$response = $handler->handle($request);
// If, when building the response, the user is still available, then ensure that the headers are sent properly
if ($this->context->getAspect('backend.user')->isLoggedIn()) {
return $this->applyHeadersToResponse($response);
}
return $response;
}
/**
* Creates the backend user object and returns it if a valid backend user is found.
*/
protected function initializeBackendUser(ServerRequestInterface $request): ?FrontendBackendUserAuthentication
{
// New backend user object
$backendUserObject = GeneralUtility::makeInstance(FrontendBackendUserAuthentication::class);
try {
$backendUserObject->start($request);
} catch (MfaRequiredException $e) {
// Do nothing, as the user is not fully authenticated - has not
// passed required multi-factor authentication - via the backend.
return null;
}
if (!empty($backendUserObject->user['uid'])) {
$this->setBackendUserAspect($backendUserObject, (int)$backendUserObject->user['workspace_id']);
$backendUserObject->fetchGroupData();
}
// Unset the user initialization if any setting / restriction applies
if (!$this->isAuthenticated($backendUserObject, $request, $request->getAttribute('normalizedParams'))) {
$backendUserObject = null;
$this->setBackendUserAspect(null);
}
return $backendUserObject;
}
/**
* Implementing the access checks that the TYPO3 CMS bootstrap script does before a user is ever logged in.
* Returns TRUE if access is OK
*/
protected function isAuthenticated(FrontendBackendUserAuthentication $user, ServerRequestInterface $request, NormalizedParams $normalizedParams): bool
{
// Check IP
$ipMask = trim($GLOBALS['TYPO3_CONF_VARS']['BE']['IPmaskList'] ?? '');
if ($ipMask && !GeneralUtility::cmpIP($normalizedParams->getRemoteAddress(), $ipMask)) {
return false;
}
// Check SSL (https)
if ($GLOBALS['TYPO3_CONF_VARS']['BE']['lockSSL'] && !$normalizedParams->isHttps()) {
return false;
}
return $user->backendCheckLogin($request);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?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\Frontend\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Handle cache timeout that is set in typoscript.
*
* @internal
*/
class CacheTimeout implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
$config = $request->getAttribute('frontend.typoscript')?->getConfigArray() ?? [];
if ($config['cache_clearAtMidnight'] ?? false) {
// @todo: We should probably decide to deprecate or remove cache_clearAtMidnight
// altogether since it is a flawed concept based on server timezone
// "when is midnight?".
$cacheDataCollector = $request->getAttribute('frontend.cache.collector');
$timeOutTime = min($GLOBALS['EXEC_TIME'] + $cacheDataCollector->resolveLifetime(), PHP_INT_MAX);
$midnightTime = mktime(0, 0, 0, (int)date('m', $timeOutTime), (int)date('d', $timeOutTime), (int)date('Y', $timeOutTime));
// If the midnight time of the expire-day is greater than the current time,
// we may set the timeOutTime to the new midnighttime.
if ($midnightTime > $GLOBALS['EXEC_TIME']) {
$cacheDataCollector->restrictMaximumLifetime($midnightTime - $GLOBALS['EXEC_TIME']);
}
}
return $response;
}
}
@@ -0,0 +1,52 @@
<?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\Frontend\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Context\Context;
/**
* Add content-length HTTP header to the response.
*
* Notice that all Content outside the length of the content-length header will be cut off!
* Therefore, content of unknown length from later-on middlewares and if admin users are logged
* in (admin panel might show...), we disable it!
*
* @internal
*/
final readonly class ContentLengthResponseHeader implements MiddlewareInterface
{
public function __construct(private Context $context) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
$typoScriptConfigArray = $request->getAttribute('frontend.typoscript')->getConfigArray();
if (
(!isset($typoScriptConfigArray['enableContentLengthHeader']) || $typoScriptConfigArray['enableContentLengthHeader'])
&& !$this->context->getPropertyFromAspect('backend.user', 'isLoggedIn', false)
&& !$this->context->getPropertyFromAspect('workspace', 'isOffline', false)
) {
$response = $response->withHeader('Content-Length', (string)$response->getBody()->getSize());
}
return $response;
}
}
@@ -0,0 +1,113 @@
<?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\Frontend\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Core\RequestId;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\CspConfigurationFactory;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\DirectiveHashCollection;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Middleware\PolicyBag;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Middleware\ResponseService;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\PolicyProvider;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope;
use TYPO3\CMS\Core\Site\Entity\Site;
/**
* Adds Content-Security-Policy headers to response.
*
* @internal
*/
final readonly class ContentSecurityPolicyHeaders implements MiddlewareInterface
{
public function __construct(
private RequestId $requestId,
private LoggerInterface $logger,
#[Autowire(service: 'cache.assets')]
private FrontendInterface $cache,
private PolicyProvider $policyProvider,
private CspConfigurationFactory $cspConfigurationFactory,
private ResponseService $responseService,
private DirectiveHashCollection $directiveHashCollection,
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
return $this->applyContentSecurityPolicy($request, $handler);
}
/**
* Apply Content-Security-Policy headers to an error response that bypassed
* the normal middleware stack (e.g. responses from ErrorController).
*/
public function applyToResponse(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
return $this->applyContentSecurityPolicy($request, $response);
}
private function applyContentSecurityPolicy(ServerRequestInterface $request, ResponseInterface|RequestHandlerInterface $subject): ResponseInterface
{
$site = $request->getAttribute('site');
$cspConfiguration = $site instanceof Site ? ($site->getConfiguration()['contentSecurityPolicies'] ?? []) : [];
$dispositionMap = $this->cspConfigurationFactory->buildDispositionMap($cspConfiguration);
$behavior = $this->cspConfigurationFactory->buildBehavior($cspConfiguration);
// return early in case CSP shall not be used
if ($dispositionMap->keys() === []) {
return $subject instanceof RequestHandlerInterface ? $subject->handle($request) : $subject;
}
$scope = Scope::frontendSite($site);
$nonce = $this->requestId->nonce;
$policyBag = new PolicyBag($scope, $dispositionMap, $behavior, $nonce, $this->directiveHashCollection);
// make sure, the nonce value is set before processing the remaining components
$request = $request
->withAttribute('nonce', $nonce)
->withAttribute('csp.policyBag', $policyBag);
$response = $subject instanceof RequestHandlerInterface ? $subject->handle($request) : $subject;
if ($response->hasHeader('Content-Security-Policy') || $response->hasHeader('Content-Security-Policy-Report-Only')) {
if ($subject instanceof RequestHandlerInterface) {
$this->logger->info('Content-Security-Policy not enforced due to existence of custom header', [
'scope' => (string)$scope,
'uri' => (string)$request->getUri(),
]);
}
return $response;
}
$processedEarlier = $policyBag->hasPolicies();
$this->policyProvider->prepare($policyBag, $request, $response);
foreach ($dispositionMap as $disposition => $dispositionConfiguration) {
$policy = $policyBag->getPolicy($disposition);
if ($policy->isEmpty()) {
continue;
}
$response = $response->withHeader(
$disposition->getHttpHeaderName(),
$policy->compile($policyBag, $this->cache)
);
}
if (!$processedEarlier && $policyBag->behavior->useNonce === false) {
$response = $this->responseService->dropNonceFromHtmlResponse($response, $nonce);
}
return $response;
}
}
@@ -0,0 +1,59 @@
<?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\Frontend\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Http\Response;
use TYPO3\CMS\Core\Middleware\AbstractContentSecurityPolicyReporter;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope;
use TYPO3\CMS\Core\Site\Entity\Site;
/**
* @internal
*/
class ContentSecurityPolicyReporter extends AbstractContentSecurityPolicyReporter
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$site = $request->getAttribute('site');
$scope = Scope::frontendSite($site);
if ($this->targetsCspReportUri($scope, $request)) {
$dispositionMap = $this->cspConfigurationFactory->buildDispositionMap(
$site instanceof Site ? ($site->getConfiguration()['contentSecurityPolicies'] ?? []) : []
);
// find at least one configured reporting endpoint for the current request
foreach ($dispositionMap->values() as $dispositionConfiguration) {
if ($this->isCspReport($scope, $request, $dispositionConfiguration)) {
$isCspReport = true;
break;
}
}
if (!($isCspReport ?? false)) {
return new HtmlResponse('Submission to CSP reporting endpoint denied', 403);
}
// @todo check/store headers `origin` + `referer`
// @todo create report, then call persist, then dispatch new event
$this->persistCspReport($scope, $request);
return (new Response())->withStatus(201);
}
return $handler->handle($request);
}
}
+66
View File
@@ -0,0 +1,66 @@
<?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\Frontend\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Http\DispatcherInterface;
use TYPO3\CMS\Core\Http\Response;
/**
* Lightweight alternative to regular frontend requests; used when $_GET[eID] is set.
* In the future, logic from the EidUtility will be moved to this class, however in most cases
* a custom PSR-15 middleware will be better suited for whatever job the eID functionality does currently.
*
* @internal
*/
readonly class EidHandler implements MiddlewareInterface
{
public function __construct(
protected DispatcherInterface $dispatcher
) {}
/**
* Dispatches the request to the corresponding eID class or eID script
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$eID = $request->getParsedBody()['eID'] ?? $request->getQueryParams()['eID'] ?? null;
if ($eID === null) {
return $handler->handle($request);
}
// Remove any output produced until now
ob_clean();
if (!is_string($eID)) {
return (new Response())->withStatus(400, 'Invalid eID');
}
$target = $GLOBALS['TYPO3_CONF_VARS']['FE']['eID_include'][$eID] ?? null;
if (empty($target)) {
return (new Response())->withStatus(404, 'eID not registered');
}
$request = $request->withAttribute('target', $target);
return $this->dispatcher->dispatch($request);
}
}
@@ -0,0 +1,116 @@
<?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\Frontend\Middleware;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\RateLimiter\LimiterInterface;
use TYPO3\CMS\Core\Authentication\Event\AfterUserLoggedInEvent;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\RateLimiter\RateLimiterFactoryInterface;
use TYPO3\CMS\Core\RateLimiter\RequestRateLimitedException;
use TYPO3\CMS\Core\Session\UserSessionManager;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\HttpUtility;
use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
/**
* This middleware authenticates a Frontend User (fe_users).
*/
readonly class FrontendUserAuthenticator implements MiddlewareInterface
{
public function __construct(
protected Context $context,
protected RateLimiterFactoryInterface $rateLimiterFactory,
protected EventDispatcherInterface $eventDispatcher,
protected LoggerInterface $logger,
) {}
/**
* Creates a frontend user authentication object, tries to authenticate a user and stores
* it in the current request as attribute.
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$frontendUser = GeneralUtility::makeInstance(FrontendUserAuthentication::class);
// Rate Limiting
$rateLimiter = $this->ensureLoginRateLimit($frontendUser, $request);
// Authenticate now
$frontendUser->start($request);
// no matter if we have an active user we try to fetch matching groups which can
// be set without an user (simulation for instance!)
$frontendUser->fetchGroupData($request);
// Register the frontend user as aspect and within the request
$this->context->setAspect('frontend.user', $frontendUser->createUserAspect());
$request = $request->withAttribute('frontend.user', $frontendUser);
if ($this->context->getAspect('frontend.user')->isLoggedIn() && $rateLimiter) {
$rateLimiter->reset();
$this->eventDispatcher->dispatch(new AfterUserLoggedInEvent($frontendUser, $request));
}
$response = $handler->handle($request);
// Store session data for fe_users
$frontendUser->storeSessionData();
$response = $frontendUser->appendCookieToResponse($response, $request->getAttribute('normalizedParams'));
// Collect garbage in Frontend requests, which aren't fully cacheable (e.g. with cookies)
if ($response->hasHeader('Set-Cookie')) {
$this->sessionGarbageCollection();
}
return $response;
}
/**
* Garbage collection for fe_sessions (with a probability)
*/
protected function sessionGarbageCollection(): void
{
UserSessionManager::create('FE')->collectGarbage();
}
protected function ensureLoginRateLimit(FrontendUserAuthentication $user, ServerRequestInterface $request): ?LimiterInterface
{
if (!$user->isActiveLogin($request)) {
return null;
}
$loginRateLimiter = $this->rateLimiterFactory->createLoginRateLimiter($request, $user->loginType);
$limit = $loginRateLimiter->consume();
if (!$limit->isAccepted()) {
$this->logger->debug('Login request has been rate limited for IP address {ipAddress}', ['ipAddress' => $request->getAttribute('normalizedParams')->getRemoteAddress()]);
$dateformat = $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'];
$lockedUntil = $limit->getRetryAfter()->getTimestamp() > 0
? ' until ' . date($dateformat, $limit->getRetryAfter()->getTimestamp()) : '';
throw new RequestRateLimitedException(
HttpUtility::HTTP_STATUS_403,
'The login is locked' . $lockedUntil . ' due to too many failed login attempts from your IP address.',
'Login Request Rate Limited',
1616175847
);
}
return $loginRateLimiter;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?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\Frontend\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Controller\ErrorController;
/**
* Checks whether the whole Frontend should be put into "Page unavailable mode"
* unless the devIPMask matches the current visitor's IP.
*
* The global setting $GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_force']
* is used for turning on the maintenance mode.
*/
class MaintenanceMode implements MiddlewareInterface
{
/**
* Calls the "unavailableAction" of the error controller if the system is in maintenance mode.
* This only applies if the REMOTE_ADDR does not match the devIpMask
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if ($GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_force']
&& !GeneralUtility::cmpIP(
$request->getAttribute('normalizedParams')->getRemoteAddress(),
$GLOBALS['TYPO3_CONF_VARS']['SYS']['devIPmask']
)
) {
return GeneralUtility::makeInstance(ErrorController::class)->unavailableAction($request, 'This page is temporarily unavailable.');
}
// Continue the regular stack if no maintenance mode is active
return $handler->handle($request);
}
}
@@ -0,0 +1,194 @@
<?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\Frontend\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\HttpUtility;
use TYPO3\CMS\Frontend\Cache\CacheInstruction;
use TYPO3\CMS\Frontend\Controller\ErrorController;
use TYPO3\CMS\Frontend\Page\CacheHashCalculator;
use TYPO3\CMS\Frontend\Page\PageAccessFailureReasons;
/**
* This middleware validates given request parameters against the common "cHash" functionality.
*/
readonly class PageArgumentValidator implements MiddlewareInterface
{
public function __construct(
private CacheHashCalculator $cacheHashCalculator,
private LoggerInterface $logger,
) {}
/**
* Validates the &cHash parameter against the other $queryParameters / GET parameters
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$cacheInstruction = $request->getAttribute('frontend.cache.instruction', new CacheInstruction());
$request = $request->withAttribute('frontend.cache.instruction', $cacheInstruction);
$pageNotFoundOnValidationError = (bool)($GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFoundOnCHashError'] ?? true);
$pageArguments = $request->getAttribute('routing');
if (!($pageArguments instanceof PageArguments)) {
// Page Arguments must be set in order to validate. This middleware only works if PageArguments
// is available, and is usually combined with the Page Resolver middleware
return GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
$request,
'Page Arguments could not be resolved',
['code' => PageAccessFailureReasons::INVALID_PAGE_ARGUMENTS]
);
}
if (!($GLOBALS['TYPO3_CONF_VARS']['FE']['disableNoCacheParameter'] ?? true)
&& ($pageArguments->getArguments()['no_cache'] ?? $request->getParsedBody()['no_cache'] ?? false)
) {
$cacheInstruction->disableCache('EXT:frontend: Caching disabled by no_cache query argument.');
}
if (!$cacheInstruction->isCachingAllowed() && !$pageNotFoundOnValidationError) {
// No need to test anything if caching was already disabled.
return $handler->handle($request);
}
// Evaluate the cache hash parameter or dynamic arguments when coming from a Site-based routing
$cHash = '';
if (isset($pageArguments->getArguments()['cHash']) && is_scalar($pageArguments->getArguments()['cHash'])) {
$cHash = (string)($pageArguments->getArguments()['cHash']);
}
$queryParams = $pageArguments->getDynamicArguments();
if ($cHash !== '' || !empty($queryParams)) {
$relevantParametersForCacheHashArgument = $this->getRelevantParametersForCacheHashCalculation($pageArguments);
if ($cHash !== '') {
if (empty($relevantParametersForCacheHashArgument)) {
// cHash was given, but nothing to be calculated, so let's do a redirect to the current page but without the cHash
$this->logger->notice('The incoming cHash "{hash}" is given but not needed. cHash is unset', ['hash' => $cHash]);
$uri = $request->getUri();
unset($queryParams['cHash']);
$uri = $uri->withQuery(HttpUtility::buildQueryString($queryParams));
return new RedirectResponse($uri, 308);
}
if (!$this->evaluateCacheHashParameter($cacheInstruction, $cHash, $relevantParametersForCacheHashArgument, $pageNotFoundOnValidationError)) {
return GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
$request,
'Request parameters could not be validated (&cHash comparison failed)',
['code' => PageAccessFailureReasons::CACHEHASH_COMPARISON_FAILED]
);
}
// No cHash given but was required
} elseif (!$this->evaluatePageArgumentsWithoutCacheHash($cacheInstruction, $pageArguments, $pageNotFoundOnValidationError)) {
return GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
$request,
'Request parameters could not be validated (&cHash empty)',
['code' => PageAccessFailureReasons::CACHEHASH_EMPTY]
);
}
}
return $handler->handle($request);
}
/**
* Filters out the arguments that are necessary for calculating cHash
*
* @return array<string, string>
*/
protected function getRelevantParametersForCacheHashCalculation(PageArguments $pageArguments): array
{
$queryParams = $pageArguments->getDynamicArguments();
$queryParams['id'] = $pageArguments->getPageId();
return $this->cacheHashCalculator->getRelevantParameters(HttpUtility::buildQueryString($queryParams));
}
/**
* Calculates a hash string based on additional parameters in the url.
* This is used to cache pages with more parameters than just id and type.
*
* @param string $cHash the chash to check
* @param array<string, string> $relevantParameters GET parameters necessary for cHash calculation
* @param bool $pageNotFoundOnCacheHashError see $GLOBALS['TYPO3_CONF_VARS']['FE']['pageNotFoundOnCHashError']
* @return bool if false, then a PageNotFound response is triggered
*/
protected function evaluateCacheHashParameter(CacheInstruction $cacheInstruction, string $cHash, array $relevantParameters, bool $pageNotFoundOnCacheHashError): bool
{
$calculatedCacheHash = $this->cacheHashCalculator->calculateCacheHash($relevantParameters);
if (hash_equals($calculatedCacheHash, $cHash)) {
return true;
}
// Early return to trigger the error controller
if ($pageNotFoundOnCacheHashError) {
return false;
}
// Caching is disabled now (but no 404)
$cacheInstruction->disableCache('EXT:frontend: Incoming cHash "' . $cHash . '" and calculated cHash "' . $calculatedCacheHash . '" did not match.'
. ' The field list used was "' . implode(',', array_keys($relevantParameters)) . '". Caching is disabled.');
return true;
}
/**
* No cHash is set but there are query parameters, check if that is correct
*
* Should only be called if NO cHash parameter is given.
*
* @param array<string, string|array> $dynamicArguments
*/
protected function evaluateQueryParametersWithoutCacheHash(CacheInstruction $cacheInstruction, array $dynamicArguments, bool $pageNotFoundOnCacheHashError): bool
{
if (!$this->cacheHashCalculator->doParametersRequireCacheHash(HttpUtility::buildQueryString($dynamicArguments))) {
return true;
}
// cHash is required, but not given, so trigger a 404
if ($pageNotFoundOnCacheHashError) {
return false;
}
// Caching is disabled now (but no 404)
$cacheInstruction->disableCache('EXT:frontend: No cHash query argument was sent for GET vars though required. Caching is disabled.');
return true;
}
/**
* No cHash is set but there are query parameters, then calculate a possible cHash from the given
* query parameters and see if a cHash is returned (similar to comparing this).
*
* Is only called if NO cHash parameter is given.
*/
protected function evaluatePageArgumentsWithoutCacheHash(CacheInstruction $cacheInstruction, PageArguments $pageArguments, bool $pageNotFoundOnCacheHashError): bool
{
// legacy behaviour
if (!($GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['enforceValidation'] ?? false)) {
return $this->evaluateQueryParametersWithoutCacheHash($cacheInstruction, $pageArguments->getDynamicArguments(), $pageNotFoundOnCacheHashError);
}
$relevantParameters = $this->getRelevantParametersForCacheHashCalculation($pageArguments);
// There are parameters that would be needed for the current page, but no cHash is given.
// Thus, a "page not found" error is thrown - as configured via "pageNotFoundOnCHashError".
if (!empty($relevantParameters) && $pageNotFoundOnCacheHashError) {
return false;
}
// There are no parameters that require a cHash.
// We end up here when the site was called with an `id` param, e.g. https://example.org/index?id=123.
// Avoid disabling caches in this case.
if (empty($relevantParameters)) {
return true;
}
// Caching is disabled now (but no 404)
$cacheInstruction->disableCache('EXT:frontend: No cHash query argument was sent for given query parameters. Caching is disabled');
return true;
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Frontend\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Routing\RouteNotFoundException;
use TYPO3\CMS\Core\Routing\SiteRouteResult;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Controller\ErrorController;
use TYPO3\CMS\Frontend\Page\PageAccessFailureReasons;
/**
* Resolve the page ID based on TYPO3's routing functionality configured in a site.
*
* Processes the page ID, page type (typeNum) and other parameters built from queryArguments and routeParameters.
*
* However, if there is a backend user logged in and he has NO access to this page (and the page is hidden),
* then the ID is determined again and the backend user is not considered for the rest of the frontend request.
*/
class PageResolver implements MiddlewareInterface
{
/**
* Resolve the page ID
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$site = $request->getAttribute('site', null);
if (!$site instanceof Site) {
return GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
$request,
'No site configuration found.',
['code' => PageAccessFailureReasons::PAGE_NOT_FOUND]
);
}
/** @var SiteRouteResult|null $previousResult */
$previousResult = $request->getAttribute('routing', null);
if (!$previousResult) {
return GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
$request,
'The requested page does not exist',
['code' => PageAccessFailureReasons::PAGE_NOT_FOUND]
);
}
// Check for the route arguments or Query Parameter ID
try {
/** @var PageArguments $pageArguments */
$pageArguments = $site->getRouter()->matchRequest($request, $previousResult);
$request = $request->withAttribute('routing', $pageArguments);
} catch (RouteNotFoundException $e) {
return GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
$request,
'The requested page does not exist',
['code' => PageAccessFailureReasons::PAGE_NOT_FOUND]
);
}
if (!$pageArguments->getPageId()) {
return GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
$request,
'The requested page does not exist',
['code' => PageAccessFailureReasons::PAGE_NOT_FOUND]
);
}
// stop in case arguments are dirty (=defined twice in route and GET query parameters)
if ($pageArguments->areDirty()) {
return GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
$request,
'The requested URL is not distinct',
['code' => PageAccessFailureReasons::PAGE_NOT_FOUND]
);
}
// merge the PageArguments with the request query parameters
$queryParams = array_replace_recursive($request->getQueryParams(), $pageArguments->getArguments());
$request = $request->withQueryParams($queryParams);
return $handler->handle($request);
}
}
@@ -0,0 +1,347 @@
<?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\Frontend\Middleware;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\CacheTag;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\Localization\Locale;
use TYPO3\CMS\Core\Localization\Locales;
use TYPO3\CMS\Core\Locking\ResourceMutex;
use TYPO3\CMS\Core\Page\AssetCollector;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\TimeTracker\TimeTracker;
use TYPO3\CMS\Core\TypoScript\FrontendTypoScript;
use TYPO3\CMS\Core\TypoScript\FrontendTypoScriptFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\HttpUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
use TYPO3\CMS\Frontend\Aspect\PreviewAspect;
use TYPO3\CMS\Frontend\Cache\CacheInstruction;
use TYPO3\CMS\Frontend\Cache\MetaDataState;
use TYPO3\CMS\Frontend\ContentObject\RegisterStack;
use TYPO3\CMS\Frontend\Controller\ErrorController;
use TYPO3\CMS\Frontend\Event\AfterTypoScriptDeterminedEvent;
use TYPO3\CMS\Frontend\Event\BeforePageCacheIdentifierIsHashedEvent;
use TYPO3\CMS\Frontend\Event\ShouldUseCachedPageDataIfAvailableEvent;
use TYPO3\CMS\Frontend\Page\CacheHashCalculator;
use TYPO3\CMS\Frontend\Page\PageAccessFailureReasons;
use TYPO3\CMS\Frontend\Page\PageInformationCreationFailedException;
use TYPO3\CMS\Frontend\Page\PageInformationFactory;
use TYPO3\CMS\Frontend\Page\PageParts;
use TYPO3\CMS\Frontend\Response\ResponseData;
/**
* This important middleware prepares a lot of the heavy lifting.
*
* It is all about page determination, caching, locking, various request attributes and
* TypoScript calculation. All these aspects depends on each other, this middleware determines
* if the requested page has to be fully or partially rendered, determines cache state and sets
* up system by adding request attributes and a setting up a couple of singletons.
*
* @internal this middleware might get removed later.
*/
final readonly class PrepareTypoScriptFrontendRendering implements MiddlewareInterface
{
public function __construct(
private EventDispatcherInterface $eventDispatcher,
private FrontendTypoScriptFactory $frontendTypoScriptFactory,
#[Autowire(service: 'cache.typoscript')]
private PhpFrontend $typoScriptCache,
#[Autowire(service: 'cache.pages')]
private FrontendInterface $pageCache,
private ResourceMutex $lock,
private Context $context,
private LoggerInterface $logger,
private ErrorController $errorController,
private TimeTracker $timeTracker,
private PageInformationFactory $pageInformationFactory,
// injecting this central stateful singleton. this is usually a smell, but ok in this case as exception.
private PageRenderer $pageRenderer,
private Locales $locales,
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
// Make sure frontend.preview aspect is given from now on
if (!$this->context->hasAspect('frontend.preview')) {
$this->context->setAspect('frontend.preview', new PreviewAspect());
}
// Verify crucial request attributes exist at this point
if (!$request->getAttribute('routing') instanceof PageArguments || !$request->getAttribute('normalizedParams') instanceof NormalizedParams) {
throw new \RuntimeException('Request attribute "routing" or "normalizedParams" not found. Error in previous middleware.', 1703150865);
}
$site = $request->getAttribute('site');
// Cache instruction attribute may have been set by previous middlewares
$cacheInstruction = $request->getAttribute('frontend.cache.instruction', new CacheInstruction());
$cacheDataCollector = $request->getAttribute('frontend.cache.collector');
$language = $request->getAttribute('language') ?? $site->getDefaultLanguage();
$routing = $request->getAttribute('routing');
// Add the (internal!) response data "collector" attribute to allow single content elements
// to register HTTP header data. We do not check for attribute existence here, since
// middlewares *before* could just add their own headers directly.
$request = $request->withAttribute('frontend.response.data', new ResponseData());
if ($this->context->getPropertyFromAspect('frontend.preview', 'isPreview', false)) {
// Disable cache if this is a preview
$cacheInstruction->disableCache('EXT:frontend: Disabled cache due to enabled frontend.preview aspect isPreview.');
}
// Make sure cache instruction attribute is always set from now on
$request = $request->withAttribute('frontend.cache.instruction', $cacheInstruction);
// Did above code or some previous call disable cache?
$isCachingAllowed = $cacheInstruction->isCachingAllowed();
// Create and add PageInformation
try {
$this->timeTracker->push('Create PageInformation');
$pageInformation = $this->pageInformationFactory->create($request);
} catch (PageInformationCreationFailedException $exception) {
return $exception->getResponse();
} finally {
$this->timeTracker->pull();
}
$request = $request->withAttribute('frontend.page.information', $pageInformation);
$sysTemplateRows = $pageInformation->getSysTemplateRows();
// Init and add register and page parts
$request = $request->withAttribute('frontend.register.stack', new RegisterStack());
$pageParts = new PageParts();
// Init "last changed" with "tstamp" of the page record, or SYS_LASTCHANGED if it's younger
$lastChanged = (int)$pageInformation->getPageRecord()['tstamp'];
if ($lastChanged < (int)$pageInformation->getPageRecord()['SYS_LASTCHANGED']) {
$lastChanged = (int)$pageInformation->getPageRecord()['SYS_LASTCHANGED'];
}
$pageParts->setLastChanged($lastChanged);
$request = $request->withAttribute('frontend.page.parts', $pageParts);
// Create FrontendTypoScript with essential info for page cache identifier
$conditionMatcherVariables = $this->prepareConditionMatcherVariables($request);
$frontendTypoScript = $this->frontendTypoScriptFactory->createSettingsAndSetupConditions(
$site,
$sysTemplateRows,
$conditionMatcherVariables,
$isCachingAllowed ? $this->typoScriptCache : null,
);
// Set up cache relevant information
$isUsingPageCacheAllowed = $this->eventDispatcher->dispatch(new ShouldUseCachedPageDataIfAvailableEvent($request, $isCachingAllowed))->shouldUseCachedPageData();
$pageCacheIdentifier = $this->createPageCacheIdentifier($request, $frontendTypoScript);
$cacheDataCollector->setPageCacheIdentifier($pageCacheIdentifier);
// Get page cache row or lock rendering
$pageCacheRow = null;
if (!$isUsingPageCacheAllowed) {
// Caching not allowed. We'll rebuild the page. Lock this.
$this->lock->acquireLock('pages', $pageCacheIdentifier);
} else {
// Try to get a page cache row.
$pageCacheRow = $this->pageCache->get($pageCacheIdentifier);
if (!is_array($pageCacheRow)) {
// Nothing in the cache, we acquire an exclusive lock now.
// There are two scenarios when locking: We're either the first process acquiring this lock. This means we'll
// "immediately" get it and can continue with page rendering. Or, another process acquired the lock already. In
// this case, the below call will wait until the lock is released again. The other process then probably wrote
// a page cache entry, which we can use.
// To handle the second case - if our process had to wait for another one creating the content for us - we
// simply query the page cache again to see if there is a page cache now.
$hadToWaitForLock = $this->lock->acquireLock('pages', $pageCacheIdentifier);
// From this point on we're the only one working on that page.
if ($hadToWaitForLock) {
// Query the cache again to see if the data is there meanwhile: We did not get the lock
// immediately, chances are high the other process created a page cache for us.
// There is a small chance the other process actually pageCache->set() the content,
// but pageCache->get() still returns false, for instance when a database returned "done"
// for the INSERT, but SELECT still does not return the new row - may happen in multi-head
// DB instances, and with some other distributed cache backends as well. The worst that
// can happen here is the page generation is done too often, which we accept as trade-off.
$pageCacheRow = $this->pageCache->get($pageCacheIdentifier);
if (is_array($pageCacheRow)) {
// Got cache row, some other process did the work for us, release our lock again.
$this->lock->releaseLock('pages');
}
}
// Keep lock, we are the one generating the page now and fill cache.
}
}
if (is_array($pageCacheRow)) {
// Got page from cache. Set up system state with it.
$pageParts->setPageContentWasLoadedFromCache();
$pageParts->setPageCacheGeneratedTimestamp($pageCacheRow['pageCacheGeneratedTimestamp']);
$pageParts->setPageCacheExpireTimestamp($pageCacheRow['pageCacheExpireTimestamp']);
foreach ($pageCacheRow['INTincScript'] as $intIncScript) {
$pageParts->addNotCachedContentElement($intIncScript);
}
$pageParts->setPageTitle($pageCacheRow['pageTitleCache']);
$pageParts->setContent($pageCacheRow['content']);
$pageParts->setHttpContentType($pageCacheRow['contentType']);
$pageParts->setPageRendererSubstitutionHash($pageCacheRow['pageRendererSubstitutionHash']);
if ($pageCacheRow['pageRendererState'] ?? false) {
$pageRendererState = unserialize($pageCacheRow['pageRendererState'], ['allowed_classes' => [Locale::class]]);
$this->pageRenderer->updateState($pageRendererState);
}
if ($pageCacheRow['assetCollectorState'] ?? false) {
$assetCollectorState = unserialize($pageCacheRow['assetCollectorState'], ['allowed_classes' => false]);
$assetCollector = GeneralUtility::makeInstance(AssetCollector::class);
$assetCollector->updateState($assetCollectorState);
}
// Restore the current tags and add them to the CacheTageCollector
$lifetime = $pageParts->getPageCacheExpireTimestamp() - $GLOBALS['EXEC_TIME'];
$cacheTags = array_map(fn(string $cacheTag) => new CacheTag($cacheTag, $lifetime), $pageCacheRow['cacheTags'] ?? []);
$cacheDataCollector->addCacheTags(...$cacheTags);
// Restore meta-data state
if (is_array($pageCacheRow['metaDataState'] ?? null)) {
GeneralUtility::makeInstance(MetaDataState::class)->updateState($pageCacheRow['metaDataState']);
}
} else {
// Init FE PageRenderer defaults when this page needs to be generated
if ($language->hasCustomTypo3Language()) {
$locale = $this->locales->createLocale($language->getTypo3Language());
} else {
// The createLocale() call is needed in order to resolve dependencies from the Locales class
$locale = $this->locales->createLocale((string)$language->getLocale());
}
$this->pageRenderer->setLanguage($locale, $request);
$pageParts->setPageRendererSubstitutionHash(md5(StringUtility::getUniqueId()));
$pageParts->setPageCacheGeneratedTimestamp($GLOBALS['EXEC_TIME']);
}
// Processing of page cache row done. Do not use this variable anymore.
unset($pageCacheRow);
try {
$needsFullSetup = !$pageParts->hasPageContentBeenLoadedFromCache() || $pageParts->hasNotCachedContentElements();
$pageType = $routing->getPageType();
$frontendTypoScript = $this->frontendTypoScriptFactory->createSetupConfigOrFullSetup(
$needsFullSetup,
$frontendTypoScript,
$site,
$sysTemplateRows,
$conditionMatcherVariables,
$pageType,
$isCachingAllowed ? $this->typoScriptCache : null,
$request,
);
if ($needsFullSetup && !$frontendTypoScript->hasPage()) {
$this->logger->error('No page configured for type={type}. There is no TypoScript object of type PAGE with typeNum={type}.', ['type' => $pageType]);
return $this->errorController->internalErrorAction(
$request,
'No page configured for type=' . $pageType . '.',
['code' => PageAccessFailureReasons::RENDERING_INSTRUCTIONS_NOT_CONFIGURED]
);
}
$setupConfigAst = $frontendTypoScript->getConfigTree();
if ($setupConfigAst->getChildByName('no_cache')?->getValue()) {
// Disable cache if config.no_cache is set!
$cacheInstruction = $request->getAttribute('frontend.cache.instruction');
$cacheInstruction->disableCache('EXT:frontend: Disabled cache due to TypoScript "config.no_cache = 1"');
}
$this->eventDispatcher->dispatch(new AfterTypoScriptDeterminedEvent($frontendTypoScript));
$request = $request->withAttribute('frontend.typoscript', $frontendTypoScript);
return $handler->handle($request);
} finally {
// Whatever happens in a middleware below, this finally is called, even when exceptions
// are raised by a lower one. This ensures locks are released no matter what.
$this->lock->releaseLock('pages');
}
}
/**
* Data available in TypoScript "condition" matching.
*/
private function prepareConditionMatcherVariables(ServerRequestInterface $request): array
{
$pageInformation = $request->getAttribute('frontend.page.information');
$topDownRootLine = $pageInformation->getRootLine();
$localRootline = $pageInformation->getLocalRootLine();
ksort($topDownRootLine);
return [
'request' => $request,
'pageId' => $pageInformation->getId(),
'page' => $pageInformation->getPageRecord(),
'fullRootLine' => $topDownRootLine,
'localRootLine' => $localRootline,
'site' => $request->getAttribute('site'),
'siteLanguage' => $request->getAttribute('language'),
];
}
/**
* This creates a hash used as page cache entry identifier and as page generation lock.
* When multiple requests try to render the same page that will result in the same page cache entry,
* this lock allows creation by one request which typically puts the result into page cache, while
* the other requests wait until this finished and re-use the result.
*/
private function createPageCacheIdentifier(ServerRequestInterface $request, FrontendTypoScript $frontendTypoScript): string
{
$pageInformation = $request->getAttribute('frontend.page.information');
$pageId = $pageInformation->getId();
$pageArguments = $request->getAttribute('routing');
$site = $request->getAttribute('site');
$dynamicArguments = [];
$queryParams = $pageArguments->getDynamicArguments();
if (!empty($queryParams) && ($pageArguments->getArguments()['cHash'] ?? false)) {
// Fetch arguments relevant for creating the page cache identifier from the PageArguments object.
// Excluded parameters are not taken into account when calculating the hash base.
$queryParams['id'] = $pageArguments->getPageId();
// @todo: Make CacheHashCalculator and CacheHashConfiguration stateless and get it injected.
$dynamicArguments = GeneralUtility::makeInstance(CacheHashCalculator::class)
->getRelevantParameters(HttpUtility::buildQueryString($queryParams));
}
$pageCacheIdentifierParameters = [
'projectPath' => Environment::getProjectPath(),
'id' => $pageId,
'type' => $pageArguments->getPageType(),
'groupIds' => implode(',', $this->context->getAspect('frontend.user')->getGroupIds()),
'MP' => $pageInformation->getMountPoint(),
'site' => $site->getIdentifier(),
// Ensure the language base is used for the hash base calculation as well, otherwise TypoScript and page-related rendering
// is not cached properly as we don't have any language-specific conditions anymore
'siteBase' => (string)$request->getAttribute('language', $site->getDefaultLanguage())->getBase(),
// additional variation trigger for static routes
'staticRouteArguments' => $pageArguments->getStaticArguments(),
// dynamic route arguments (if route was resolved)
'dynamicArguments' => $dynamicArguments,
'sysTemplateRows' => $pageInformation->getSysTemplateRows(),
'constantConditionList' => $frontendTypoScript->getSettingsConditionList(),
'setupConditionList' => $frontendTypoScript->getSetupConditionList(),
];
$pageCacheIdentifierParameters = $this->eventDispatcher
->dispatch(new BeforePageCacheIdentifierIsHashedEvent($request, $pageCacheIdentifierParameters))
->getPageCacheIdentifierParameters();
return $pageId . '_' . hash('xxh3', serialize($pageCacheIdentifierParameters));
}
}
+194
View File
@@ -0,0 +1,194 @@
<?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\Frontend\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\DateTimeAspect;
use TYPO3\CMS\Core\Context\LanguageAspectFactory;
use TYPO3\CMS\Core\Context\VisibilityAspect;
use TYPO3\CMS\Core\Domain\DateTimeFactory;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\RootlineUtility;
use TYPO3\CMS\Frontend\Aspect\PreviewAspect;
use TYPO3\CMS\Frontend\Controller\ErrorController;
use TYPO3\CMS\Frontend\Page\PageAccessFailureReasons;
/**
* Middleware for handling preview settings
* used when simulating / previewing pages or content through query params when
* previewing access or time restricted content via for example backend preview links
*/
readonly class PreviewSimulator implements MiddlewareInterface
{
public function __construct(
protected Context $context,
protected PageRepository $pageRepository,
) {}
/**
* Evaluates preview settings if a backend user is logged in
*
* @throws \Exception
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$isLoggedIn = $this->context->getPropertyFromAspect('backend.user', 'isLoggedIn', false);
$isOfflineWorkspace = $this->context->getPropertyFromAspect('workspace', 'isOffline', false);
// When previewing a workspace with the preview link, the PreviewUserAuthentication is NOT marked as
// "isLoggedIn" as it does not have a valid user ID. For this reason, we also check if the Workspace is offline. See WorkspacePreview middleware
if ($isLoggedIn || $isOfflineWorkspace) {
$pageArguments = $request->getAttribute('routing', null);
if (!$pageArguments instanceof PageArguments) {
return GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
$request,
'Page Arguments could not be resolved',
['code' => PageAccessFailureReasons::INVALID_PAGE_ARGUMENTS]
);
}
$visibilityAspect = $this->context->getAspect('visibility');
// The preview flag is set if the current page turns out to be hidden
$showHiddenPages = $this->checkIfPageIsHidden($pageArguments->getPageId(), $request);
$rootlineRequiresPreviewFlag = $this->checkIfRootlineRequiresPreview($pageArguments->getPageId());
$simulatingDate = $this->simulateDate($request);
$simulatingGroup = $this->simulateUserGroup($request);
$showHiddenRecords = $visibilityAspect->includeHidden();
$isPreview = $simulatingDate || $simulatingGroup || $showHiddenRecords || $showHiddenPages || $isOfflineWorkspace || $rootlineRequiresPreviewFlag;
if ($this->context->hasAspect('frontend.preview')) {
/** @var PreviewAspect $previewAspect */
$previewAspect = $this->context->getAspect('frontend.preview');
$isPreview = $previewAspect->isPreview() || $isPreview;
}
$this->context->setAspect('frontend.preview', new PreviewAspect($isPreview));
if ($showHiddenPages || $rootlineRequiresPreviewFlag) {
$newAspect = new VisibilityAspect(true, $visibilityAspect->includeHiddenContent(), $visibilityAspect->includeDeletedRecords(), $visibilityAspect->includeScheduledRecords());
$this->context->setAspect('visibility', $newAspect);
}
}
return $handler->handle($request);
}
/**
* Evaluate if the "extendToSubpages" flag was set on any of the previous ancestor pages,
* but be sure to not check for the current page itself.
*/
protected function checkIfRootlineRequiresPreview(int $pageId): bool
{
$rootlineUtility = GeneralUtility::makeInstance(RootlineUtility::class, $pageId, '', $this->context);
$groupRestricted = false;
$timeRestricted = false;
$hidden = false;
try {
$rootLine = $rootlineUtility->get();
// Remove the current page from the rootline
array_shift($rootLine);
foreach ($rootLine as $page) {
// Skip root node and pages which do not define extendToSubpages
if ((int)($page['uid'] ?? 0) === 0 || !(bool)($page['extendToSubpages'] ?? false)) {
continue;
}
$groupRestricted = (bool)(string)($page['fe_group'] ?? '');
$timeRestricted = (int)($page['starttime'] ?? 0) || (int)($page['endtime'] ?? 0);
$hidden = (int)($page['hidden'] ?? 0);
// Stop as soon as a page in the rootline has extendToSubpages set
break;
}
} catch (\Exception) {
// if the rootline cannot be resolved (404 because of delete placeholder in workspaces for example)
// we do not want to fail here but rather continue handling the request to trigger the middleware 404 handling
}
return $groupRestricted || $timeRestricted || $hidden;
}
/**
* Checks if the page is hidden in the active workspace + language setup.
*/
protected function checkIfPageIsHidden(int $pageId, ServerRequestInterface $request): bool
{
$site = $request->getAttribute('site', null);
// always check both the page in the requested language and the page in the default language, as due to the
// overlay handling, a hidden default page will require setting the preview flag to allow previewing of the
// translation
$languageAspectFromRequest = LanguageAspectFactory::createFromSiteLanguage($request->getAttribute('language', $site->getDefaultLanguage()));
$pageIsHidden = $this->pageRepository->checkIfPageIsHidden($pageId, $languageAspectFromRequest);
if ($languageAspectFromRequest->getId() > 0) {
$pageIsHidden = $pageIsHidden || $this->pageRepository->checkIfPageIsHidden(
$pageId,
LanguageAspectFactory::createFromSiteLanguage($site->getDefaultLanguage())
);
}
return $pageIsHidden;
}
/**
* Simulate dates for preview functionality
* When previewing a time restricted page from the backend, the parameter ADMCMD_simTime it added containing
* a timestamp with the time to preview. The globals 'SIM_EXEC_TIME' and 'SIM_ACCESS_TIME' and the 'DateTimeAspect'
* are used to simulate rendering at that point in time.
* Ideally the global access is removed in future versions.
* This functionality needs to be loaded after BackendAuthenticator as it is only relevant for
* logged in backend users and needs to be done before any page resolving starts.
*/
protected function simulateDate(ServerRequestInterface $request): bool
{
$queryTime = (int)($request->getQueryParams()['ADMCMD_simTime'] ?? 0);
if ($queryTime === 0) {
return false;
}
$GLOBALS['SIM_EXEC_TIME'] = $queryTime;
$GLOBALS['SIM_ACCESS_TIME'] = $queryTime - $queryTime % 60;
$this->context->setAspect('date', new DateTimeAspect(DateTimeFactory::createFromTimestamp($queryTime)));
return true;
}
/**
* Simulate user group for preview functionality. When previewing a page with a user group restriction,
* the parameter ADMCMD_simUser = <groupId> will be added to the preview url. Simulation happens.
* This functionality needs to be loaded after BackendAuthenticator as it is only relevant for
* logged in backend users and needs to be done before any page resolving starts.
*/
protected function simulateUserGroup(ServerRequestInterface $request): bool
{
$simulateUserGroup = (int)($request->getQueryParams()['ADMCMD_simUser'] ?? 0);
if (!$simulateUserGroup) {
return false;
}
$frontendUser = $request->getAttribute('frontend.user');
$frontendUser->user[$frontendUser->usergroup_column] = (string)$simulateUserGroup;
$frontendUser->userGroups[$simulateUserGroup] = [
'uid' => $simulateUserGroup,
'title' => '_PREVIEW_',
];
// let's fake having a user with that group, too
$frontendUser->user[$frontendUser->userid_column] = PHP_INT_MAX;
// Set this option so the is_online timestamp is not updated in updateOnlineTimestamp()
$frontendUser->user['is_online'] = $this->context->getPropertyFromAspect('date', 'timestamp');
$this->context->setAspect('frontend.user', $frontendUser->createUserAspect());
return true;
}
}
@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Frontend\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Http\ImmediateResponseException;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\LinkHandling\PageTypeLinkResolver;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
use TYPO3\CMS\Frontend\Controller\ErrorController;
use TYPO3\CMS\Frontend\Page\PageAccessFailureReasons;
/**
* Redirects pages of type mount points, shortcuts and link to their destination.
*
* @internal
*/
readonly class ShortcutAndMountPointRedirect implements MiddlewareInterface
{
public function __construct(
protected PageTypeLinkResolver $pageTypeLinkResolver,
protected LoggerInterface $logger,
protected SiteFinder $siteFinder,
protected ErrorController $errorController,
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$exposeInformation = $GLOBALS['TYPO3_CONF_VARS']['FE']['exposeRedirectInformation'] ?? false;
// Check for shortcut page and mount point redirect
try {
$redirectToUri = $this->getRedirectUri($request);
} catch (ImmediateResponseException $e) {
return $e->getResponse();
}
/** @var PageArguments $pageArguments */
$pageArguments = $request->getAttribute('routing', null);
if ($redirectToUri !== null && $redirectToUri !== (string)$request->getUri()) {
$message = 'TYPO3 Shortcut/Mountpoint' . ($exposeInformation ? ' at page with ID ' . $pageArguments->getPageId() : '');
return new RedirectResponse(
$redirectToUri,
307,
['X-Redirect-By' => $message]
);
}
// See if the current page is of doktype "Link", if so, do a redirect as well.
$pageInformation = $request->getAttribute('frontend.page.information');
$pageRecord = $pageInformation->getPageRecord();
if ((int)$pageRecord['doktype'] === PageRepository::DOKTYPE_LINK) {
$url = $this->pageTypeLinkResolver->resolvePageLinkUrl($pageRecord, $request);
$message = 'TYPO3 Link' . ($exposeInformation ? ' at page with ID ' . $pageArguments->getPageId() : '');
$status = $this->pageTypeLinkResolver->getRedirectStatus($pageRecord);
if ($status !== null && $url !== '') {
return new RedirectResponse(
$url,
$status,
['X-Redirect-By' => $message]
);
}
$this->logger->error(
'Page of type "Link" could not be resolved properly',
[
'page' => $pageRecord,
]
);
return $this->errorController->pageNotFoundAction(
$request,
'Page of type "Link" could not be resolved properly',
['code' => PageAccessFailureReasons::INVALID_LINK_PAGE]
);
}
return $handler->handle($request);
}
protected function getRedirectUri(ServerRequestInterface $request): ?string
{
$redirectToUri = $this->getRedirectUriForShortcut($request);
if ($redirectToUri !== null) {
return $redirectToUri;
}
return $this->getRedirectUriForMountPoint($request);
}
/**
* Returns URI of target page, if the current page is a Shortcut.
*
* If the current page is of type shortcut and accessed directly via its URL,
* the user will be redirected to shortcut target.
*/
protected function getRedirectUriForShortcut(ServerRequestInterface $request): ?string
{
$pageInformation = $request->getAttribute('frontend.page.information');
$originalShortcutPageRecord = $pageInformation->getOriginalShortcutPageRecord();
if (!empty($originalShortcutPageRecord)
&& $originalShortcutPageRecord['doktype'] == PageRepository::DOKTYPE_SHORTCUT
) {
// Check if the shortcut page is actually on the current site, if not, this is a "page not found"
// because the request was www.mydomain.com/?id=23 where page ID 23 (which is a shortcut) is on another domain/site.
if ((int)($request->getQueryParams()['id'] ?? 0) > 0) {
try {
$targetSite = $this->siteFinder->getSiteByPageId($originalShortcutPageRecord['l10n_parent'] ?: $originalShortcutPageRecord['uid']);
} catch (SiteNotFoundException) {
$targetSite = null;
}
$site = $request->getAttribute('site');
if ($targetSite !== $site) {
$response = $this->errorController->pageNotFoundAction(
$request,
'ID was outside the domain',
['code' => PageAccessFailureReasons::ACCESS_DENIED_HOST_PAGE_MISMATCH]
);
throw new ImmediateResponseException($response, 1638022483);
}
}
return $this->getUriToCurrentPageForRedirect($request);
}
return null;
}
/**
* Returns URI of target page, if the current page is an overlaid mountpoint.
*
* If the current page is of type mountpoint and should be overlaid with the contents of the mountpoint page
* and is accessed directly, the user will be redirected to the mountpoint context.
*/
protected function getRedirectUriForMountPoint(ServerRequestInterface $request): ?string
{
$pageInformation = $request->getAttribute('frontend.page.information');
$originalMountPointPageRecord = $pageInformation->getOriginalMountPointPageRecord();
if (!empty($originalMountPointPageRecord)
&& (int)$originalMountPointPageRecord['doktype'] === PageRepository::DOKTYPE_MOUNTPOINT
) {
return $this->getUriToCurrentPageForRedirect($request);
}
return null;
}
protected function getUriToCurrentPageForRedirect(ServerRequestInterface $request): string
{
$pageInformation = $request->getAttribute('frontend.page.information');
$pageRecord = $pageInformation->getPageRecord();
$parameter = $pageRecord['uid'];
/** @var PageArguments $pageArguments */
$pageArguments = $request->getAttribute('routing');
$type = $pageArguments->getPageType();
if ($type) {
$parameter .= ',' . $type;
}
$contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class);
$contentObjectRenderer->setRequest($request);
return $contentObjectRenderer->createUrl([
'parameter' => $parameter,
'addQueryString' => 'untrusted',
'addQueryString.' => ['exclude' => 'id,type'],
'forceAbsoluteUrl' => true,
]);
}
}
@@ -0,0 +1,104 @@
<?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\Frontend\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Routing\SiteRouteResult;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Controller\ErrorController;
use TYPO3\CMS\Frontend\Page\PageAccessFailureReasons;
/**
* Resolves redirects of site if base is not /
* Can be replaced or extended by extensions if GeoIP-based or user-agent based language redirects need to happen.
*
* Please note that the redirect usually does not contain the Query Parameters, as special query parameters
* like "id", "L" and "cHash" could then result in an error loop.
* One special case (adding a "/") is keeping the query parameters though.
*/
class SiteBaseRedirectResolver implements MiddlewareInterface
{
/**
* Redirect to default language if required
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$site = $request->getAttribute('site', null);
$language = $request->getAttribute('language', null);
$routeResult = $request->getAttribute('routing', null);
// Usually called when "https://www.example.com" was entered, but all sites have "https://www.example.com/lang-key/"
// So a redirect to the first possible language is done.
if ($site instanceof Site && !($language instanceof SiteLanguage)) {
if ($routeResult instanceof SiteRouteResult && $routeResult->getTail() === '') {
$language = $site->getDefaultLanguage();
if ($language->isEnabled()) {
return new RedirectResponse($language->getBase(), 307);
}
// Default language is disabled, check for the first (enabled) language in list to redirect to that
foreach ($site->getLanguages() as $language) {
return new RedirectResponse($language->getBase(), 307);
}
}
return GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
$request,
'The requested page does not exist',
['code' => PageAccessFailureReasons::PAGE_NOT_FOUND]
);
}
// Language is found, and hidden but also not visible to the BE user, this needs to fail
if ($language instanceof SiteLanguage && !$this->isLanguageEnabled($language, $GLOBALS['BE_USER'] ?? null)) {
return GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
$request,
'Page is not available in the requested language.',
['code' => PageAccessFailureReasons::LANGUAGE_NOT_AVAILABLE]
);
}
if ($language instanceof SiteLanguage && $routeResult instanceof SiteRouteResult) {
$requestedUri = $request->getUri();
$tail = $routeResult->getTail();
// a URL was called via "/fr-FR/" but the page is actually called "/fr-FR", let's do a redirect
if ($tail === '/') {
$uri = $requestedUri->withPath(rtrim($requestedUri->getPath(), '/'));
return new RedirectResponse($uri, 307);
}
}
return $handler->handle($request);
}
/**
* Checks if the language is allowed in Frontend, if not, check if there is valid BE user
*/
protected function isLanguageEnabled(SiteLanguage $language, ?BackendUserAuthentication $user = null): bool
{
// language is hidden, check if a possible backend user is allowed to access the language
if ($language->enabled() || $user?->checkLanguageAccess($language)) {
return true;
}
return false;
}
}
+82
View File
@@ -0,0 +1,82 @@
<?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\Frontend\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Core\Localization\Locales;
use TYPO3\CMS\Core\Routing\SiteMatcher;
use TYPO3\CMS\Core\Routing\SiteRouteResult;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Frontend\Controller\ErrorController;
use TYPO3\CMS\Frontend\Page\PageAccessFailureReasons;
/**
* Identifies if a site is configured for the request, based on "id" and "L" GET/POST parameters, or the requested
* string.
*
* If a site is found, the request is populated with the found language+site objects. If none is found, the main magic
* is handled by the PageResolver middleware.
*/
readonly class SiteResolver implements MiddlewareInterface
{
public function __construct(
protected SiteMatcher $matcher,
protected LoggerInterface $logger,
protected ErrorController $errorController,
) {}
/**
* Resolve the site/language information by checking the page ID or the URL.
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
/** @var SiteRouteResult $routeResult */
$routeResult = $this->matcher->matchRequest($request);
$site = $routeResult->getSite();
if ($site instanceof Site && $site->invalidSets !== []) {
$invalidSets = implode(', ', array_keys($site->invalidSets));
$this->logger->error('Site {identifier} depends on unavailable sets: {invalidSets}', [
'identifier' => $site->getIdentifier(),
'invalidSets' => $invalidSets,
]);
return $this->errorController->internalErrorAction(
$request,
sprintf(
'Site %s depends on unavailable sets: %s',
$site->getIdentifier(),
$invalidSets,
),
['code' => PageAccessFailureReasons::INVALID_SITE_SETS]
);
}
$request = $request->withAttribute('site', $site);
$request = $request->withAttribute('language', $routeResult->getLanguage());
$request = $request->withAttribute('routing', $routeResult);
if ($routeResult->getLanguage() instanceof SiteLanguage) {
Locales::setSystemLocaleFromSiteLanguage($routeResult->getLanguage());
}
return $handler->handle($request);
}
}
+191
View File
@@ -0,0 +1,191 @@
<?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\Frontend\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Http\Response;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Routing\InvalidRouteArgumentsException;
use TYPO3\CMS\Core\Routing\RouterInterface;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\SystemResource\Exception\SystemResourceException;
use TYPO3\CMS\Core\SystemResource\Publishing\SystemResourcePublisherInterface;
use TYPO3\CMS\Core\SystemResource\SystemResourceFactory;
use TYPO3\CMS\Core\SystemResource\Type\SystemResourceInterface;
/**
* Resolves static routes - can return configured content directly or load content from file / urls
*/
readonly class StaticRouteResolver implements MiddlewareInterface
{
public function __construct(
protected RequestFactory $requestFactory,
protected LinkService $linkService,
protected SystemResourceFactory $systemResourceFactory,
protected SystemResourcePublisherInterface $resourcePublisher,
) {}
/**
* Checks if there is a valid site with route configuration.
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if (($site = $request->getAttribute('site')) instanceof Site
&& ($configuration = $site->getConfiguration()['routes'] ?? null)
) {
$path = ltrim($request->getUri()->getPath(), '/');
$routeConfig = $this->getApplicableStaticRoute($configuration, $site, $path);
if (is_array($routeConfig)) {
try {
[$content, $contentType] = $this->resolveByType($request, $site, $routeConfig['type'], $routeConfig);
} catch (InvalidRouteArgumentsException $e) {
return new Response('Invalid route', 404, ['Content-Type' => 'text/plain']);
}
return new HtmlResponse($content, 200, ['Content-Type' => $contentType]);
}
}
return $handler->handle($request);
}
/**
* Find the proper configuration for the static route in the static route configuration. Mainly:
* - needs to have a valid "route" property
* - needs to have a "type"
*
* @param array $staticRouteConfiguration the "routes" part of the site configuration
* @param Site $site the current site where the configuration is based on
* @param string $uriPath the path of the current request - used to match the "route" value of a single static route
* @return array|null the configuration for the static route that matches, or null if no route is given
*/
protected function getApplicableStaticRoute(array $staticRouteConfiguration, Site $site, string $uriPath): ?array
{
$routeNames = array_map(static function (?string $route) use ($site) {
if ($route === null || $route === '') {
return null;
}
return ltrim(trim($site->getBase()->getPath(), '/') . '/' . ltrim($route, '/'), '/');
}, array_column($staticRouteConfiguration, 'route'));
// Remove empty routes which would throw an error (could happen within creating a false route in the GUI)
$routeNames = array_filter($routeNames);
if (in_array($uriPath, $routeNames, true)) {
$key = array_search($uriPath, $routeNames, true);
// Only allow routes with a type "given"
if (isset($staticRouteConfiguration[$key]['type'])) {
return $staticRouteConfiguration[$key];
}
}
return null;
}
protected function getFromFile(File $file): array
{
$content = $file->getContents();
$contentType = $file->getMimeType();
return [$content, $contentType];
}
protected function getFromUri(string $uri): array
{
$response = $this->requestFactory->request($uri);
$contentType = 'text/plain; charset=utf-8';
$content = '';
if ($response->getStatusCode() === 200) {
$content = $response->getBody()->getContents();
$contentType = $response->getHeader('Content-Type');
}
return [$content, $contentType];
}
protected function getPageUri(ServerRequestInterface $request, Site $site, array $urlParams): string
{
$parameters = [];
// Add additional parameters, if set via TypoLink
if (isset($urlParams['parameters'])) {
parse_str($urlParams['parameters'], $parameters);
}
$parameters['type'] = $urlParams['pagetype'] ?? 0;
$parameters['_language'] = $request->getAttribute('language', null);
$uri = $site->getRouter()->generateUri(
(int)($urlParams['pageuid'] ?? 0),
$parameters,
'',
RouterInterface::ABSOLUTE_URL
);
return (string)$uri;
}
/**
* @throws InvalidRouteArgumentsException
*/
protected function resolveByType(ServerRequestInterface $request, Site $site, string $type, array $routeConfig): array
{
switch ($type) {
case 'staticText':
if (!isset($routeConfig['content']) || !is_string($routeConfig['content'])) {
throw new \InvalidArgumentException('A static route of type "staticText" must have a content defined.', 1704704705);
}
$content = $routeConfig['content'];
$contentType = 'text/plain; charset=utf-8';
break;
case 'uri':
$urlParams = $this->linkService->resolve($routeConfig['source']);
if ($urlParams['type'] === 'url' || $urlParams['type'] === 'page') {
$uri = $urlParams['url'] ?? $this->getPageUri($request, $site, $urlParams);
[$content, $contentType] = $this->getFromUri($uri);
} elseif ($urlParams['type'] === 'file') {
[$content, $contentType] = $this->getFromFile($urlParams['file']);
} else {
throw new \InvalidArgumentException('Can only handle URIs of type page, url or file.', 1537348076);
}
break;
case 'asset':
if (!($routeConfig['asset'] ?? null)) {
throw new \InvalidArgumentException('A static route of type "asset" must have an asset defined.', 1721134959);
}
try {
$resource = $this->systemResourceFactory->createResource($routeConfig['asset']);
if (!$resource instanceof SystemResourceInterface) {
throw new \InvalidArgumentException(sprintf('The asset "%s" (resolved to "%s") is not a system resource.', $routeConfig['asset'], $resource), 1758785685);
}
} catch (SystemResourceException $e) {
throw new \InvalidArgumentException(sprintf('Could not resolve asset "%s"', $routeConfig['asset']), 1721134960, $e);
}
$content = $resource->getContents();
$contentType = $resource->getMimeType();
break;
default:
throw new \InvalidArgumentException(
'Can only handle static file configurations with type uri, staticText or asset',
1537348083
);
}
return [$content, $contentType];
}
}
@@ -0,0 +1,89 @@
<?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\Frontend\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\TimeTracker\TimeTracker;
use TYPO3\CMS\Frontend\Event\AfterTypoScriptDeterminedEvent;
/**
* Initializes the time tracker (singleton) for the whole TYPO3 Frontend
*
* @internal
*/
final class TimeTrackerInitialization implements MiddlewareInterface
{
private bool $isDebugEnabledInTypoScriptConfig = false;
public function __construct(private readonly TimeTracker $timeTracker) {}
/**
* Starting time tracking (by setting up a singleton object)
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$timeTrackingEnabled = $this->isBackendUserCookieSet($request);
$this->timeTracker->setEnabled($timeTrackingEnabled);
$this->timeTracker->start(microtime(true));
$this->timeTracker->push('');
$response = $handler->handle($request);
// Finish time tracking
$this->timeTracker->pull();
$this->timeTracker->finish();
if ($this->isDebugModeEnabled()) {
return $response->withHeader('X-TYPO3-Parsetime', $this->timeTracker->getParseTime() . 'ms');
}
return $response;
}
/**
* This middleware is run pretty early in the FE chain to initialize correctly.
* It however should only add the response header if debugging is enabled in TypoScript 'config',
* which is not available in the incoming Request, yet.
* It thus listens on the AfterTypoScriptDeterminedEvent to set $this->isDebugEnabledInTypoScriptConfig.
*/
#[AsEventListener('typo3-frontend/timetracker-init-middleware')]
public function typoScriptDeterminedListener(AfterTypoScriptDeterminedEvent $event): void
{
$typoScriptConfig = $event->getFrontendTypoScript()->getConfigArray();
if (!empty($typoScriptConfig['debug'] ?? false)) {
$this->isDebugEnabledInTypoScriptConfig = true;
}
}
private function isBackendUserCookieSet(ServerRequestInterface $request): bool
{
$configuredCookieName = trim($GLOBALS['TYPO3_CONF_VARS']['BE']['cookieName']) ?: 'be_typo_user';
return !empty($request->getCookieParams()[$configuredCookieName]);
}
private function isDebugModeEnabled(): bool
{
if ($this->isDebugEnabledInTypoScriptConfig) {
return true;
}
return !empty($GLOBALS['TYPO3_CONF_VARS']['FE']['debug']);
}
}