<?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\Locking\ResourceMutex; 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\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; /** * Initialize TypoScript, get page content from cache if possible, lock * rendering if needed and create more TypoScript data if needed. * * @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, ) {} public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $site = $request->getAttribute('site'); $sysTemplateRows = $request->getAttribute('frontend.page.information')->getSysTemplateRows(); $isCachingAllowed = $request->getAttribute('frontend.cache.instruction')->isCachingAllowed(); // Create FrontendTypoScript with essential info for page cache identifier $conditionMatcherVariables = $this->prepareConditionMatcherVariables($request); $frontendTypoScript = $this->frontendTypoScriptFactory->createSettingsAndSetupConditions( $site, $sysTemplateRows, $conditionMatcherVariables, $isCachingAllowed ? $this->typoScriptCache : null, ); $isUsingPageCacheAllowed = $this->eventDispatcher ->dispatch(new ShouldUseCachedPageDataIfAvailableEvent($request, $isCachingAllowed)) ->shouldUseCachedPageData(); $pageCacheIdentifier = $this->createPageCacheIdentifier($request, $frontendTypoScript); $pageCacheRow = null; if (!$isUsingPageCacheAllowed) { // Caching is 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)) { // We have the content, some other process did the work for us, release our lock again. $this->lock->releaseLock('pages'); } } // Keep the lock set, because we are the ones generating the page now and filling the cache. } } $controller = $request->getAttribute('frontend.controller'); $controller->newHash = $pageCacheIdentifier; $pageContentWasLoadedFromCache = false; if (is_array($pageCacheRow)) { $controller->config['INTincScript'] = $pageCacheRow['INTincScript']; $controller->config['INTincScript_ext'] = $pageCacheRow['INTincScript_ext']; $controller->config['pageTitleCache'] = $pageCacheRow['pageTitleCache']; $controller->content = $pageCacheRow['content']; $controller->setContentType($pageCacheRow['contentType']); $controller->cacheGenerated = $pageCacheRow['tstamp']; $controller->pageContentWasLoadedFromCache = true; $pageContentWasLoadedFromCache = true; // Restore the current tags and add them to the CacheTageCollector $cacheDataCollector = $request->getAttribute('frontend.cache.collector'); $lifetime = $pageCacheRow['expires'] - $GLOBALS['EXEC_TIME']; $cacheTags = array_map(fn(string $cacheTag) => new CacheTag($cacheTag, $lifetime), $pageCacheRow['cacheTags'] ?? []); $cacheDataCollector->addCacheTags(...$cacheTags); } try { $needsFullSetup = !$pageContentWasLoadedFromCache || $controller->isINTincScript(); $pageType = $request->getAttribute('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 ($pageContentWasLoadedFromCache && ($setupConfigAst->getChildByName('debug')?->getValue() || !empty($GLOBALS['TYPO3_CONF_VARS']['FE']['debug']))) { // Prepare X-TYPO3-Debug-Cache HTTP header $dateFormat = $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy']; $timeFormat = $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm']; $controller->debugInformationHeader = 'Cached page generated ' . date($dateFormat . ' ' . $timeFormat, $controller->cacheGenerated) . '. Expires ' . date($dateFormat . ' ' . $timeFormat, $pageCacheRow['expires']); } 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); // b/w compat $controller->config['config'] = $frontendTypoScript->getConfigArray(); $GLOBALS['TYPO3_REQUEST'] = $request; $response = $handler->handle($request); } finally { // Whatever happens in a below middleware, this finally is called, even when exceptions // are raised by a lower middleware. This ensures locks are released no matter what. $this->lock->releaseLock('pages'); } return $response; } /** * 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'), 'tsfe' => $request->getAttribute('frontend.controller'), ]; } /** * 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 = [ '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)); } }