Newer
Older
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 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,
);
Simon Schaufelberger
committed
$isUsingPageCacheAllowed = $this->eventDispatcher
->dispatch(new ShouldUseCachedPageDataIfAvailableEvent($request, $isCachingAllowed))
->shouldUseCachedPageData();
$pageCacheIdentifier = $this->createPageCacheIdentifier($request, $frontendTypoScript);
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
$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);
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
}
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;
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
/**
* 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));
}