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
+71
View File
@@ -0,0 +1,71 @@
<?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\Cache;
/**
* This class contains cache details and is created or updated in middlewares of the
* Frontend rendering chain and added as Request attribute "frontend.cache.instruction".
*
* Its main goal is to *disable* the Frontend cache mechanisms in various scenarios, for
* instance when the admin panel is used to simulate access times, or when security
* mechanisms like cHash evaluation do not match.
*/
final class CacheInstruction
{
private bool $allowCaching = true;
private array $disabledCacheReasons = [];
/**
* Instruct the core Frontend rendering to disable Frontend caching. Extensions with
* custom middlewares may set this.
*
* Note multiple cache layers are involved during Frontend rendering: For instance multiple
* TypoScript layers, the page cache and potentially others. Those caches are read from and
* written to within various middlewares. Depending on the position of a call to this method
* within the middleware stack, it can happen that some or all caches have already been
* read of written.
*
* Extensions that use this method should keep an eye on their middleware positions in the
* stack to estimate the performance impact of this call. It's of course best to not use
* the 'disable cache' mechanic at all, but to handle caching properly in extensions.
*/
public function disableCache(string $reason): void
{
if (empty($reason)) {
throw new \RuntimeException(
'A non-empty reason must be given to disable cache. At least mention the extension name that triggers it.',
1701528694
);
}
$this->allowCaching = false;
$this->disabledCacheReasons[] = $reason;
}
public function isCachingAllowed(): bool
{
return $this->allowCaching;
}
/**
* @internal Typically only consumed by extensions like EXT:adminpanel
*/
public function getDisabledCacheReasons(): array
{
return $this->disabledCacheReasons;
}
}
+272
View File
@@ -0,0 +1,272 @@
<?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\Cache;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\EndTimeRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\StartTimeRestriction;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Event\ModifyCacheLifetimeForPageEvent;
use TYPO3\CMS\Frontend\Event\ModifyCacheLifetimeForRowEvent;
/**
* Calculates the max lifetime the given page should be stored in TYPO3's page cache.
*
* The "lifetime" is the number of seconds from the current time, it is not a full time/timestamp
* Example: If the lifetime is "3600" (=1h), the page will be cached for 1h.
*
* @internal This class is not part of the TYPO3 Core API
*/
#[Autoconfigure(public: true)]
class CacheLifetimeCalculator
{
protected const defaultCacheTimeout = 365 * 86400; // 1 year
public function __construct(
#[Autowire(service: 'cache.runtime')]
protected readonly FrontendInterface $runtimeCache,
protected readonly EventDispatcherInterface $eventDispatcher,
protected readonly ConnectionPool $connectionPool,
protected readonly TcaSchemaFactory $tcaSchemaFactory,
) {}
/**
* Get the cache lifetime in seconds for the given record.
*/
public function calculateLifetimeForRow(string $tableName, array $record, int $defaultCacheTimeoutInSeconds = 0): int
{
$cachedCacheLifetimeIdentifier = sprintf('calculateLifetimeForRow_%s_%d', $tableName, ($record['uid'] ?? 0));
$cachedCacheLifetime = $this->runtimeCache->get($cachedCacheLifetimeIdentifier);
if ($cachedCacheLifetime !== false) {
return (int)$cachedCacheLifetime;
}
$cacheTimeout = $defaultCacheTimeoutInSeconds ?: self::defaultCacheTimeout;
if ($this->tcaSchemaFactory->has($tableName)) {
$schema = $this->tcaSchemaFactory->get($tableName);
// If the record has a starttime or endtime, we have to adjust the cache timeout
foreach ([TcaSchemaCapability::RestrictionStartTime, TcaSchemaCapability::RestrictionEndTime] as $capability) {
if (!$schema->hasCapability($capability)) {
continue;
}
$timeField = $schema->getCapability($capability)->getFieldName();
if (array_key_exists($timeField, $record) && $record[$timeField] > 0 && ((int)$record[$timeField] - $GLOBALS['ACCESS_TIME']) > 0) {
$cacheTimeout = min($cacheTimeout, (int)$record[$timeField] - $GLOBALS['ACCESS_TIME']);
}
}
}
// Get the time, rounded to the minute (do not pollute MySQL cache!)
// It is ok that we do not take seconds into account here because this
// value will be subtracted later. So we never get the time "before"
// the cache change.
$currentTimestamp = (int)$GLOBALS['ACCESS_TIME'];
$cacheTimeout = min($currentTimestamp, $cacheTimeout);
$event = new ModifyCacheLifetimeForRowEvent(
$cacheTimeout,
$tableName,
$record
);
$event = $this->eventDispatcher->dispatch($event);
$cacheTimeout = $event->cacheLifetime;
$this->runtimeCache->set($cachedCacheLifetimeIdentifier, (string)$cacheTimeout);
return $cacheTimeout;
}
/**
* Get the cache lifetime in seconds for the given page.
*/
public function calculateLifetimeForPage(int $pageId, array $pageRecord, array $renderingInstructions, Context $context): int
{
$cachedCacheLifetimeIdentifier = 'cacheLifeTimeForPage_' . $pageId;
$cachedCacheLifetime = $this->runtimeCache->get($cachedCacheLifetimeIdentifier);
if ($cachedCacheLifetime !== false) {
return (int)$cachedCacheLifetime;
}
if ($pageRecord['cache_timeout'] ?? false) {
// Cache period was set for the page:
$cacheTimeout = (int)$pageRecord['cache_timeout'];
} else {
// Cache period was set via TypoScript "config.cache_period", otherwise it's the default of 24 hours
$cacheTimeout = (int)($renderingInstructions['cache_period'] ?? self::defaultCacheTimeout);
}
$cacheTimeout = $this->calculateLifetimeForRow('pages', $pageRecord, $cacheTimeout);
// Calculate the timeout time for records on the page and adjust cache timeout if necessary
// Get the configuration
$tablesToConsider = $this->getCurrentPageCacheConfiguration($pageId, $renderingInstructions);
// Get the time, rounded to the minute (do not pollute MySQL cache!)
// It is ok that we do not take seconds into account here because this
// value will be subtracted later. So we never get the time "before"
// the cache change.
$currentTimestamp = (int)$GLOBALS['ACCESS_TIME'];
$cacheTimeout = min($this->calculatePageCacheLifetime($tablesToConsider, $currentTimestamp), $cacheTimeout);
$event = new ModifyCacheLifetimeForPageEvent(
$cacheTimeout,
$pageId,
$pageRecord,
$renderingInstructions,
$context
);
$event = $this->eventDispatcher->dispatch($event);
$cacheTimeout = $event->getCacheLifetime();
$this->runtimeCache->set($cachedCacheLifetimeIdentifier, (string)$cacheTimeout);
return $cacheTimeout;
}
/**
* Calculates page cache timeout according to the records with starttime/endtime on the page.
*
* @return int Page cache timeout or PHP_INT_MAX if the timeout cannot be determined
*/
protected function calculatePageCacheLifetime(array $tablesToConsider, int $currentTimestamp): int
{
$result = PHP_INT_MAX;
// Find timeout by checking every table
foreach ($tablesToConsider as $tableDef) {
$result = min($result, $this->getFirstTimeValueForRecord($tableDef, $currentTimestamp));
}
// We return + 1 second just to ensure that cache is definitely regenerated
return $result === PHP_INT_MAX ? PHP_INT_MAX : $result - $currentTimestamp + 1;
}
/**
* Obtains a list of table/pid pairs to consider for page caching.
*
* TS configuration looks like this:
*
* The cache lifetime of all pages takes starttime and endtime of news records of page 14 into account:
* config.cache.all = tt_news:14
*
* The cache lifetime of the current page allows to take records (e.g. fe_users) into account:
* config.cache.all = fe_users:current
*
* The cache lifetime of page 42 takes starttime and endtime of news records of page 15 and addresses of page 16 into account:
* config.cache.42 = tt_news:15,tt_address:16
*
* @return array Array of 'tablename:pid' pairs. There is at least a current page id in the array
* @see calculatePageCacheLifetime()
*/
protected function getCurrentPageCacheConfiguration(int $currentPageId, array $renderingInstructions): array
{
$result = ['tt_content:' . $currentPageId];
if (isset($renderingInstructions['cache.'][$currentPageId])) {
$result = array_merge($result, GeneralUtility::trimExplode(',', str_replace(':current', ':' . $currentPageId, $renderingInstructions['cache.'][$currentPageId])));
}
if (isset($renderingInstructions['cache.']['all'])) {
$result = array_merge($result, GeneralUtility::trimExplode(',', str_replace(':current', ':' . $currentPageId, $renderingInstructions['cache.']['all'])));
}
return array_unique($result);
}
/**
* Find the minimum starttime or endtime value in the table and pid that is greater than the current time.
*
* @param string $tableDef Table definition (format tablename:pid)
* @param int $currentTimestamp the UNIX timestamp of the current time
* @throws \InvalidArgumentException
* @return int Value of the next start/stop time or PHP_INT_MAX if not found
* @see calculatePageCacheLifetime()
*/
protected function getFirstTimeValueForRecord(string $tableDef, int $currentTimestamp): int
{
$result = PHP_INT_MAX;
[$tableName, $pid] = GeneralUtility::trimExplode(':', $tableDef);
if (empty($tableName) || !isset($pid)) {
throw new \InvalidArgumentException('Unexpected value for parameter $tableDef. Expected <tablename>:<pid>, got \'' . htmlspecialchars($tableDef) . '\'.', 1307190365);
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName);
$queryBuilder->getRestrictions()
->removeByType(StartTimeRestriction::class)
->removeByType(EndTimeRestriction::class);
$timeFields = [];
$timeConditions = $queryBuilder->expr()->or();
if ($this->tcaSchemaFactory->has($tableName)) {
$schema = $this->tcaSchemaFactory->get($tableName);
// If the record has a starttime or endtime, we have to adjust the cache timeout
foreach ([TcaSchemaCapability::RestrictionStartTime, TcaSchemaCapability::RestrictionEndTime] as $capability) {
if (!$schema->hasCapability($capability)) {
continue;
}
$timeField = $schema->getCapability($capability)->getFieldName();
$queryBuilder->addSelectLiteral(
'MIN('
. 'CASE WHEN '
. $queryBuilder->expr()->lte(
$timeField,
$queryBuilder->createNamedParameter($currentTimestamp, Connection::PARAM_INT)
)
. ' THEN NULL ELSE ' . $queryBuilder->quoteIdentifier($timeField) . ' END'
. ') AS ' . $queryBuilder->quoteIdentifier($timeField)
);
$timeConditions = $timeConditions->with(
$queryBuilder->expr()->gt(
$timeField,
$queryBuilder->createNamedParameter($currentTimestamp, Connection::PARAM_INT)
)
);
$timeFields[] = $timeField;
}
}
// if starttime or endtime are defined, evaluate them
if ($timeFields !== []) {
// find the timestamp, when the current page's content changes the next time
$row = $queryBuilder
->from($tableName)
->where(
$queryBuilder->expr()->eq(
'pid',
$queryBuilder->createNamedParameter($pid, Connection::PARAM_INT)
),
$timeConditions
)
->executeQuery()
->fetchAssociative();
if ($row) {
foreach ($timeFields as $timeField) {
// if a MIN value is found, take it into account for the
// cache lifetime we have to filter out start/endtimes < $currentTimestamp,
// as the SQL query also returns rows with starttime < $currentTimestamp
// and endtime > $currentTimestamp (and using a starttime from the past
// would be wrong)
if ($row[$timeField] !== null && (int)$row[$timeField] > $currentTimestamp) {
$result = min($result, (int)$row[$timeField]);
}
}
}
}
return $result;
}
}
+91
View File
@@ -0,0 +1,91 @@
<?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\Cache;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\DirectiveHashCollection;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ModelService;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\PolicyRegistry;
/**
* Meta-data class handling cacheable states for generic frontend functionality.
*
* @internal
*/
#[Autoconfigure(public: true)]
readonly class MetaDataState
{
public function __construct(
private ModelService $modelService,
private PolicyRegistry $policyRegistry,
private DirectiveHashCollection $directiveHashCollection,
) {}
public function getState(): array
{
return [
'PolicyRegistry::$mutationCollections' => json_encode($this->policyRegistry->getMutationCollections()),
'HashCollection' => json_encode($this->directiveHashCollection),
];
}
public function updateState(array $state): void
{
foreach ($state as $name => $value) {
switch ($name) {
case 'PolicyRegistry::$mutationCollections':
$this->updatePolicyRegistryMutationCollections($value);
break;
case 'HashCollection':
$this->updateHashCollection($value);
break;
}
}
}
private function updatePolicyRegistryMutationCollections(mixed $value): void
{
$array = $this->decodeJsonString($value);
if (is_array($array)) {
$this->policyRegistry->setMutationsCollections(
...array_map($this->modelService->buildMutationCollectionFromArray(...), $array)
);
}
}
private function updateHashCollection(mixed $value): void
{
$array = $this->decodeJsonString($value);
if (is_array($array)) {
$this->directiveHashCollection->updateFromJson($array);
}
}
private function decodeJsonString(mixed $value): ?array
{
if (!is_string($value) || $value === '') {
return null;
}
try {
$array = json_decode($value, true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return is_array($array) ? $array : null;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?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\Cache;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ConsumableNonce;
/**
* Substitutes a cached nonce value with the actual nonce value that
* is valid for the current request, and which is issued as HTTP CSP
* header during the frontend rendering process.
*/
class NonceValueSubstitution
{
/**
* @param array{content: string, nonce: string} $context
*/
public function substituteNonce(array $context): ?string
{
$currentNonce = $GLOBALS['TYPO3_REQUEST']?->getAttribute('nonce');
if (!$currentNonce instanceof ConsumableNonce
|| empty($context['content'])
|| empty($context['nonce'])
|| $currentNonce->value === $context['nonce']
|| !str_contains($context['content'], $context['nonce'])
) {
return null;
}
return str_replace($context['nonce'], $currentNonce->consumeInline(self::class), $context['content']);
}
}