TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:27 +02:00
commit 44c503b2d1
233 changed files with 31556 additions and 0 deletions
+232
View File
@@ -0,0 +1,232 @@
<?php
/*
* 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\Page;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Crypto\HashAlgo;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Logic for cHash calculation
*/
#[Autoconfigure(public: true)]
class CacheHashCalculator
{
/**
* Initialise class properties by using the relevant TYPO3 configuration
*/
public function __construct(
protected CacheHashConfiguration $configuration,
protected HashService $hashService,
) {}
/**
* Calculates the cHash based on the provided parameters
*
* @param array $params Array of cHash key-value pairs
* @return string Hash of all the values
*/
public function calculateCacheHash(array $params): string
{
if ($params === []) {
return '';
}
unset($params['encryptionKey']);
ksort($params);
$hashService = GeneralUtility::makeInstance(HashService::class);
return !empty($params) ? $hashService->hmac(serialize($params), self::class, HashAlgo::SHA3_256) : '';
}
/**
* Returns the cHash based on provided query parameters and added values from internal call
*
* @param string $queryString Query-parameters: "&xxx=yyy&zzz=uuu
* @return string Hash of all the values
* @throws \RuntimeException
*/
public function generateForParameters($queryString)
{
$cacheHashParams = $this->getRelevantParameters($queryString);
return $this->calculateCacheHash($cacheHashParams);
}
/**
* Checks whether a parameter of the given $queryString requires cHash calculation
*
* @param string $queryString
* @return bool
*/
public function doParametersRequireCacheHash($queryString)
{
if (!$this->configuration->hasData(CacheHashConfiguration::ASPECT_REQUIRED_CACHE_HASH_PRESENCE_PARAMETERS)) {
return false;
}
$parameterNames = array_keys($this->splitQueryStringToArray($queryString));
foreach ($parameterNames as $parameterName) {
$hasRequiredParameter = $this->configuration->applies(
CacheHashConfiguration::ASPECT_REQUIRED_CACHE_HASH_PRESENCE_PARAMETERS,
$parameterName
);
if ($hasRequiredParameter) {
return true;
}
}
return false;
}
/**
* Splits the input query-parameters into an array with certain parameters filtered out.
* Used to create the cHash value
*
* @param string $queryString Query-parameters: "&xxx=yyy&zzz=uuu
* @return array Array with key/value pairs of query-parameters WITHOUT a certain list of
* @throws \RuntimeException
* @see \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::typoLink()
* @internal
*/
public function getRelevantParameters($queryString)
{
$parameters = $this->splitQueryStringToArray($queryString);
$relevantParameters = [];
foreach ($parameters as $parameterName => $parameterValue) {
if ($this->isAdminPanelParameter($parameterName) || $this->isExcludedParameter($parameterName) || $this->isCoreParameter($parameterName)) {
continue;
}
if ($this->hasCachedParametersWhiteList() && !$this->isInCachedParametersWhiteList($parameterName)) {
continue;
}
if (($parameterValue === null || $parameterValue === '') && $this->isAllowedWithEmptyValue($parameterName)) {
continue;
}
$relevantParameters[$parameterName] = $parameterValue;
}
if (!empty($relevantParameters)) {
if (empty($parameters['id'])) {
throw new \RuntimeException('ID parameter needs to be passed for the cHash calculation!', 1467983513);
}
$relevantParameters['id'] = $parameters['id'];
// Finish and sort parameters array by keys:
ksort($relevantParameters);
}
return $relevantParameters;
}
/**
* Parses the query string and converts it to an array.
* Unlike parse_str it only creates an array with one level.
*
* e.g. foo[bar]=baz will be array('foo[bar]' => 'baz')
*
* @param string $queryString
* @return array
*/
protected function splitQueryStringToArray($queryString)
{
$parameters = array_filter(explode('&', ltrim($queryString, '?')));
$parameterArray = [];
foreach ($parameters as $parameter) {
// should not remove empty values with trimExplode, otherwise cases like &=value, value is used as parameterName.
$parts = GeneralUtility::trimExplode('=', $parameter, false);
$parameterName = $parts[0];
$parameterValue = $parts[1] ?? '';
if (trim($parameterName) === '') {
// This parameter cannot appear in $_GET in PHP even if its value is not empty, so it should be ignored!
continue;
}
$parameterArray[rawurldecode($parameterName)] = rawurldecode($parameterValue);
}
return $parameterArray;
}
/**
* Checks whether the given parameter is out of a known data-set starting
* with ADMCMD.
*
* @param string $key
* @return bool
*/
protected function isAdminPanelParameter($key)
{
return $key === 'ADMCMD_simUser' || $key === 'ADMCMD_simTime' || $key === 'ADMCMD_prev';
}
/**
* Checks whether the given parameter is a core parameter
*
* @param string $key
* @return bool
*/
protected function isCoreParameter($key)
{
return $key === 'id' || $key === 'type' || $key === 'no_cache' || $key === 'cHash' || $key === 'MP' || $key === 'logintype';
}
/**
* Checks whether the given parameter should be excluded from cHash calculation
*
* @param string $key
* @return bool
*/
protected function isExcludedParameter($key)
{
return $this->configuration->applies(
CacheHashConfiguration::ASPECT_EXCLUDED_PARAMETERS,
$key
);
}
/**
* Checks whether the given parameter is an exclusive parameter for cHash calculation
*
* @param string $key
* @return bool
*/
protected function isInCachedParametersWhiteList($key)
{
return $this->configuration->applies(
CacheHashConfiguration::ASPECT_CACHED_PARAMETERS_WHITELIST,
$key
);
}
/**
* Checks whether cachedParametersWhiteList parameters are configured
*
* @return bool
*/
protected function hasCachedParametersWhiteList()
{
return $this->configuration->hasData(
CacheHashConfiguration::ASPECT_CACHED_PARAMETERS_WHITELIST
);
}
/**
* Check whether the given parameter may be used even with an empty value
*
* @param string $key
* @return bool
*/
protected function isAllowedWithEmptyValue($key)
{
return $this->configuration->shallExcludeAllEmptyParameters()
|| $this->configuration->applies(
CacheHashConfiguration::ASPECT_EXCLUDED_PARAMETERS_IF_EMPTY,
$key
);
}
}
+187
View File
@@ -0,0 +1,187 @@
<?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\Page;
/**
* Model for configuration properties in $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash'].
*
* URL parameter names are prefixed with the following indicators:
* + = (equals): exact match, default behavior if not given
* + + ^ (startsWith): matching the beginning of a parameter name
* + ~ (contains): matching any inline occurrence in a parameter name
*
* Example:
* $configuration = new CacheHashConfiguration([
* 'excludedParameters' => ['utm_source', '^tx_my_plugin[aspects]', '~[temporary]'],
* ...
* ]);
*/
class CacheHashConfiguration
{
public const ASPECT_CACHED_PARAMETERS_WHITELIST = 'cachedParametersWhiteList';
public const ASPECT_EXCLUDED_PARAMETERS = 'excludedParameters';
public const ASPECT_EXCLUDED_PARAMETERS_IF_EMPTY = 'excludedParametersIfEmpty';
public const ASPECT_REQUIRED_CACHE_HASH_PRESENCE_PARAMETERS = 'requireCacheHashPresenceParameters';
protected const PROPERTY_EXCLUDE_ALL_EMPTY_PARAMETERS = 'excludeAllEmptyParameters';
protected const INDICATOR_STARTS_WITH = '^';
protected const INDICATOR_CONTAINS = '~';
protected const INDICATOR_EQUALS = '=';
protected const ALLOWED_INDICATORS = [
self::INDICATOR_STARTS_WITH,
self::INDICATOR_CONTAINS,
self::INDICATOR_EQUALS,
];
protected const ALLOWED_PROPERTY_NAMES = [
self::PROPERTY_EXCLUDE_ALL_EMPTY_PARAMETERS,
self::ASPECT_CACHED_PARAMETERS_WHITELIST,
self::ASPECT_EXCLUDED_PARAMETERS,
self::ASPECT_EXCLUDED_PARAMETERS_IF_EMPTY,
self::ASPECT_REQUIRED_CACHE_HASH_PRESENCE_PARAMETERS,
];
/**
* @var array $configuration The raw configuration of `$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']`
*/
protected array $configuration;
/**
* @var array $data The resolved aspects to be applied, based on the configuration
*/
protected array $data = [];
public function __construct(?array $configuration = null)
{
$configuration = $configuration ?? $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash'] ?? [];
$this->configuration = array_filter($configuration, [$this, 'isAllowedProperty'], ARRAY_FILTER_USE_KEY);
$this->processConfiguration();
}
public function shallExcludeAllEmptyParameters(): bool
{
return !empty($this->configuration[self::PROPERTY_EXCLUDE_ALL_EMPTY_PARAMETERS]);
}
public function applies(string $aspect, string $value): bool
{
return $this->equals($aspect, $value)
|| $this->contains($aspect, $value)
|| $this->startsWith($aspect, $value);
}
public function equals(string $aspect, string $value): bool
{
$data = $this->getData($aspect, self::INDICATOR_EQUALS);
return !empty($data) && in_array($value, $data, true);
}
public function startsWith(string $aspect, string $value): bool
{
$data = $this->getData($aspect, self::INDICATOR_STARTS_WITH);
if (empty($data)) {
return false;
}
foreach ($data as $item) {
if (str_starts_with($value, $item)) {
return true;
}
}
return false;
}
public function contains(string $aspect, string $value): bool
{
$data = $this->getData($aspect, self::INDICATOR_CONTAINS);
if (empty($data)) {
return false;
}
foreach ($data as $item) {
if (str_contains($value, $item)) {
return true;
}
}
return false;
}
public function hasData(string $aspect): bool
{
return !empty($this->data[$aspect]);
}
protected function getData(string $aspect, string $indicator): ?array
{
return $this->data[$aspect][$indicator] ?? null;
}
protected function defineData(string $aspect): void
{
if (empty($this->configuration[$aspect])) {
return;
}
if (!is_array($this->configuration[$aspect])) {
throw new \LogicException(
sprintf('Expected array value, got %s', gettype($this->configuration[$aspect])),
1580225311
);
}
$data = [];
foreach ($this->configuration[$aspect] as $value) {
if (!is_scalar($value)) {
throw new \LogicException(
sprintf('Expected scalar value, got %s', gettype($value)),
1580225312
);
}
if ($value === '') {
continue;
}
$indicator = $value[0] ?? null;
// normalize value to be indicated
if (!in_array($indicator, self::ALLOWED_INDICATORS, true)) {
$indicator = self::INDICATOR_EQUALS;
$value = self::INDICATOR_EQUALS . $value;
}
if (strlen((string)$value) === 1) {
throw new \LogicException(
sprintf('Empty value after %s indicator', $indicator),
1580225313
);
}
$data[$indicator][] = substr((string)$value, 1);
}
if (!empty($data)) {
$this->data[$aspect] = $data;
}
}
protected function processConfiguration(): void
{
$this->data = [];
$this->defineData(self::ASPECT_CACHED_PARAMETERS_WHITELIST);
$this->defineData(self::ASPECT_EXCLUDED_PARAMETERS);
$this->defineData(self::ASPECT_EXCLUDED_PARAMETERS_IF_EMPTY);
$this->defineData(self::ASPECT_REQUIRED_CACHE_HASH_PRESENCE_PARAMETERS);
}
protected function isAllowedProperty(string $propertyName): bool
{
return in_array($propertyName, self::ALLOWED_PROPERTY_NAMES, true);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?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\Page;
use Psr\Http\Message\ServerRequestInterface;
/**
* A small utility layer to determine "absRefPrefix" from TypoScript,
* the string that is prepended to all relative URLs in the frontend,
* such as links and images.
*
* @internal It is highly possible that this class will not be needed anymore, so use it with care, if really needed
*/
final readonly class FrontendUrlPrefix
{
public function getUrlPrefix(ServerRequestInterface $request): string
{
$typoScriptConfigArray = $request->getAttribute('frontend.typoscript')?->getConfigArray();
$normalizedParams = $request->getAttribute('normalizedParams');
// TypoScript config.forceAbsoluteUrls overrides config.absRefPrefix
if ($typoScriptConfigArray['forceAbsoluteUrls'] ?? false) {
return $normalizedParams->getSiteUrl();
}
$absRefPrefix = trim($typoScriptConfigArray['absRefPrefix'] ?? '');
return $absRefPrefix === 'auto' ? $normalizedParams->getSitePath() : $absRefPrefix;
}
}
+101
View File
@@ -0,0 +1,101 @@
<?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\Page;
/**
* Contains a list of all reasons that TYPO3 internally uses when a page cannot be found/rendered etc.
*/
final class PageAccessFailureReasons
{
// Page resolving issues
public const string NO_PAGES_FOUND = 'page.database.empty';
public const string PAGE_NOT_FOUND = 'page';
public const string ROOTLINE_BROKEN = 'page.rootline';
public const string INVALID_LINK_PAGE = 'page.invalid_external_url';
// Site configuration issues
public const string INVALID_SITE_SETS = 'site.sets.invalid';
// Page configuration issues
public const string RENDERING_INSTRUCTIONS_NOT_FOUND = 'rendering_instructions';
public const string RENDERING_INSTRUCTIONS_NOT_CONFIGURED = 'rendering_instructions.type';
// Validation errors
public const string INVALID_PAGE_ARGUMENTS = 'page.invalid_arguments';
public const string CACHEHASH_COMPARISON_FAILED = 'cache_hash.comparison';
public const string CACHEHASH_EMPTY = 'cache_hash.empty';
// Language-related issues
public const string LANGUAGE_NOT_AVAILABLE = 'language';
public const string LANGUAGE_NOT_AVAILABLE_STRICT_MODE = 'language.strict';
public const string LANGUAGE_AND_FALLBACKS_NOT_AVAILABLE = 'language.fallbacks';
public const string LANGUAGE_DEFAULT_NOT_AVAILABLE = 'language.default';
// Access restrictions
public const string ACCESS_DENIED_GENERAL = 'access';
public const string ACCESS_DENIED_PAGE_NOT_RESOLVED = 'access.page';
public const string ACCESS_DENIED_SUBSECTION_NOT_RESOLVED = 'access.subsection';
public const string ACCESS_DENIED_HOST_PAGE_MISMATCH = 'access.host_mismatch';
public const string ACCESS_DENIED_INVALID_PAGETYPE = 'access.pagetype';
// System errors
public const string DATABASE_CONNECTION_FAILED = 'system.database';
/**
* Labels for the status codes
*
* @var string[]
*/
private array $messages = [
self::NO_PAGES_FOUND => 'No page on rootlevel found',
self::PAGE_NOT_FOUND => 'The requested page does not exist',
self::INVALID_LINK_PAGE => 'Page of type "Link" could not be resolved properly',
self::RENDERING_INSTRUCTIONS_NOT_FOUND => 'No TypoScript record found',
self::RENDERING_INSTRUCTIONS_NOT_CONFIGURED => 'The page is not configured',
self::INVALID_PAGE_ARGUMENTS => 'Page Arguments could not be resolved',
self::CACHEHASH_COMPARISON_FAILED => 'Request parameters could not be validated (&cHash comparison failed)',
self::CACHEHASH_EMPTY => 'Request parameters could not be validated (&cHash empty)',
self::LANGUAGE_NOT_AVAILABLE => 'Page is not available in the requested language',
self::LANGUAGE_NOT_AVAILABLE_STRICT_MODE => 'Page is not available in the requested language (strict)',
self::LANGUAGE_AND_FALLBACKS_NOT_AVAILABLE => 'Page is not available in the requested language (fallbacks did not apply)',
self::LANGUAGE_DEFAULT_NOT_AVAILABLE => 'Page is not available in default language',
self::ACCESS_DENIED_GENERAL => 'The requested page was not accessible',
self::ACCESS_DENIED_PAGE_NOT_RESOLVED => 'ID was not an accessible page',
self::ACCESS_DENIED_SUBSECTION_NOT_RESOLVED => 'Subsection was found and not accessible',
self::ACCESS_DENIED_HOST_PAGE_MISMATCH => 'ID was outside the domain',
self::ACCESS_DENIED_INVALID_PAGETYPE => 'The requested page type cannot be rendered',
self::DATABASE_CONNECTION_FAILED => 'Database Connection failed',
self::ROOTLINE_BROKEN => 'The requested page did not have a proper connection to the tree-root',
];
/**
* @param string $reasonCode a valid reason code (see above)
*/
public function getMessageForReason(string $reasonCode): string
{
if (!isset($this->messages[$reasonCode])) {
throw new \InvalidArgumentException('No message for page access reason code "' . $reasonCode . '" found.', 1529299833);
}
return $this->messages[$reasonCode];
}
}
+254
View File
@@ -0,0 +1,254 @@
<?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\Page;
use TYPO3\CMS\Core\Page\PageLayout;
/**
* This DTO carries various Frontend rendering related page information. It is
* set up by a Frontend middleware and attached to as 'frontend.page.information'
* Request attribute.
*/
final class PageInformation
{
private int $id;
private array $pageRecord;
private string $mountPoint = '';
private int $contentFromPid;
/**
* Gets set when we are processing a page of type shortcut in the early stages
* of the request, used later in a middleware to resolve the shortcut and redirect again.
*/
private ?array $originalShortcutPageRecord = null;
/**
* Gets set when we are processing a page of type mountpoint with enabled overlay in getPageAndRootline()
* Used later in a middleware to determine the final target URL where the user should be redirected to.
*/
private ?array $originalMountPointPageRecord = null;
/**
* Rootline of page records all the way to the root.
*
* Both language and version overlays are applied to these page records:
* All "data" fields are set to language / version overlay values, *except* uid and
* pid, which are the default-language and live-version ids.
*
* First array row with the highest key is the deepest page (the requested page),
* then parent pages with descending keys until (but not including) the
* project root pseudo page 0.
*
* When page uid 5 is called in this example:
* [0] Project name
* |- [2] An organizational page, probably with is_siteroot=1 and a site config
* |- [3] Site root with a sys_template having "root" flag set
* |- [5] Here you are
*
* This $absoluteRootLine is:
* [3] => [uid = 5, pid = 3, title = Here you are, ...]
* [2] => [uid = 3, pid = 2, title = Site root with a sys_template having "root" flag set, ...]
* [1] => [uid = 2, pid = 0, title = An organizational page, probably with is_siteroot=1 and a site config, ...]
*
* Read-only! Extensions may read but never write this property!
*
* @var array<int, array<string, mixed>>
*/
private array $rootLine;
/**
* This is the "local" rootline of a deep page that stops at the first parent
* sys_template record that has "root" flag set, in natural parent-child order.
*
* Both language and version overlays are applied to these page records:
* All "data" fields are set to language / version overlay values, *except* uid and
* pid, which are the default-language and live-version ids.
*
* When page uid 5 is called in this example:
* [0] Project name
* |- [2] An organizational page, probably with is_siteroot=1 and a site config
* |- [3] Site root with a sys_template having "root" flag set
* |- [5] Here you are
*
* This rootLine is:
* [0] => [uid = 3, pid = 2, title = Site root with a sys_template having "root" flag set, ...]
* [1] => [uid = 5, pid = 3, title = Here you are, ...]
*
* @var array<int, array<string, mixed>>
*/
private array $localRootLine;
/**
* List of all sys_template rows attached to rootLine pages.
*
* @var array<int, array<string, mixed>>
*/
private array $sysTemplateRows;
/**
* The resolved PageLayout of the page (selected backend layout)
*
* @var PageLayout|null
*/
private ?PageLayout $pageLayout = null;
/**
* @internal Only to be set by core
*/
public function setId(int $id): void
{
$this->id = $id;
}
public function getId(): int
{
return $this->id;
}
/**
* @internal Only to be set by core
*/
public function setPageRecord(array $pageRecord): void
{
$this->pageRecord = $pageRecord;
}
public function getPageRecord(): array
{
return $this->pageRecord;
}
/**
* @internal Only to be set by core
*/
public function setMountPoint(string $mountPoint): void
{
$this->mountPoint = $mountPoint;
}
/**
* @internal Only to be read by core
*/
public function getMountPoint(): string
{
return $this->mountPoint;
}
/**
* @internal Only to be set by core
*/
public function setRootLine(array $rootLine): void
{
$this->rootLine = $rootLine;
}
public function getRootLine(): array
{
return $this->rootLine;
}
/**
* @internal Only to be set by core
*/
public function setLocalRootLine(array $localRootLine): void
{
$this->localRootLine = $localRootLine;
}
public function getLocalRootLine(): array
{
return $this->localRootLine;
}
/**
* @internal Only to be set by core
*/
public function setSysTemplateRows(array $sysTemplateRows): void
{
$this->sysTemplateRows = $sysTemplateRows;
}
/**
* @internal Only to be read by core
*/
public function getSysTemplateRows(): array
{
return $this->sysTemplateRows;
}
/**
* @internal Only to be set by core
*/
public function setOriginalShortcutPageRecord(array $originalShortcutPageRecord): void
{
$this->originalShortcutPageRecord = $originalShortcutPageRecord;
}
/**
* @internal Only to be read by core
*/
public function getOriginalShortcutPageRecord(): ?array
{
return $this->originalShortcutPageRecord;
}
/**
* @internal Only to be set by core
*/
public function setOriginalMountPointPageRecord(array $originalMountPointPageRecord): void
{
$this->originalMountPointPageRecord = $originalMountPointPageRecord;
}
/**
* @internal Only to be read by core
*/
public function getOriginalMountPointPageRecord(): ?array
{
return $this->originalMountPointPageRecord;
}
/**
* @internal Only to be set by core
*/
public function setContentFromPid(int $contentFromPid): void
{
$this->contentFromPid = $contentFromPid;
}
/**
* @internal Only to be read by core
*/
public function getContentFromPid(): int
{
return $this->contentFromPid;
}
/**
* @internal Only to be set by core
*/
public function setPageLayout(PageLayout $pageLayout): void
{
$this->pageLayout = $pageLayout;
}
public function getPageLayout(): ?PageLayout
{
return $this->pageLayout;
}
}
@@ -0,0 +1,43 @@
<?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\Page;
use Psr\Http\Message\ResponseInterface;
/**
* Exception created in PageInformationFactory that contains an "early" Response
* when further calculation is stopped and the final PageInformation object can
* not be created. It is typically caught and handled in the Frontend
* middleware that triggers PageInformation creation.
*
* @internal
*/
final class PageInformationCreationFailedException extends \Exception
{
public function __construct(
private readonly ResponseInterface $response,
int $code
) {
$this->code = $code;
}
public function getResponse(): ResponseInterface
{
return $this->response;
}
}
+713
View File
@@ -0,0 +1,713 @@
<?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\Page;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\LanguageAspect;
use TYPO3\CMS\Core\Context\LanguageAspectFactory;
use TYPO3\CMS\Core\DataHandling\PageDoktypeRegistry;
use TYPO3\CMS\Core\Domain\Access\RecordAccessVoter;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Error\Http\LinkedPageNotResolvableException;
use TYPO3\CMS\Core\Error\Http\ShortcutTargetPageNotFoundException;
use TYPO3\CMS\Core\Error\Http\StatusException;
use TYPO3\CMS\Core\Exception\Page\CircularPageReferenceChainException;
use TYPO3\CMS\Core\Exception\Page\PageReferenceResolvingReachedIterationLimitException;
use TYPO3\CMS\Core\Exception\Page\RootLineException;
use TYPO3\CMS\Core\LinkHandling\PageTypeLinkResolver;
use TYPO3\CMS\Core\Page\PageLayoutResolver;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Site\Entity\NullSite;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Type\Bitmask\PageTranslationVisibility;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\TypoScript\IncludeTree\SysTemplateRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Utility\RootlineUtility;
use TYPO3\CMS\Frontend\Authentication\FrontendBackendUserAuthentication;
use TYPO3\CMS\Frontend\Controller\ErrorController;
use TYPO3\CMS\Frontend\Event\AfterPageAndLanguageIsResolvedEvent;
use TYPO3\CMS\Frontend\Event\AfterPageWithRootLineIsResolvedEvent;
use TYPO3\CMS\Frontend\Event\BeforePageIsResolvedEvent;
/**
* Create the PageInformation object. This is typically fired by a
* middleware. It does all the heavy lifting, page access checks,
* resolves shortcuts, workspaces, languages and similar.
*
* Possible results:
* - The fully set up PageInformation object is returned.
* - A PageInformationCreationFailedException is thrown that contains
* an early Response from the ErrorController
* - A StatusException is thrown when ErrorController itself failed
*
* @todo: This class also sets / resets the Context language aspect
* as not directly obvious side effect. This can't be refactored
* currently due to the dependency of stateful PageRepository to
* the stateful singleton Context.
*
* @internal
*/
final readonly class PageInformationFactory
{
public function __construct(
private Context $context,
private EventDispatcherInterface $eventDispatcher,
private LoggerInterface $logger,
private RecordAccessVoter $accessVoter,
private ErrorController $errorController,
private SysTemplateRepository $sysTemplateRepository,
private PageLayoutResolver $pageLayoutResolver,
private TcaSchemaFactory $tcaSchemaFactory,
private PageTypeLinkResolver $pageTypeLinkResolver,
private PageDoktypeRegistry $pageDoktypeRegistry,
private PageRepository $pageRepository,
) {}
/**
* Set up proper PageInformation object later available as
* 'frontend.page.information' Request attribute.
*
* At this point, the Context object already contains relevant preview
* settings, for instance if a backend user is logged in.
*
* As a not obvious side effect, this class also *sets* the
*
* @internal Extensions should not call themselves, use events.
* @throws PageInformationCreationFailedException
* @throws StatusException
*/
public function create(ServerRequestInterface $request): PageInformation
{
// Set Initial, not yet validated values from routing.
$pageInformation = new PageInformation();
$pageInformation->setId($request->getAttribute('routing')->getPageId());
if ($GLOBALS['TYPO3_CONF_VARS']['FE']['enable_mount_pids'] ?? false) {
$mountPoint = (string)($request->getAttribute('routing')->getArguments()['MP'] ?? '');
// Ensure no additional arguments are given via the &MP=123-345,908-172 (e.g. "/")
$pageInformation->setMountPoint(preg_replace('/[^0-9,-]/', '', $mountPoint));
}
$event = $this->eventDispatcher->dispatch(new BeforePageIsResolvedEvent($request, $pageInformation));
$pageInformation = $event->getPageInformation();
$pageInformation = $this->setPageAndRootline($request, $pageInformation);
$this->checkCrossDomainWithDirectId($request, $pageInformation);
$event = $this->eventDispatcher->dispatch(new AfterPageWithRootLineIsResolvedEvent($request, $pageInformation));
if ($event->getResponse()) {
throw new PageInformationCreationFailedException($event->getResponse(), 1705419743);
}
$pageInformation = $event->getPageInformation();
$pageInformation = $this->settingLanguage($request, $pageInformation);
$pageInformation = $this->setContentFromPid($request, $pageInformation);
$pageInformation = $this->setPageLayout($pageInformation);
$this->checkBackendUserAccess($request, $pageInformation);
$event = $this->eventDispatcher->dispatch(new AfterPageAndLanguageIsResolvedEvent($request, $pageInformation));
if ($event->getResponse()) {
throw new PageInformationCreationFailedException($event->getResponse(), 1705420010);
}
$pageInformation = $event->getPageInformation();
$pageInformation = $this->setSysTemplateRows($request, $pageInformation);
$pageInformation = $this->setLocalRootLine($request, $pageInformation);
$this->verifySiteOrSysTemplateRowExists($request, $pageInformation);
return $pageInformation;
}
/**
* Main lifting. Final page and the matching root line are determined and loaded.
*
* Note this methods may be called a second time in case of 'content_from_pid'.
*
* @throws PageInformationCreationFailedException
* @throws StatusException
*/
private function setPageAndRootline(ServerRequestInterface $request, PageInformation $pageInformation): PageInformation
{
$id = $pageInformation->getId();
$mountPoint = $pageInformation->getMountPoint();
$pageRecord = $this->pageRepository->getPage($id);
if (empty($pageRecord)) {
// @todo: This logic could be streamlined is general. The idea of PageRepository->getPage() is
// to have *one* query that does all the access checks already, to save queries. Only
// if this goes wrong more queries are fired to find out details.
// This is ugly. It would be better to query the page without checks and return it, probably
// only doing the deleted=1 check. If that does not resolve: 404. Then apply the hidden
// and groups checks in PHP on the page record without further queries, to error out
// whenever a single step goes wrong. Or have a single method that follows this strategy
// and throws dedicated exceptions like a PageHiddenException or similar to catch it here
// and act accordingly.
// This strategy would turn the guesswork logic below around and would be easier to
// maintain and follow in general. It would also clean up the various methods with their
// subtle differences: getPage($id), getPage($id, true) and getPage_noCheck($id).
// PageRepository->getPage() did not return a page. This can have
// different reasons. We want to error out with different status codes.
$schema = $this->tcaSchemaFactory->get('pages');
$includeHiddenPages = $this->context->getPropertyFromAspect('visibility', 'includeHiddenPages') || $this->context->getPropertyFromAspect('backend.user', 'isLoggedIn', false);
if ($schema->hasCapability(TcaSchemaCapability::RestrictionDisabledField) && !$includeHiddenPages) {
// Page is hidden, user has no access. 404. This is deliberately done in default language
// since language overlays should not be rendered when default language is hidden.
$rawPageRecord = $this->pageRepository->getPage_noCheck($id);
$hiddenField = $schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName();
if ($rawPageRecord === [] || $rawPageRecord[$hiddenField]) {
$response = $this->errorController->pageNotFoundAction(
$request,
'The requested page does not exist!',
['code' => PageAccessFailureReasons::PAGE_NOT_FOUND]
);
throw new PageInformationCreationFailedException($response, 1674144383);
}
}
$requestedPageRowWithoutGroupCheck = $this->pageRepository->getPage($id, true);
if (!empty($requestedPageRowWithoutGroupCheck)) {
// We know now the page could not be received, but the reason is *not* that the
// page is hidden and the user has no hidden access. So group access failed? 403.
$response = $this->errorController->accessDeniedAction(
$request,
'ID was not an accessible page',
[
'code' => PageAccessFailureReasons::ACCESS_DENIED_PAGE_NOT_RESOLVED,
'direct_access' => [
0 => $requestedPageRowWithoutGroupCheck,
],
],
);
throw new PageInformationCreationFailedException($response, 1705325336);
}
// Else 404 for 'record not exists' or similar.
$response = $this->errorController->pageNotFoundAction(
$request,
'The requested page does not exist!',
['code' => PageAccessFailureReasons::PAGE_NOT_FOUND]
);
throw new PageInformationCreationFailedException($response, 1533931330);
}
$pageInformation->setPageRecord($pageRecord);
$pageDoktype = (int)($pageRecord['doktype']);
// Spacer and sysfolders are not accessible in frontend
if (!$this->pageDoktypeRegistry->isPageTypeViewable($pageDoktype)) {
$response = $this->errorController->pageNotFoundAction(
$request,
'The requested page does not exist!',
['code' => PageAccessFailureReasons::ACCESS_DENIED_INVALID_PAGETYPE]
);
throw new PageInformationCreationFailedException($response, 1533931343);
}
if ($pageDoktype === PageRepository::DOKTYPE_LINK || $pageDoktype === PageRepository::DOKTYPE_SHORTCUT) {
// Resolve shortcut page target.
// Clear mount point if page is a shortcut: If the shortcut goes to
// another page, we leave the rootline which the MP expects.
$mountPoint = '';
$pageInformation->setMountPoint($mountPoint);
// Saving the page so that we can check later - when we know about languages - whether we took the correct shortcut
// or if a translation of the page overwrites the shortcut target, and we need to follow the new target.
$pageInformation = $this->settingLanguage($request, $pageInformation);
// Reset vars to new state that may have been created by settingLanguage()
$pageRecord = $pageInformation->getPageRecord();
if ($pageDoktype === PageRepository::DOKTYPE_LINK) {
$pageInformation->setOriginalShortcutPageRecord($pageRecord);
$typolinkInformation = $this->pageTypeLinkResolver->resolveTypolinkParts($pageRecord);
// The link destination was a page, we have to prevent infinitive loops
if ($typolinkInformation['type'] === 'page') {
try {
$pageRecord = $this->pageRepository->resolveLinkPage($pageRecord);
} catch (ShortcutTargetPageNotFoundException|LinkedPageNotResolvableException|CircularPageReferenceChainException|PageReferenceResolvingReachedIterationLimitException) {
$response = $this->errorController->pageNotFoundAction(
$request,
'ID was not an accessible page',
['code' => PageAccessFailureReasons::PAGE_NOT_FOUND]
);
throw new PageInformationCreationFailedException($response, 1705335066);
}
$pageInformation->setPageRecord($pageRecord);
$id = (int)$pageRecord['uid'];
$pageInformation->setId($id);
$pageDoktype = (int)($pageRecord['doktype'] ?? 0);
}
}
if ($pageDoktype === PageRepository::DOKTYPE_SHORTCUT) {
$pageInformation->setOriginalShortcutPageRecord($pageRecord);
try {
$pageRecord = $this->pageRepository->resolveShortcutPage($pageRecord);
} catch (ShortcutTargetPageNotFoundException|LinkedPageNotResolvableException|CircularPageReferenceChainException|PageReferenceResolvingReachedIterationLimitException) {
$response = $this->errorController->pageNotFoundAction(
$request,
'ID was not an accessible page',
['code' => PageAccessFailureReasons::PAGE_NOT_FOUND]
);
throw new PageInformationCreationFailedException($response, 1705335065);
}
$pageInformation->setPageRecord($pageRecord);
$id = (int)$pageRecord['uid'];
$pageInformation->setId($id);
$pageDoktype = (int)($pageRecord['doktype'] ?? 0);
}
}
if ($pageDoktype === PageRepository::DOKTYPE_MOUNTPOINT && $pageRecord['mount_pid_ol']) {
// If the page is a mount point which should be overlaid with the contents of the mounted page,
// it must never be accessible directly, but only in the mount point context.
// We thus change the current page id.
$originalMountPointPageRecord = $pageRecord;
$pageInformation->setOriginalMountPointPageRecord($pageRecord);
$pageRecord = $this->pageRepository->getPage((int)$originalMountPointPageRecord['mount_pid']);
if (empty($pageRecord)) {
// Target mount point page not accessible for some reason.
$response = $this->errorController->pageNotFoundAction(
$request,
'The requested page does not exist!',
['code' => PageAccessFailureReasons::PAGE_NOT_FOUND]
);
throw new PageInformationCreationFailedException($response, 1705425523);
}
$pageInformation->setPageRecord($pageRecord);
if ($mountPoint === '' || !empty($pageInformation->getOriginalShortcutPageRecord())) {
// If the current page is a shortcut, the MP parameter will be replaced
$mountPoint = $pageRecord['uid'] . '-' . $originalMountPointPageRecord['uid'];
} else {
$mountPoint = $mountPoint . ',' . $pageRecord['uid'] . '-' . $originalMountPointPageRecord['uid'];
}
$pageInformation->setMountPoint($mountPoint);
$id = (int)$pageRecord['uid'];
$pageInformation->setId($id);
}
// Get rootLine and error out if it can not be retrieved.
$pageInformation->setRootLine($this->getRootlineOrThrow($request, $id, $mountPoint));
// Check 'extendToSubpages' in rootLine and backend user section.
$this->checkRootlineForIncludeSection($request, $pageInformation);
return $pageInformation;
}
/**
* Determine the final Context language aspect, page record based on
* language settings, existing page overlay and its rootLine.
*
* May reset:
* $pageInformation->pageRecord
* $pageInformation->pageRepository
* $pageInformation->rootLine
*
* @throws PageInformationCreationFailedException
* @throws StatusException
*/
private function settingLanguage(ServerRequestInterface $request, PageInformation $pageInformation): PageInformation
{
$site = $request->getAttribute('site');
$language = $request->getAttribute('language', $site->getDefaultLanguage());
$languageAspect = LanguageAspectFactory::createFromSiteLanguage($language);
$languageId = $languageAspect->getId();
$languageContentId = $languageAspect->getContentId();
$pageRecord = $pageInformation->getPageRecord();
$pageTranslationVisibility = new PageTranslationVisibility((int)($pageRecord['l18n_cfg'] ?? 0));
if ($languageAspect->getId() > 0) {
// If the incoming language is set to another language than default
$olRec = $this->pageRepository->getPageOverlay($pageRecord, $languageAspect);
$overlaidLanguageId = (int)($olRec['language_tag'] ?? 0);
if ($overlaidLanguageId !== $languageAspect->getId()) {
// If requested translation is not available
if ($pageTranslationVisibility->shouldHideTranslationIfNoTranslatedRecordExists()) {
$response = $this->errorController->pageNotFoundAction(
$request,
'Page is not available in the requested language.',
['code' => PageAccessFailureReasons::LANGUAGE_NOT_AVAILABLE]
);
throw new PageInformationCreationFailedException($response, 1533931388);
}
switch ($languageAspect->getLegacyLanguageMode()) {
case 'strict':
$response = $this->errorController->pageNotFoundAction(
$request,
'Page is not available in the requested language (strict).',
['code' => PageAccessFailureReasons::LANGUAGE_NOT_AVAILABLE_STRICT_MODE]
);
throw new PageInformationCreationFailedException($response, 1533931395);
case 'content_fallback':
// Setting content uid (but leaving the sys_language_uid) when a content_fallback value was found.
foreach ($languageAspect->getFallbackChain() as $orderValue) {
if ($orderValue === '0' || $orderValue === 0 || $orderValue === '') {
$languageContentId = 0;
break;
}
if (MathUtility::canBeInterpretedAsInteger($orderValue) && $overlaidLanguageId === (int)$orderValue) {
$languageContentId = (int)$orderValue;
break;
}
if ($orderValue === 'pageNotFound') {
// The existing fallbacks have not been found, but instead of continuing page rendering
// with default language, a "page not found" message should be shown instead.
$response = $this->errorController->pageNotFoundAction(
$request,
'Page is not available in the requested language (fallbacks did not apply).',
['code' => PageAccessFailureReasons::LANGUAGE_AND_FALLBACKS_NOT_AVAILABLE]
);
throw new PageInformationCreationFailedException($response, 1533931402);
}
}
break;
default:
// Default is that everything defaults to the default language.
$languageId = ($languageContentId = 0);
}
}
// Define the language aspect again now
$languageAspect = new LanguageAspect(
$languageId,
$languageContentId,
$languageAspect->getOverlayType(),
$languageAspect->getFallbackChain()
);
// Setting localized page record if an overlay record was found (which it is only if a language is used)
// Doing this ensures that page properties like the page title are resolved in the correct language.
$pageInformation->setPageRecord($olRec);
}
// Set the final language aspect!
$this->context->setAspect('language', $languageAspect);
if ((!$languageAspect->getContentId() || !$languageAspect->getId())
&& $pageTranslationVisibility->shouldBeHiddenInDefaultLanguage()
) {
// If default language is not available
$response = $this->errorController->pageNotFoundAction(
$request,
'Page is not available in default language.',
['code' => PageAccessFailureReasons::LANGUAGE_DEFAULT_NOT_AVAILABLE]
);
throw new PageInformationCreationFailedException($response, 1533931423);
}
if ($languageAspect->getId() > 0) {
// Updating rootLine with translations if the language key is set.
$pageInformation->setRootLine(
$this->getRootlineOrThrow($request, $pageInformation->getId(), $pageInformation->getMountPoint())
);
}
return $pageInformation;
}
/**
* Check the value of 'content_from_pid' of the current page record, to see if the
* current request should actually show content from another page.
* If so, PageInformation->getContentFromPid() is set to the page id of the content
* page, while PageInformation->getId() is kept as the original page id.
* If there is no 'content_from_pid', PageInformation->getId() and
* PageInformation->getContentFromPid() end up carrying the same page ids.
*
* @throws PageInformationCreationFailedException
* @throws StatusException
*/
private function setContentFromPid(ServerRequestInterface $request, PageInformation $pageInformation): PageInformation
{
$contentFromPid = (int)($pageInformation->getPageRecord()['content_from_pid'] ?? 0);
if ($contentFromPid === 0) {
// $pageInformation->contentFromPid is always initialized, usually identical with id.
$pageInformation->setContentFromPid($pageInformation->getId());
return $pageInformation;
}
// Verify content target pid is good and resolves all access restrictions and similar.
$targetPageInformation = new PageInformation();
$targetPageInformation->setId($contentFromPid);
$targetPageInformation = $this->setPageAndRootline($request, $targetPageInformation);
// Above call did not throw. Set the verified id.
$pageInformation->setContentFromPid($targetPageInformation->getId());
return $pageInformation;
}
/**
* Resolve the selected backend layout for the current page and add it to the page information
*/
private function setPageLayout(PageInformation $pageInformation): PageInformation
{
$pageLayout = $this->pageLayoutResolver->getLayoutForPage(
$pageInformation->getPageRecord(),
$pageInformation->getRootLine()
);
if ($pageLayout !== null) {
$pageInformation->setPageLayout($pageLayout);
}
return $pageInformation;
}
/**
* Checks if visibility of the page is blocked upwards in the root line.
*
* The blocking feature of a page must be turned on by setting the page
* record field 'extendToSubpages' to 1 for 'hidden', 'starttime', 'endtime'
* 'fe_group' restrictions to bubble down in rootLine.
*
* Additionally, this method checks for backend user sections in root line
* and if found, evaluates if a backend user is logged in and has access.
*
* @throws PageInformationCreationFailedException
* @throws StatusException
*/
private function checkRootlineForIncludeSection(ServerRequestInterface $request, PageInformation $pageInformation): void
{
$rootLine = $pageInformation->getRootLine();
for ($a = 0; $a < count($rootLine); $a++) {
$rootLineEntry = $rootLine[$a];
if (!$this->accessVoter->accessGrantedForPageInRootLine($rootLineEntry, $this->context)) {
// accessGrantedForPageInRootLine() does the main check for 'extendToSubpages'.
$response = $this->errorController->accessDeniedAction(
$request,
'Subsection was found and not accessible',
[
'code' => PageAccessFailureReasons::ACCESS_DENIED_SUBSECTION_NOT_RESOLVED,
'sub_section' => [
0 => $rootLineEntry,
],
],
);
throw new PageInformationCreationFailedException($response, 1705337296);
}
if ((int)$rootLineEntry['doktype'] === PageRepository::DOKTYPE_BE_USER_SECTION) {
// Only logged in backend users with 'PAGE_SHOW' permissions are allowed to FE render backend user sections.
$isBackendUserLoggedIn = $this->context->getPropertyFromAspect('backend.user', 'isLoggedIn', false);
if (!$isBackendUserLoggedIn) {
$response = $this->errorController->accessDeniedAction(
$request,
'Subsection was found and not accessible',
['code' => PageAccessFailureReasons::ACCESS_DENIED_SUBSECTION_NOT_RESOLVED]
);
throw new PageInformationCreationFailedException($response, 1705416744);
}
if (!$this->getBackendUser()->doesUserHaveAccess($pageInformation->getPageRecord(), Permission::PAGE_SHOW)) {
$response = $this->errorController->accessDeniedAction(
$request,
'Subsection was found and not accessible',
['code' => PageAccessFailureReasons::ACCESS_DENIED_SUBSECTION_NOT_RESOLVED]
);
throw new PageInformationCreationFailedException($response, 1705337701);
}
}
}
}
/**
* When calling a page with a direct id 'https://my.domain/?id=123',
* this the site object of 'my.domain' is determined from an earlier middleware.
* If now '123' is *not* a (sub) page of the 'my.domain' site, we error out since
* we don't want to directly render content of a different site page within "our" site.
* Except if '123' is a shortcut, we still allow it, since that will trigger a
* redirect to the url with the shortcut target domain later.
*
* @throws PageInformationCreationFailedException
* @throws StatusException
*/
private function checkCrossDomainWithDirectId(ServerRequestInterface $request, PageInformation $pageInformation): void
{
$directlyRequestedId = (int)($request->getQueryParams()['id'] ?? 0);
$shortcutId = (int)($pageInformation->getOriginalShortcutPageRecord()['uid'] ?? 0);
if ($directlyRequestedId && $shortcutId !== $directlyRequestedId) {
$rootLine = $pageInformation->getRootLine();
$siteRootPageId = $request->getAttribute('site')->getRootPageId();
$siteRootWithinRootlineFound = false;
foreach ($rootLine as $pageInRootLine) {
if ((int)$pageInRootLine['uid'] === $siteRootPageId) {
$siteRootWithinRootlineFound = true;
break;
}
}
if (!$siteRootWithinRootlineFound) {
$response = $this->errorController->pageNotFoundAction(
$request,
'ID was outside the domain',
['code' => PageAccessFailureReasons::ACCESS_DENIED_HOST_PAGE_MISMATCH]
);
throw new PageInformationCreationFailedException($response, 1705397417);
}
}
}
/**
* When a backend user is logged in, it needs at least 'show' permissions.
*
* @throws PageInformationCreationFailedException
* @throws StatusException
*/
private function checkBackendUserAccess(ServerRequestInterface $request, PageInformation $pageInformation): void
{
// No backend user was logged in, nothing to check
if (!$this->context->getPropertyFromAspect('backend.user', 'isLoggedIn', false)) {
return;
}
// PreviewSimulator did not detect anything
if (!$this->context->getPropertyFromAspect('frontend.preview', 'isPreview', false)) {
return;
}
// Editor has no show permission for this page PLUS regular user is not allowed to see the page? 403.
if (!$GLOBALS['BE_USER']->doesUserHaveAccess($pageInformation->getPageRecord(), Permission::PAGE_SHOW)
&& !$this->accessVoter->accessGranted('pages', $pageInformation->getPageRecord(), $this->context)
) {
$response = $this->errorController->accessDeniedAction(
$request,
'ID was not an accessible page',
['code' => PageAccessFailureReasons::ACCESS_DENIED_PAGE_NOT_RESOLVED]
);
throw new PageInformationCreationFailedException($response, 1705422293);
}
}
/**
* Determine relevant sys_template rows and set to PageInformation object.
*
* @todo: Even though all rootline sys_template records are fetched with only one query
* in below implementation, we could potentially join or sub select sys_template
* records already when pages rootline is queried. This will save one query.
* This could be done when we manage to switch PageRepository / RootlineUtility to a CTE.
* @see \TYPO3\CMS\Extbase\Configuration\BackendConfigurationManager::getTypoScriptSetup()
* for similar usage.
* @throws PageInformationCreationFailedException
* @throws StatusException
*/
private function setSysTemplateRows(ServerRequestInterface $request, PageInformation $pageInformation): PageInformation
{
$site = $request->getAttribute('site');
$rootLine = $pageInformation->getRootLine();
if ($site instanceof Site && $site->isTypoScriptRoot()) {
$rootLineUntilSite = [];
foreach ($rootLine as $index => $rootlinePage) {
$rootLineUntilSite[$index] = $rootlinePage;
$pageId = (int)($rootlinePage['uid'] ?? 0);
if ($pageId === $site->getRootPageId()) {
break;
}
}
$rootLine = $rootLineUntilSite;
}
$sysTemplateRows = $this->sysTemplateRepository->getSysTemplateRowsByRootline($rootLine, $request);
$pageInformation->setSysTemplateRows($sysTemplateRows);
return $pageInformation;
}
/**
* Calculate "local" rootLine that stops at first root=1 template.
*/
private function setLocalRootLine(ServerRequestInterface $request, PageInformation $pageInformation): PageInformation
{
$site = $request->getAttribute('site');
$sysTemplateRows = $pageInformation->getSysTemplateRows();
$rootLine = $pageInformation->getRootLine();
$sysTemplateRowsIndexedByPid = array_combine(array_column($sysTemplateRows, 'pid'), $sysTemplateRows);
$localRootline = [];
foreach ($rootLine as $rootlinePage) {
array_unshift($localRootline, $rootlinePage);
$pageId = (int)($rootlinePage['uid'] ?? 0);
if ($pageId === $site->getRootPageId() && $site->isTypoScriptRoot()) {
break;
}
if ($pageId > 0 && (int)($sysTemplateRowsIndexedByPid[$pageId]['root'] ?? 0) === 1) {
break;
}
}
$pageInformation->setLocalRootLine($localRootline);
return $pageInformation;
}
/**
* @return non-empty-array
* @throws PageInformationCreationFailedException
* @throws StatusException
*/
private function getRootlineOrThrow(ServerRequestInterface $request, int $pageId, string $mountPoint): array
{
$rootLine = [];
try {
$rootLine = GeneralUtility::makeInstance(RootlineUtility::class, $pageId, $mountPoint)->get();
} catch (RootLineException) {
// Empty / broken rootline handled below.
}
if (!empty($rootLine)) {
// All good.
return $rootLine;
}
// Error case: Log and render error.
$message = 'The requested page did not have a proper connection to the tree root!';
$context = [
'pageId' => $pageId,
'requestUrl' => $request->getAttribute('normalizedParams')->getRequestUrl(),
];
$this->logger->error($message, $context);
try {
$response = $this->errorController->internalErrorAction(
$request,
$message,
['code' => PageAccessFailureReasons::ROOTLINE_BROKEN]
);
throw new PageInformationCreationFailedException($response, 1533931350);
} catch (StatusException $up) {
$this->logger->error($message, ['exception' => $up]);
throw $up;
}
}
/**
* @throws PageInformationCreationFailedException
* @throws StatusException
*/
private function verifySiteOrSysTemplateRowExists(ServerRequestInterface $request, PageInformation $pageInformation): void
{
$site = $request->getAttribute('site');
if ((!$site instanceof NullSite && !$site->isTypoScriptRoot()) && $pageInformation->getSysTemplateRows() === []) {
// @todo: The above check for NullSite is done for ext:redirects to not explode on "not existing" sites here.
// This is of course a hack that should vanish when the early url creation of ext:redirects and its fragile
// bootstrap strategy is resolved.
// This check should be: "if (!$site->isTypoScriptRoot() && $pageInformation->getSysTemplateRows() === []) {"
// Early exception if there is no typoscript definition in current site and no sys_template at all.
$message = 'No site configuration or TypoScript template record found!';
$this->logger->error($message);
try {
$response = $this->errorController->internalErrorAction(
$request,
$message,
['code' => PageAccessFailureReasons::RENDERING_INSTRUCTIONS_NOT_FOUND]
);
throw new PageInformationCreationFailedException($response, 1705656657);
} catch (StatusException $up) {
$this->logger->error($message, ['exception' => $up]);
throw $up;
}
}
}
private function getBackendUser(): ?FrontendBackendUserAuthentication
{
return $GLOBALS['BE_USER'] ?? null;
}
}
+201
View File
@@ -0,0 +1,201 @@
<?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\Page;
/**
* Data object to collect sections the full response body is compiled from.
* This is used and fed by various related middlewares.
*
* This object is attached as "frontend.page.parts" attribute to the frontend
* application request object.
*
* Note most of the data accumulated here is part of the page cache row, so the
* state is either generated from scratch during first page rendering, or recreated
* from cache upon consecutive renderings.
*
* This data object is highly experimental and marked as @internal since it
* will likely change when the rendering and cache related parts of the frontend
* middleware chain see further refactorings.
*
* @internal Experimental, will change.
*/
final class PageParts
{
/**
* Feed with the initial http body content string in TypoScriptFrontendInitialization
* when content is fetched from cache. It is later used in FE RequestHandler to substitute
* INT placeholders with their actual content (among other things) and then set as
* Response HTTP body.
*
* @todo: This exists here since we need some place to park the initial content retrieved from cache.
* This property could (should?) vanish at some point when the middleware PrepareTypoScriptFrontendRendering
* and the RequestHandler see further refactoring loops. Note there are events like AfterCacheableContentIsGeneratedEvent
* that already allow custom manipulation of content.
*/
private string $content = '';
/**
* "Last change" is page record "SYS_LASTCHANGED", initialized with pages record "tstamp", whichever is higher.
* This value is later modified by ContentObjectRenderer when records are rendered. The pages record column
* "SYS_LASTCHANGED" is then written to the highest value at the end of FE rendering.
* The main goal of pages "SYS_LASTCHANGED" is to have a DB field on pages that "knows" when a record that is
* displayed on the page is last changed. This information is for instance used in the ext:seo sitemap XML.
*
* This approach is flawed for multimple reasons: The "last updated" value can only be "gathered" during FE
* rendering since BE does not necessarily know all elements rendered on a page when for example a news plugin
* fetches record from "elsewhere", e.g. a record storage page. This means the "final" value of pages "SYS_LASTCHANGED"
* is only ready after all content elements have been rendered. It also relies on such a plugin actively taking
* care of updating $lastChanged here (@see ContentObjectRenderer->lastChanged()). Rendering a "Last updated"
* value on a page thus only works when it is output at the end (or as USER_INT which are calculated in the end).
* Additionally, a plugin like the ext:seo pages sitemap (which only gives a list of pages but does not actually
* render all pages) can only render a correct value after a page containing a "just changed" content element
* has been FE rendered at least once to have the newest "SYS_LASTCHANGED" value in DB.
*/
private int $lastChanged = 0;
/**
* Becomes the HTTP Response header 'Content-Type'.
* This is obviously Response and not Response body-stream related, but we currently "park" it here for
* applications like Extbase that need to reset to something like application/json.
*
* @todo: This should be remodeled. This should be part of a construct that gathers page parts and
* Response related details to finally compile a Response in the end. "Parking" that information
* here is a temporary measure on the way to a better solution where for instance single
* cObj return a data object instead of a string.
*/
private string $httpContentType = 'text/html; charset=utf-8';
/**
* Registry of "not cached" elements, typically COA_INT, USER_INT and uncached extbase plugins. Each
* entry contains state needed to render the element, most contain a placeholder name to be substituted
* within the content (due to the current FE rendering structure), and the rendering definition of the
* element. See the single consumers for details.
*/
private array $notCachedContentElementRegistry = [];
/**
* Data the final page title is generated from.
*/
private array $pageTitle = [];
/**
* Unique string for PageRenderer to substitute placeholders with final data.
*/
private string $pageRendererSubstitutionHash = '';
private bool $pageContentWasLoadedFromCache = false;
private int $pageCacheGeneratedTimestamp;
private ?int $pageCacheExpiresTimestamp = null;
public function setContent(string $content): void
{
$this->content = $content;
}
public function getContent(): string
{
return $this->content;
}
public function setLastChanged(int $timestamp): void
{
$this->lastChanged = $timestamp;
}
public function getLastChanged(): int
{
return $this->lastChanged;
}
public function setHttpContentType(string $contentType): void
{
$this->httpContentType = $contentType;
}
public function getHttpContentType(): string
{
return $this->httpContentType;
}
public function addNotCachedContentElement(array $data): void
{
$this->notCachedContentElementRegistry[] = $data;
}
public function hasNotCachedContentElements(): bool
{
return !empty($this->notCachedContentElementRegistry);
}
public function getNotCachedContentElementRegistry(): array
{
return $this->notCachedContentElementRegistry;
}
public function setPageTitle(array $pageTitle): void
{
$this->pageTitle = $pageTitle;
}
public function getPageTitle(): array
{
return $this->pageTitle;
}
public function setPageRendererSubstitutionHash(string $hash): void
{
$this->pageRendererSubstitutionHash = $hash;
}
public function getPageRendererSubstitutionHash(): string
{
return $this->pageRendererSubstitutionHash;
}
public function setPageContentWasLoadedFromCache(): void
{
$this->pageContentWasLoadedFromCache = true;
}
public function hasPageContentBeenLoadedFromCache(): bool
{
return $this->pageContentWasLoadedFromCache;
}
public function setPageCacheGeneratedTimestamp(int $timestamp): void
{
$this->pageCacheGeneratedTimestamp = $timestamp;
}
public function getPageCacheGeneratedTimestamp(): int
{
return $this->pageCacheGeneratedTimestamp;
}
public function setPageCacheExpireTimestamp(int $timestamp): void
{
$this->pageCacheExpiresTimestamp = $timestamp;
}
public function getPageCacheExpireTimestamp(): ?int
{
return $this->pageCacheExpiresTimestamp;
}
}