Files
cms-frontend/Classes/Middleware/PrepareTypoScriptFrontendRendering.php

348 lines
19 KiB
PHP

<?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));
}
}