Files
cms-frontend/Classes/Http/RequestHandler.php
T

1324 lines
67 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\Http;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LogLevel;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\CacheEntry;
use TYPO3\CMS\Core\Cache\CacheTag;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\EventDispatcher\ListenerProvider;
use TYPO3\CMS\Core\Http\Response;
use TYPO3\CMS\Core\Information\Typo3Information;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Page\AssetCollector;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\PageTitle\PageTitleProviderManager;
use TYPO3\CMS\Core\Resource\Event\GeneratePublicUrlForResourceEvent;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ConsumableNonce;
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\SystemResource\Exception\SystemResourceException;
use TYPO3\CMS\Core\SystemResource\Publishing\SystemResourcePublisherInterface;
use TYPO3\CMS\Core\SystemResource\SystemResourceFactory;
use TYPO3\CMS\Core\SystemResource\Type\SystemResourceInterface;
use TYPO3\CMS\Core\TimeTracker\TimeTracker;
use TYPO3\CMS\Core\Type\DocType;
use TYPO3\CMS\Core\TypoScript\TypoScriptService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Cache\CacheLifetimeCalculator;
use TYPO3\CMS\Frontend\Cache\MetaDataState;
use TYPO3\CMS\Frontend\Cache\NonceValueSubstitution;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
use TYPO3\CMS\Frontend\Event\AfterCacheableContentIsGeneratedEvent;
use TYPO3\CMS\Frontend\Event\AfterCachedPageIsPersistedEvent;
use TYPO3\CMS\Frontend\Event\ModifyHrefLangTagsEvent;
use TYPO3\CMS\Frontend\Page\PageParts;
use TYPO3\CMS\Frontend\Resource\PublicUrlPrefixer;
use TYPO3\CMS\Frontend\Response\ResponseData;
/**
* This is the main entry point of the TypoScript driven standard front-end.
*
* "handle()" is called when all PSR-15 middlewares have set up the PSR-7 ServerRequest.
*
* Then, this class creates the Response with main body content using the ContentObjectRenderer
* based on TypoScript configuration and to add main HTTP headers.
*/
readonly class RequestHandler implements RequestHandlerInterface
{
public function __construct(
private EventDispatcherInterface $eventDispatcher,
private ListenerProvider $listenerProvider,
private TimeTracker $timeTracker,
private SystemResourceFactory $systemResourceFactory,
private SystemResourcePublisherInterface $resourcePublisher,
private TypoScriptService $typoScriptService,
private Context $context,
private ResponseService $responseService,
private PolicyProvider $policyProvider,
// injecting this central stateful singleton. this is usually a smell, but ok in this case as exception.
private PageRenderer $pageRenderer,
#[Autowire(service: 'cache.pages')]
private FrontendInterface $pageCache,
private ConnectionPool $connectionPool,
private CacheLifetimeCalculator $cacheLifetimeCalculator,
private LanguageServiceFactory $languageServiceFactory,
private PageTitleProviderManager $pageTitleProviderManager,
private Typo3Information $typo3Information,
private AssetCollector $assetCollector,
private MetaDataState $metaDataState,
) {}
/**
* Handle frontend request after middlewares finished to create a response.
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
// b/w compat
$GLOBALS['TYPO3_REQUEST'] = $request;
// Forward `ConsumableNonce` containing a nonce to `PageRenderer`
$nonce = $request->getAttribute('nonce');
$nonce = $nonce instanceof ConsumableNonce ? $nonce : null;
$this->pageRenderer->setNonce($nonce);
$policyBag = $request->getAttribute('csp.policyBag');
$policyBag = $policyBag instanceof PolicyBag ? $policyBag : null;
// Make sure all FAL resources are prefixed with absPrefPrefix
$this->listenerProvider->addListener(
GeneratePublicUrlForResourceEvent::class,
PublicUrlPrefixer::class,
'prefixWithAbsRefPrefix'
);
$pageParts = $request->getAttribute('frontend.page.parts');
if (!$pageParts instanceof PageParts) {
throw new \RuntimeException('Attribute frontend.page.parts must be an instance of PageParts at this point', 1761829876);
}
$content = $pageParts->getContent();
if (!$pageParts->hasPageContentBeenLoadedFromCache()) {
$typoScriptConfigArray = $request->getAttribute('frontend.typoscript')->getConfigArray();
$cacheInstruction = $request->getAttribute('frontend.cache.instruction');
$cacheDataCollector = $request->getAttribute('frontend.cache.collector');
$pageInformation = $request->getAttribute('frontend.page.information');
$this->timeTracker->push('Page generation');
$docType = DocType::createFromConfigurationKey($typoScriptConfigArray['doctype'] ?? '');
$this->pageRenderer->setDocType($docType, $request);
// Content generation
$this->timeTracker->incStackPointer();
$this->timeTracker->push('Page generation PAGE object');
$content = $this->generatePageContent($request);
$this->timeTracker->pull($this->timeTracker->LR ? $content : '');
$this->timeTracker->decStackPointer();
// In case the nonce value was actually consumed during the rendering process, add a
// permanent substitution of the current value (that will be cached), with a future
// value (that will be generated and issued in the HTTP CSP header).
// Side-note: Nonce values that are consumed in non-cacheable parts (USER_INT/COA_INT)
// are not handled here, since it would require writing the caches at the very end of
// the whole frontend rendering process.
if ($nonce !== null) {
// prepare the policy in any case (even if nonce was not consumed)
// (`AvoidContentSecurityPolicyNonceEventListener` adjusts the behavior)
if ($policyBag !== null) {
$this->policyProvider->prepare($policyBag, $request, $content);
}
// register nonce substitution if explicitly enabled, otherwise (if undefined)
// use it if nonce value was consumed or any non-cached content elements exist
if ($policyBag?->behavior->useNonce
?? (count($nonce) > 0 || $pageParts->hasNotCachedContentElements())
) {
$pageParts->addNotCachedContentElement([
'substKey' => null,
'target' => NonceValueSubstitution::class . '->substituteNonce',
'parameters' => ['nonce' => $nonce->value],
'permanent' => true,
]);
}
if ($policyBag?->behavior->useNonce === false) {
$content = $this->responseService->dropNonceFromHtml($content, $nonce);
}
}
$event = new AfterCacheableContentIsGeneratedEvent($request, $content, $cacheDataCollector->getPageCacheIdentifier(), $cacheInstruction->isCachingAllowed());
$event = $this->eventDispatcher->dispatch($event);
$content = $event->getContent();
// Write page cache if allowed
if ($event->isCachingEnabled()) {
$pageId = $pageInformation->getId();
$pageRecord = $pageInformation->getPageRecord();
$lifetime = $this->cacheLifetimeCalculator->calculateLifetimeForPage($pageInformation->getId(), $pageInformation->getPageRecord(), $typoScriptConfigArray, $this->context);
$cacheDataCollector->addCacheTags(new CacheTag('pageId_' . $pageId, $lifetime));
if ($pageId !== $pageInformation->getContentFromPid()) {
// Respect the page cache when content from different pid is shown
$cacheDataCollector->addCacheTags(new CacheTag('pageId_' . $pageInformation->getContentFromPid(), $lifetime));
}
if ((int)($pageRecord['_LOCALIZED_UID'] ?? 0) > 0) {
// Respect the translation page id on translated pages
$cacheDataCollector->addCacheTags(new CacheTag('pageId_' . $pageRecord['_LOCALIZED_UID'], $lifetime));
}
if (!empty($pageRecord['cache_tags'])) {
$tags = GeneralUtility::trimExplode(',', $pageRecord['cache_tags'], true);
array_walk($tags, fn(string $tag) => $cacheDataCollector->addCacheTags(new CacheTag($tag, $lifetime)));
}
$cacheData = [
'page_id' => $pageId,
'content' => $content,
'contentType' => $pageParts->getHttpContentType(),
'INTincScript' => $pageParts->getNotCachedContentElementRegistry(),
'pageRendererSubstitutionHash' => $pageParts->getPageRendererSubstitutionHash(),
'pageRendererState' => serialize($this->pageRenderer->getState()),
'assetCollectorState' => serialize($this->assetCollector->getState()),
'pageTitleCache' => $pageParts->getPageTitle(),
'pageCacheGeneratedTimestamp' => $GLOBALS['EXEC_TIME'],
'metaDataState' => $this->metaDataState->getState(),
];
$cacheDataCollector->enqueueCacheEntry(
new CacheEntry(
identifier: 'tsfe-page-cache',
content: $cacheData,
persist: function (ServerRequestInterface $request, string $identifier, mixed $content) {
$cacheDataCollector = $request->getAttribute('frontend.cache.collector');
$cacheTimeout = $cacheDataCollector->resolveLifetime();
$pageCacheTags = array_map(fn(CacheTag $cacheTag) => $cacheTag->name, $cacheDataCollector->getCacheTags());
$content['cacheTags'] = $pageCacheTags;
$content['pageCacheExpireTimestamp'] = $GLOBALS['EXEC_TIME'] + $cacheTimeout;
$this->pageCache->set($cacheDataCollector->getPageCacheIdentifier(), $content, $pageCacheTags, $cacheTimeout);
// Event for cache post processing (eg. writing static files)
$this->eventDispatcher->dispatch(
new AfterCachedPageIsPersistedEvent($request, $cacheDataCollector->getPageCacheIdentifier(), $content, $cacheTimeout)
);
}
)
);
}
$this->updateSysLastChangedInPageRecord($request);
$this->timeTracker->pull();
}
// Render non-cached page parts by replacing placeholders which are taken from cache or added during page generation
if ($pageParts->hasNotCachedContentElements()) {
$this->timeTracker->push('Non-cached objects');
$content = $this->calculateNonCachedElements($request, $content);
$this->timeTracker->pull();
}
$content = $this->displayPreviewInfoMessage($request, $content);
// Create a default Response object and add headers and body to it
$response = new Response();
$response = $this->addHttpHeadersToResponse($request, $response, $content);
$response->getBody()->write($content);
return $response;
}
/**
* Generates the main body part for the page, and if "config.disableAllHeaderCode" is not active, triggers
* pageRenderer to evaluate includeCSS, headTag etc. TypoScript processing to populate the pageRenderer.
*/
protected function generatePageContent(ServerRequestInterface $request): string
{
// Generate the main content between the <body> tags
// This has to be done first, as some additional frontend related code could have been written?!
$pageContent = $this->generatePageBodyContent($request);
// If 'disableAllHeaderCode' is set, all the pageRenderer settings are not evaluated
$typoScriptConfigArray = $request->getAttribute('frontend.typoscript')->getConfigArray();
if ($typoScriptConfigArray['disableAllHeaderCode'] ?? false) {
return $pageContent;
}
// Now, populate pageRenderer with all additional data
$this->processHtmlBasedRenderingSettings($request);
// Add previously generated page content within the <body> tag afterwards
$this->pageRenderer->addBodyContent(LF . $pageContent);
$pageParts = $request->getAttribute('frontend.page.parts');
if ($pageParts->hasNotCachedContentElements()) {
// Render complete page, keep placeholders for JavaScript and CSS
return $this->pageRenderer->renderPageWithUncachedObjects($pageParts->getPageRendererSubstitutionHash());
}
// Render complete page
return $this->pageRenderer->renderFrontendPage($request);
}
/**
* Generates the main content part within <body> tags (except JS files/CSS files), this means:
* render everything that can be cached, otherwise put placeholders for COA_INT/USER_INT objects
* in the content that is processed later-on.
*/
protected function generatePageBodyContent(ServerRequestInterface $request): string
{
$typoScriptPageSetupArray = $request->getAttribute('frontend.typoscript')->getPageArray();
$contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class);
$contentObjectRenderer->setRequest($request);
$contentObjectRenderer->start($request->getAttribute('frontend.page.information')->getPageRecord(), 'pages');
$pageContent = $contentObjectRenderer->cObjGet($typoScriptPageSetupArray) ?: '';
if ($typoScriptPageSetupArray['wrap'] ?? false) {
$pageContent = $contentObjectRenderer->wrap($pageContent, $typoScriptPageSetupArray['wrap']);
}
if ($typoScriptPageSetupArray['stdWrap.'] ?? false) {
$pageContent = $contentObjectRenderer->stdWrap($pageContent, $typoScriptPageSetupArray['stdWrap.']);
}
return $pageContent;
}
/**
* Calculate non cached elements and inline to given cacheable content.
*/
protected function calculateNonCachedElements(ServerRequestInterface $request, string $content): string
{
$content = $this->recursivelyReplaceIntPlaceholdersInContent($request, $content);
$this->timeTracker->push('Substitute header section');
$titleTagContent = $this->generatePageTitle($request);
$this->pageRenderer->setTitle($titleTagContent);
$pageParts = $request->getAttribute('frontend.page.parts');
$content = $this->pageRenderer->renderJavaScriptAndCssForProcessingOfUncachedContentObjects($request, $content, $pageParts->getPageRendererSubstitutionHash());
// Replace again, because header and footer data and page renderer replacements may introduce additional placeholders (see #44825)
$content = $this->recursivelyReplaceIntPlaceholdersInContent($request, $content);
$this->timeTracker->pull();
return $content;
}
/**
* At this point, the cacheable content has just been generated: Content is available but hasn't been added
* to PageRenderer yet. The method is called after the "main" page content, since some JS may be inserted at that point
* that has been registered by cacheable plugins.
* PageRenderer is now populated with all <head> data and additional JavaScript/CSS/FooterData/HeaderData that can be cached.
* Once finished, the content is added to the >addBodyContent() functionality.
*/
protected function processHtmlBasedRenderingSettings(ServerRequestInterface $request): void
{
$typoScript = $request->getAttribute('frontend.typoscript');
$typoScriptSetupArray = $typoScript->getSetupArray();
$typoScriptConfigArray = $typoScript->getConfigArray();
$typoScriptPageArray = $typoScript->getPageArray();
if ($typoScriptConfigArray['moveJsFromHeaderToFooter'] ?? false) {
$this->pageRenderer->enableMoveJsFromHeaderToFooter();
}
if ($typoScriptConfigArray['pageRendererTemplateFile'] ?? false) {
try {
$resource = $this->systemResourceFactory->createResource($typoScriptConfigArray['pageRendererTemplateFile']);
$this->pageRenderer->setTemplateFile((string)$resource);
} catch (SystemResourceException) {
// Custom template is not set if createResource() throws
}
}
$headerComment = trim($typoScriptConfigArray['headerComment'] ?? '');
if ($headerComment) {
$this->pageRenderer->addInlineComment("\t" . str_replace(LF, LF . "\t", $headerComment) . LF);
}
$htmlTagAttributes = [];
// @todo: Check when/if there are scenarios where attribute 'language' is not yet set in $request.
$siteLanguage = $request->getAttribute('language') ?? $request->getAttribute('site')->getDefaultLanguage();
if ($siteLanguage->getLocale()->isRightToLeftLanguageDirection()) {
$htmlTagAttributes['dir'] = 'rtl';
}
$docType = DocType::createFromConfigurationKey($typoScriptConfigArray['doctype'] ?? '');
// Set document type
$docTypeParts = [];
$xmlDocument = true;
// XML prologue
$xmlPrologue = (string)($typoScriptConfigArray['xmlprologue'] ?? '');
switch ($xmlPrologue) {
case 'none':
$xmlDocument = false;
break;
case 'xml_10':
case 'xml_11':
case '':
if ($docType->isXmlCompliant()) {
$docTypeParts[] = $docType->getXmlPrologue();
} else {
$xmlDocument = false;
}
break;
default:
$docTypeParts[] = $xmlPrologue;
}
// DTD
if ($docType->getDoctypeDeclaration() !== '') {
$docTypeParts[] = $docType->getDoctypeDeclaration();
}
if (!empty($docTypeParts)) {
$this->pageRenderer->setXmlPrologAndDocType(implode(LF, $docTypeParts));
}
// See https://www.w3.org/International/questions/qa-html-language-declarations.en.html#attributes
// and https://datatracker.ietf.org/doc/html/rfc5646
$htmlTagAttributes[$docType->isXmlCompliant() ? 'xml:lang' : 'lang'] = $siteLanguage->getHreflang();
if ($docType->isXmlCompliant() || $docType === DocType::html5 && $xmlDocument) {
// We add this to HTML5 to achieve a slightly better backwards compatibility
$htmlTagAttributes['xmlns'] = 'http://www.w3.org/1999/xhtml';
if (is_array($typoScriptConfigArray['namespaces.'] ?? false)) {
foreach ($typoScriptConfigArray['namespaces.'] as $prefix => $uri) {
// $uri gets htmlspecialchared later
$htmlTagAttributes['xmlns:' . htmlspecialchars($prefix)] = $uri;
}
}
}
$contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class);
$contentObjectRenderer->setRequest($request);
$contentObjectRenderer->start($request->getAttribute('frontend.page.information')->getPageRecord(), 'pages');
$this->pageRenderer->setHtmlTag($this->generateHtmlTag($htmlTagAttributes, $typoScriptConfigArray, $contentObjectRenderer));
$headTag = $typoScriptPageArray['headTag'] ?? '<head>';
if (isset($typoScriptPageArray['headTag.'])) {
$headTag = $contentObjectRenderer->stdWrap($headTag, $typoScriptPageArray['headTag.']);
}
$this->pageRenderer->setHeadTag($headTag);
$this->pageRenderer->addInlineComment($this->typo3Information->getInlineHeaderComment());
if ($typoScriptPageArray['shortcutIcon'] ?? false) {
try {
$favIconResource = $this->systemResourceFactory->createPublicResource($typoScriptPageArray['shortcutIcon']);
if ($favIconResource instanceof SystemResourceInterface) {
$this->pageRenderer->setIconMimeType(' type="' . $favIconResource->getMimeType() . '"');
}
$this->pageRenderer->setFavIcon((string)$this->resourcePublisher->generateUri($favIconResource, $request));
} catch (SystemResourceException) {
// FavIcon is not set if sanitize() throws
}
}
// Inline CSS from plugins, files, libraries and inline
if (is_array($typoScriptSetupArray['plugin.'] ?? false)) {
$stylesFromPlugins = '';
foreach ($typoScriptSetupArray['plugin.'] as $key => $iCSScode) {
if (is_array($iCSScode)) {
if (($iCSScode['_CSS_DEFAULT_STYLE'] ?? false) && empty($typoScriptConfigArray['removeDefaultCss'])) {
$cssDefaultStyle = $contentObjectRenderer->stdWrapValue('_CSS_DEFAULT_STYLE', $iCSScode);
$stylesFromPlugins .= '/* default styles for extension "' . substr($key, 0, -1) . '" */' . LF . $cssDefaultStyle . LF;
}
}
}
if (!empty($stylesFromPlugins)) {
$this->addCssToPageRenderer($request, $stylesFromPlugins, 'InlineDefaultCss');
}
}
if (is_array($typoScriptPageArray['includeCSS.'] ?? false)) {
foreach ($typoScriptPageArray['includeCSS.'] as $key => $cssResource) {
if (is_array($cssResource)) {
continue;
}
$cssResourceConfig = $additionalAttributes = $typoScriptPageArray['includeCSS.'][$key . '.'] ?? [];
if (isset($cssResourceConfig['if.']) && !$contentObjectRenderer->checkIf($cssResourceConfig['if.'])) {
continue;
}
try {
$cssResource = $this->systemResourceFactory->createResource($cssResource);
} catch (SystemResourceException) {
continue;
}
$crossOrigin = (string)($cssResourceConfig['crossorigin'] ?? '');
$additionalAttributes = $this->cleanupAdditionalAttributeKeys($additionalAttributes, 'css');
$this->pageRenderer->addCssFile(
$cssResource,
($cssResourceConfig['alternate'] ?? false) ? 'alternate stylesheet' : 'stylesheet',
($cssResourceConfig['media'] ?? false) ?: 'all',
($cssResourceConfig['title'] ?? false) ?: '',
null,
(bool)($cssResourceConfig['forceOnTop'] ?? false),
$cssResourceConfig['allWrap'] ?? '',
null,
$cssResourceConfig['allWrap.']['splitChar'] ?? '|',
(bool)($cssResourceConfig['inline'] ?? false),
$additionalAttributes,
$cssResourceConfig['integrity'] ?? '',
$crossOrigin
);
}
}
if (is_array($typoScriptPageArray['includeCSSLibs.'] ?? false)) {
foreach ($typoScriptPageArray['includeCSSLibs.'] as $key => $cssResource) {
if (is_array($cssResource)) {
continue;
}
$cssResourceConfig = $additionalAttributes = $typoScriptPageArray['includeCSSLibs.'][$key . '.'] ?? [];
if (isset($cssResourceConfig['if.']) && !$contentObjectRenderer->checkIf($cssResourceConfig['if.'])) {
continue;
}
try {
$cssResource = $this->systemResourceFactory->createResource($cssResource);
} catch (SystemResourceException) {
continue;
}
$crossOrigin = (string)($cssResourceConfig['crossorigin'] ?? '');
$additionalAttributes = $this->cleanupAdditionalAttributeKeys($additionalAttributes, 'css');
$this->pageRenderer->addCssLibrary(
$cssResource,
($cssResourceConfig['alternate'] ?? false) ? 'alternate stylesheet' : 'stylesheet',
($cssResourceConfig['media'] ?? false) ?: 'all',
($cssResourceConfig['title'] ?? false) ?: '',
null,
(bool)($cssResourceConfig['forceOnTop'] ?? false),
$cssResourceConfig['allWrap'] ?? '',
null,
$cssResourceConfig['allWrap.']['splitChar'] ?? '|',
(bool)($cssResourceConfig['inline'] ?? false),
$additionalAttributes,
$cssResourceConfig['integrity'] ?? '',
$crossOrigin
);
}
}
$style = $contentObjectRenderer->cObjGet($typoScriptPageArray['cssInline.'] ?? null, 'cssInline.');
if (trim($style)) {
$this->addCssToPageRenderer($request, $style, 'additionalTSFEInlineStyle');
}
// JavaScript includes
if (is_array($typoScriptPageArray['includeJSLibs.'] ?? false)) {
foreach ($typoScriptPageArray['includeJSLibs.'] as $key => $jsResource) {
if (is_array($jsResource)) {
continue;
}
$jsResourceConfig = $additionalAttributes = $typoScriptPageArray['includeJSLibs.'][$key . '.'] ?? [];
if (isset($jsResourceConfig['if.']) && !$contentObjectRenderer->checkIf($jsResourceConfig['if.'])) {
continue;
}
try {
$jsResource = $this->systemResourceFactory->createResource($jsResource);
} catch (SystemResourceException) {
continue;
}
$crossOrigin = (string)($jsResourceConfig['crossorigin'] ?? '');
$additionalAttributes = $this->cleanupAdditionalAttributeKeys($additionalAttributes, 'js');
$this->pageRenderer->addJsLibrary(
$key,
$jsResource,
$jsResourceConfig['type'] ?? null,
null,
(bool)($jsResourceConfig['forceOnTop'] ?? false),
$jsResourceConfig['allWrap'] ?? '',
null,
$jsResourceConfig['allWrap.']['splitChar'] ?? '|',
(bool)($jsResourceConfig['async'] ?? false),
$jsResourceConfig['integrity'] ?? '',
(bool)($jsResourceConfig['defer'] ?? false),
$crossOrigin,
(bool)($jsResourceConfig['nomodule'] ?? false),
$additionalAttributes
);
}
}
if (is_array($typoScriptPageArray['includeJSFooterlibs.'] ?? false)) {
foreach ($typoScriptPageArray['includeJSFooterlibs.'] as $key => $jsResource) {
if (is_array($jsResource)) {
continue;
}
$jsResourceConfig = $additionalAttributes = $typoScriptPageArray['includeJSFooterlibs.'][$key . '.'] ?? [];
if (isset($jsResourceConfig['if.']) && !$contentObjectRenderer->checkIf($jsResourceConfig['if.'])) {
continue;
}
try {
$jsResource = $this->systemResourceFactory->createResource($jsResource);
} catch (SystemResourceException) {
continue;
}
$crossOrigin = (string)($jsResourceConfig['crossorigin'] ?? '');
$additionalAttributes = $this->cleanupAdditionalAttributeKeys($additionalAttributes, 'js');
$this->pageRenderer->addJsFooterLibrary(
$key,
$jsResource,
$jsResourceConfig['type'] ?? null,
null,
(bool)($jsResourceConfig['forceOnTop'] ?? false),
$jsResourceConfig['allWrap'] ?? '',
null,
$jsResourceConfig['allWrap.']['splitChar'] ?? '|',
(bool)($jsResourceConfig['async'] ?? false),
$jsResourceConfig['integrity'] ?? '',
(bool)($jsResourceConfig['defer'] ?? false),
$crossOrigin,
(bool)($jsResourceConfig['nomodule'] ?? false),
$additionalAttributes
);
}
}
if (is_array($typoScriptPageArray['includeJS.'] ?? false)) {
foreach ($typoScriptPageArray['includeJS.'] as $key => $jsResource) {
if (is_array($jsResource)) {
continue;
}
$jsResourceConfig = $typoScriptPageArray['includeJS.'][$key . '.'] ?? [];
if (isset($jsResourceConfig['if.']) && !$contentObjectRenderer->checkIf($jsResourceConfig['if.'])) {
continue;
}
try {
$jsResource = $this->systemResourceFactory->createResource($jsResource);
} catch (SystemResourceException) {
continue;
}
$crossOrigin = (string)($jsResourceConfig['crossorigin'] ?? '');
$this->pageRenderer->addJsFile(
$jsResource,
$jsResourceConfig['type'] ?? null,
null,
(bool)($jsResourceConfig['forceOnTop'] ?? false),
$jsResourceConfig['allWrap'] ?? '',
null,
$jsResourceConfig['allWrap.']['splitChar'] ?? '|',
(bool)($jsResourceConfig['async'] ?? false),
$jsResourceConfig['integrity'] ?? '',
(bool)($jsResourceConfig['defer'] ?? false),
$crossOrigin,
(bool)($jsResourceConfig['nomodule'] ?? false),
// @todo: This does not use the same logic as with "additionalAttributes" above. Also not documented correctly.
$jsResourceConfig['data.'] ?? []
);
}
}
if (is_array($typoScriptPageArray['includeJSFooter.'] ?? false)) {
foreach ($typoScriptPageArray['includeJSFooter.'] as $key => $jsResource) {
if (is_array($jsResource)) {
continue;
}
$jsResourceConfig = $typoScriptPageArray['includeJSFooter.'][$key . '.'] ?? [];
if (isset($jsResourceConfig['if.']) && !$contentObjectRenderer->checkIf($jsResourceConfig['if.'])) {
continue;
}
try {
$jsResource = $this->systemResourceFactory->createResource($jsResource);
} catch (SystemResourceException) {
continue;
}
$crossOrigin = (string)($jsResourceConfig['crossorigin'] ?? '');
$this->pageRenderer->addJsFooterFile(
$jsResource,
$jsResourceConfig['type'] ?? null,
null,
(bool)($jsResourceConfig['forceOnTop'] ?? false),
$jsResourceConfig['allWrap'] ?? '',
null,
$jsResourceConfig['allWrap.']['splitChar'] ?? '|',
(bool)($jsResourceConfig['async'] ?? false),
$jsResourceConfig['integrity'] ?? '',
(bool)($jsResourceConfig['defer'] ?? false),
$crossOrigin,
(bool)($jsResourceConfig['nomodule'] ?? false),
// @todo: This does not use the same logic as with "additionalAttributes" above. Also not documented correctly.
$jsResourceConfig['data.'] ?? []
);
}
}
// Header and footer data
if (is_array($typoScriptPageArray['headerData.'] ?? false)) {
$this->pageRenderer->addHeaderData($contentObjectRenderer->cObjGet($typoScriptPageArray['headerData.'], 'headerData.'));
}
if (is_array($typoScriptPageArray['footerData.'] ?? false)) {
$this->pageRenderer->addFooterData($contentObjectRenderer->cObjGet($typoScriptPageArray['footerData.'], 'footerData.'));
}
$titleTagContent = $this->generatePageTitle($request);
$this->pageRenderer->setTitle($titleTagContent);
// @internal hook for EXT:seo, will be gone soon, do not use it in your own extensions
$_params = ['request' => $request];
$_ref = null;
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['TYPO3\CMS\Frontend\Page\PageGenerator']['generateMetaTags'] ?? [] as $_funcRef) {
GeneralUtility::callUserFunction($_funcRef, $_params, $_ref);
}
$this->generateHrefLangTags($request);
$this->generateMetaTagHtml($typoScriptPageArray['meta.'] ?? [], $contentObjectRenderer);
// Javascript inline and inline footer code
$inlineJS = implode(LF, $contentObjectRenderer->cObjGetSeparated($typoScriptPageArray['jsInline.'] ?? null, 'jsInline.'));
$inlineFooterJs = implode(LF, $contentObjectRenderer->cObjGetSeparated($typoScriptPageArray['jsFooterInline.'] ?? null, 'jsFooterInline.'));
if (($typoScriptConfigArray['removeDefaultJS'] ?? 'external') === 'external') {
// "removeDefaultJS" is "external" by default
// This keeps inlineJS from *_INT Objects from being moved to external files.
// At this point in frontend rendering *_INT Objects only have placeholders instead
// of actual content. Moving these placeholders to external files would break the JS file with
// syntax errors due to the placeholders, and the needed JS would never get included to the page.
// Therefore, inlineJS from *_INT Objects must not be moved to external files but kept internal.
$inlineJSint = '';
$this->stripIntObjectPlaceholder($inlineJS, $inlineJSint);
if ($inlineJSint) {
$this->pageRenderer->addJsInlineCode('TS_inlineJSint', $inlineJSint);
}
if (trim($inlineJS)) {
$this->pageRenderer->addJsFile(GeneralUtility::writeJavaScriptContentToTemporaryFile($inlineJS), null);
}
if ($inlineFooterJs) {
$inlineFooterJSint = '';
$this->stripIntObjectPlaceholder($inlineFooterJs, $inlineFooterJSint);
if ($inlineFooterJSint) {
$this->pageRenderer->addJsFooterInlineCode('TS_inlineFooterJSint', $inlineFooterJSint);
}
$this->pageRenderer->addJsFooterFile(GeneralUtility::writeJavaScriptContentToTemporaryFile($inlineFooterJs), null);
}
} else {
// Include only inlineJS
if ($inlineJS) {
$this->pageRenderer->addJsInlineCode('TS_inlineJS', $inlineJS);
}
if ($inlineFooterJs) {
$this->pageRenderer->addJsFooterInlineCode('TS_inlineFooter', $inlineFooterJs);
}
}
if (is_array($typoScriptPageArray['inlineLanguageLabelFiles.'] ?? false)) {
foreach ($typoScriptPageArray['inlineLanguageLabelFiles.'] as $key => $languageFile) {
if (is_array($languageFile)) {
continue;
}
$languageFileConfig = $typoScriptPageArray['inlineLanguageLabelFiles.'][$key . '.'] ?? [];
if (isset($languageFileConfig['if.']) && !$contentObjectRenderer->checkIf($languageFileConfig['if.'])) {
continue;
}
$this->pageRenderer->addInlineLanguageLabelFile(
$languageFile,
($languageFileConfig['selectionPrefix'] ?? false) ? $languageFileConfig['selectionPrefix'] : '',
($languageFileConfig['stripFromSelectionName'] ?? false) ? $languageFileConfig['stripFromSelectionName'] : ''
);
}
}
if (is_array($typoScriptPageArray['inlineSettings.'] ?? false)) {
$this->pageRenderer->addInlineSettingArray('TS', $typoScriptPageArray['inlineSettings.']);
}
// Header complete, now the body tag is added so the regular content can be applied later-on
if ($typoScriptConfigArray['disableBodyTag'] ?? false) {
$this->pageRenderer->addBodyContent(LF);
} else {
$bodyTag = '<body>';
if ($typoScriptPageArray['bodyTag'] ?? false) {
$bodyTag = $typoScriptPageArray['bodyTag'];
} elseif ($typoScriptPageArray['bodyTagCObject'] ?? false) {
$bodyTag = $contentObjectRenderer->cObjGetSingle($typoScriptPageArray['bodyTagCObject'], $typoScriptPageArray['bodyTagCObject.'] ?? [], 'bodyTagCObject');
}
if (trim($typoScriptPageArray['bodyTagAdd'] ?? '')) {
$bodyTag = preg_replace('/>$/', '', trim($bodyTag)) . ' ' . trim($typoScriptPageArray['bodyTagAdd']) . '>';
}
$this->pageRenderer->addBodyContent(LF . $bodyTag);
}
}
/**
* Searches for placeholder created from *_INT cObjects, removes them from
* $searchString and merges them to $intObjects
*
* @param string $searchString The String which should be cleaned from int-object markers
* @param string $intObjects The String the found int-placeholders are moved to (for further processing)
*/
protected function stripIntObjectPlaceholder(&$searchString, &$intObjects)
{
$tempArray = [];
preg_match_all('/\\<\\!--INT_SCRIPT.[a-z0-9]*--\\>/', $searchString, $tempArray);
$searchString = (string)preg_replace('/\\<\\!--INT_SCRIPT.[a-z0-9]*--\\>/', '', $searchString);
$intObjects = implode('', $tempArray[0]);
}
/**
* Generate meta tags from meta tag TypoScript
*
* @param array $metaTagTypoScript TypoScript configuration for meta tags
*/
protected function generateMetaTagHtml(array $metaTagTypoScript, ContentObjectRenderer $cObj)
{
$conf = $this->typoScriptService->convertTypoScriptArrayToPlainArray($metaTagTypoScript);
foreach ($conf as $key => $properties) {
$replace = false;
if (is_array($properties)) {
$nodeValue = $properties['_typoScriptNodeValue'] ?? '';
$value = trim((string)$cObj->stdWrap($nodeValue, $metaTagTypoScript[$key . '.']));
if ($value === '' && !empty($properties['value'])) {
$value = $properties['value'];
}
} else {
$value = $properties;
}
$attribute = 'name';
if ((is_array($properties) && !empty($properties['httpEquivalent'])) || strtolower($key) === 'refresh') {
$attribute = 'http-equiv';
}
if (is_array($properties) && !empty($properties['attribute'])) {
$attribute = $properties['attribute'];
}
if (is_array($properties) && !empty($properties['replace'])) {
$replace = true;
}
if (!is_array($value)) {
$value = (array)$value;
}
foreach ($value as $subValue) {
if (trim($subValue ?? '') !== '') {
$this->pageRenderer->setMetaTag($attribute, $key, $subValue, [], $replace);
}
}
}
}
/**
* Adds inline CSS code, by respecting the inlineStyle2TempFile option
*
* @param string $cssStyles the inline CSS styling
* @param string $inlineBlockName the block name to add it
*/
protected function addCssToPageRenderer(ServerRequestInterface $request, string $cssStyles, string $inlineBlockName): void
{
$typoScriptConfigArray = $request->getAttribute('frontend.typoscript')->getConfigArray();
// This option is enabled by default on purpose
if (empty($typoScriptConfigArray['inlineStyle2TempFile'] ?? true)) {
$this->pageRenderer->addCssInlineBlock($inlineBlockName, $cssStyles);
} else {
$this->pageRenderer->addCssFile('PKG:typo3/app:' . Environment::getRelativePublicPath() . GeneralUtility::writeStyleSheetContentToTemporaryFile($cssStyles));
}
}
/**
* Generates the <html> tag by evaluating TypoScript configuration, usually found via:
*
* - Adding extra attributes in addition to pre-generated ones (e.g. "dir")
* config.htmlTag.attributes.no-js = 1
* config.htmlTag.attributes.empty-attribute =
*
* - Adding one full string (no stdWrap!) to the "<html $htmlTagAttributes {config.htmlTag_setParams}>" tag
* config.htmlTag_setParams = string|"none"
*
* If config.htmlTag_setParams = none is set, even the pre-generated values are not added at all anymore.
*
* - "config.htmlTag_stdWrap" always applies over the whole compiled tag.
*
* @param array $htmlTagAttributes pre-generated attributes by doctype/direction etc. values.
* @param array $configuration the TypoScript configuration "config." array
* @param ContentObjectRenderer $cObj
* @return string the full <html> tag as string
*/
protected function generateHtmlTag(array $htmlTagAttributes, array $configuration, ContentObjectRenderer $cObj): string
{
if (is_array($configuration['htmlTag.']['attributes.'] ?? null)) {
$attributeString = '';
foreach ($configuration['htmlTag.']['attributes.'] as $attributeName => $value) {
if (str_ends_with($attributeName, '.')) {
// Skip this one, but only if the default value is set
if (isset($configuration['htmlTag.']['attributes.'][rtrim($attributeName, '.')])) {
continue;
}
$attributeName = rtrim($attributeName, '.');
$value = '';
}
if (is_array($configuration['htmlTag.']['attributes.'][$attributeName . '.'] ?? null)) {
$value = $cObj->stdWrap($value, $configuration['htmlTag.']['attributes.'][$attributeName . '.']);
}
$attributeString .= ' ' . htmlspecialchars($attributeName) . ($value !== '' ? '="' . htmlspecialchars((string)$value) . '"' : '');
// If e.g. "htmlTag.attributes.dir" is set, make sure it is not added again with "implodeAttributes()"
if (isset($htmlTagAttributes[$attributeName])) {
unset($htmlTagAttributes[$attributeName]);
}
}
$attributeString = ltrim(GeneralUtility::implodeAttributes($htmlTagAttributes) . $attributeString);
} elseif (($configuration['htmlTag_setParams'] ?? '') === 'none') {
$attributeString = '';
} elseif (isset($configuration['htmlTag_setParams'])) {
$attributeString = $configuration['htmlTag_setParams'];
} else {
$attributeString = GeneralUtility::implodeAttributes($htmlTagAttributes);
}
$htmlTag = '<html' . ($attributeString ? ' ' . $attributeString : '') . '>';
if (isset($configuration['htmlTag_stdWrap.'])) {
$htmlTag = $cObj->stdWrap($htmlTag, $configuration['htmlTag_stdWrap.']);
}
return $htmlTag;
}
protected function generateHrefLangTags(ServerRequestInterface $request): void
{
$typoScriptConfigArray = $request->getAttribute('frontend.typoscript')->getConfigArray();
if ($typoScriptConfigArray['disableHrefLang'] ?? false) {
return;
}
$endingSlash = DocType::createFromConfigurationKey($typoScriptConfigArray['doctype'] ?? '')->isXmlCompliant() ? '/' : '';
$hrefLangs = $this->eventDispatcher->dispatch(new ModifyHrefLangTagsEvent($request))->getHrefLangs();
if (count($hrefLangs) > 1) {
$data = [];
foreach ($hrefLangs as $hrefLang => $href) {
$data[] = sprintf('<link %s%s>', GeneralUtility::implodeAttributes([
'rel' => 'alternate',
'hreflang' => $hrefLang,
'href' => $href,
], true), $endingSlash);
}
$this->pageRenderer->addHeaderData(implode(LF, $data));
}
}
/**
* Include the preview block in case we're looking at a hidden page in the LIVE workspace
*/
protected function displayPreviewInfoMessage(ServerRequestInterface $request, string $content): string
{
$isInWorkspace = $this->context->getPropertyFromAspect('workspace', 'isOffline', false);
$isInPreviewMode = $this->context->hasAspect('frontend.preview') && $this->context->getPropertyFromAspect('frontend.preview', 'isPreview');
$typoScriptConfigArray = $request->getAttribute('frontend.typoscript')->getConfigArray();
if (!$isInPreviewMode || $isInWorkspace || ($typoScriptConfigArray['disablePreviewNotification'] ?? false)) {
return $content;
}
if ($typoScriptConfigArray['message_preview'] ?? '') {
$message = $typoScriptConfigArray['message_preview'];
} else {
$label = $this->getLanguageService()->translate('preview', 'frontend.general');
$styles = [];
$styles[] = 'position: fixed';
$styles[] = 'top: 15px';
$styles[] = 'right: 15px';
$styles[] = 'padding: 8px 18px';
$styles[] = 'background: #fff3cd';
$styles[] = 'border: 1px solid #ffeeba';
$styles[] = 'font-family: sans-serif';
$styles[] = 'font-size: .875em';
$styles[] = 'font-weight: bold';
$styles[] = 'color: #856404';
$styles[] = 'z-index: 20000';
$styles[] = 'user-select: none';
$styles[] = 'pointer-events: none';
$styles[] = 'text-align: center';
$styles[] = 'border-radius: 2px';
$message = '<div id="typo3-preview-info" style="' . implode(';', $styles) . '">' . htmlspecialchars($label) . '</div>';
}
if (!empty($message)) {
$content = str_ireplace('</body>', $message . '</body>', $content);
}
return $content;
}
/**
* Filter out known TypoScript attributes so that they are NOT passed along
* to a <link rel...> or <script...> tag as additional attributes.
* NOTE: Some keys are unset here even though they are valid attributes to
* the <link> or <script> tag. This is because these extra attribute keys are specifically
* evaluated, in the addCssFile/addCssLibrary/addJsFile/addJsFooterLibrary methods.
*
* @param string $cleanupType: Indicate if "css" <link> or "js" <script> is cleaned up.
*/
private function cleanupAdditionalAttributeKeys(array $additionalAttributes, string $cleanupType): array
{
// Common (CSS+JS)
unset(
$additionalAttributes['if.'],
$additionalAttributes['external'],
$additionalAttributes['allWrap'],
$additionalAttributes['allWrap.'],
$additionalAttributes['forceOnTop']
);
if ($cleanupType === 'css') {
unset(
$additionalAttributes['alternate'],
$additionalAttributes['media'],
$additionalAttributes['title'],
$additionalAttributes['inline'],
$additionalAttributes['internal'],
$additionalAttributes['integrity'],
$additionalAttributes['crossorigin']
);
}
if ($cleanupType === 'js') {
unset(
$additionalAttributes['type'],
$additionalAttributes['crossorigin'],
$additionalAttributes['integrity'],
$additionalAttributes['defer'],
$additionalAttributes['nomodule']
);
}
return $additionalAttributes;
}
/**
* Setting the SYS_LASTCHANGED value in the page record: This value is set to the highest timestamp
* of records rendered on the page. This includes all records with no regard to hidden records, user
* protection and so on. This updates a translated "pages" record (_LOCALIZED_UID) if the Frontend
* is called with a translation.
*
* @see ContentObjectRenderer::lastChanged()
*/
protected function updateSysLastChangedInPageRecord(ServerRequestInterface $request): void
{
// Only update if browsing the live workspace
$isInWorkspace = $this->context->getPropertyFromAspect('workspace', 'isOffline', false);
if ($isInWorkspace) {
return;
}
$pageInformation = $request->getAttribute('frontend.page.information');
$pageParts = $request->getAttribute('frontend.page.parts');
$pageRecord = $pageInformation->getPageRecord();
if ($pageRecord['SYS_LASTCHANGED'] < $pageParts->getLastChanged()) {
$connection = $this->connectionPool->getConnectionForTable('pages');
$pageId = $pageRecord['_LOCALIZED_UID'] ?? $pageInformation->getId();
$connection->update('pages', ['SYS_LASTCHANGED' => $pageParts->getLastChanged()], ['uid' => (int)$pageId]);
}
}
/**
* Create and return page title. This ends up as HTML <head> <title> tag.
*
* @todo: This is currently called twice: Once after calculation of cached elements,
* and (if exists) a second time after calculating non-cached elements. This
* is due to PageRenderer rendering logic being triggered at unfortunate places.
* The entire logic is odd and should be modeled and routed in a better way
* when PageRenderer is disentangled.
*/
protected function generatePageTitle(ServerRequestInterface $request): string
{
$site = $request->getAttribute('site');
$language = $request->getAttribute('language') ?? $site->getDefaultLanguage();
$typoScriptConfigArray = $request->getAttribute('frontend.typoscript')->getConfigArray();
$pageParts = $request->getAttribute('frontend.page.parts');
$pageInformation = $request->getAttribute('frontend.page.information');
// config.noPageTitle = 2 - means do not render the page title
if ((int)($typoScriptConfigArray['noPageTitle'] ?? 0) === 2) {
return '';
}
$contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class);
$contentObjectRenderer->setRequest($request);
$contentObjectRenderer->start($pageInformation->getPageRecord(), 'pages');
// Check for a custom pageTitleSeparator, and perform stdWrap on it
$pageTitleSeparator = (string)$contentObjectRenderer->stdWrapValue('pageTitleSeparator', $typoScriptConfigArray);
if ($pageTitleSeparator !== '' && $pageTitleSeparator === ($typoScriptConfigArray['pageTitleSeparator'] ?? '')) {
$pageTitleSeparator .= ' ';
}
$titleProvider = $this->pageTitleProviderManager;
$titleProvider->setPageTitleCache($pageParts->getPageTitle());
$pageTitle = $titleProvider->getTitle($request);
$pageParts->setPageTitle($titleProvider->getPageTitleCache());
$noPageTitle = (bool)($typoScriptConfigArray['noPageTitle'] ?? false);
$showPageTitleFirst = (bool)($typoScriptConfigArray['pageTitleFirst'] ?? false);
$showWebsiteTitle = (bool)($typoScriptConfigArray['showWebsiteTitle'] ?? true);
$websiteTitle = '';
if ($showWebsiteTitle) {
if (trim($language->getWebsiteTitle()) !== '') {
$websiteTitle = trim($language->getWebsiteTitle());
} else {
$siteConfiguration = $site->getConfiguration();
if (trim($siteConfiguration['websiteTitle'] ?? '') !== '') {
$websiteTitle = trim($siteConfiguration['websiteTitle']);
}
}
}
$pageTitle = $noPageTitle ? '' : $pageTitle;
if ($pageTitle === '' || $websiteTitle === '') {
// only show a separator if there are both site title and page title
$pageTitleSeparator = '';
} elseif (empty($pageTitleSeparator)) {
// use the default separator if none given
$pageTitleSeparator = ': ';
}
if ($showPageTitleFirst) {
$titleTagContent = $pageTitle . $pageTitleSeparator . $websiteTitle;
} else {
$titleTagContent = $websiteTitle . $pageTitleSeparator . $pageTitle;
}
if (isset($typoScriptConfigArray['pageTitle.']) && is_array($typoScriptConfigArray['pageTitle.'])) {
// stdWrap for pageTitle if set in config.pageTitle.
$pageTitleStdWrapArray = [
'pageTitle' => $titleTagContent,
'pageTitle.' => $typoScriptConfigArray['pageTitle.'],
];
$titleTagContent = (string)$contentObjectRenderer->stdWrapValue('pageTitle', $pageTitleStdWrapArray);
}
return $titleTagContent;
}
/**
* Replace non-cached element placeholders ("INT" placeholders) in content. In case the replacement
* adds additional placeholders, it loops until no new placeholders are found.
*/
protected function recursivelyReplaceIntPlaceholdersInContent(ServerRequestInterface $request, string $content): string
{
$pageParts = $request->getAttribute('frontend.page.parts');
do {
$nonCacheableData = $pageParts->getNotCachedContentElementRegistry();
$content = $this->processNonCacheableContentPartsAndSubstituteContentMarkers($nonCacheableData, $request, $content);
// Check if there were new items added to INTincScript during the previous execution:
// array_diff_assoc throws notices if values are arrays but not strings. We suppress this here.
$nonCacheableData = @array_diff_assoc($pageParts->getNotCachedContentElementRegistry(), $nonCacheableData);
$reprocess = count($nonCacheableData) > 0;
} while ($reprocess);
return $content;
}
/**
* Splits content by <!--INT_SCRIPT.12345 --> and puts the content back
* together with content from processed content elements.
*/
protected function processNonCacheableContentPartsAndSubstituteContentMarkers(array $nonCacheableData, ServerRequestInterface $request, string $incomingContent): string
{
$this->timeTracker->push('Split content');
// Splits content with the key.
$contentSplitByUncacheableMarkers = explode('<!--INT_SCRIPT.', $incomingContent);
$this->timeTracker->setTSlogMessage('Parts: ' . count($contentSplitByUncacheableMarkers), LogLevel::INFO);
$this->timeTracker->pull();
$content = '';
foreach ($contentSplitByUncacheableMarkers as $counter => $contentPart) {
// If the split had a comment-end after 32 characters it's probably a split-string
if (substr($contentPart, 32, 3) === '-->') {
$nonCacheableKey = 'INT_SCRIPT.' . substr($contentPart, 0, 32);
$nonCacheableConfig = [];
foreach ($nonCacheableData as $nonCacheableDataValues) {
if ($nonCacheableDataValues['substKey'] === $nonCacheableKey) {
$nonCacheableConfig = $nonCacheableDataValues;
break;
}
}
if (!empty($nonCacheableConfig)) {
$label = 'Include ' . $nonCacheableConfig['type'];
$this->timeTracker->push($label);
$contentObjectRendererForNonCacheable = GeneralUtility::makeInstance(ContentObjectRenderer::class);
$contentObjectRendererForNonCacheable->setRequest($request);
$contentObjectRendererForNonCacheable->updateState(unserialize($nonCacheableConfig['cObjData'], ['allowed_classes' => false]));
$nonCacheableContent = match ($nonCacheableConfig['type']) {
'COA' => $contentObjectRendererForNonCacheable->cObjGetSingle('COA', $nonCacheableConfig['conf']),
'FUNC' => $contentObjectRendererForNonCacheable->cObjGetSingle('USER', $nonCacheableConfig['conf']),
'POSTUSERFUNC' => $contentObjectRendererForNonCacheable->callUserFunction($nonCacheableConfig['postUserFunc'], $nonCacheableConfig['conf'], $nonCacheableConfig['content']),
default => '',
};
$content .= $nonCacheableContent;
$content .= substr($contentPart, 35);
$this->timeTracker->pull($nonCacheableContent);
} else {
$content .= substr($contentPart, 35);
}
} elseif ($counter) {
// If it's not the first entry (which would be "0" of the array keys), then re-add the INT_SCRIPT part
$content .= '<!--INT_SCRIPT.' . $contentPart;
} else {
$content .= $contentPart;
}
}
// Invoke permanent, general handlers. This has been implemented for nonce handling.
foreach ($nonCacheableData as $item) {
// @todo: Separate nonce handling from INT handling.
if (empty($item['permanent']) || empty($item['target'])) {
continue;
}
$parameters = array_merge($item['parameters'] ?? [], ['content' => $content]);
$content = GeneralUtility::callUserFunction($item['target'], $parameters) ?? $content;
}
return $content;
}
protected function addHttpHeadersToResponse(ServerRequestInterface $request, ResponseInterface $response, string $content): ResponseInterface
{
$pageParts = $request->getAttribute('frontend.page.parts');
$typoScriptConfigTree = $request->getAttribute('frontend.typoscript')->getConfigTree();
// @todo: Check when/if there are scenarios where attribute 'language' is not yet set in $request.
$language = $request->getAttribute('language') ?? $request->getAttribute('site')->getDefaultLanguage();
$response = $response->withHeader('Content-Type', $pageParts->getHttpContentType());
// Set header for content language unless disabled
if (empty($typoScriptConfigTree->getChildByName('disableLanguageHeader')?->getValue())) {
$response = $response->withHeader('Content-Language', (string)$language->getLocale());
}
// Add a Response header to show debug information if a page was fetched from cache
if ($pageParts->hasPageContentBeenLoadedFromCache()
&& ($typoScriptConfigTree->getChildByName('debug')?->getValue() || !empty($GLOBALS['TYPO3_CONF_VARS']['FE']['debug']))
) {
$dateFormat = $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'];
$timeFormat = $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'];
$response = $response->withHeader(
'X-TYPO3-Debug-Cache',
'Cached page generated ' . date($dateFormat . ' ' . $timeFormat, $pageParts->getPageCacheGeneratedTimestamp()) . '.'
. ' Expires ' . date($dateFormat . ' ' . $timeFormat, $pageParts->getPageCacheExpireTimestamp())
);
}
// Add status code and response headers which were set by in the frontend.response.data attribute
$responseData = $request->getAttribute('frontend.response.data');
if ($responseData instanceof ResponseData) {
// @todo: Note status and version should most likely vanish here again, see attribute comments.
$response = $response->withStatus($responseData->getStatusCode());
$response = $response->withProtocolVersion($responseData->getProtocolVersion());
foreach ($responseData->getHeaders() as $name => $value) {
$response = $response->withHeader($name, $value);
}
}
// Add cache related headers for proxy / client caching
$headers = $this->getClientCacheHeaders($request, $content);
foreach ($headers as $header => $value) {
$response = $response->withHeader($header, $value);
}
// Add additional headers if configured via TypoScript
$additionalHeaders = $this->getAdditionalHeadersFromTypoScript($request);
foreach ($additionalHeaders as $headerConfig) {
if ($headerConfig['statusCode']) {
$response = $response->withStatus((int)$headerConfig['statusCode']);
}
if ($headerConfig['replace']) {
$response = $response->withHeader($headerConfig['header'], $headerConfig['value']);
} else {
$response = $response->withAddedHeader($headerConfig['header'], $headerConfig['value']);
}
}
return $response;
}
protected function getClientCacheHeaders(ServerRequestInterface $request, string $content): array
{
$normalizedParams = $request->getAttribute('normalizedParams');
$pageParts = $request->getAttribute('frontend.page.parts');
$cacheInstruction = $request->getAttribute('frontend.cache.instruction');
$lifetime = $request->getAttribute('frontend.cache.collector')->resolveLifetime();
$typoScriptConfigArray = $request->getAttribute('frontend.typoscript')->getConfigArray();
$clientCachingPossible = $cacheInstruction->isCachingAllowed() // server side caching denied for some reason, client follows
&& !$pageParts->hasNotCachedContentElements() // having not cached elements must disable client caching
&& !$this->context->getAspect('frontend.user')->isUserOrGroupSet() // no client caching with logged in FE user
&& $this->context->getPropertyFromAspect('workspace', 'isLive', true); // live workspace
if ($clientCachingPossible
&& !$this->context->getPropertyFromAspect('backend.user', 'isLoggedIn', false)
&& (($typoScriptConfigArray['sendCacheHeadersForSharedCaches'] ?? '') === 'force'
|| (($typoScriptConfigArray['sendCacheHeadersForSharedCaches'] ?? '') === 'auto' && $normalizedParams->isBehindReverseProxy()))
) {
// Client side caching possible, user not logged in to BE,
// TypoScript "config.sendCacheHeadersForSharedCaches" takes precedence over "config.sendCacheHeaders" below.
return [
'Expires' => gmdate('D, d M Y H:i:s T', (min($GLOBALS['EXEC_TIME'] + $lifetime, PHP_INT_MAX))),
'ETag' => '"' . md5($content) . '"',
// Do not cache for private caches, but store in shared caches
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#:~:text=Age%3A%20100-,s%2Dmaxage,-The%20s%2Dmaxage
'Cache-Control' => 'max-age=0, s-maxage=' . $lifetime,
'Pragma' => 'public',
];
}
if ($clientCachingPossible
&& !$this->context->getPropertyFromAspect('backend.user', 'isLoggedIn', false)
&& !empty($typoScriptConfigArray['sendCacheHeaders'])
) {
// Client side caching possible, user not logged in to BE, TypoScript "config.sendCacheHeaders" configured.
return [
'Expires' => gmdate('D, d M Y H:i:s T', (min($GLOBALS['EXEC_TIME'] + $lifetime, PHP_INT_MAX))),
'ETag' => '"' . md5($content) . '"',
'Cache-Control' => 'max-age=' . $lifetime,
'Pragma' => 'public',
];
}
if ($this->context->getPropertyFromAspect('backend.user', 'isLoggedIn', false)) {
// User is logged in to BE. Add client cache information details for admin panel.
if ($clientCachingPossible) {
$this->timeTracker->setTSlogMessage('Cache-headers with max-age "' . $lifetime . '" would have been sent');
} else {
$noClientCacheReasons = [];
if (!$cacheInstruction->isCachingAllowed()) {
$noClientCacheReasons[] = 'Caching disabled.';
}
if ($pageParts->hasNotCachedContentElements()) {
$noClientCacheReasons[] = 'Not cache elements on page.';
}
if ($this->context->getPropertyFromAspect('frontend.user', 'isLoggedIn', false)) {
$noClientCacheReasons[] = 'Frontend user logged in.';
}
if ($this->context->getPropertyFromAspect('workspace', 'isOffline', false)) {
$noClientCacheReasons[] = 'Draft workspace selected.';
}
$this->timeTracker->setTSlogMessage('Request would not allow client side caching: "' . implode(' ', $noClientCacheReasons) . '"', LogLevel::NOTICE);
}
}
// Fallback: No client cache headers by default. Explicitly set 'private, no-store'
// so a .htaccess does not accidentally add unwanted default headers.
return [
'Cache-Control' => 'private, no-store',
];
}
/**
* Determine additional headers from TypoScript config.additionalHeaders
*/
protected function getAdditionalHeadersFromTypoScript(ServerRequestInterface $request): array
{
$typoScriptConfigArray = $request->getAttribute('frontend.typoscript')->getConfigArray();
if (!isset($typoScriptConfigArray['additionalHeaders.'])) {
return [];
}
$additionalHeaders = [];
$additionalHeadersConfig = $typoScriptConfigArray['additionalHeaders.'];
ksort($additionalHeadersConfig);
foreach ($additionalHeadersConfig as $options) {
if (!is_array($options)) {
continue;
}
$header = trim($options['header'] ?? '');
if ($header === '') {
continue;
}
$headerKeyValue = GeneralUtility::trimExplode(':', $header, false, 2);
$additionalHeaders[] = [
'header' => $headerKeyValue[0],
'value' => $headerKeyValue[1],
// "replace existing headers" is turned on by default, unless turned off
'replace' => ($options['replace'] ?? '') !== '0',
'statusCode' => (int)($options['httpResponseCode'] ?? 0) ?: null,
];
}
return $additionalHeaders;
}
protected function getLanguageService(): LanguageService
{
return $this->languageServiceFactory->createFromUserPreferences($GLOBALS['BE_USER'] ?? null);
}
}