348 lines
19 KiB
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));
|
|
}
|
|
}
|