TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:09 +02:00
commit af8cc155b5
6818 changed files with 642608 additions and 0 deletions
@@ -0,0 +1,453 @@
<?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\Core\Configuration;
use TYPO3\CMS\Core\Configuration\Exception\SettingsWriteException;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Crypto\Random;
use TYPO3\CMS\Core\Service\OpcodeCacheService;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Handle loading and writing of global and local (instance specific)
* configuration.
*
* This class handles the access to the files
* - EXT:core/Configuration/DefaultConfiguration.php (default TYPO3_CONF_VARS)
* - config/system/settings.php or typo3conf/system/settings.php - previously known as LocalConfiguration.php
* - config/system/additional.php or typo3conf/system/additional.php (optional additional code blocks) - previously known as typo3conf/AdditionalConfiguration.php
*
* IMPORTANT:
* This class is intended for internal core use ONLY.
* Extensions should usually use the resulting $GLOBALS['TYPO3_CONF_VARS'] array,
* do not try to modify settings in the config/system/settings.php file with an extension.
* @internal
*/
class ConfigurationManager
{
/**
* @var string Path to default TYPO3_CONF_VARS file, relative to the public web folder
*/
protected $defaultConfigurationFile = __DIR__ . '/../../Configuration/DefaultConfiguration.php';
/**
* @var string Path to description file for TYPO3_CONF_VARS, relative to the public web folder
*/
protected $defaultConfigurationDescriptionFile = 'EXT:core/Configuration/DefaultConfigurationDescription.yaml';
/**
* @var string Path to factory configuration file used during installation as LocalConfiguration boilerplate
*/
protected $factoryConfigurationFile = __DIR__ . '/../../Configuration/FactoryConfiguration.php';
/**
* @var string Path to possible additional factory configuration file delivered by packages
*/
protected $additionalFactoryConfigurationFile = 'AdditionalFactoryConfiguration.php';
/**
* Writing to these configuration paths is always allowed,
* even if the requested sub path does not exist yet.
*/
protected array $allowedSettingsPaths = [
'EXTCONF',
'DB',
'SYS/caching/cacheConfigurations',
'SYS/encryptionKey',
'SYS/session',
'EXTENSIONS',
];
/**
* Return default configuration array
*
* @return array
*/
public function getDefaultConfiguration()
{
return require $this->getDefaultConfigurationFileLocation();
}
/**
* Get the file location of the default configuration file,
* currently the path and filename.
*
* @return string
* @internal
*/
public function getDefaultConfigurationFileLocation()
{
return $this->defaultConfigurationFile;
}
/**
* Get the file location of the default configuration description file,
* currently the path and filename.
*
* @return string
* @internal
*/
public function getDefaultConfigurationDescriptionFileLocation()
{
return $this->defaultConfigurationDescriptionFile;
}
/**
* Return configuration array of typo3conf/system/settings.php or config/system/settings.php
*
* @return array Content array of local configuration file
*/
public function getLocalConfiguration(): array
{
return require $this->getSystemConfigurationFileLocation();
}
/**
* Get the file location of the TYPO3-project specific settings file,
* currently the path and filename.
*
* Path to local overload TYPO3_CONF_VARS file.
*
* @internal
*/
public function getSystemConfigurationFileLocation(bool $relativeToProjectRoot = false): string
{
// For composer-based installations, the file is in config/system/settings.php
$path = Environment::getConfigPath() . '/system/settings.php';
if ($relativeToProjectRoot) {
return substr($path, strlen(Environment::getProjectPath()) + 1);
}
return $path;
}
/**
* Returns local configuration array merged with default configuration
*/
public function getMergedLocalConfiguration(): array
{
$localConfiguration = $this->getDefaultConfiguration();
ArrayUtility::mergeRecursiveWithOverrule($localConfiguration, $this->getLocalConfiguration());
return $localConfiguration;
}
/**
* Get the file location of the additional configuration file,
* currently the path and filename.
*
* @return string
* @internal
*/
public function getAdditionalConfigurationFileLocation()
{
// For composer-based installations, the file is in config/system/additional.php
return Environment::getConfigPath() . '/system/additional.php';
}
/**
* Get absolute file location of factory configuration file
*
* @return string
*/
protected function getFactoryConfigurationFileLocation()
{
return $this->factoryConfigurationFile;
}
/**
* Get absolute file location of factory configuration file
*
* @return string
*/
protected function getAdditionalFactoryConfigurationFileLocation()
{
return Environment::getLegacyConfigPath() . '/' . $this->additionalFactoryConfigurationFile;
}
/**
* Override local configuration with new values.
*
* @param array $configurationToMerge Override configuration array
*/
public function updateLocalConfiguration(array $configurationToMerge)
{
$newLocalConfiguration = $this->getLocalConfiguration();
ArrayUtility::mergeRecursiveWithOverrule($newLocalConfiguration, $configurationToMerge);
$this->writeLocalConfiguration($newLocalConfiguration);
}
/**
* Get a value at given path from default configuration
*
* @param string $path Path to search for
* @return mixed Value at path
*/
public function getDefaultConfigurationValueByPath($path)
{
return ArrayUtility::getValueByPath($this->getDefaultConfiguration(), $path);
}
/**
* Get a value at given path from local configuration
*
* @param string $path Path to search for
* @return mixed Value at path
*/
public function getLocalConfigurationValueByPath($path)
{
return ArrayUtility::getValueByPath($this->getLocalConfiguration(), $path);
}
/**
* Get a value from configuration, this is default configuration
* merged with local configuration
*
* @param string $path Path to search for
* @return mixed
*/
public function getConfigurationValueByPath($path)
{
$defaultConfiguration = $this->getDefaultConfiguration();
ArrayUtility::mergeRecursiveWithOverrule($defaultConfiguration, $this->getLocalConfiguration());
return ArrayUtility::getValueByPath($defaultConfiguration, $path);
}
/**
* Update a given path in local configuration to a new value.
* Warning: TO BE USED ONLY to update a single feature.
* NOT TO BE USED within iterations to update multiple features.
* To update multiple features use setLocalConfigurationValuesByPathValuePairs().
*
* @param string $path Path to update
* @param mixed $value Value to set
* @return bool TRUE on success
*/
public function setLocalConfigurationValueByPath($path, $value)
{
$result = false;
if ($this->isValidLocalConfigurationPath($path)) {
$localConfiguration = $this->getLocalConfiguration();
$localConfiguration = ArrayUtility::setValueByPath($localConfiguration, $path, $value);
$result = $this->writeLocalConfiguration($localConfiguration);
}
return $result;
}
/**
* Update / set a list of path and value pairs in local configuration file
*
* @param array $pairs Key is path, value is value to set
* @return bool TRUE on success
*/
public function setLocalConfigurationValuesByPathValuePairs(array $pairs)
{
$localConfiguration = $this->getLocalConfiguration();
foreach ($pairs as $path => $value) {
if ($this->isValidLocalConfigurationPath($path)) {
$localConfiguration = ArrayUtility::setValueByPath($localConfiguration, $path, $value);
}
}
return $this->writeLocalConfiguration($localConfiguration);
}
/**
* Remove keys from LocalConfiguration
*
* @param array $keys Array with key paths to remove from LocalConfiguration
* @return bool TRUE if something was removed
*/
public function removeLocalConfigurationKeysByPath(array $keys): bool
{
$result = false;
$localConfiguration = $this->getLocalConfiguration();
foreach ($keys as $path) {
// Remove key if path is within LocalConfiguration
if (ArrayUtility::isValidPath($localConfiguration, $path)) {
$result = true;
$localConfiguration = ArrayUtility::removeByPath($localConfiguration, $path);
}
}
if ($result) {
$this->writeLocalConfiguration($localConfiguration);
}
return $result;
}
/**
* Enables a certain feature and writes the option to system/settings.php
* Short-hand method
* Warning: TO BE USED ONLY to enable a single feature.
* NOT TO BE USED within iterations to enable multiple features.
* To update multiple features use setLocalConfigurationValuesByPathValuePairs().
*
* @param string $featureName something like "InlineSvgImages"
* @return bool true on successful writing the setting
*/
public function enableFeature(string $featureName): bool
{
return $this->setLocalConfigurationValueByPath('SYS/features/' . $featureName, true);
}
/**
* Disables a feature and writes the option to system/settings.php
* Short-hand method
* Warning: TO BE USED ONLY to disable a single feature.
* NOT TO BE USED within iterations to disable multiple features.
* To update multiple features use setLocalConfigurationValuesByPathValuePairs().
*
* @param string $featureName something like "InlineSvgImages"
* @return bool true on successful writing the setting
*/
public function disableFeature(string $featureName): bool
{
return $this->setLocalConfigurationValueByPath('SYS/features/' . $featureName, false);
}
/**
* Checks if the configuration can be written.
*
* @return bool
* @internal
*/
public function canWriteConfiguration()
{
$fileLocation = $this->getSystemConfigurationFileLocation();
return @is_writable(file_exists($fileLocation) ? $fileLocation : dirname($fileLocation));
}
/**
* Reads the configuration array and exports it to the global variable
*
* @internal
* @throws \UnexpectedValueException
*/
public function exportConfiguration(): void
{
if (@is_file($this->getSystemConfigurationFileLocation())) {
$localConfiguration = $this->getLocalConfiguration();
$defaultConfiguration = $this->getDefaultConfiguration();
ArrayUtility::mergeRecursiveWithOverrule($defaultConfiguration, $localConfiguration);
$GLOBALS['TYPO3_CONF_VARS'] = $defaultConfiguration;
} else {
// No LocalConfiguration (yet), load DefaultConfiguration
$GLOBALS['TYPO3_CONF_VARS'] = $this->getDefaultConfiguration();
}
// Load AdditionalConfiguration
if (@is_file($this->getAdditionalConfigurationFileLocation())) {
require $this->getAdditionalConfigurationFileLocation();
}
}
/**
* Write configuration array to %config-dir%/system/settings.php
*
* @param array $configuration The local configuration to be written
* @throws \RuntimeException
* @return bool TRUE on success
* @internal
*/
public function writeLocalConfiguration(array $configuration)
{
$systemSettingsFile = $this->getSystemConfigurationFileLocation();
if (!$this->canWriteConfiguration()) {
throw new SettingsWriteException(
$this->getSystemConfigurationFileLocation(true) . ' is not writable.',
1346323822
);
}
$configuration = ArrayUtility::sortByKeyRecursive($configuration);
$result = GeneralUtility::writeFile(
$systemSettingsFile,
"<?php\n"
. 'return '
. ArrayUtility::arrayExport($configuration)
. ";\n",
true
);
GeneralUtility::makeInstance(OpcodeCacheService::class)->clearAllActive($systemSettingsFile);
return $result;
}
/**
* Write additional configuration array to config/system/additional.php / typo3conf/system/additional.php
*
* @param array $additionalConfigurationLines The configuration lines to be written
* @throws \RuntimeException
* @return bool TRUE on success
* @internal
*/
public function writeAdditionalConfiguration(array $additionalConfigurationLines)
{
return GeneralUtility::writeFile(
$this->getAdditionalConfigurationFileLocation(),
"<?php\n" . implode("\n", $additionalConfigurationLines) . "\n",
true
);
}
/**
* Uses FactoryConfiguration file and a possible AdditionalFactoryConfiguration
* file in typo3conf to create a basic config/system/settings.php. This is used
* by the installer in an early step.
*
* @throws \RuntimeException
* @internal
*/
public function createLocalConfigurationFromFactoryConfiguration()
{
if (file_exists($this->getSystemConfigurationFileLocation())) {
throw new \RuntimeException(
basename($this->getSystemConfigurationFileLocation(true)) . ' already exists',
1364836026
);
}
$localConfigurationArray = require $this->getFactoryConfigurationFileLocation();
$additionalFactoryConfigurationFileLocation = $this->getAdditionalFactoryConfigurationFileLocation();
if (file_exists($additionalFactoryConfigurationFileLocation)) {
$additionalFactoryConfigurationArray = require $additionalFactoryConfigurationFileLocation;
ArrayUtility::mergeRecursiveWithOverrule(
$localConfigurationArray,
$additionalFactoryConfigurationArray
);
}
$randomKey = GeneralUtility::makeInstance(Random::class)->generateRandomHexString(96);
$localConfigurationArray['SYS']['encryptionKey'] = $randomKey;
$this->writeLocalConfiguration($localConfigurationArray);
}
/**
* Check if access / write to given path in local configuration is allowed.
*
* @param string $path Path to search for
* @return bool TRUE if access is allowed
*/
protected function isValidLocalConfigurationPath(string $path): bool
{
// Early return for white listed paths
foreach ($this->allowedSettingsPaths as $allowedSettingsPath) {
if (str_starts_with($path, $allowedSettingsPath)) {
return true;
}
}
return ArrayUtility::isValidPath($this->getDefaultConfiguration(), $path);
}
}
@@ -0,0 +1,96 @@
<?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\Core\Configuration\Event;
/**
* Listeners to this event are able to modify or enhance the data structure identifier,
* which is used for a given TCA flex field.
*
* This event can be used to add additional data to an identifier. Be careful here, especially if
* stuff from the source record like uid or pid is added! This may easily lead to issues with
* data handler details like copy or move records, localization and version overlays.
* Test this very well! Multiple listeners may add information to the same identifier here -
* take care to namespace array keys. Information added here can be later used in the
* data structure related PSR-14 Events (BeforeFlexFormDataStructureParsedEvent and
* AfterFlexFormDataStructureParsedEvent) again.
*
* See the note on FlexFormTools regarding the schema of $dataStructure.
*/
final class AfterFlexFormDataStructureIdentifierInitializedEvent
{
/**
* @param array $fieldTca Full TCA of the field in question that has type=flex set
* @param string $tableName The table name of the TCA field
* @param string $fieldName The field name
* @param array $row The data row
* @param array $identifier The data structure identifier (set by event listener of the default)
*/
public function __construct(
private readonly array $fieldTca,
private readonly string $tableName,
private readonly string $fieldName,
private readonly array $row,
private array $identifier,
) {}
/**
* Returns the full TCA of the currently handled field, having
* `type=flex` set.
*/
public function getFieldTca(): array
{
return $this->fieldTca;
}
public function getTableName(): string
{
return $this->tableName;
}
public function getFieldName(): string
{
return $this->fieldName;
}
/**
* Returns the whole database row of the current record.
*/
public function getRow(): array
{
return $this->row;
}
/**
* Allows to modify or completely replace the initialized data
* structure identifier.
*/
public function setIdentifier(array $identifier): void
{
$this->identifier = $identifier;
}
/**
* Returns the initialized data structure identifier, which has
* either been defined by an event listener or set to the default
* by the `FlexFormTools` component.
*/
public function getIdentifier(): array
{
return $this->identifier;
}
}
@@ -0,0 +1,61 @@
<?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\Core\Configuration\Event;
/**
* Listeners to this event are able to modify or enhance a flex form data
* structure that corresponds to a given identifier, after it was parsed and
* before it is used by further components.
*
* Note: Since this event is not stoppable, all registered listeners are
* called. Therefore, you might want to namespace your identifiers in a way,
* that there is little chance they overlap (e.g. prefix with extension name).
*
* See the note on FlexFormTools regarding the schema of $dataStructure.
*/
final class AfterFlexFormDataStructureParsedEvent
{
public function __construct(
private array $dataStructure,
private readonly array $identifier,
) {}
public function getIdentifier(): array
{
return $this->identifier;
}
/**
* Returns the current data structure, which has been processed and
* parsed by the `FlexFormTools` component. Might contain additional
* data from previously called listeners.
*/
public function getDataStructure(): array
{
return $this->dataStructure;
}
/**
* Allows to modify or completely replace the parsed data
* structure identifier.
*/
public function setDataStructure(array $dataStructure): void
{
$this->dataStructure = $dataStructure;
}
}
@@ -0,0 +1,33 @@
<?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\Core\Configuration\Event;
final class AfterRichtextConfigurationPreparedEvent
{
public function __construct(private array $configuration) {}
public function getConfiguration(): array
{
return $this->configuration;
}
public function setConfiguration(array $configuration): void
{
$this->configuration = $configuration;
}
}
@@ -0,0 +1,37 @@
<?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\Core\Configuration\Event;
/**
* Event after $tca which later becomes $GLOBALS['TCA'] has been built.
* Allows to further manipulate $tca before it is cached and set as $GLOBALS['TCA'].
*/
final class AfterTcaCompilationEvent
{
public function __construct(private array $tca) {}
public function getTca(): array
{
return $this->tca;
}
public function setTca(array $tca): void
{
$this->tca = $tca;
}
}
@@ -0,0 +1,112 @@
<?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\Core\Configuration\Event;
use Psr\EventDispatcher\StoppableEventInterface;
/**
* Listeners to this event are able to specify the data structure identifier,
* used for a given TCA flex field.
*
* Listeners should call ->setIdentifier() to set the identifier or ignore the
* event to allow other listeners to set it. Do not set an empty string as this
* will immediately stop event propagation!
*
* The identifier SHOULD include the keys specified in the Identifier definition
* on FlexFormTools, and nothing else. Adding other keys may or may not work,
* depending on other code that is enabled, and they are not guaranteed nor
* covered by BC guarantees.
*
* Warning: If adding source record details like the uid or pid here, this may turn out to be fragile.
* Be sure to test scenarios like workspaces and data handler copy/move well, additionally, this may
* break in between different core versions.
* It is probably a good idea to return at least something like [ 'type' => 'myExtension', ... ], see
* the core internal 'tca' and 'record' return values below
*
* See the note on FlexFormTools regarding the schema of $dataStructure.
*/
final class BeforeFlexFormDataStructureIdentifierInitializedEvent implements StoppableEventInterface
{
private ?array $identifier = null;
/**
* @param array $fieldTca Full TCA of the field in question that has type=flex set
* @param string $tableName The table name of the TCA field
* @param string $fieldName The field name
* @param array $row The data row
*/
public function __construct(
private readonly array $fieldTca,
private readonly string $tableName,
private readonly string $fieldName,
private readonly array $row,
) {}
/**
* Returns the full TCA of the currently handled field, having
* `type=flex` set.
*/
public function getFieldTca(): array
{
return $this->fieldTca;
}
public function getTableName(): string
{
return $this->tableName;
}
public function getFieldName(): string
{
return $this->fieldName;
}
/**
* Returns the whole database row of the current record.
*/
public function getRow(): array
{
return $this->row;
}
/**
* Allows to define the data structure identifier for the TCA field.
* Setting an identifier will immediately stop propagation. Avoid
* setting this parameter to an empty array as this will also stop
* propagation.
*/
public function setIdentifier(array $identifier): void
{
$this->identifier = $identifier;
}
/**
* Returns the current data structure identifier, which will always be
* `null` for listeners, since the event propagation is
* stopped as soon as a listener defines an identifier.
*/
public function getIdentifier(): ?array
{
return $this->identifier ?? null;
}
public function isPropagationStopped(): bool
{
return isset($this->identifier);
}
}
@@ -0,0 +1,72 @@
<?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\Core\Configuration\Event;
use Psr\EventDispatcher\StoppableEventInterface;
/**
* Listeners to this event are able to specify a flex form data structure that
* corresponds to a given identifier.
*
* Listeners should call ->setDataStructure() to set the data structure (this
* can either be a resolved data structure string, a "FILE:" reference or a
* fully parsed data structure as array) or ignore the event to allow other
* listeners to set it. Do not set an empty array or string as this will
* immediately stop event propagation!
*
* See the note on FlexFormTools regarding the schema of $dataStructure.
*/
final class BeforeFlexFormDataStructureParsedEvent implements StoppableEventInterface
{
private array|string|null $dataStructure = null;
public function __construct(
private readonly array $identifier,
) {}
/**
* Returns the current data structure, which will always be `null`
* for listeners, since the event propagation is stopped as soon as
* a listener sets a data structure.
*/
public function getDataStructure(): array|string|null
{
return $this->dataStructure ?? null;
}
/**
* Allows to either set an already parsed data structure as `array`,
* a file reference or the XML structure as `string`. Setting a data
* structure will immediately stop propagation. Avoid setting this parameter
* to an empty array or string as this will also stop propagation.
*/
public function setDataStructure(array|string $dataStructure): void
{
$this->dataStructure = $dataStructure;
}
public function getIdentifier(): array
{
return $this->identifier;
}
public function isPropagationStopped(): bool
{
return isset($this->dataStructure);
}
}
@@ -0,0 +1,37 @@
<?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\Core\Configuration\Event;
/**
* Event before $tca which later becomes $GLOBALS['TCA'] is overridden by TCA/Overrides.
* Allows to manipulate $tca, before overrides are merged.
*/
final class BeforeTcaOverridesEvent
{
public function __construct(private array $tca) {}
public function getTca(): array
{
return $this->tca;
}
public function setTca(array $tca): void
{
$this->tca = $tca;
}
}
@@ -0,0 +1,48 @@
<?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\Core\Configuration\Event;
/**
* Event fired before a site configuration is written to a yaml file
* allows dynamic modification of the site's configuration before writing.
*/
final class SiteConfigurationBeforeWriteEvent
{
public function __construct(
private readonly string $siteIdentifier,
private array $configuration
) {}
public function getSiteIdentifier(): string
{
return $this->siteIdentifier;
}
public function getConfiguration(): array
{
return $this->configuration;
}
/**
* @param array $configuration overwrite the configuration array of the site
*/
public function setConfiguration(array $configuration): void
{
$this->configuration = $configuration;
}
}
@@ -0,0 +1,28 @@
<?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\Core\Configuration\Event;
/**
* @internal
*/
final readonly class SiteConfigurationChangedEvent
{
public function __construct(
public string $siteIdentifier,
) {}
}
@@ -0,0 +1,48 @@
<?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\Core\Configuration\Event;
/**
* Event after a site configuration has been read from a yaml file
* before it is cached - allows dynamic modification of the site's configuration.
*/
final class SiteConfigurationLoadedEvent
{
public function __construct(
private readonly string $siteIdentifier,
private array $configuration
) {}
public function getSiteIdentifier(): string
{
return $this->siteIdentifier;
}
public function getConfiguration(): array
{
return $this->configuration;
}
/**
* @param array $configuration overwrite the configuration array of the site
*/
public function setConfiguration(array $configuration): void
{
$this->configuration = $configuration;
}
}
@@ -0,0 +1,24 @@
<?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\Core\Configuration\Exception;
use TYPO3\CMS\Core\Exception;
/**
* An exception thrown if ExtensionConfiguration->get() is called for
* an extension that has no configuration.
*/
class ExtensionConfigurationExtensionNotConfiguredException extends Exception {}
@@ -0,0 +1,24 @@
<?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\Core\Configuration\Exception;
use TYPO3\CMS\Core\Exception;
/**
* An exception thrown if ExtensionConfiguration->get() is called with
* a path that does not exist within the extension configuration.
*/
class ExtensionConfigurationPathDoesNotExistException extends Exception {}
@@ -0,0 +1,25 @@
<?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\Core\Configuration\Exception;
/**
* Thrown when settings.php cannot be written.
*
* @internal
*/
class SettingsWriteException extends \RuntimeException {}
@@ -0,0 +1,27 @@
<?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\Core\Configuration\Exception;
use TYPO3\CMS\Core\Exception;
/**
* Thrown when a site configuration can not be written.
*
* @internal
*/
class SiteConfigurationWriteException extends Exception {}
@@ -0,0 +1,112 @@
<?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\Core\Configuration\Extension;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend;
use TYPO3\CMS\Core\Package\Cache\PackageDependentCacheIdentifier;
use TYPO3\CMS\Core\Package\PackageManager;
/**
* @internal Bootstrap related ext_localconf loading. Extensions must not use this.
*/
#[Autoconfigure(public: true)]
final readonly class ExtLocalconfFactory
{
public function __construct(
private PackageManager $packageManager,
#[Autowire(service: 'cache.core')]
private PhpFrontend $codeCache,
) {}
/**
* Execute all extension "ext_localconf.php" files of loaded extensions.
* Cache to a single file and use if exists.
*/
public function load(): void
{
$cacheIdentifier = $this->getExtLocalconfCacheIdentifier();
$hasCache = $this->codeCache->require($cacheIdentifier) !== false;
if (!$hasCache) {
$this->loadSingleExtLocalconfFiles();
$this->createCacheEntry();
}
}
public function loadUncached(): void
{
$this->loadSingleExtLocalconfFiles();
}
/**
* Create cache entry for concatenated ext_localconf.php files
*/
public function createCacheEntry(): void
{
$phpCodeToCache = [];
// Set same globals as in loadSingleExtLocalconfFiles()
$phpCodeToCache[] = '/**';
$phpCodeToCache[] = ' * Compiled ext_localconf.php cache file';
$phpCodeToCache[] = ' */';
// Iterate through loaded extensions and add ext_localconf content
foreach ($this->packageManager->getActivePackages() as $package) {
$extensionKey = $package->getPackageKey();
$extLocalconfPath = $package->getPackagePath() . 'ext_localconf.php';
if (@file_exists($extLocalconfPath)) {
// Include a header per extension to make the cache file more readable
$phpCodeToCache[] = '/**';
$phpCodeToCache[] = ' * Extension: ' . $extensionKey;
$phpCodeToCache[] = ' * File: ' . $extLocalconfPath;
$phpCodeToCache[] = ' */';
// Add ext_localconf.php content of extension
$phpCodeToCache[] = 'namespace {';
$phpCodeToCache[] = trim((string)file_get_contents($extLocalconfPath));
$phpCodeToCache[] = '}';
$phpCodeToCache[] = '';
$phpCodeToCache[] = '';
}
}
$phpCodeToCache = implode(LF, $phpCodeToCache);
// Remove all start and ending php tags from content, and remove strict_types=1 declaration.
$phpCodeToCache = preg_replace('/<\\?php|\\?>/is', '', $phpCodeToCache);
$phpCodeToCache = preg_replace('/declare\\s?+\\(\\s?+strict_types\\s?+=\\s?+1\\s?+\\);/is', '', (string)$phpCodeToCache);
$this->codeCache->set($this->getExtLocalconfCacheIdentifier(), $phpCodeToCache);
}
/**
* Require ext_localconf.php files from extensions
*/
private function loadSingleExtLocalconfFiles(): void
{
foreach ($this->packageManager->getActivePackages() as $package) {
$extLocalconfPath = $package->getPackagePath() . 'ext_localconf.php';
if (file_exists($extLocalconfPath)) {
require $extLocalconfPath;
}
}
}
/**
* Cache identifier of concatenated ext_localconf file
*/
private function getExtLocalconfCacheIdentifier(): string
{
return (new PackageDependentCacheIdentifier($this->packageManager))->withPrefix('ext_localconf')->toString();
}
}
@@ -0,0 +1,279 @@
<?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\Core\Configuration;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
use TYPO3\CMS\Core\Configuration\Exception\ExtensionConfigurationExtensionNotConfiguredException;
use TYPO3\CMS\Core\Configuration\Exception\ExtensionConfigurationPathDoesNotExistException;
use TYPO3\CMS\Core\EventDispatcher\NoopEventDispatcher;
use TYPO3\CMS\Core\Package\PackageManager;
use TYPO3\CMS\Core\TypoScript\AST\AstBuilder;
use TYPO3\CMS\Core\TypoScript\TypoScriptStringFactory;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* API to get() instance specific extension configuration options.
*
* Extension authors are encouraged to use this API - it is currently a simple
* wrapper to access TYPO3_CONF_VARS['EXTENSIONS'] but could later become something
* different in case core decides to store extension configuration elsewhere.
*
* Extension authors must not access TYPO3_CONF_VARS['EXTENSIONS'] on their own.
*
* Extension configurations are often 'feature flags' currently defined by
* ext_conf_template.txt files. The core (more specifically the install tool)
* takes care default values and overridden values are properly prepared upon
* loading or updating an extension.
*
* Note only ->get() is official API and other public methods are low level
* core internal API that is usually only used by extension manager and install tool.
*/
#[AsAlias('extension-configuration', public: true)]
readonly class ExtensionConfiguration
{
/**
* Get a single configuration value, a sub array or the whole configuration.
*
* Examples:
* // Simple and typical usage: Get a single config value, or an array if the key is a "TypoScript"
* // a-like sub-path in ext_conf_template.txt "foo.bar = defaultValue"
* ->get('myExtension', 'aConfigKey');
*
* // Get all current configuration values, always an array
* ->get('myExtension');
*
* // Get a nested config value if the path is a "TypoScript" a-like sub-path
* // in ext_conf_template.txt "topLevelKey.subLevelKey = defaultValue"
* ->get('myExtension', 'topLevelKey/subLevelKey')
*
* Notes:
* - If a configuration or configuration path of an extension is not found, the
* code tries to synchronize configuration with ext_conf_template.txt first, only
* if still not found, it will throw exceptions.
* - Return values are NOT type safe: A boolean false could be returned as string 0.
* Cast accordingly.
* - This API throws exceptions if the path does not exist or the extension
* configuration is not available. The install tool takes care any new
* ext_conf_template.txt values are available TYPO3_CONF_VARS['EXTENSIONS'],
* a thrown exception indicates a programming error on developer side
* and should not be caught.
* - It is not checked if the extension in question is loaded at all,
* it's just checked the extension configuration path exists.
* - Extensions should typically not get configuration of a different extension.
*
* @param string $extension Extension name
* @param string $path Configuration path - e.g. "featureCategory/coolThingIsEnabled"
* @return mixed The value. Can be a sub array or a single value.
* @throws ExtensionConfigurationExtensionNotConfiguredException If the extension configuration does not exist
* @throws ExtensionConfigurationPathDoesNotExistException If a requested path in the extension configuration does not exist
*/
public function get(string $extension, string $path = ''): mixed
{
$hasBeenSynchronized = false;
if (!$this->hasConfiguration($extension)) {
// This if() should not be hit at "casual" runtime, but only in early setup phases
$this->synchronizeExtConfTemplateWithLocalConfigurationOfAllExtensions(true);
$hasBeenSynchronized = true;
if (!$this->hasConfiguration($extension)) {
// If there is still no such entry, even after sync -> throw
throw new ExtensionConfigurationExtensionNotConfiguredException(
'No extension configuration for extension ' . $extension . ' found. Either this extension'
. ' has no extension configuration or the configuration is not up to date. Execute the'
. ' install tool to update configuration.',
1509654728
);
}
}
if (empty($path)) {
return $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension];
}
if (!ArrayUtility::isValidPath($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'], $extension . '/' . $path)) {
// This if() should not be hit at "casual" runtime, but only in early setup phases
if (!$hasBeenSynchronized) {
$this->synchronizeExtConfTemplateWithLocalConfigurationOfAllExtensions(true);
}
// If there is still no such entry, even after sync -> throw
if (!ArrayUtility::isValidPath($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'], $extension . '/' . $path)) {
throw new ExtensionConfigurationPathDoesNotExistException(
'Path ' . $path . ' does not exist in extension configuration',
1509977699
);
}
}
return ArrayUtility::getValueByPath($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'], $extension . '/' . $path);
}
/**
* Store a new or overwrite an existing configuration value.
*
* This is typically used by core internal low level tasks like the install
* tool but may become handy if an extension needs to update extension configuration
* on the fly for whatever reason.
*
* Examples:
* // Set a full extension configuration ($value could be a nested array, too)
* ->set('myExtension', ['aFeature' => 'true', 'aCustomClass' => 'css-foo'])
*
* // Unset a whole extension configuration
* ->set('myExtension')
*
* Notes:
* - Do NOT call this at arbitrary places during runtime (eg. NOT in ext_localconf.php or
* similar). ->set() is not supposed to be called each request since it writes LocalConfiguration
* each time. This API is however OK to be called from extension manager hooks.
* - Values are not type safe, if the install tool wrote them,
* boolean true could become string 1 on ->get()
* - It is not possible to store 'null' as value, giving $value=null
* or no value at all will unset the path
* - Setting a value and calling ->get() afterwards will still return the new value.
* - Warning on system/additional.php: If this file overwrites settings, it spoils the
* ->set() call and values may not end up as expected.
*
* @param string $extension Extension name
* @param mixed|null $value The value. If null, unset the path
* @internal
*/
public function set(string $extension, mixed $value = null): void
{
if (empty($extension)) {
throw new \RuntimeException('extension name must not be empty', 1509715852);
}
$configurationManager = GeneralUtility::makeInstance(ConfigurationManager::class);
if ($value === null) {
// Remove whole extension config
$configurationManager->removeLocalConfigurationKeysByPath(['EXTENSIONS/' . $extension]);
if (isset($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension])) {
unset($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension]);
}
} else {
// Set full extension config
$configurationManager->setLocalConfigurationValueByPath('EXTENSIONS/' . $extension, $value);
$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension] = $value;
}
}
/**
* Set new configuration of all extensions and reload TYPO3_CONF_VARS.
* This is a "do all" variant of set() for all extensions that prevents
* writing and loading system/settings.php many times.
*
* @param array $configuration Configuration of all extensions
* @internal
*/
public function setAll(array $configuration, bool $skipWriteIfLocalConfigurationDoesNotExist = false): void
{
$configurationManager = GeneralUtility::makeInstance(ConfigurationManager::class);
if ($skipWriteIfLocalConfigurationDoesNotExist === false || @file_exists($configurationManager->getSystemConfigurationFileLocation())) {
$configurationManager->setLocalConfigurationValueByPath('EXTENSIONS', $configuration);
}
$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'] = $configuration;
}
/**
* If there are new config settings in ext_conf_template of an extension,
* they are found here and synchronized to LocalConfiguration['EXTENSIONS'].
*
* Used when entering the install tool, during installation and if calling ->get()
* with an extension or path that is not yet found in LocalConfiguration
*
* @internal
*/
public function synchronizeExtConfTemplateWithLocalConfigurationOfAllExtensions(bool $skipWriteIfLocalConfigurationDoesNotExist = false): void
{
$activePackages = GeneralUtility::makeInstance(PackageManager::class)->getActivePackages();
$fullConfiguration = [];
$currentLocalConfiguration = $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'] ?? [];
foreach ($activePackages as $package) {
if (!@is_file($package->getPackagePath() . 'ext_conf_template.txt')) {
continue;
}
$extensionKey = $package->getPackageKey();
$currentExtensionConfig = $currentLocalConfiguration[$extensionKey] ?? [];
$extConfTemplateConfiguration = $this->getExtConfTablesWithoutCommentsAsNestedArrayWithoutDots($extensionKey);
ArrayUtility::mergeRecursiveWithOverrule($extConfTemplateConfiguration, $currentExtensionConfig);
if (!empty($extConfTemplateConfiguration)) {
$fullConfiguration[$extensionKey] = $extConfTemplateConfiguration;
}
}
// Write new config if changed. Loose array comparison to not write if only array key order is different
if ($fullConfiguration != $currentLocalConfiguration) {
$this->setAll($fullConfiguration, $skipWriteIfLocalConfigurationDoesNotExist);
}
}
/**
* Read values from ext_conf_template, verify if they are in LocalConfiguration.php
* already and if not, add them.
*
* Used public by extension manager when updating extension
*
* @internal
*/
public function synchronizeExtConfTemplateWithLocalConfiguration(string $extensionKey): void
{
$package = GeneralUtility::makeInstance(PackageManager::class)->getPackage($extensionKey);
if (!@is_file($package->getPackagePath() . 'ext_conf_template.txt')) {
return;
}
$currentLocalConfiguration = $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extensionKey] ?? [];
$extConfTemplateConfiguration = $this->getExtConfTablesWithoutCommentsAsNestedArrayWithoutDots($extensionKey);
ArrayUtility::mergeRecursiveWithOverrule($extConfTemplateConfiguration, $currentLocalConfiguration);
// Write new config if changed. Loose array comparison to not write if only array key order is different
if ($extConfTemplateConfiguration != $currentLocalConfiguration) {
$this->set($extensionKey, $extConfTemplateConfiguration);
}
}
/**
* Helper method of ext_conf_template.txt parsing.
*
* Poor man version of getDefaultConfigurationFromExtConfTemplateAsValuedArray() which ignores
* comments and returns ext_conf_template as array where nested keys have no dots.
*/
protected function getExtConfTablesWithoutCommentsAsNestedArrayWithoutDots(string $extensionKey): array
{
$rawConfigurationString = $this->getDefaultConfigurationRawString($extensionKey);
$typoScriptStringFactory = GeneralUtility::makeInstance(TypoScriptStringFactory::class);
$typoScriptTree = $typoScriptStringFactory->parseFromString($rawConfigurationString, new AstBuilder(new NoopEventDispatcher()));
return GeneralUtility::removeDotsFromTS($typoScriptTree->toArray());
}
/**
* Helper method of ext_conf_template.txt parsing.
*
* Return content of an extensions' ext_conf_template.txt file if
* the file exists, empty string if file does not exist.
*/
protected function getDefaultConfigurationRawString(string $extensionKey): string
{
$rawString = '';
$extConfTemplateFileLocation = GeneralUtility::getFileAbsFileName(
'EXT:' . $extensionKey . '/ext_conf_template.txt'
);
if (file_exists($extConfTemplateFileLocation)) {
$rawString = (string)file_get_contents($extConfTemplateFileLocation);
}
return $rawString;
}
protected function hasConfiguration(string $extension): bool
{
return isset($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension]) && is_array($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'][$extension]);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?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\Core\Configuration;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
/**
* A lightweight API class to check if a feature is enabled.
*
* Features are simple options (true/false), and are stored in the
* global configuration array $TYPO3_CONF_VARS[SYS][features].
*
* For disabling or enabling a feature the "ConfigurationManager"
* should be used.
*
* -- Naming --
*
* Feature names should NEVER be named "enable" or have a negation, or contain versions or years
* "enableFeatureXyz"
* "disableOverlays"
* "schedulerRevamped"
* "useDoctrineQueries"
* "disablePreparedStatements"
* "disableHooksInFE"
*
* Proper namings for features
* "ExtendedRichtextFormat"
* "NativeYamlParser"
* "InlinePageTranslations"
* "TypoScriptParserIncludesAsXml"
* "NativeDoctrineQueries"
*
* Ideally, these feature switches are added via the Install Tool or via FactoryConfiguration
* and can be used for Extensions as well.
*
* --- Usage ---
*
* if (GeneralUtility::makeInstance(Features::class)->isFeatureEnabled('InlineSvg')) {
* ... do stuff here ...
* }
*/
#[AsAlias('features', public: true)]
readonly class Features
{
/**
* A list of features that are always activated (mainly happens if a previous feature switch is now always
* "turned on" to enforce a behaviour, but still valid for extension authors to ensure the feature switch
* returns "enabled" for future versions.
*/
private const array ALWAYS_ACTIVE_FEATURES = [
// Enabled since v15.0 at any time.
'extbase.consistentDateTimeHandling',
// Enabled since v13.0 at any time.
'security.usePasswordPolicyForFrontendUsers',
'security.backend.enforceContentSecurityPolicy',
// Enabled since v12.0 at any time.
'subrequestPageErrors',
'yamlImportsFollowDeclarationOrder',
'security.frontend.htmlSanitizeParseFuncDefault',
'runtimeDbQuotingOfTcaConfiguration',
// Enabled since v11.0 at any time.
'fluidBasedPageModule',
// Enabled since v10.0 at any time.
'simplifiedControllerActionDispatching',
'unifiedPageTranslationHandling',
'felogin.extbase',
];
/**
* Checks if a feature is active
*
* @param string $featureName the name of the feature
*/
public function isFeatureEnabled(string $featureName): bool
{
if (in_array($featureName, self::ALWAYS_ACTIVE_FEATURES, true)) {
return true;
}
return isset($GLOBALS['TYPO3_CONF_VARS']['SYS']['features'][$featureName])
&& $GLOBALS['TYPO3_CONF_VARS']['SYS']['features'][$featureName] === true;
}
}
@@ -0,0 +1,25 @@
<?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\Core\Configuration\FlexForm\Exception;
use TYPO3\CMS\Core\Exception;
/**
* Abstract exception thrown if data structures can not be resolved, found or parsed.
*/
abstract class AbstractInvalidDataStructureException extends Exception {}
@@ -0,0 +1,23 @@
<?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\Core\Configuration\FlexForm\Exception;
/**
* Thrown if parsed data structure contains invalid config like references in sections
*/
class InvalidDataStructureException extends AbstractInvalidDataStructureException {}
@@ -0,0 +1,23 @@
<?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\Core\Configuration\FlexForm\Exception;
/**
* Thrown if parseFlexFormDataStructureByIdentifier() is given an empty string
*/
class InvalidIdentifierException extends AbstractInvalidDataStructureException {}
@@ -0,0 +1,25 @@
<?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\Core\Configuration\FlexForm\Exception;
/**
* Thrown if TCA is invalid.
* This may happen if a record is opened that points to a data structure of
* a no longer loaded extension
*/
class InvalidTcaException extends AbstractInvalidDataStructureException {}
@@ -0,0 +1,23 @@
<?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\Core\Configuration\FlexForm\Exception;
/**
* Thrown if data structure can not be determined due to missing TCA schema
*/
class InvalidTcaSchemaException extends AbstractInvalidDataStructureException {}
@@ -0,0 +1,891 @@
<?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\Core\Configuration\FlexForm;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Configuration\Event\AfterFlexFormDataStructureIdentifierInitializedEvent;
use TYPO3\CMS\Core\Configuration\Event\AfterFlexFormDataStructureParsedEvent;
use TYPO3\CMS\Core\Configuration\Event\AfterTcaCompilationEvent;
use TYPO3\CMS\Core\Configuration\Event\BeforeFlexFormDataStructureIdentifierInitializedEvent;
use TYPO3\CMS\Core\Configuration\Event\BeforeFlexFormDataStructureParsedEvent;
use TYPO3\CMS\Core\Configuration\Event\BeforeTcaOverridesEvent;
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidDataStructureException;
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidIdentifierException;
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidTcaException;
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidTcaSchemaException;
use TYPO3\CMS\Core\Configuration\Tca\TcaMigration;
use TYPO3\CMS\Core\Configuration\Tca\TcaPreparation;
use TYPO3\CMS\Core\Schema\Field\FlexFormFieldType;
use TYPO3\CMS\Core\Schema\TcaSchema;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Unified service class for TCA type="flex" operations.
*
* This service provides comprehensive FlexForm handling capabilities that work with both:
* - TCA Schema objects (high-level, schema-aware operations)
* - Raw TCA configuration arrays (low-level operations during schema building)
*
* Note: Using the raw TCA configuration is not recommended and only available to support
* FlexFormTools during schema building. For extensions this might be the case on using
* {@see BeforeTcaOverridesEvent} or {@see AfterTcaCompilationEvent}.
*
* Usage examples:
* ```php
* // With TCA Schema (typical application usage)
* $flexFormTools->getDataStructureIdentifier($fieldTca, $table, $field, $row, $tcaSchema);
*
* // With raw TCA array (only during schema)
* $flexFormTools->getDataStructureIdentifier($fieldTca, $table, $field, $row, $rawTcaArray);
* ```
*
* The service automatically detects the input type and uses the appropriate resolution strategy.
*/
#[Autoconfigure(public: true)]
readonly class FlexFormTools
{
public function __construct(
private EventDispatcherInterface $eventDispatcher,
private TcaMigration $tcaMigration,
private TcaPreparation $tcaPreparation,
) {}
/**
* The method locates a specific data structure from given TCA and row combination
* and returns an identifier string that can be handed around, and can be resolved
* to a single data structure later without giving $row and $tca data again.
*
* Note: The returned syntax is meant to only specify the target location of the data structure.
* It SHOULD NOT be abused and enriched with data from the record that is dealt with. For
* instance, it is not allowed to add source record specific date like the "uid" or the "pid"!
* If that is done, it is up to the hook consumer to take care of possible side effects, e.g. if
* the DataHandler copies or moves records around and those references change.
*
* This method gets: Source data that influences the target location of a data structure
* This method returns: Target specification of the data structure
*
* This method is "paired" with method parseDataStructureByIdentifier() that
* will resolve the returned syntax again and returns the data structure itself.
*
* Both methods can be extended via events to return and accept additional
* identifier strings if needed, and to transmit further information within the identifier strings.
*
* Important: The TCA for data structure definitions MUST be overridden by 'columnsOverrides'
* as the "ds" config is a string, containing the data structure or a file pointer.
*
* Note: This method and the resolving methods below are well unit tested and document all
* nasty details this way.
*
* @param array $fieldTca Full TCA of the field in question that has type=flex set
* @param string $tableName The table name of the TCA field
* @param string $fieldName The field name
* @param array $row The data row
* @param array|TcaSchema|null $schema Either be the Tca Schema object or raw TCA configuration. Only omit in
* case handling is done via events. Otherwise, this will throw an exception
* on resolving the default identifier {@see InvalidTcaSchemaException}.
* Using the raw TCA configuration is furthermore not recommended and only
* available to support FlexFormTools during schema building. For extensions
* this might be the case on using {@see BeforeTcaOverridesEvent} or
* {@see AfterTcaCompilationEvent}.
*
* @return string Identifier JSON string
* @throws \RuntimeException If TCA is misconfigured
* @throws InvalidTcaException
*/
public function getDataStructureIdentifier(array $fieldTca, string $tableName, string $fieldName, array $row, array|TcaSchema|null $schema = null): string
{
$dataStructureIdentifier = $this->eventDispatcher
->dispatch(new BeforeFlexFormDataStructureIdentifierInitializedEvent($fieldTca, $tableName, $fieldName, $row))
->getIdentifier() ?? $this->getDefaultDataStructureIdentifier($tableName, $fieldName, $row, $schema);
$dataStructureIdentifier = $this->eventDispatcher
->dispatch(new AfterFlexFormDataStructureIdentifierInitializedEvent($fieldTca, $tableName, $fieldName, $row, $dataStructureIdentifier))
->getIdentifier();
return json_encode($dataStructureIdentifier, JSON_THROW_ON_ERROR);
}
/**
* Parse a data structure identified by $identifier to the final data structure array.
* This method is called after getDataStructureIdentifier(), finds the data structure
* and returns it.
*
* Events allow to manipulate the find logic and to post process the data structure array.
*
* Important: The TCA for data structure definitions MUST be overridden by 'columnsOverrides'
* as the "ds" config is a string, containing the data structure or a file pointer.
*
* After the data structure definition is found, the method resolves:
* - FILE:EXT: prefix of the data structure itself - the ds is in a file
* - FILE:EXT: prefix for sheets - if single sheets are in files
* - Create a sDEF sheet if the data structure has non, yet.
* - TCA Migration and Preparation is done for the resolved fields
*
* After that method is run, the data structure is fully resolved to an array,
* and same base normalization is done: If the ds did not contain a sheet,
* it will have one afterward as "sDEF".
*
* This method gets: Target specification of the data structure.
* This method returns: The normalized data structure parsed to an array.
*
* @param string $identifier JSON string to find the data structure location
* @param array|TcaSchema|null $schema Either be the Tca Schema object or raw TCA configuration. Only omit in
* case handling is done via events. Otherwise, this will throw an exception
* on resolving the default identifier {@see InvalidTcaSchemaException}.
* Using the raw TCA configuration is furthermore not recommended and only
* available to support FlexFormTools during schema building. For extensions
* this might be the case on using {@see BeforeTcaOverridesEvent} or
* {@see AfterTcaCompilationEvent}.
*
* @return array Parsed and normalized data structure
* @throws InvalidIdentifierException
* @throws InvalidTcaSchemaException
* @throws InvalidDataStructureException
*/
public function parseDataStructureByIdentifier(string $identifier, array|TcaSchema|null $schema = null): array
{
// Throw an exception for an empty string. This might be a valid use case for new
// records in some situations, so this is catchable to give callers a chance to deal with that.
if ($identifier === '') {
throw new InvalidIdentifierException(
'Empty string given to parseDataStructureByIdentifier(). This exception might '
. ' be caught to handle some new record situations properly',
1478100828
);
}
$parsedIdentifier = json_decode($identifier, true);
if (!is_array($parsedIdentifier) || $parsedIdentifier === []) {
// If there is some identifier and it can't be decoded, programming error -> not catchable
throw new \RuntimeException(
'Identifier could not be decoded to an array.',
1478345642
);
}
$dataStructure = $this->eventDispatcher
->dispatch(new BeforeFlexFormDataStructureParsedEvent($parsedIdentifier))
->getDataStructure() ?? $this->getDefaultStructureForIdentifier($parsedIdentifier, $schema);
$dataStructure = $this->convertDataStructureToArray($dataStructure);
$dataStructure = $this->ensureDefaultSheet($dataStructure);
$dataStructure = $this->resolveFileDirectives($dataStructure);
$dataStructure = $this->checkMigratePrepareFlexTca($dataStructure);
return $this->eventDispatcher
->dispatch(new AfterFlexFormDataStructureParsedEvent($dataStructure, $parsedIdentifier))
->getDataStructure();
}
/**
* Clean up FlexForm value XML to hold only the values it may according to its Data Structure.
* The order of tags will follow that of the data structure.
*
* @param array|TcaSchema $schema Main schema only, no sub schema! Using the raw TCA configuration is
* furthermore not recommended and only available to support FlexFormTools
* during schema building. For extensions this might be the case on
* using {@see BeforeTcaOverridesEvent} or {@see AfterTcaCompilationEvent}.
*
* @internal Signature may change, for instance to split 'DS finding' and flexArray2Xml(),
* which would allow broader use of the method. It is currently consumed by
* cleanup:flexforms CLI only.
*/
public function cleanFlexFormXML(string $table, string $field, array $row, array|TcaSchema $schema): string
{
if ((is_array($schema) && !isset($schema['columns'][$field]['config'])) || ($schema instanceof TcaSchema && !$schema->hasField($field)) || !isset($row[$field])) {
throw new \RuntimeException('Can not clean up FlexForm XML for a column not declared in TCA or not in record.', 1697554398);
}
try {
$fieldTca = is_array($schema) ? ['config' => $schema['columns'][$field]['config']] : ['config' => $schema->getField($field)->getConfiguration()];
$dataStructureArray = $this->parseDataStructureByIdentifier($this->getDataStructureIdentifier($fieldTca, $table, $field, $row, $schema), $schema);
} catch (InvalidIdentifierException) {
// Data structure can not be resolved or parsed. Reset value to empty string.
return '';
}
$valueArray = GeneralUtility::xml2array($row[$field]);
if (!is_array($valueArray)) {
// Current flex form values can not be parsed to an array. The entire thing is invalid. Reset to empty string.
return '';
}
if (!is_array($dataStructureArray['sheets'] ?? false)) {
// We might return empty string instead of throwing here, unsure.
throw new \RuntimeException('Data structure should always declare at least one sheet', 1697555523);
}
$newValueArray = [];
foreach ($dataStructureArray['sheets'] as $sheetKey => $sheetData) {
foreach (($sheetData['ROOT']['el'] ?? []) as $sheetElementKey => $sheetElementData) {
// For all elements allowed in Data Structure.
if (($sheetElementData['type'] ?? '') === 'array') {
// This is a section.
if (!is_array($sheetElementData['el'] ?? false) || !is_array($valueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['el'] ?? false)) {
// No possible containers defined for this section in DS, or no values set for this section.
continue;
}
foreach ($valueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['el'] as $valueSectionContainerKey => $valueSectionContainers) {
// We have containers for this section in values.
if (!is_array($valueSectionContainers ?? false)) {
// Values don't validate to an array, skip.
continue;
}
foreach ($valueSectionContainers as $valueContainerType => $valueContainerElements) {
// For all value containers in this section.
if (!is_array($sheetElementData['el'][$valueContainerType]['el'] ?? false)) {
// There is no DS for this container type, skip.
continue;
}
foreach (array_keys($sheetElementData['el'][$valueContainerType]['el']) as $containerElement) {
// Container type of this value container exists in DS. Iterate DS container to pick allowed single elements.
if (isset($valueContainerElements['el'][$containerElement]['vDEF'])) {
$newValueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey][$valueContainerType]['el'][$containerElement]['vDEF']
= $valueContainerElements['el'][$containerElement]['vDEF'];
}
}
}
if (isset($valueSectionContainers['_TOGGLE'])) {
// This was removed in TYPO3 v13, see #102551
unset($newValueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['el'][$valueSectionContainerKey]['_TOGGLE']);
}
}
} elseif (isset($valueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['vDEF'])) {
// Not a section but a simple field. Keep value if set.
$newValueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['vDEF'] = $valueArray['data'][$sheetKey]['lDEF'][$sheetElementKey]['vDEF'];
}
}
}
return $this->flexArray2Xml($newValueArray);
}
/**
* Convert FlexForm data array to XML
*
* @internal
*/
public function flexArray2Xml(array $array): string
{
// Map the weird keys from the internal array to tags and attributes.
$options = [
'parentTagMap' => [
'data' => 'sheet',
'sheet' => 'language',
'language' => 'field',
'el' => 'field',
'field' => 'value',
'field:el' => 'el',
'el:_IS_NUM' => 'section',
'section' => 'itemType',
],
'disableTypeAttrib' => 2,
];
return '<?xml version="1.0" encoding="utf-8" standalone="yes" ?>' . LF
. GeneralUtility::array2xml($array, '', 0, 'T3FlexForms', 4, $options);
}
/**
* Parses the flexForm XML string and converts it to an array.
* The resulting array will be multidimensional, as a value "bla.blubb"
* results in two levels, and a value "bla.blubb.bla" results in three levels.
*/
public function convertFlexFormContentToArray(string $flexFormContent): array
{
$settings = [];
$flexFormArray = GeneralUtility::xml2array($flexFormContent);
$flexFormArray = $flexFormArray['data'] ?? [];
foreach ($flexFormArray as $languages) {
if (!is_array($languages['lDEF'] ?? false)) {
continue;
}
foreach ($languages['lDEF'] as $valueKey => $valueDefinition) {
if (!str_contains($valueKey, '.')) {
$settings[$valueKey] = $this->walkFlexFormNode($valueDefinition);
} else {
$valueKeyParts = explode('.', $valueKey);
$currentNode = &$settings;
foreach ($valueKeyParts as $valueKeyPart) {
$currentNode = &$currentNode[$valueKeyPart];
}
if (is_array($valueDefinition)) {
if (array_key_exists('vDEF', $valueDefinition)) {
$currentNode = $valueDefinition['vDEF'];
} else {
$currentNode = $this->walkFlexFormNode($valueDefinition);
}
} else {
$currentNode = $valueDefinition;
}
}
}
}
return $settings;
}
/**
* Parses the flexForm XML string and converts it to an array.
* The resulting array will be multidimensional. Sheets are
* respected to support property paths in multiple sheets.
*
* A value such as "settings.pageId" results in three levels:
* "'sDEF' => ['settings' => ['pageId' => 123]]" and a value such
* as "settings.storages.newsPid" results in four levels:
* "'sDEF' => ['settings' => ['storages' => ['newsPid' => 123]]]"
*
* @param string $flexFormContent flexForm xml string
*/
public function convertFlexFormContentToSheetsArray(string $flexFormContent): array
{
$settings = [];
$flexFormArray = GeneralUtility::xml2array($flexFormContent);
$flexFormArray = $flexFormArray['data'] ?? [];
foreach ($flexFormArray as $sheetName => $sheet) {
foreach ($sheet as $language => $fields) {
if ($language !== 'lDEF') {
continue;
}
foreach ($fields as $valueKey => $valueDefinition) {
if (!str_contains($valueKey, '.')) {
$settings[$sheetName][$valueKey] = $this->walkFlexFormNode($valueDefinition);
} else {
$valueKeyParts = explode('.', $valueKey);
$currentNode = &$settings[$sheetName];
foreach ($valueKeyParts as $valueKeyPart) {
$currentNode = &$currentNode[$valueKeyPart];
}
if (is_array($valueDefinition)) {
if (array_key_exists('vDEF', $valueDefinition)) {
$currentNode = $valueDefinition['vDEF'];
} else {
$currentNode = $this->walkFlexFormNode($valueDefinition);
}
} else {
$currentNode = $valueDefinition;
}
}
}
}
}
return $settings;
}
/**
* Finds data structure in TCA, defined in column config 'ds'
*
* fieldTca = [
* 'config' => [
* 'type' => 'flex',
* 'ds' => '<T3DataStructure>...' OR 'FILE:...',
* ]
* ]
*
* This method returns an array of the form:
* [
* 'type' => 'tca',
* 'tableName' => $tableName,
* 'fieldName' => $fieldName,
* 'dataStructureKey' => $key,
* ];
*
* Example:
* [
* 'type' => 'tca',
* 'tableName' => 'tt_content',
* 'fieldName' => 'pi_flexform',
* 'dataStructureKey' => 'default',
* ];
*
* In case the TCA table supports record types and the given $row uses a record type with a custom
* data structure (via columnsOverrides) the record type is used as "dataStructureKey".
*
* Example:
* [
* 'type' => 'tca',
* 'tableName' => 'tt_content',
* 'fieldName' => 'pi_flexform',
* 'dataStructureKey' => 'powermail_pi1',
* ];
*
* @return array Identifier as array, see example above
* @throws InvalidTcaException
* @throws InvalidTcaSchemaException
*/
protected function getDefaultDataStructureIdentifier(string $tableName, string $fieldName, array $row, array|TcaSchema|null $schema = null): array
{
if ($schema === null) {
throw new InvalidTcaSchemaException('Can not resolve default data structure without TCA.', 1753182123);
}
$defaultIdentifier = [
'type' => 'tca',
'tableName' => $tableName,
'fieldName' => $fieldName,
'dataStructureKey' => null,
];
return is_array($schema)
? $this->getDataStructureIdentifierFromRawTca($schema, $tableName, $fieldName, $row, $defaultIdentifier)
: $this->getDataStructureIdentifierFromTcaSchema($schema, $tableName, $fieldName, $row, $defaultIdentifier);
}
/**
* Finds and returns the data structure from TCA - defined in column config 'ds'
*
* fieldTca = [
* 'config' => [
* 'type' => 'flex',
* 'ds' => '<T3DataStructure>...' OR 'FILE:...',
* ]
* ]
*
* Based on an identifier, e.g.:
* [
* 'type' => 'tca',
* 'tableName' => 'tt_content',
* 'fieldName' => 'pi_flexform',
* 'dataStructureKey' => 'default',
* ];
*
* this method returns '<T3DataStructure>...' OR 'FILE:...'.
*
* In case the TCA table supports record types and the "dataStructureKey" points to a record type,
* which is only the case if a record type defines a custom flex config (via columnsOverrides), this
* custom data structure is returned.
*
* @return string resolved data structure
* @throws InvalidTcaSchemaException
*/
protected function getDefaultStructureForIdentifier(array $identifier, array|TcaSchema|null $schema = null): string
{
// For the default only type "tca" is handled. Custom types need to be handled by corresponding events.
if (($identifier['type'] ?? '') !== 'tca') {
throw new InvalidIdentifierException(
'Identifier ' . json_encode($identifier) . ' could not be resolved',
1478104554
);
}
$tableName = (string)($identifier['tableName'] ?? '');
$fieldName = (string)($identifier['fieldName'] ?? '');
$dataStructureKey = (string)($identifier['dataStructureKey'] ?? '');
if ($tableName === '' || $fieldName === '' || $dataStructureKey === '') {
throw new \RuntimeException(
'Incomplete "tca" based identifier: ' . json_encode($identifier),
1478113471
);
}
if ($schema === null) {
throw new InvalidTcaSchemaException('Can not resolve default data structure without TCA.', 1753182125);
}
$dataStructure = is_array($schema)
? $this->resolveDataStructureFromRawTca($schema, $fieldName, $dataStructureKey)
: $this->resolveDataStructureFromTcaSchema($schema, $tableName, $fieldName, $dataStructureKey);
if ($dataStructure === '') {
throw new InvalidIdentifierException(
'Specified identifier ' . json_encode($identifier) . ' does not resolve to a valid data structure',
1732199538
);
}
return $dataStructure;
}
protected function convertDataStructureToArray(string|array $dataStructure): array
{
if (is_array($dataStructure)) {
return $dataStructure;
}
// Resolve FILE: prefix pointing to a DS in a file
if (str_starts_with(trim($dataStructure), 'FILE:')) {
$fileName = substr(trim($dataStructure), 5);
$file = GeneralUtility::getFileAbsFileName($fileName);
if (empty($file) || !is_file($file)) {
throw new InvalidIdentifierException(
'Data structure file "' . $fileName . '" could not be resolved to an existing file',
1478105826
);
}
$dataStructure = (string)file_get_contents($file);
}
// Parse main structure
$dataStructure = GeneralUtility::xml2array($dataStructure);
// Throw if it still is not an array, probably because GeneralUtility::xml2array() failed.
// This also may happen if artificial identifiers were constructed which don't resolve. The
// flex form "exclude" access rights systems does that -> catchable
if (!is_array($dataStructure)) {
throw new InvalidIdentifierException(
'Parse error: Data structure could not be resolved to a valid structure.',
1478106090
);
}
return $dataStructure;
}
/**
* Ensures a data structure has a default sheet, and no duplicate data
*/
protected function ensureDefaultSheet(array $dataStructure): array
{
if (isset($dataStructure['ROOT']) && isset($dataStructure['sheets'])) {
throw new \RuntimeException(
'Parsed data structure has both ROOT and sheets on top level. That is invalid.',
1440676540
);
}
if (isset($dataStructure['ROOT']) && is_array($dataStructure['ROOT'])) {
$dataStructure['sheets']['sDEF']['ROOT'] = $dataStructure['ROOT'];
unset($dataStructure['ROOT']);
}
return $dataStructure;
}
/**
* Resolve FILE:EXT and EXT: for single sheets
*/
protected function resolveFileDirectives(array $dataStructure): array
{
if (isset($dataStructure['sheets']) && is_array($dataStructure['sheets'])) {
foreach ($dataStructure['sheets'] as $sheetName => $sheetStructure) {
if (!is_array($sheetStructure)) {
if (str_starts_with(trim($sheetStructure), 'FILE:')) {
$file = GeneralUtility::getFileAbsFileName(substr(trim($sheetStructure), 5));
} else {
$file = GeneralUtility::getFileAbsFileName(trim($sheetStructure));
}
if ($file && @is_file($file)) {
$sheetStructure = GeneralUtility::xml2array((string)file_get_contents($file));
}
}
$dataStructure['sheets'][$sheetName] = $sheetStructure;
}
}
return $dataStructure;
}
/**
* Check for invalid flex form structures, migrate and prepare single fields.
* @throws InvalidDataStructureException
*/
private function checkMigratePrepareFlexTca(array $dataStructure): array
{
if (!is_array($dataStructure['sheets'] ?? null)) {
return $dataStructure;
}
$newStructure = $dataStructure;
foreach ($dataStructure['sheets'] as $sheetName => $sheetStructure) {
if (!is_array($sheetStructure['ROOT']['el'])) {
continue;
}
foreach ($sheetStructure['ROOT']['el'] as $sheetElementName => $sheetElementConfig) {
if (!is_array($sheetElementConfig)) {
continue;
}
if (($sheetElementConfig['type'] ?? null) === 'array' xor ($sheetElementConfig['section'] ?? null) === '1') {
// Section element, but type=array without section=1 or vice versa is not ok
throw new InvalidDataStructureException(
'Broken data structure on field name ' . $sheetElementName . '. section without type or vice versa is not allowed',
1440685208
);
}
if (($sheetElementConfig['type'] ?? null) === 'array' && ($sheetElementConfig['section'] ?? null) === '1') {
// Section element
if (!is_array($sheetElementConfig['el'] ?? null)) {
continue;
}
foreach ($sheetElementConfig['el'] as $containerName => $containerConfig) {
if (!is_array($containerConfig['el'] ?? null)) {
continue;
}
foreach ($containerConfig['el'] as $containerElementName => $containerElementConfig) {
if (!is_array($containerElementConfig)) {
continue;
}
if (
// inline, file, group and category are always DB relations
in_array($containerElementConfig['config']['type'] ?? [], ['inline', 'file', 'folder', 'group', 'category'], true)
// MM is not allowed (usually type=select, otherwise the upper check should kick in)
|| isset($containerElementConfig['config']['MM'])
// foreign_table is not allowed (usually type=select, otherwise the upper check should kick in)
|| isset($containerElementConfig['config']['foreign_table'])
) {
// Nesting types that use DB relations in container sections is not supported.
throw new InvalidDataStructureException(
'Invalid flex form data structure on field name "' . $containerElementName . '" with element "' . $sheetElementName . '"'
. ' in section container "' . $containerName . '": Nesting elements that have database relations in flex form'
. ' sections is not allowed.',
1458745468
);
}
if (($containerElementConfig['type'] ?? null) === 'array' && ($containerElementConfig['section'] ?? null) === '1') {
// Nesting sections is not supported. Throw an exception if configured.
throw new InvalidDataStructureException(
'Invalid flex form data structure on field name "' . $containerElementName . '" with element "' . $sheetElementName . '"'
. ' in section container "' . $containerName . '": Nesting sections in container elements'
. ' sections is not allowed.',
1458745712
);
}
$containerElementConfig = $this->migrateFlexField($containerElementName, $containerElementConfig);
$containerElementConfig = $this->prepareFlexField($containerElementName, $containerElementConfig);
$newStructure['sheets'][$sheetName]['ROOT']['el'][$sheetElementName]['el'][$containerName]['el'][$containerElementName] = $containerElementConfig;
}
}
} else {
// Normal element
$sheetElementConfig = $this->migrateFlexField($sheetElementName, $sheetElementConfig);
$sheetElementConfig = $this->prepareFlexField($sheetElementName, $sheetElementConfig);
$newStructure['sheets'][$sheetName]['ROOT']['el'][$sheetElementName] = $sheetElementConfig;
}
}
}
return $newStructure;
}
private function migrateFlexField(string $fieldName, array $fieldConfig): array
{
// TcaMigration of this field. Call the TcaMigration and log any deprecations.
$dummyTca = [
'dummyTable' => [
'columns' => [
$fieldName => $fieldConfig,
],
],
];
$tcaProcessingResult = $this->tcaMigration->migrate($dummyTca);
// Messages are reset on each `migrate()` execution
$messages = $tcaProcessingResult->getMessages();
if (!empty($messages)) {
$context = 'FlexFormTools did an on-the-fly migration of a flex form data structure. This is deprecated and will be removed.'
. ' Merge the following changes into the flex form definition "' . $fieldName . '":';
array_unshift($messages, $context);
trigger_error(implode(LF, $messages), E_USER_DEPRECATED);
}
return $tcaProcessingResult->getTca()['dummyTable']['columns'][$fieldName];
}
private function prepareFlexField(string $fieldName, array $fieldConfig): array
{
$dummyTca = [
'dummyTable' => [
'columns' => [
$fieldName => $fieldConfig,
],
],
];
$preparedTca = $this->tcaPreparation->prepare($dummyTca, true);
return $preparedTca['dummyTable']['columns'][$fieldName];
}
/**
* Resolve data structure identifier from raw TCA configuration.
*/
private function getDataStructureIdentifierFromRawTca(array $schema, string $tableName, string $fieldName, array $row, array $defaultIdentifier): array
{
// Check for record type specific configuration
if (isset($schema['ctrl']['type'])) {
$recordType = $row[$schema['ctrl']['type']] ?? '';
if (isset($schema['types'][$recordType]) && ($fieldConfig = $this->getRecordTypeSpecificFieldConfig($schema, $recordType, $fieldName)) !== []) {
if ($fieldConfig['config']['type'] === 'flex' && $fieldConfig['config']['ds'] !== '') {
$defaultIdentifier['dataStructureKey'] = $recordType;
return $defaultIdentifier;
}
throw new InvalidTcaException(
'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" with record type "' . $recordType . '"'
. ' The field is either not configured as type="flex" or no valid data structure is defined for this record type.',
1751796941
);
}
}
// Fall back to base field configuration
$baseField = $schema['columns'][$fieldName]['config'] ?? [];
if (($baseField['type'] ?? '') === 'flex' && ($baseField['ds'] ?? '') !== '') {
$defaultIdentifier['dataStructureKey'] = 'default';
return $defaultIdentifier;
}
throw new InvalidTcaException(
'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" config section:'
. ' The field is either not configured as type="flex" or no valid data structure is defined.',
1732198005
);
}
/**
* Resolve data structure identifier from TCA Schema.
*/
private function getDataStructureIdentifierFromTcaSchema(TcaSchema $schema, string $tableName, string $fieldName, array $row, array $defaultIdentifier): array
{
if ($schema->getName() !== $tableName) {
throw new InvalidTcaSchemaException('Given Tca Schema does not match table ' . $tableName . ' from data structure identifier.', 1753182124);
}
// Check for record type specific configuration
if ($schema->supportsSubSchema()) {
$recordType = (string)($row[$schema->getSubSchemaTypeInformation()->getFieldName()] ?? '');
if ($recordType !== '' && $schema->hasSubSchema($recordType) && ($subSchema = $schema->getSubSchema($recordType))->hasField($fieldName)) {
$flexField = $subSchema->getField($fieldName);
if ($flexField instanceof FlexFormFieldType && $flexField->getDataStructure() !== '') {
$defaultIdentifier['dataStructureKey'] = $recordType;
return $defaultIdentifier;
}
throw new InvalidTcaException(
'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" with record type "' . $recordType . '"'
. ' The field is either not configured as type="flex" or no valid data structure is defined for this record type.',
1751796940
);
}
}
// Fall back to base field
$baseField = $schema->getField($fieldName);
if ($baseField instanceof FlexFormFieldType && $baseField->getDataStructure() !== '') {
$defaultIdentifier['dataStructureKey'] = 'default';
return $defaultIdentifier;
}
throw new InvalidTcaException(
'TCA misconfiguration in table "' . $tableName . '" field "' . $fieldName . '" config section:'
. ' The field is either not configured as type="flex" or no valid data structure is defined.',
1732198004
);
}
/**
* Resolve data structure from raw TCA configuration.
*/
private function resolveDataStructureFromRawTca(array $schema, string $fieldName, string $dataStructureKey): string
{
// Try record type specific configuration first
if (isset($schema['ctrl']['type'], $schema['types'][$dataStructureKey])
&& ($fieldConfig = $this->getRecordTypeSpecificFieldConfig($schema, $dataStructureKey, $fieldName)) !== []
&& ($fieldConfig['config']['type'] ?? '') === 'flex'
&& is_string($fieldConfig['config']['ds'] ?? false)
) {
return $fieldConfig['config']['ds'];
}
// Fall back to default configuration
if ($dataStructureKey === 'default') {
$baseField = $schema['columns'][$fieldName]['config'] ?? [];
if (($baseField['type'] ?? '') === 'flex' && is_string($baseField['ds'] ?? false)) {
return $baseField['ds'];
}
}
return '';
}
/**
* Resolve data structure from TCA Schema.
*/
private function resolveDataStructureFromTcaSchema(TcaSchema $schema, string $table, string $field, string $dataStructureKey): string
{
if ($schema->getName() !== $table) {
throw new InvalidTcaSchemaException('Given Tca Schema does not match table ' . $table . ' from data structure identifier.', 1753182126);
}
// Try record type specific configuration first
if ($schema->supportsSubSchema()
&& $schema->hasSubSchema($dataStructureKey)
&& ($subSchema = $schema->getSubSchema($dataStructureKey))->hasField($field)
&& ($flexField = $subSchema->getField($field)) instanceof FlexFormFieldType
) {
return $flexField->getDataStructure();
}
// Fall back to default configuration
if ($dataStructureKey === 'default' && ($flexField = $schema->getField($field)) instanceof FlexFormFieldType) {
return $flexField->getDataStructure();
}
return '';
}
/**
* Returns the record type specific configuration, also already taking columnsOverrides into account.
* In case the field is not defined for the record type, no configuration is returned.
*/
protected function getRecordTypeSpecificFieldConfig(array $tcaForTable, string $recordType, string $fieldName): array
{
$recordTypeConfig = $tcaForTable['types'][$recordType];
$showItemArray = GeneralUtility::trimExplode(',', $recordTypeConfig['showitem'] ?? '', true);
foreach ($showItemArray as $aShowItemFieldString) {
[$name, , $paletteName] = GeneralUtility::trimExplode(';', $aShowItemFieldString . ';;;');
if ($name === '--div--') {
continue;
}
if ($name === '--palette--' && !empty($paletteName)) {
if (!isset($tcaForTable['palettes'][$paletteName]['showitem'])) {
continue;
}
$palettesArray = GeneralUtility::trimExplode(',', $tcaForTable['palettes'][$paletteName]['showitem']);
foreach ($palettesArray as $aPalettesString) {
[$name] = GeneralUtility::trimExplode(';', $aPalettesString . ';;');
if ($name === $fieldName && isset($tcaForTable['columns'][$name])) {
return array_replace_recursive($tcaForTable['columns'][$name], $recordTypeConfig['columnsOverrides'][$name] ?? []);
}
}
} elseif ($name === $fieldName && isset($tcaForTable['columns'][$name])) {
return array_replace_recursive($tcaForTable['columns'][$name], $recordTypeConfig['columnsOverrides'][$name] ?? []);
}
}
return [];
}
/**
* Parses a flexForm node recursively and takes care of sections etc.
* Helper method of convertFlexFormContentToArray() and convertFlexFormContentToSheetsArray().
*/
private function walkFlexFormNode(mixed $nodeArray): mixed
{
if (!is_array($nodeArray)) {
return $nodeArray;
}
$result = [];
foreach ($nodeArray as $nodeKey => $nodeValue) {
if ($nodeKey === 'vDEF') {
return $nodeValue;
}
if (in_array($nodeKey, ['el', '_arrayContainer'])) {
return $this->walkFlexFormNode($nodeValue);
}
if (($nodeKey[0] ?? '') === '_') {
continue;
}
if (strpos((string)$nodeKey, '.')) {
$nodeKeyParts = explode('.', $nodeKey);
$currentNode = &$result;
$nodeKeyPartsCount = count($nodeKeyParts);
for ($i = 0; $i < $nodeKeyPartsCount - 1; $i++) {
$currentNode = &$currentNode[$nodeKeyParts[$i]];
}
$newNode = [next($nodeKeyParts) => $nodeValue];
$subVal = $this->walkFlexFormNode($newNode);
$currentNode[key($subVal)] = current($subVal);
} elseif (is_array($nodeValue)) {
if (array_key_exists('vDEF', $nodeValue)) {
$result[$nodeKey] = $nodeValue['vDEF'];
} else {
$result[$nodeKey] = $this->walkFlexFormNode($nodeValue);
}
} else {
$result[$nodeKey] = $nodeValue;
}
}
return $result;
}
}
@@ -0,0 +1,20 @@
<?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\Core\Configuration\Loader\Exception;
class YamlFileLoadingException extends \RuntimeException {}
@@ -0,0 +1,20 @@
<?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\Core\Configuration\Loader\Exception;
class YamlParseException extends \RuntimeException {}
@@ -0,0 +1,20 @@
<?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\Core\Configuration\Loader\Exception;
class YamlPlaceholderException extends \RuntimeException {}
@@ -0,0 +1,300 @@
<?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\Core\Configuration\Loader;
use Psr\Log\LoggerInterface;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use TYPO3\CMS\Core\Configuration\Loader\Exception\YamlFileLoadingException;
use TYPO3\CMS\Core\Configuration\Loader\Exception\YamlParseException;
use TYPO3\CMS\Core\Configuration\Processor\PlaceholderProcessorList;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* A YAML file loader that allows to load YAML files, based on the Symfony/Yaml component
*
* In addition to just load a YAML file, it adds some special functionality.
*
* - A special "imports" key in the YAML file allows to include other YAML files recursively.
* The actual YAML file gets loaded after the import statements, which are interpreted first,
* at the very beginning. Imports can be referenced with a relative path.
*
* - Merging configuration options of import files when having simple "lists" will add items to the list instead
* of overwriting them.
*
* - Special placeholder values set via %optionA.suboptionB% replace the value with the named path of the configuration
* The placeholders will act as a full replacement of this value.
*
* - Environment placeholder values set via %env(option)% will be replaced by env variables of the same name
*/
readonly class YamlFileLoader
{
// see also: EnvPlaceholderProcessor
public const PATTERN_PARTS = '%[^(%]+?\([\'"]?([^(]*?)[\'"]?\)%|%([^%()]*?)%';
public const PROCESS_PLACEHOLDERS = 0x01;
public const PROCESS_IMPORTS = 0x02;
public const ALLOW_EMPTY_FILE = 0x04;
public function __construct(
private LoggerInterface $logger,
) {}
/**
* Loads and parses a YAML file, and returns an array with the found data
*
* @param string $fileName either relative to TYPO3's base project folder or prefixed with EXT:...
* @param int $flags Flags to configure behaviour of the loader: see public PROCESS_ constants above
* @return array the configuration as array
*/
public function load(string $fileName, int $flags = self::PROCESS_PLACEHOLDERS | self::PROCESS_IMPORTS): array
{
return $this->loadAndParse($fileName, $flags, null);
}
/**
* Internal method which does all the logic. Built so it can be re-used recursively.
*
* @param string $fileName either relative to TYPO3's base project folder or prefixed with EXT:...
* @param string|null $currentFileName when called recursively
* @return array the configuration as array
*/
protected function loadAndParse(string $fileName, int $flags, ?string $currentFileName): array
{
$sanitizedFileName = $this->getStreamlinedFileName($fileName, $currentFileName);
$content = $this->getFileContents($sanitizedFileName);
try {
$content = Yaml::parse($content);
} catch (ParseException $e) {
throw new YamlParseException(
'YAML file "' . $fileName . '" has syntax errors: ' . $e->getMessage(),
1740817000,
$e
);
}
if ($content === null && $this->hasFlag($flags, self::ALLOW_EMPTY_FILE)) {
$content = [];
}
if (!is_array($content)) {
throw new YamlParseException(
'YAML file "' . $fileName . '" does not contain data.',
1497332874
);
}
if ($this->hasFlag($flags, self::PROCESS_IMPORTS)) {
$content = $this->processImports($content, $flags, $sanitizedFileName);
}
if ($this->hasFlag($flags, self::PROCESS_PLACEHOLDERS)) {
// Check for "%" placeholders
$content = $this->processPlaceholders($content, $content);
}
return $content;
}
/**
* Put into a separate method to ease the pains with unit tests
*
* @return string the contents or empty string if file_get_contents fails
*/
protected function getFileContents(string $fileName): string
{
return is_readable($fileName) ? (string)file_get_contents($fileName) : '';
}
/**
* Fetches the absolute file name, but if a different file name is given, it is built relative to that.
*
* @param string $fileName either relative to TYPO3's base project folder or prefixed with EXT:...
* @param string|null $currentFileName when called recursively this contains the absolute file name of the file that included this file
* @return string the contents of the file
* @throws YamlFileLoadingException when the file was not accessible
*/
protected function getStreamlinedFileName(string $fileName, ?string $currentFileName): string
{
if (!empty($currentFileName)) {
if (PathUtility::isExtensionPath($fileName) || PathUtility::isAbsolutePath($fileName)) {
$streamlinedFileName = GeneralUtility::getFileAbsFileName($fileName);
} else {
// Now this path is considered to be relative the current file name
$streamlinedFileName = PathUtility::getAbsolutePathOfRelativeReferencedFileOrPath(
$currentFileName,
$fileName
);
if (!GeneralUtility::isAllowedAbsPath($streamlinedFileName)) {
throw new YamlFileLoadingException(
'Referencing a file which is outside of TYPO3s main folder',
1560319866
);
}
}
} else {
$streamlinedFileName = GeneralUtility::getFileAbsFileName($fileName);
}
if (!$streamlinedFileName) {
throw new YamlFileLoadingException('YAML File "' . $fileName . '" could not be loaded', 1485784246);
}
return $streamlinedFileName;
}
/**
* Checks for the special "imports" key on the main level of a file,
* which calls "load" recursively.
*/
protected function processImports(array $content, int $flags, ?string $fileName): array
{
if (isset($content['imports']) && is_array($content['imports'])) {
// Reverse the order of imports to follow the order of the declarations, see #92100
$content['imports'] = array_reverse($content['imports']);
foreach ($content['imports'] as $import) {
try {
$import = $this->processPlaceholders($import, $content);
$resource = $import['resource'];
if ($import['glob'] ?? false) {
$resource = $this->getStreamlinedFileName($resource, $fileName);
foreach (array_reverse(glob($resource)) as $file) {
$content = ArrayUtility::replaceAndAppendScalarValuesRecursive($this->loadAndParse($file, $flags, $fileName), $content);
}
} else {
$importedContent = $this->loadAndParse($resource, $flags, $fileName);
// override the imported content with the one from the current file
$content = ArrayUtility::replaceAndAppendScalarValuesRecursive($importedContent, $content);
}
} catch (ParseException|YamlParseException|YamlFileLoadingException $exception) {
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
}
}
unset($content['imports']);
}
return $content;
}
/**
* Main function that gets called recursively to check for %...% placeholders
* inside the array
*
* @param array $content the current sub-level content array
* @param array $referenceArray the global configuration array
* @return array the modified sub-level content array
*/
protected function processPlaceholders(array $content, array $referenceArray): array
{
foreach ($content as $k => $v) {
if ($this->containsPlaceholder($k)) {
$resolvedKey = $this->processPlaceholderLine($k, $referenceArray);
if (isset($content[$resolvedKey])) {
if ($k === $resolvedKey) {
throw new \UnexpectedValueException(
'Unresolvable placeholder key "' . $k . '" could not be substituted.',
1719672440
);
}
throw new \UnexpectedValueException(
'Placeholder key "' . $k . '" can not be substituted with "' . $resolvedKey . '" because key already exists',
1719316250
);
}
unset($content[$k]);
$k = $resolvedKey;
$content[$k] = $v;
}
if (is_array($v)) {
$content[$k] = $this->processPlaceholders($v, $referenceArray);
} elseif ($this->containsPlaceholder($v)) {
$content[$k] = $this->processPlaceholderLine($v, $referenceArray);
}
}
return $content;
}
protected function processPlaceholderLine(string $line, array $referenceArray): mixed
{
$parts = $this->getParts($line);
foreach ($parts as $partKey => $part) {
$result = $this->processSinglePlaceholder($partKey, $part, $referenceArray);
// Replace whole content if placeholder is the only thing in this line
if ($line === $partKey) {
$line = $result;
} elseif (is_string($result) || is_numeric($result)) {
$line = str_replace($partKey, $result, $line);
} else {
throw new \UnexpectedValueException(
'Placeholder can not be substituted if result is not string or numeric',
1581502783
);
}
if ($result !== $partKey && $this->containsPlaceholder($line)) {
$line = $this->processPlaceholderLine($line, $referenceArray);
}
}
return $line;
}
protected function processSinglePlaceholder(string $placeholder, string $value, array $referenceArray): mixed
{
$processorList = GeneralUtility::makeInstance(
PlaceholderProcessorList::class,
$GLOBALS['TYPO3_CONF_VARS']['SYS']['yamlLoader']['placeholderProcessors']
);
foreach ($processorList->compile() as $processor) {
if ($processor->canProcess($placeholder, $referenceArray)) {
try {
$result = $processor->process($value, $referenceArray);
} catch (\UnexpectedValueException) {
$result = $placeholder;
}
if (is_array($result)) {
$result = $this->processPlaceholders($result, $referenceArray);
}
break;
}
}
return $result ?? $placeholder;
}
protected function getParts(string $placeholders): array
{
// find occurrences of placeholders like %some()% and %array.access%.
// Only find the innermost ones, so we can nest them.
preg_match_all(
'/' . self::PATTERN_PARTS . '/',
$placeholders,
$parts,
PREG_UNMATCHED_AS_NULL
);
$matches = array_filter(
array_merge($parts[1], $parts[2])
);
return array_combine($parts[0], $matches);
}
/**
* Finds possible placeholders.
* May find false positives for complexer structures, but they will be sorted later on.
*/
protected function containsPlaceholder(mixed $value): bool
{
return is_string($value) && substr_count($value, '%') >= 2;
}
protected function hasFlag(int $flags, int $flag): bool
{
return ($flags & $flag) === $flag;
}
}
@@ -0,0 +1,102 @@
<?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\Core\Configuration\Loader;
use TYPO3\CMS\Core\Configuration\Loader\Exception\YamlPlaceholderException;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\String\StringFragmentPattern;
use TYPO3\CMS\Core\Utility\String\StringFragmentSplitter;
/**
* A guard for protecting YAML placeholders - keeps existing, but escalates on adding new placeholders
*/
class YamlPlaceholderGuard
{
protected StringFragmentSplitter $fragmentSplitter;
public function __construct(protected array $existingConfiguration)
{
$fragmentPattern = GeneralUtility::makeInstance(
StringFragmentPattern::class,
StringFragmentSplitter::TYPE_EXPRESSION,
YamlFileLoader::PATTERN_PARTS
);
$this->fragmentSplitter = GeneralUtility::makeInstance(
StringFragmentSplitter::class,
$fragmentPattern
);
}
/**
* Modifies existing configuration.
*/
public function process(array $modified): array
{
return $this->protectPlaceholders($this->existingConfiguration, $modified);
}
/**
* Detects placeholders that have been introduced and handles* them.
* (*) currently throws an exception, but could be purged or escaped as well
*
* @param array<string, mixed> $current
* @param array<string, mixed> $modified
* @param list<string> $steps configuration keys traversed so far
* @return array<string, mixed> sanitized configuration (currently not used, exception thrown before)
* @throws YamlPlaceholderException
*/
protected function protectPlaceholders(array $current, array $modified, array $steps = []): array
{
foreach ($modified as $key => $value) {
$currentSteps = array_merge($steps, [$key]);
if (is_array($value)) {
$modified[$key] = $this->protectPlaceholders(
$current[$key] ?? [],
$value,
$currentSteps
);
} elseif (is_string($value)) {
$splitFlags = StringFragmentSplitter::FLAG_UNMATCHED_AS_NULL;
$newFragments = $this->fragmentSplitter->split($value, $splitFlags);
if (is_string($current[$key] ?? null)) {
$currentFragments = $this->fragmentSplitter->split($current[$key] ?? '', $splitFlags);
} else {
$currentFragments = null;
}
// in case there are new fragments (at least one matching the pattern)
if ($newFragments !== null) {
// compares differences in `expression` fragments only
$differences = $currentFragments === null
? $newFragments->withOnlyType(StringFragmentSplitter::TYPE_EXPRESSION)
: $newFragments->withOnlyType(StringFragmentSplitter::TYPE_EXPRESSION)
->diff($currentFragments->withOnlyType(StringFragmentSplitter::TYPE_EXPRESSION));
if (count($differences) > 0) {
throw new YamlPlaceholderException(
sprintf(
'Introducing placeholder%s %s for %s is not allowed',
count($differences) !== 1 ? 's' : '',
implode(', ', $differences->getFragments()),
implode('.', $currentSteps)
),
1651690534
);
}
}
}
}
return $modified;
}
}
@@ -0,0 +1,130 @@
<?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\Core\Configuration\Processor\Placeholder;
use TYPO3\CMS\Core\Configuration\Processor\PlaceholderProcessorList;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Can process a string consisting of one or multiple %env(...)% YAML
* placeholders, and replace them with their evaluated values.
* This in contrast to the EnvVariableProcessor, which only evaluates
* a single ENV value (without the placeholders), and is also utilized
* for the actual expansion here by means of the PlaceholderProcessorList.
*/
class EnvPlaceholderProcessor
{
// see also YamlFileLoader
public const PATTERN_PARTS = '%[^(%]+?\([\'"]?([^(]*?)[\'"]?\)%|%([^%()]*?)%';
protected array $processorList = [];
public function __construct(
) {
$processorList = GeneralUtility::makeInstance(
PlaceholderProcessorList::class,
$GLOBALS['TYPO3_CONF_VARS']['SYS']['yamlLoader']['placeholderProcessors']
);
$this->processorList = $processorList->compile();
}
public function canProcess(mixed $placeholder): bool
{
// only strings may be candidates for $placeholder substitution
if (!is_string($placeholder)) {
return false;
}
return str_contains($placeholder, '%env(');
}
public function process(string $value): string
{
return $this->processPlaceholderLine($value);
}
/**
* The following methods are taken from YamlFileLoader, but adapted
* for isolated usage and preventing circular dependencies.
*/
protected function processPlaceholderLine(string $line): string
{
$parts = $this->getParts($line);
foreach ($parts as $partKey => $part) {
$result = $this->processSinglePlaceholder($partKey, $part);
// Replace whole content if placeholder is the only thing in this line
if ($line === $partKey) {
$line = $result;
} elseif (is_string($result) || is_numeric($result)) {
$line = str_replace($partKey, $result, $line);
} else {
throw new \UnexpectedValueException(
'ENV Placeholder can not be substituted if result is not string or numeric',
1770965068
);
}
if ($result !== $partKey && $this->containsPlaceholder($line)) {
$line = $this->processPlaceholderLine($line);
}
}
return $line;
}
protected function processSinglePlaceholder(string $placeholder, string $value): mixed
{
foreach ($this->processorList as $processor) {
if ($processor->canProcess($placeholder, [])) {
try {
$result = $processor->process($value, []);
} catch (\UnexpectedValueException) {
$result = $placeholder;
}
break;
}
}
return $result ?? $placeholder;
}
// These two methods are used just as in YamlFileLoader and
// might be moved to utility classes; however for now they
// are replicated to be able to be adapted.
protected function getParts(string $placeholders): array
{
// find occurrences of placeholders like %some()% and %array.access%.
// Only find the innermost ones, so we can nest them.
preg_match_all(
'/' . self::PATTERN_PARTS . '/',
$placeholders,
$parts,
PREG_UNMATCHED_AS_NULL
);
$matches = array_filter(
array_merge($parts[1], $parts[2])
);
return array_combine($parts[0], $matches);
}
/**
* Finds possible placeholders.
* May find false positives for complexer structures, but they will be sorted later on.
*/
protected function containsPlaceholder(mixed $value): bool
{
return is_string($value) && substr_count($value, '%') >= 2;
}
}
@@ -0,0 +1,39 @@
<?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\Core\Configuration\Processor\Placeholder;
/**
* Return value from environment variable
*
* Environment variables may only contain word characters and underscores (a-zA-Z0-9_)
* to be compatible to shell environments.
*/
readonly class EnvVariableProcessor implements PlaceholderProcessorInterface
{
public function canProcess(string $placeholder, array $referenceArray): bool
{
return str_contains($placeholder, '%env(');
}
public function process(string $value, array $referenceArray)
{
$envVar = getenv($value);
if ($envVar === false) {
throw new \UnexpectedValueException('Value not found', 1581501124);
}
return $envVar;
}
}
@@ -0,0 +1,26 @@
<?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\Core\Configuration\Processor\Placeholder;
interface PlaceholderProcessorInterface
{
public function canProcess(string $placeholder, array $referenceArray): bool;
/**
* @return mixed
*/
public function process(string $value, array $referenceArray);
}
@@ -0,0 +1,50 @@
<?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\Core\Configuration\Processor\Placeholder;
/**
* Returns the value for a placeholder as fetched from the referenceArray
*/
readonly class ValueFromReferenceArrayProcessor implements PlaceholderProcessorInterface
{
public function canProcess(string $placeholder, array $referenceArray): bool
{
return !str_contains($placeholder, '(');
}
/**
* Returns the value for a placeholder as fetched from the referenceArray
*
* @param string $value the string to search for
* @param array $referenceArray the main configuration array where to look up the data
*
* @return array|mixed|string
*/
public function process(string $value, array $referenceArray)
{
$parts = explode('.', $value);
$referenceData = $referenceArray;
foreach ($parts as $part) {
if (isset($referenceData[$part])) {
$referenceData = $referenceData[$part];
} else {
// return unsubstituted placeholder
throw new \UnexpectedValueException('Value not found', 1581501216);
}
}
return $referenceData;
}
}
@@ -0,0 +1,62 @@
<?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\Core\Configuration\Processor;
use TYPO3\CMS\Core\Configuration\Processor\Placeholder\PlaceholderProcessorInterface;
use TYPO3\CMS\Core\Service\DependencyOrderingService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Orders and returns given PlaceholderProcessors
*/
class PlaceholderProcessorList
{
/**
* @var PlaceholderProcessorInterface[]
*/
protected $processors;
public function __construct($processorList = [])
{
$this->processors = $processorList;
}
/**
* @return PlaceholderProcessorInterface[]
*/
public function compile(): array
{
$processors = [];
$orderingService = GeneralUtility::makeInstance(DependencyOrderingService::class);
$orderedProcessors = $orderingService->orderByDependencies($this->processors, 'before', 'after');
foreach ($orderedProcessors as $processorClassName => $providerConfig) {
if (isset($providerConfig['disabled']) && $providerConfig['disabled'] === true) {
continue;
}
$processor = GeneralUtility::makeInstance($processorClassName);
if (!$processor instanceof PlaceholderProcessorInterface) {
throw new \UnexpectedValueException(
'Placeholder processor ' . $processorClassName . ' must implement PlaceholderProcessorInterface',
1581343410
);
}
$processors[] = $processor;
}
return $processors;
}
}
+229
View File
@@ -0,0 +1,229 @@
<?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\Core\Configuration;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Configuration\Event\AfterRichtextConfigurationPreparedEvent;
use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader;
use TYPO3\CMS\Core\TypoScript\TypoScriptService;
use TYPO3\CMS\Core\Utility\ArrayUtility;
/**
* Prepare richtext configuration. Used in DataHandler and FormEngine
*
* @internal Internal class for the time being - may change / vanish any time
*/
readonly class Richtext
{
public function __construct(
private EventDispatcherInterface $eventDispatcher,
#[Autowire(service: 'cache.runtime')]
private FrontendInterface $runtimeCache,
private YamlFileLoader $yamlFileLoader,
private TypoScriptService $typoScriptService,
) {}
/**
* This is an intermediate class / method to retrieve RTE
* configuration until all core places use data providers to do that.
*
* @param string $table The table the field is in
* @param string $field Field name
* @param int $pid Real page id
* @param string $recordType Record type value
* @param array $tcaFieldConf ['config'] section of TCA field
*/
public function getConfiguration(string $table, string $field, int $pid, string $recordType, array $tcaFieldConf): array
{
// create instance of NodeFactory, ask for "text" element
//
// As soon as the Data handler starts using FormDataProviders, this class can vanish again, and the hack to
// test for specific rich text instances can be dropped: Split the "TcaText" data provider into multiple parts, each
// RTE should register and own data provider that does the transformation / configuration providing. This way,
// the explicit check for different RTE classes is removed from core and "hooked in" by the RTE's.
// The main problem here is that all parameters that the processing needs is handed over to as TSconfig
// "dotted array" syntax. We convert at least the processing information available under "processing"
// together with pageTS, this way it can be overridden and understood in RteHtmlParser.
// However, all other parts of the core will depend on the non-dotted syntax (coming from YAML directly)
$pageTs = $this->getPageTsConfiguration($table, $field, $pid, $recordType);
// determine which preset to use
$pageTs['preset'] = $pageTs['fieldSpecificPreset'] ?? $tcaFieldConf['richtextConfiguration'] ?? $pageTs['generalPreset'] ?? 'default';
unset($pageTs['fieldSpecificPreset']);
unset($pageTs['generalPreset']);
// load configuration from preset
$configuration = $this->loadConfigurationFromPreset($pageTs['preset']);
// overlay preset configuration with pageTs
ArrayUtility::mergeRecursiveWithOverrule(
$configuration,
$this->addFlattenedPageTsConfig($pageTs)
);
// Handle "mode" / "transformation" config when overridden
if (!isset($configuration['proc.']['mode']) && !isset($configuration['proc.']['overruleMode'])) {
$configuration['proc.']['overruleMode'] = 'default';
}
$event = $this->eventDispatcher->dispatch(new AfterRichtextConfigurationPreparedEvent($configuration));
return $event->getConfiguration();
}
/**
* Load a configuration preset from an external resource (currently only YAML is supported).
* This is the default behaviour and can be overridden by page TSconfig.
*
* @return array the parsed configuration
*/
protected function loadConfigurationFromPreset(string $presetName = ''): array
{
$configuration = [];
if (!empty($presetName) && isset($GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets'][$presetName])) {
$identifier = 'richtext_' . $presetName;
$configuration = $this->runtimeCache->get($identifier);
if ($configuration === false) {
$configuration = $this->yamlFileLoader->load($GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets'][$presetName]);
// For future versions, you should however rely on the "processing" key and not the "proc" key.
if (is_array($configuration['processing'] ?? null)) {
$configuration['proc.'] = $this->convertPlainArrayToTypoScriptArray($configuration['processing']);
}
$this->runtimeCache->set($identifier, $configuration);
}
}
return $configuration;
}
/**
* Return RTE section of page TS
*
* @param int $pid Page ts of given pid
* @return array RTE section of pageTs of given pid
*/
protected function getRtePageTsConfigOfPid(int $pid): array
{
return BackendUtility::getPagesTSconfig($pid)['RTE.'] ?? [];
}
/**
* Returns an array with Typoscript the old way (with dot)
* Since the functionality in YAML is without the dots, but the new configuration is used without the dots
* this functionality adds also an explicit = 1 to the arrays
*
* @param array $plainArray An array
* @return array array with TypoScript as usual (with dot)
*/
protected function convertPlainArrayToTypoScriptArray(array $plainArray)
{
$typoScriptArray = [];
foreach ($plainArray as $key => $value) {
if (is_array($value)) {
if (!isset($typoScriptArray[$key])) {
$typoScriptArray[$key] = 1;
}
$typoScriptArray[$key . '.'] = $this->convertPlainArrayToTypoScriptArray($value);
} else {
$typoScriptArray[$key] = $value ?? '';
}
}
return $typoScriptArray;
}
/**
* Add all PageTS.RTE options keys to configuration without dots
*
* We need to keep the dotted keys for backwards compatibility like ext:rtehtmlarea
*
* @param array $typoScriptArray TypoScriptArray
* @return array array with config without dots added
*/
protected function addFlattenedPageTsConfig(array $typoScriptArray): array
{
foreach ($typoScriptArray as $key => $data) {
if (!str_ends_with($key, '.')) {
continue;
}
$typoScriptArray[substr($key, 0, -1)] = $this->typoScriptService->convertTypoScriptArrayToPlainArray($typoScriptArray[$key]);
}
return $typoScriptArray;
}
/**
* Load PageTS configuration for the RTE
*
* Return RTE section of page TS, taking into account overloading via table, field and record type
*
* @param string $table The table the field is in
* @param string $field Field name
* @param int $pid Real page id
* @param string $recordType Record type value
*/
protected function getPageTsConfiguration(string $table, string $field, int $pid, string $recordType): array
{
// Load page TSconfig configuration
$fullPageTsConfig = $this->getRtePageTsConfigOfPid($pid);
$defaultPageTsConfigOverrides = $fullPageTsConfig['default.'] ?? null;
$defaultPageTsConfigOverrides['generalPreset'] = $fullPageTsConfig['default.']['preset'] ?? null;
$fieldSpecificPageTsConfigOverrides = $fullPageTsConfig['config.'][$table . '.'][$field . '.'] ?? null;
unset($fullPageTsConfig['default.'], $fullPageTsConfig['config.']);
// First use RTE.*
$rtePageTsConfiguration = $fullPageTsConfig;
// Then overload with RTE.default.*
if (is_array($defaultPageTsConfigOverrides)) {
ArrayUtility::mergeRecursiveWithOverrule($rtePageTsConfiguration, $defaultPageTsConfigOverrides);
}
$rtePageTsConfiguration['fieldSpecificPreset'] = $fieldSpecificPageTsConfigOverrides['types.'][$recordType . '.']['preset']
?? $fieldSpecificPageTsConfigOverrides['preset'] ?? null;
// Then overload with RTE.config.tt_content.bodytext
if (is_array($fieldSpecificPageTsConfigOverrides)) {
$fieldSpecificPageTsConfigOverridesWithoutType = $fieldSpecificPageTsConfigOverrides;
unset($fieldSpecificPageTsConfigOverridesWithoutType['types.']);
ArrayUtility::mergeRecursiveWithOverrule($rtePageTsConfiguration, $fieldSpecificPageTsConfigOverridesWithoutType);
// Then overload with RTE.config.tt_content.bodytext.types.textmedia
if (
$recordType
&& isset($fieldSpecificPageTsConfigOverrides['types.'][$recordType . '.'])
&& is_array($fieldSpecificPageTsConfigOverrides['types.'][$recordType . '.'])
) {
ArrayUtility::mergeRecursiveWithOverrule(
$rtePageTsConfiguration,
$fieldSpecificPageTsConfigOverrides['types.'][$recordType . '.']
);
}
}
unset($rtePageTsConfiguration['preset']);
return $rtePageTsConfiguration;
}
}
+336
View File
@@ -0,0 +1,336 @@
<?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\Core\Configuration;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Finder\Finder;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Cache\Event\CacheWarmupEvent;
use TYPO3\CMS\Core\Cache\Exception\InvalidDataException;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend;
use TYPO3\CMS\Core\Configuration\Event\SiteConfigurationChangedEvent;
use TYPO3\CMS\Core\Configuration\Event\SiteConfigurationLoadedEvent;
use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\Entity\SiteSettings;
use TYPO3\CMS\Core\Site\Entity\SiteTSconfig;
use TYPO3\CMS\Core\Site\Entity\SiteTypoScript;
use TYPO3\CMS\Core\Site\Set\SetError;
use TYPO3\CMS\Core\Site\Set\SetRegistry;
use TYPO3\CMS\Core\Site\SiteSettingsFactory;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Responsibility: Handles the format of the configuration (currently yaml), and the location of the file system folder
*
* Reads all available site configuration options, and puts them into Site objects.
*
* @internal
*/
#[Autoconfigure(public: true)]
readonly class SiteConfiguration
{
/**
* Config yaml file name.
*/
private const string CONFIG_FILE_NAME = 'config.yaml';
/**
* File naming containing TypoScript Setup.
*/
private const string TYPOSCRIPT_SETUP_FILE_NAME = 'setup.typoscript';
/**
* File naming containing TypoScript Constants.
*/
private const string TYPOSCRIPT_CONSTANTS_FILE_NAME = 'constants.typoscript';
/**
* File naming containing page TSconfig definitions
*/
private const string PAGE_TSCONFIG_FILE_NAME = 'page.tsconfig';
/**
* YAML file name with all settings related to Content-Security-Policies.
*/
private const string CONTENT_SECURITY_FILE_NAME = 'csp.yaml';
/**
* Identifier to store all configuration data in the core cache.
*/
private const string CACHE_IDENTIFIER = 'sites-configuration';
public function __construct(
#[Autowire('%env(TYPO3:configPath)%/sites')]
protected string $configPath,
protected SiteSettingsFactory $siteSettingsFactory,
protected SetRegistry $setRegistry,
protected EventDispatcherInterface $eventDispatcher,
#[Autowire(service: 'cache.core')]
protected PhpFrontend $cache,
private readonly YamlFileLoader $yamlFileLoader,
#[Autowire(service: 'cache.runtime')]
protected readonly FrontendInterface $runtimeCache,
) {}
/**
* Return all site objects which have been found in the filesystem.
*
* @return Site[]
*/
public function getAllExistingSites(bool $useCache = true): array
{
if ($useCache && $this->runtimeCache->has(self::CACHE_IDENTIFIER)) {
return $this->runtimeCache->get(self::CACHE_IDENTIFIER);
}
return $this->resolveAllExistingSites($useCache);
}
/**
* Resolve all site objects which have been found in the filesystem.
*
* @return Site[]
*/
public function resolveAllExistingSites(bool $useCache = true): array
{
$sites = [];
$siteConfiguration = $this->getAllSiteConfigurationFromFiles($useCache);
foreach ($siteConfiguration as $identifier => $configuration) {
// cast $identifier to string, as the identifier can potentially only consist of (int) digit numbers
$identifier = (string)$identifier;
$siteSettings = $this->siteSettingsFactory->getSettings($identifier, $configuration);
$siteTypoScript = $this->getSiteTypoScript($identifier);
$siteTSconfig = $this->getSiteTSconfig($identifier);
$configuration['contentSecurityPolicies'] = $this->getContentSecurityPolicies($identifier);
$configuration['routeEnhancers'] = ArrayUtility::replaceAndAppendScalarValuesRecursive(
$this->getRouteEnhancersFromSets($configuration['dependencies'] ?? []),
$configuration['routeEnhancers'] ?? []
);
$rootPageId = (int)($configuration['rootPageId'] ?? 0);
if ($rootPageId > 0) {
$site = new Site($identifier, $rootPageId, $configuration, $siteSettings, $siteTypoScript, $siteTSconfig);
$this->determineInvalidSets($site);
$sites[$identifier] = $site;
}
}
$this->runtimeCache->set(self::CACHE_IDENTIFIER, $sites);
return $sites;
}
/**
* Resolve all site objects which have been found in the filesystem containing settings only from the `config.yaml`
* file ignoring values from the `settings.yaml` and `csp.yaml` file.
*
* @return Site[]
* @internal Not part of public API. Used as intermediate solution until settings are handled by a dedicated GUI.
*/
public function resolveAllExistingSitesRaw(): array
{
$sites = [];
$siteConfiguration = $this->getAllSiteConfigurationFromFiles(false);
foreach ($siteConfiguration as $identifier => $configuration) {
// cast $identifier to string, as the identifier can potentially only consist of (int) digit numbers
$identifier = (string)$identifier;
$inlineSettings = $configuration['settings'] ?? [];
$siteSettings = SiteSettings::createFromSettingsTree($inlineSettings);
$siteTypoScript = $this->getSiteTypoScript($identifier);
$rootPageId = (int)($configuration['rootPageId'] ?? 0);
if ($rootPageId > 0) {
$site = new Site($identifier, $rootPageId, $configuration, $siteSettings, $siteTypoScript);
$this->determineInvalidSets($site);
$sites[$identifier] = $site;
}
}
return $sites;
}
/**
* Returns an array of paths in which a site configuration is found.
*
* @internal
*/
public function getAllSiteConfigurationPaths(): array
{
$finder = new Finder();
$paths = [];
try {
$finder->files()->depth(0)->name(self::CONFIG_FILE_NAME)->in($this->configPath . '/*');
} catch (\InvalidArgumentException $e) {
$finder = [];
}
foreach ($finder as $fileInfo) {
$path = $fileInfo->getPath();
$paths[basename($path)] = $path;
}
return $paths;
}
/**
* Read the site configuration from config files.
*
* @throws InvalidDataException
*/
protected function getAllSiteConfigurationFromFiles(bool $useCache = true): array
{
// Check if the data is already cached
$siteConfiguration = $useCache ? $this->cache->require(self::CACHE_IDENTIFIER) : false;
if ($siteConfiguration !== false) {
return $siteConfiguration;
}
$finder = new Finder();
try {
$finder->files()->depth(0)->name(self::CONFIG_FILE_NAME)->in($this->configPath . '/*');
} catch (\InvalidArgumentException $e) {
// Directory $this->configPath does not exist yet
$finder = [];
}
$siteConfiguration = [];
foreach ($finder as $fileInfo) {
$configuration = $this->yamlFileLoader->load(GeneralUtility::fixWindowsFilePath((string)$fileInfo));
$identifier = basename($fileInfo->getPath());
$event = $this->eventDispatcher->dispatch(new SiteConfigurationLoadedEvent($identifier, $configuration));
$siteConfiguration[$identifier] = $event->getConfiguration();
}
$this->cache->set(self::CACHE_IDENTIFIER, 'return ' . var_export($siteConfiguration, true) . ';');
return $siteConfiguration;
}
/**
* Load plain configuration without additional settings.
*
* This method should only be used in case the original configuration as it exists in the file should be loaded,
* for example for writing / editing configuration.
*
* All read related actions should be performed on the site entity.
*
* @param string $siteIdentifier
*/
public function load(string $siteIdentifier): array
{
$fileName = $this->configPath . '/' . $siteIdentifier . '/' . self::CONFIG_FILE_NAME;
return $this->yamlFileLoader->load(GeneralUtility::fixWindowsFilePath($fileName), YamlFileLoader::PROCESS_IMPORTS);
}
protected function getSiteTypoScript(string $siteIdentifier): ?SiteTypoScript
{
$data = [
'setup' => self::TYPOSCRIPT_SETUP_FILE_NAME,
'constants' => self::TYPOSCRIPT_CONSTANTS_FILE_NAME,
];
$definitions = [];
foreach ($data as $type => $fileName) {
$path = $this->configPath . '/' . $siteIdentifier . '/' . $fileName;
if (file_exists($path)) {
$contents = @file_get_contents(GeneralUtility::fixWindowsFilePath($path));
if ($contents !== false) {
$definitions[$type] = $contents;
}
}
}
if ($definitions === []) {
return null;
}
return new SiteTypoScript(...$definitions);
}
protected function getSiteTSconfig(string $siteIdentifier): ?SiteTSconfig
{
$pageTSconfig = null;
$path = $this->configPath . '/' . $siteIdentifier . '/' . self::PAGE_TSCONFIG_FILE_NAME;
if (file_exists($path)) {
$contents = @file_get_contents(GeneralUtility::fixWindowsFilePath($path));
if ($contents !== false) {
$pageTSconfig = $contents;
}
}
if ($pageTSconfig === null) {
return null;
}
return new SiteTSconfig(
pageTSconfig: $pageTSconfig
);
}
protected function getContentSecurityPolicies(string $siteIdentifier): array
{
$fileName = $this->configPath . '/' . $siteIdentifier . '/' . self::CONTENT_SECURITY_FILE_NAME;
if (file_exists($fileName)) {
return $this->yamlFileLoader->load(GeneralUtility::fixWindowsFilePath($fileName));
}
return [];
}
/**
* Get route enhancers from site sets.
*/
protected function getRouteEnhancersFromSets(array $dependencies): array
{
$routeEnhancers = [];
$sets = $this->setRegistry->getSets(...$dependencies);
foreach ($sets as $set) {
$routeEnhancers = ArrayUtility::replaceAndAppendScalarValuesRecursive(
$routeEnhancers,
$set->routeEnhancers
);
}
return $routeEnhancers;
}
protected function determineInvalidSets(Site $site): void
{
$site->invalidSets = array_filter(
$this->setRegistry->getInvalidSets(),
static fn($setName) => in_array($setName, $site->getSets(), true),
ARRAY_FILTER_USE_KEY
);
foreach ($site->getSets() as $set) {
if (!$this->setRegistry->hasSet($set) && !isset($site->invalidSets[$set])) {
$site->invalidSets[$set] = [
'name' => $set,
'error' => SetError::notFound,
'context' => 'site:' . $site->getIdentifier(),
];
}
}
}
#[AsEventListener(event: SiteConfigurationChangedEvent::class)]
public function siteConfigurationChanged(): void
{
$this->cache->remove(self::CACHE_IDENTIFIER);
$this->runtimeCache->remove(self::CACHE_IDENTIFIER);
}
#[AsEventListener('typo3-core/site-configuration')]
public function warmupCaches(CacheWarmupEvent $event): void
{
if ($event->hasGroup('system')) {
$this->getAllSiteConfigurationFromFiles(false);
}
}
}
+243
View File
@@ -0,0 +1,243 @@
<?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\Core\Configuration;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Yaml\Yaml;
use TYPO3\CMS\Core\Configuration\Event\SiteConfigurationBeforeWriteEvent;
use TYPO3\CMS\Core\Configuration\Event\SiteConfigurationChangedEvent;
use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException;
use TYPO3\CMS\Core\Configuration\Loader\Exception\YamlPlaceholderException;
use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader;
use TYPO3\CMS\Core\Configuration\Loader\YamlPlaceholderGuard;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Writes Site objects into site configuration files.
*
* @internal
*/
readonly class SiteWriter
{
/**
* Config yaml file name.
*/
private const string CONFIG_FILE_NAME = 'config.yaml';
/**
* YAML file name with all settings.
*/
private const string SETTINGS_FILE_NAME = 'settings.yaml';
public function __construct(
protected readonly string $configPath,
protected readonly EventDispatcherInterface $eventDispatcher,
private readonly YamlFileLoader $yamlFileLoader,
) {}
/**
* Creates a site configuration with one language "English" which is the de-facto default language for TYPO3 in general.
*
* @param string[] $dependencies Site set identifiers to add as dependencies
* @throws SiteConfigurationWriteException
*/
public function createNewBasicSite(string $identifier, int $rootPageId, string $base, array $dependencies = []): void
{
// Create a default site configuration called "main" as best practice
$configuration = [
'rootPageId' => $rootPageId,
'base' => $base,
'languages' => [
0 => [
'title' => 'English',
'enabled' => true,
'languageId' => 0,
'base' => '/',
'locale' => 'en_US.UTF-8',
'navigationTitle' => 'English',
'flag' => 'us',
],
],
'errorHandling' => [],
'routes' => [],
'dependencies' => $dependencies,
];
$this->write($identifier, $configuration);
}
public function writeSettings(string $siteIdentifier, array $settings): void
{
$fileName = $this->configPath . '/' . $siteIdentifier . '/' . self::SETTINGS_FILE_NAME;
if ($settings === []) {
if (!is_file($fileName)) {
return;
}
$yamlFileContents = '# No site specific settings defined';
} else {
$yamlFileContents = Yaml::dump($settings, 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP);
}
if (!GeneralUtility::writeFile($fileName, $yamlFileContents, true)) {
throw new SiteConfigurationWriteException('Unable to write site settings in sites/' . $siteIdentifier . '/' . self::SETTINGS_FILE_NAME, 1590487411);
}
}
/**
* Add or update a site configuration
*
* @param bool $protectPlaceholders whether to disallow introducing new placeholders
* @todo enforce $protectPlaceholders with TYPO3 v13.0
* @throws SiteConfigurationWriteException
*/
public function write(string $siteIdentifier, array $configuration, bool $protectPlaceholders = false): void
{
$folder = $this->configPath . '/' . $siteIdentifier;
$fileName = $folder . '/' . self::CONFIG_FILE_NAME;
$newConfiguration = $configuration;
if (!file_exists($folder)) {
GeneralUtility::mkdir_deep($folder);
if ($protectPlaceholders && $newConfiguration !== []) {
$newConfiguration = $this->protectPlaceholders([], $newConfiguration);
}
} elseif (file_exists($fileName)) {
// load without any processing to have the unprocessed base to modify
$newConfiguration = $this->yamlFileLoader->load(GeneralUtility::fixWindowsFilePath($fileName), 0);
// load the processed configuration to diff changed values,
// but don't process placeholders, because all properties that
// were modified via GUI are unprocessed values as well
$processed = $this->yamlFileLoader->load(GeneralUtility::fixWindowsFilePath($fileName), YamlFileLoader::PROCESS_IMPORTS);
// find properties that were modified via GUI
$newModified = array_replace_recursive(
self::findRemoved($processed, $configuration),
self::findModified($processed, $configuration)
);
if ($protectPlaceholders && $newModified !== []) {
$newModified = $this->protectPlaceholders($newConfiguration, $newModified);
}
// change _only_ the modified keys, leave the original non-changed areas alone
ArrayUtility::mergeRecursiveWithOverrule($newConfiguration, $newModified);
}
$event = $this->eventDispatcher->dispatch(new SiteConfigurationBeforeWriteEvent($siteIdentifier, $newConfiguration));
$newConfiguration = $this->sortConfiguration($event->getConfiguration());
$yamlFileContents = Yaml::dump($newConfiguration, 99, 2);
if (!GeneralUtility::writeFile($fileName, $yamlFileContents, true)) {
throw new SiteConfigurationWriteException('Unable to write site configuration in sites/' . $siteIdentifier . '/' . self::CONFIG_FILE_NAME, 1590487011);
}
$this->eventDispatcher->dispatch(new SiteConfigurationChangedEvent($siteIdentifier));
}
/**
* Renames a site identifier (and moves the folder)
*
* @throws SiteConfigurationWriteException
*/
public function rename(string $currentIdentifier, string $newIdentifier): void
{
if (!rename($this->configPath . '/' . $currentIdentifier, $this->configPath . '/' . $newIdentifier)) {
throw new SiteConfigurationWriteException('Unable to rename folder sites/' . $currentIdentifier, 1522491300);
}
$this->eventDispatcher->dispatch(new SiteConfigurationChangedEvent($newIdentifier));
}
/**
* Removes the config.yaml file of a site configuration.
* Also clears the cache.
*
* @throws SiteNotFoundException|SiteConfigurationWriteException
*/
public function delete(string $siteIdentifier): void
{
$fileName = $this->configPath . '/' . $siteIdentifier . '/' . self::CONFIG_FILE_NAME;
if (!file_exists($fileName)) {
throw new SiteNotFoundException('Site configuration file ' . self::CONFIG_FILE_NAME . ' within the site ' . $siteIdentifier . ' not found.', 1522866184);
}
if (!unlink($fileName)) {
throw new SiteConfigurationWriteException('Unable to delete folder sites/' . $siteIdentifier, 1596462020);
}
$this->eventDispatcher->dispatch(new SiteConfigurationChangedEvent($siteIdentifier));
}
/**
* Detects placeholders that have been introduced and handles* them.
* (*) currently throws an exception, but could be purged or escaped as well
*
* @param array<string, mixed> $existingConfiguration
* @param array<string, mixed> $modifiedConfiguration
* @return array<string, mixed> sanitized configuration (currently not used, exception thrown before)
* @throws SiteConfigurationWriteException
*/
protected function protectPlaceholders(array $existingConfiguration, array $modifiedConfiguration): array
{
try {
return GeneralUtility::makeInstance(YamlPlaceholderGuard::class, $existingConfiguration)
->process($modifiedConfiguration);
} catch (YamlPlaceholderException $exception) {
throw new SiteConfigurationWriteException($exception->getMessage(), 1670361271, $exception);
}
}
protected function sortConfiguration(array $newConfiguration): array
{
ksort($newConfiguration);
if (isset($newConfiguration['imports'])) {
$imports = $newConfiguration['imports'];
unset($newConfiguration['imports']);
$newConfiguration['imports'] = $imports;
}
return $newConfiguration;
}
protected static function findModified(array $currentConfiguration, array $newConfiguration): array
{
$differences = [];
foreach ($newConfiguration as $key => $value) {
if (!isset($currentConfiguration[$key]) || $currentConfiguration[$key] !== $value) {
if (!isset($value) && isset($currentConfiguration[$key])) {
$differences[$key] = '__UNSET';
} elseif (isset($currentConfiguration[$key])
&& is_array($value)
&& is_array($currentConfiguration[$key])
) {
$differences[$key] = self::findModified($currentConfiguration[$key], $value);
} else {
$differences[$key] = $value;
}
}
}
return $differences;
}
protected static function findRemoved(array $currentConfiguration, array $newConfiguration): array
{
$removed = [];
foreach ($currentConfiguration as $key => $value) {
if (!isset($newConfiguration[$key])) {
$removed[$key] = '__UNSET';
} elseif (isset($value) && is_array($value) && is_array($newConfiguration[$key])) {
$removedInRecursion = self::findRemoved($value, $newConfiguration[$key]);
if (!empty($removedInRecursion)) {
$removed[$key] = $removedInRecursion;
}
}
}
return $removed;
}
}
+293
View File
@@ -0,0 +1,293 @@
<?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\Core\Configuration\Tca;
/**
* Automatically "enrich" TCA. This mainly adds "columns" definitions
* based on "ctrl" settings. This is *not* for migration or preparation.
*
* This class is executed when building final TCA *after* "base"
* files in "Configuration/TCA" have been loaded, and *before*
* files in "Configuration/TCA/Overrides" are loaded.
*
* @internal Class and API may change any time.
*/
final readonly class TcaEnrichment
{
public function enrich(array $tca): array
{
$tca = $this->enrichDisabledField($tca);
$tca = $this->enrichStarttimeField($tca);
$tca = $this->enrichEndtimeField($tca);
$tca = $this->enrichFeGroupField($tca);
$tca = $this->enrichEditLockField($tca);
$tca = $this->enrichDescriptionField($tca);
$tca = $this->enrichLanguageField($tca);
$tca = $this->setTransOrigPointerFieldInCtrl($tca);
$tca = $this->enrichTransOrigPointerField($tca);
$tca = $this->enrichTransOrigDiffSourceField($tca);
$tca = $this->enrichTranslationSourceField($tca);
return $tca;
}
private function enrichDisabledField(array $tca): array
{
foreach ($tca as $table => $tableDefinition) {
$disabledFieldName = $tableDefinition['ctrl']['enablecolumns']['disabled'] ?? null;
if ($disabledFieldName && !is_array($tableDefinition['columns'][$disabledFieldName] ?? null)) {
$tca[$table]['columns'][$disabledFieldName] = [
'label' => 'core.db.general:enabled',
'exclude' => true,
'config' => [
'type' => 'check',
'renderType' => 'checkboxToggle',
'default' => 0,
'items' => [
[
'label' => '',
'invertStateDisplay' => true,
],
],
],
];
}
}
return $tca;
}
private function enrichStarttimeField(array $tca): array
{
foreach ($tca as $table => $tableDefinition) {
$starttimeFieldName = $tableDefinition['ctrl']['enablecolumns']['starttime'] ?? null;
if ($starttimeFieldName && !is_array($tableDefinition['columns'][$starttimeFieldName] ?? null)) {
$tca[$table]['columns'][$starttimeFieldName] = [
'exclude' => true,
'label' => 'core.db.general:starttime',
'config' => [
'type' => 'datetime',
'default' => 0,
],
];
}
}
return $tca;
}
private function enrichEndtimeField(array $tca): array
{
foreach ($tca as $table => $tableDefinition) {
$endtimeFieldName = $tableDefinition['ctrl']['enablecolumns']['endtime'] ?? null;
if ($endtimeFieldName && !is_array($tableDefinition['columns'][$endtimeFieldName] ?? null)) {
$tca[$table]['columns'][$endtimeFieldName] = [
'exclude' => true,
'label' => 'core.db.general:endtime',
'config' => [
'type' => 'datetime',
'default' => 0,
'range' => [
'upper' => mktime(0, 0, 0, 1, 1, 2106),
],
],
];
}
}
return $tca;
}
private function enrichFeGroupField(array $tca): array
{
foreach ($tca as $table => $tableDefinition) {
$feGroupFieldName = $tableDefinition['ctrl']['enablecolumns']['fe_group'] ?? null;
if ($feGroupFieldName && !is_array($tableDefinition['columns'][$feGroupFieldName] ?? null)) {
$tca[$table]['columns'][$feGroupFieldName] = [
'exclude' => true,
'label' => 'core.db.general:fe_group',
'config' => [
'type' => 'select',
'renderType' => 'selectMultipleSideBySide',
'size' => 5,
'maxitems' => 20,
'items' => [
[
'label' => 'core.db.general:fe_group.hide_at_login',
'value' => -1,
],
[
'label' => 'core.db.general:fe_group.any_login',
'value' => -2,
],
[
'label' => 'core.db.general:fe_group.usergroups',
'value' => '--div--',
],
],
'exclusiveKeys' => '-1,-2',
'foreign_table' => 'fe_groups',
],
];
}
}
return $tca;
}
private function enrichEditLockField(array $tca): array
{
foreach ($tca as $table => $tableDefinition) {
$editLockFieldName = $tableDefinition['ctrl']['editlock'] ?? null;
if ($editLockFieldName && !is_array($tableDefinition['columns'][$editLockFieldName] ?? null)) {
$tca[$table]['columns'][$editLockFieldName] = [
'displayCond' => 'HIDE_FOR_NON_ADMINS',
'label' => 'core.db.general:editlock',
'config' => [
'type' => 'check',
'renderType' => 'checkboxToggle',
],
];
}
}
return $tca;
}
private function enrichDescriptionField(array $tca): array
{
foreach ($tca as $table => $tableDefinition) {
$descriptionFieldName = $tableDefinition['ctrl']['descriptionColumn'] ?? null;
if ($descriptionFieldName && !is_array($tableDefinition['columns'][$descriptionFieldName] ?? null)) {
$tca[$table]['columns'][$descriptionFieldName] = [
'exclude' => true,
'label' => 'core.db.general:description',
'config' => [
'type' => 'text',
'rows' => 5,
'cols' => 30,
'max' => 2000,
],
];
}
}
return $tca;
}
private function enrichLanguageField(array $tca): array
{
foreach ($tca as $table => $tableDefinition) {
$languageFieldName = $tableDefinition['ctrl']['languageField'] ?? null;
if ($languageFieldName && !is_array($tableDefinition['columns'][$languageFieldName] ?? null)) {
$tca[$table]['columns'][$languageFieldName] = [
'exclude' => true,
'label' => 'core.db.general:language',
'config' => [
'type' => 'language',
],
];
}
if ($languageFieldName && !is_array($tableDefinition['columns']['language_tag'] ?? null)) {
$tca[$table]['columns']['language_tag'] = [
'exclude' => true,
'label' => 'Language Tag',
'config' => [
'type' => 'input',
'size' => 10,
'max' => 35,
'eval' => 'trim',
'default' => '',
],
];
}
}
return $tca;
}
/**
* When 'languageField' is set, 'transOrigPointerField' must be set as well.
* We silently add 'transOrigPointerField' if that iss not the case.
*
* @todo: This obviously needs a consolidation in ctrl. We should have a single, probably
* boolean ctrl toggle to make a table 'localization' aware, with core then handling
* all internals. This will require streamlining the field names along the way, and
* we can think about this as soon as sys_language_uid=-1 is gone.
*/
private function setTransOrigPointerFieldInCtrl(array $tca): array
{
foreach ($tca as $table => $tableDefinition) {
if (isset($tableDefinition['ctrl']['languageField']) && !isset($tableDefinition['ctrl']['transOrigPointerField'])) {
$tca[$table]['ctrl']['transOrigPointerField'] = 'l10n_parent';
}
}
return $tca;
}
private function enrichTransOrigPointerField(array $tca): array
{
foreach ($tca as $table => $tableDefinition) {
$transOrigPointerFieldName = $tableDefinition['ctrl']['transOrigPointerField'] ?? null;
if ($transOrigPointerFieldName && !is_array($tableDefinition['columns'][$transOrigPointerFieldName] ?? null)) {
$languageFieldName = $tableDefinition['ctrl']['languageField'];
$tca[$table]['columns'][$transOrigPointerFieldName] = [
'displayCond' => 'FIELD:' . $languageFieldName . ':>:0',
'label' => 'core.db.general:l18n_parent',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'items' => [
[
'label' => '',
'value' => 0,
],
],
'foreign_table' => $table,
'foreign_table_where' => 'AND {#' . $table . '}.{#pid}=###CURRENT_PID### AND {#' . $table . '}.{#' . $languageFieldName . '} IN (-1,0)',
'default' => 0,
],
];
}
}
return $tca;
}
private function enrichTransOrigDiffSourceField(array $tca): array
{
foreach ($tca as $table => $tableDefinition) {
$transOrigDiffSourceFieldName = $tableDefinition['ctrl']['transOrigDiffSourceField'] ?? null;
if ($transOrigDiffSourceFieldName && !is_array($tableDefinition['columns'][$transOrigDiffSourceFieldName] ?? null)) {
$tca[$table]['columns'][$transOrigDiffSourceFieldName] = [
'config' => [
'type' => 'passthrough',
'default' => '',
],
];
}
}
return $tca;
}
private function enrichTranslationSourceField(array $tca): array
{
foreach ($tca as $table => $tableDefinition) {
$translationSourceFieldName = $tableDefinition['ctrl']['translationSource'] ?? null;
if ($translationSourceFieldName && !is_array($tableDefinition['columns'][$translationSourceFieldName] ?? null)) {
$tca[$table]['columns'][$translationSourceFieldName] = [
'config' => [
'type' => 'passthrough',
],
];
}
}
return $tca;
}
}
+196
View File
@@ -0,0 +1,196 @@
<?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\Core\Configuration\Tca;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Finder\Finder;
use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend;
use TYPO3\CMS\Core\Configuration\Event\AfterTcaCompilationEvent;
use TYPO3\CMS\Core\Configuration\Event\BeforeTcaOverridesEvent;
use TYPO3\CMS\Core\Package\Cache\PackageDependentCacheIdentifier;
use TYPO3\CMS\Core\Package\PackageManager;
/**
* @internal Bootstrap related base TCA loading. Extensions must not use this.
*/
#[Autoconfigure(public: true)]
final readonly class TcaFactory
{
public function __construct(
private PackageManager $packageManager,
private EventDispatcherInterface $eventDispatcher,
#[Autowire(service: 'cache.core')]
private PhpFrontend $codeCache,
) {}
/**
* The main production worker method.
*/
public function get(): array
{
$cacheData = $this->codeCache->require($this->getTcaCacheIdentifier());
if ($cacheData) {
$tca = $cacheData['tca'];
} else {
$tca = $this->create();
$this->createBaseTcaCacheFile($tca);
}
return $tca;
}
/**
* This is (indirectly) used by extension manager when loading
* extensions, by install tool bootstrap and cache warmup.
*/
public function create(): array
{
$tca = $this->loadConfigurationTcaFiles();
$tca = $this->dispatchBeforeTcaOverridesEvent($tca);
$tca = $this->enrichTca($tca);
$tca = $this->loadConfigurationTcaOverridesFiles($tca);
$tca = $this->migrateTca($tca);
$tca = $this->prepareTca($tca);
return $this->dispatchAfterTcaCompilationEvent($tca);
}
/**
* This is used by install tool LoadTcaService to check certain aspects of TCA
*/
public function createNotMigrated(): array
{
$tca = $this->loadConfigurationTcaFiles();
$tca = $this->dispatchBeforeTcaOverridesEvent($tca);
$tca = $this->enrichTca($tca);
return $this->loadConfigurationTcaOverridesFiles($tca);
}
/**
* Public since it's also used by CacheWarmupCommand
*/
public function createBaseTcaCacheFile(array $tca): void
{
$this->codeCache->set(
$this->getTcaCacheIdentifier(),
'return '
. var_export(['tca' => $tca], true)
. ';'
);
}
private function getTcaCacheIdentifier(): string
{
return (new PackageDependentCacheIdentifier($this->packageManager))->withPrefix('tca_base')->toString();
}
private function loadConfigurationTcaFiles(): array
{
// To require TCA in a safe scoped environment avoiding local variable clashes.
// Note: Return type 'mixed' is intended, otherwise broken TCA files with missing "return [];" statement would
// emit a "return value must be of type array, int returned" PHP TypeError. This is mitigated by an array
// check below.
$scopedReturnRequire = static function (string $filename): mixed {
return require $filename;
};
// First load "full table" files from Configuration/TCA
$tca = [];
$activePackages = $this->packageManager->getActivePackages();
foreach ($activePackages as $package) {
try {
$finder = Finder::create()->files()->sortByName()->depth(0)->name('*.php')->in($package->getPackagePath() . 'Configuration/TCA');
} catch (\InvalidArgumentException) {
// No such directory in this package
continue;
}
foreach ($finder as $fileInfo) {
$tcaOfTable = $scopedReturnRequire($fileInfo->getPathname());
if (is_array($tcaOfTable)) {
$tcaTableName = substr($fileInfo->getBasename(), 0, -4);
$tca[$tcaTableName] = $tcaOfTable;
}
}
}
return $tca;
}
private function enrichTca(array $tca): array
{
return (new TcaEnrichment())->enrich($tca);
}
private function loadConfigurationTcaOverridesFiles(array $tca): array
{
// To require TCA Overrides in a safe scoped environment avoiding local variable clashes.
$scopedRequire = static function (string $filename): void {
require $filename;
};
// Execute override files from Configuration/TCA/Overrides
$GLOBALS['TCA'] = $tca;
$activePackages = $this->packageManager->getActivePackages();
foreach ($activePackages as $package) {
try {
$finder = Finder::create()->files()->sortByName()->depth(0)->name('*.php')->in($package->getPackagePath() . 'Configuration/TCA/Overrides');
} catch (\InvalidArgumentException) {
// No such directory in this package
continue;
}
foreach ($finder as $fileInfo) {
$scopedRequire($fileInfo->getPathname());
}
}
$tca = $GLOBALS['TCA'];
unset($GLOBALS['TCA']);
return $tca;
}
private function migrateTca(array $tca): array
{
// Call the TcaMigration and log any deprecations.
$tcaMigration = new TcaMigration();
$tcaProcessingResult = $tcaMigration->migrate($tca);
$messages = $tcaProcessingResult->getMessages();
if (!empty($messages)) {
$context = 'Automatic TCA migration done during bootstrap. Please adapt TCA accordingly, these migrations'
. ' will be removed. The backend module "Configuration -> TCA" shows the modified values.'
. ' Please adapt these areas:';
array_unshift($messages, $context);
trigger_error(implode(LF, $messages), E_USER_DEPRECATED);
}
return $tcaProcessingResult->getTca();
}
private function prepareTca(array $tca): array
{
return (new TcaPreparation())->prepare($tca);
}
private function dispatchBeforeTcaOverridesEvent($tca): array
{
return $this->eventDispatcher->dispatch(new BeforeTcaOverridesEvent($tca))->getTca();
}
private function dispatchAfterTcaCompilationEvent($tca): array
{
$GLOBALS['TCA'] = $tca;
$tca = $this->eventDispatcher->dispatch(new AfterTcaCompilationEvent($tca))->getTca();
unset($GLOBALS['TCA']);
return $tca;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,624 @@
<?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\Core\Configuration\Tca;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
/**
* Prepare TCA. Used in bootstrap and Flex Form Data Structures,
* executed *after* TcaMigration.
* This is used to add *internal TCA details* needed by core.
* For instance, type=category fields receive all the relation
* details in order to work properly.
*
* @internal Class and API may change any time.
*/
readonly class TcaPreparation
{
/**
* Prepare TCA
*
* This class is typically called within bootstrap with empty caches after all TCA
* files from extensions have been loaded. The preparation is then applied and
* the prepared result is cached.
* For flex form TCA, this class is called dynamically if opening a record in the backend.
*
* See unit tests for details.
*/
public function prepare(array $tca, bool $isFlexForm = false): array
{
$tca = $this->configureCategoryRelations($tca, $isFlexForm);
$tca = $this->configureFileReferences($tca, $isFlexForm);
$tca = $this->configureEmailSoftReferences($tca);
$tca = $this->configureLinkSoftReferences($tca);
$tca = $this->configureSelectSingle($tca);
$tca = $this->configureRelationshipToOne($tca);
$tca = $this->addSystemFieldsToShowitemTypes($tca);
$tca = $this->addIgnoredPageTypeRestrictionRecords($tca);
return $tca;
}
/**
* Prepares TCA configuration of type='category' fields.
*
* It adds some TCA config settings so category fields end up with similar
* config as type='select' field, but in a more restricted way.
* Some settings could also be set in TCA directly, but some fields
* can not be overridden, e.g. foreign_table.
*
* This also sets necessary MM properties, in case relationship is
* set to "manyToMany", which is the default. Note it is "oneToMany"
* with flex forms, since flex forms do NOT support "manyToMany".
*
* Finally, all category fields with a "manyToMany" relationship are
* added to the MM_oppositeUsage of sys_category "items".
*
* Important: Since this method defines a "foreign_table_where", this
* must always be executed before prepareQuotingOfTableNamesAndColumnNames().
*/
protected function configureCategoryRelations(array $tca, bool $isFlexForm): array
{
foreach ($tca as $table => &$tableDefinition) {
if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) {
continue;
}
foreach ($tableDefinition['columns'] as $fieldName => &$fieldConfig) {
if (($fieldConfig['config']['type'] ?? '') !== 'category') {
continue;
}
if (!isset($fieldConfig['label'])) {
$fieldConfig['label'] = 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_category.categories';
}
// Force foreign_table for type category
$fieldConfig['config']['foreign_table'] = 'sys_category';
// Initialize default column configuration and merge it with already defined
$fieldConfig['config']['size'] ??= 20;
$defaultTag = \Local\Multilanguage\Service\DefaultLanguageTagService::getTag();
$fieldConfig['config']['foreign_table_where'] ??= " AND {#sys_category}.{#language_tag} IN ('" . $defaultTag . "', '')";
if (empty($fieldConfig['config']['relationship'])) {
// In case no relationship is given, set "manyToMany" for non flex form, but "oneToMany" with flex form.
$fieldConfig['config']['relationship'] = $isFlexForm ? 'oneToMany' : 'manyToMany';
}
// Sanitize 'relationship'
if ($isFlexForm && !in_array($fieldConfig['config']['relationship'], ['oneToOne', 'oneToMany'], true)) {
throw new \UnexpectedValueException(
'"relationship" must be one of "oneToOne" or "oneToMany", "manyToMany" is not supported as "relationship"'
. ' for field ' . $fieldName . ' of type "category" in flexform.',
1627640208
);
}
if (!in_array($fieldConfig['config']['relationship'], ['oneToOne', 'oneToMany', 'manyToMany'], true)) {
throw new \RuntimeException(
$fieldName . ' of table ' . $table . ' is defined as type category with relationship "'
. $fieldConfig['config']['relationship'] . '", but only "oneToOne", "oneToMany" and "manyToMany"'
. ' are allowed.',
1627898896
);
}
// Set the maxitems value (necessary for DataHandling and FormEngine)
if ($fieldConfig['config']['relationship'] === 'oneToOne') {
// In case relationship is set to "oneToOne", the database column for this
// field will be an integer column. This means, only one uid can be stored.
// Therefore, maxitems must be 1. Sanitize for flex form fields as well.
if ((int)($fieldConfig['config']['maxitems'] ?? 0) > 1) {
throw new \RuntimeException(
$fieldName . ' of table ' . $table . ' is defined as type category with an oneToOne relationship. '
. 'Therefore maxitems must be 1. Otherwise, use oneToMany or manyToMany as relationship instead.',
1627335016
);
}
$fieldConfig['config']['maxitems'] = 1;
} elseif (!($fieldConfig['config']['maxitems'] ?? false)) {
// In case maxitems is not set or set to 0, set the default value "99999"
$fieldConfig['config']['maxitems'] = 99999;
} elseif ($fieldConfig['config']['relationship'] === 'oneToMany'
&& (int)($fieldConfig['config']['maxitems'] ?? 0) === 1
) {
throw new \RuntimeException(
$fieldName . ' of table ' . $table . ' is defined as type category with a ' . $fieldConfig['config']['relationship']
. ' relationship. Therefore, maxitems can not be set to 1. Use oneToOne as relationship instead.',
1627335017
);
}
// Add the default value if not set
if (!isset($fieldConfig['config']['default'])
&& $fieldConfig['config']['relationship'] !== 'oneToMany'
) {
// @todo: This is db wise not accurate: A oneToOne relation without a relation being assigned,
// @todo: should have NULL as value, not 0, since 0 looks like a uid, but it isn't.
// @todo: The field should be nullable and DH should handle that.
$fieldConfig['config']['default'] = 0;
}
// Add MM related properties in case relationship is set to "manyToMany".
// This will not be done for the sys_category table itself. Not relevant with flex forms.
if ($fieldConfig['config']['relationship'] === 'manyToMany' && $table !== 'sys_category') {
// Note these settings are hard coded here and can't be overridden.
$fieldConfig['config'] = array_replace_recursive($fieldConfig['config'], [
'MM' => 'sys_category_record_mm',
'MM_opposite_field' => 'items',
'MM_match_fields' => [
'tablenames' => $table,
'fieldname' => $fieldName,
],
]);
// Register opposite references for the foreign side of a category relation
if (empty($tca['sys_category']['columns']['items']['config']['MM_oppositeUsage'][$table])) {
$tca['sys_category']['columns']['items']['config']['MM_oppositeUsage'][$table] = [];
}
if (!in_array($fieldName, $tca['sys_category']['columns']['items']['config']['MM_oppositeUsage'][$table], true)) {
$tca['sys_category']['columns']['items']['config']['MM_oppositeUsage'][$table][] = $fieldName;
}
// Take specific value of exclude flag into account
if (!isset($fieldConfig['exclude'])) {
$fieldConfig['exclude'] = true;
}
}
}
}
return $tca;
}
protected function configureFileReferences(array $tca, bool $isFlexForm): array
{
foreach ($tca as $table => &$tableDefinition) {
if (!isset($tableDefinition['columns']) || !is_array($tableDefinition['columns'])) {
continue;
}
foreach ($tableDefinition['columns'] as $fieldName => &$fieldConfig) {
if (($fieldConfig['config']['type'] ?? '') === 'file') {
// Set static values for this type. Most of them are not needed due to the
// dedicated TCA type. However a lot of underlying code in DataHandler and
// friends relies on those keys, especially "foreign_table" and "foreign_selector".
// @todo Check which of those values can be removed since only used by FormEngine
$fieldConfig['config'] = array_replace_recursive($fieldConfig['config'], [
'foreign_table' => 'sys_file_reference',
'foreign_field' => 'uid_foreign',
'foreign_sortby' => 'sorting_foreign',
'foreign_table_field' => 'tablenames',
'foreign_label' => 'uid_local',
'foreign_selector' => 'uid_local',
]);
if (!isset($fieldConfig['config']['foreign_match_fields']['fieldname'])) {
$fieldConfig['config']['foreign_match_fields']['fieldname'] = $fieldName;
}
if (!$isFlexForm) {
$fieldConfig['config']['foreign_match_fields']['tablenames'] = $table;
}
$fieldConfig['config'] = $this->configureAllowedDisallowedFileExtensions($fieldConfig['config']);
}
if (is_array($fieldConfig['config']['overrideChildTca'] ?? null)) {
$fieldConfig['config']['overrideChildTca'] = $this->configureAllowedDisallowedInOverrideChildTca($fieldConfig['config']['overrideChildTca']);
}
}
unset($fieldConfig);
if (is_array($tableDefinition['types'] ?? null)) {
foreach ($tableDefinition['types'] as &$typeConfig) {
if (!isset($typeConfig['columnsOverrides']) || !is_array($typeConfig['columnsOverrides'])) {
continue;
}
foreach ($typeConfig['columnsOverrides'] as &$columnsOverridesConfig) {
if (!isset($columnsOverridesConfig['config']) || !is_array($columnsOverridesConfig['config'])) {
continue;
}
$columnsOverridesConfig['config'] = $this->configureAllowedDisallowedFileExtensions($columnsOverridesConfig['config']);
if (is_array($columnsOverridesConfig['config']['overrideChildTca'] ?? null)) {
$columnsOverridesConfig['config']['overrideChildTca'] = $this->configureAllowedDisallowedInOverrideChildTca($columnsOverridesConfig['config']['overrideChildTca']);
}
}
}
}
}
return $tca;
}
/**
* configureFileReferences() helper
*/
protected function configureAllowedDisallowedInOverrideChildTca(array $overrideChildTcaConfig): array
{
if (is_array($overrideChildTcaConfig['columns'] ?? null)) {
foreach ($overrideChildTcaConfig['columns'] as &$overrideChildTcaColumnConfig) {
if (!isset($overrideChildTcaColumnConfig['config'])) {
continue;
}
$overrideChildTcaColumnConfig['config'] = $this->configureAllowedDisallowedFileExtensions($overrideChildTcaColumnConfig['config']);
}
unset($overrideChildTcaColumnConfig);
}
if (is_array($overrideChildTcaConfig['types'] ?? null)) {
foreach ($overrideChildTcaConfig['types'] as &$overrideChildTcaTypeConfig) {
if (!isset($overrideChildTcaTypeConfig['config'])) {
continue;
}
$overrideChildTcaTypeConfig['config'] = $this->configureAllowedDisallowedFileExtensions($overrideChildTcaTypeConfig['config']);
}
}
return $overrideChildTcaConfig;
}
/**
* configureFileReferences() helper
*/
protected function configureAllowedDisallowedFileExtensions(array $config): array
{
if (!empty($allowed = ($config['allowed'] ?? null))) {
$config['allowed'] = $this->prepareFileExtensions($allowed);
}
if (!empty($disallowed = ($config['disallowed'] ?? null))) {
$config['disallowed'] = $this->prepareFileExtensions($disallowed);
}
return $config;
}
/**
* configureFileReferences() helper: Ensures format, replaces placeholders and remove duplicates
*/
protected function prepareFileExtensions(mixed $fileExtensions): string
{
if (is_array($fileExtensions)) {
$fileExtensions = implode(',', $fileExtensions);
} else {
$fileExtensions = (string)$fileExtensions;
}
// Replace placeholders with the corresponding $GLOBALS value for now
if (preg_match_all('/common-(image|text|media)-types/', $fileExtensions, $matches)) {
foreach ($matches[1] as $key => $type) {
$fileExtensions = str_replace(
$matches[0][$key],
$GLOBALS['TYPO3_CONF_VARS'][$type === 'image' ? 'GFX' : 'SYS'][$type . 'file_ext'] ?? '',
$fileExtensions
);
}
}
return StringUtility::uniqueList($fileExtensions);
}
/**
* Add "'softref' = 'email[subst]'" to all 'type' = 'email' column fields.
*/
protected function configureEmailSoftReferences(array $tca): array
{
foreach ($tca as &$tableDefinition) {
if (!is_array($tableDefinition['columns'] ?? null)) {
continue;
}
foreach ($tableDefinition['columns'] as &$fieldConfig) {
if (($fieldConfig['config']['type'] ?? null) === 'email') {
// Hard set/override: 'softref' is not listed as property for type=email at all,
// there is little need to have this configurable.
$fieldConfig['config']['softref'] = 'email[subst]';
}
}
}
return $tca;
}
/**
* Add "'softref' = 'typolink'" to all 'type' = 'link' column fields.
*/
protected function configureLinkSoftReferences(array $tca): array
{
foreach ($tca as &$tableDefinition) {
if (!is_array($tableDefinition['columns'] ?? null)) {
continue;
}
foreach ($tableDefinition['columns'] as &$fieldConfig) {
if (($fieldConfig['config']['type'] ?? null) === 'link') {
// Hard set/override: 'softref' is not listed as property for type=link at all,
// there is little need to have this configurable.
$fieldConfig['config']['softref'] = 'typolink';
}
}
}
return $tca;
}
/**
* Add "'relationship' for TCA type "select" fields, having "selectSingle" set as renderType and are
* pointing to a "foreign_table". Depending on further configuration, this will set the "relationship"
* to either "manyToMany" (in case "MM" is set) or to "manyToOne".
* Already defined "relationship" is not overwritten!
*
* This is mainly done to prevent checks on the renderType, which should be avoided.
*/
protected function configureSelectSingle(array $tca): array
{
foreach ($tca as &$tableDefinition) {
if (!is_array($tableDefinition['columns'] ?? null)) {
continue;
}
foreach ($tableDefinition['columns'] as &$fieldConfig) {
if (($fieldConfig['config']['type'] ?? null) !== 'select'
|| ($fieldConfig['config']['renderType'] ?? null) !== 'selectSingle'
|| !isset($fieldConfig['config']['foreign_table'])
|| isset($fieldConfig['config']['relationship'])
) {
continue;
}
if (isset($fieldConfig['config']['MM'])) {
$fieldConfig['config']['relationship'] = 'manyToMany';
} else {
$fieldConfig['config']['relationship'] = 'manyToOne';
}
}
}
return $tca;
}
/**
* Add "'maxitems' => 1" to all relation type column fields with 'relationship' set to 'oneToOne' or 'manyToOne'.
*/
protected function configureRelationshipToOne(array $tca): array
{
foreach ($tca as &$tableDefinition) {
if (!is_array($tableDefinition['columns'] ?? null)) {
continue;
}
foreach ($tableDefinition['columns'] as &$fieldConfig) {
$type = $fieldConfig['config']['type'] ?? null;
if (in_array($type, ['select', 'inline', 'group', 'folder', 'file'], true)
&& in_array($fieldConfig['config']['relationship'] ?? null, ['oneToOne', 'manyToOne'], true)
) {
// Hard set/override: 'maxitems' to 1, since relationship [x]ToOne - as the name suggests -
// only allows a single item to be selected.
$fieldConfig['config']['maxitems'] = 1;
}
}
}
return $tca;
}
/**
* Ensure that all system fields (CType, colPos, hidden etc.) are automatically added
* to the showitem list of all CTypes in tt_content. As custom CTypes might have added
* the fields, the respective fields also need to be removed first.
*/
protected function addSystemFieldsToShowitemTypes(array $tca): array
{
// @todo Only deal with this for tt_content in v13, as other parts might be too intrusive
// might change in v14
if (!isset($tca['tt_content'])) {
return $tca;
}
// Only proceed in case the record type field is defined
$typeField = (string)($tca['tt_content']['ctrl']['type'] ?? '');
if ($typeField === '') {
return $tca;
}
// Build list of values (fields and palettes) which should be removed
// from custom palettes, because they will be added automatically.
$listOfValuesToRemove = [
'--div--;core.form.tabs:general',
'--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general',
'--div--;core.form.tabs:language',
'--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language',
'--div--;core.form.tabs:access',
'--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access',
'--div--;core.form.tabs:notes',
'--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:notes',
'--palette--;;general',
'--palette--;;language',
'--palette--;;access',
'--palette--;;hidden',
'colPos',
];
$listOfValuesToRemove[] = $typeField;
if (($languageField = (string)($tca['tt_content']['ctrl']['languageField'] ?? '')) !== '') {
$listOfValuesToRemove[] = $languageField;
}
if (($transOrigPointerField = (string)($tca['tt_content']['ctrl']['transOrigPointerField'] ?? '')) !== '') {
$listOfValuesToRemove[] = $transOrigPointerField;
}
$enablecolumns = $tca['tt_content']['ctrl']['enablecolumns'] ?? [];
foreach ($enablecolumns as $fieldName) {
$listOfValuesToRemove[] = $fieldName;
}
if (($editlock = (string)($tca['tt_content']['ctrl']['editlock'] ?? '')) !== '') {
$listOfValuesToRemove[] = $editlock;
}
if (($descriptionColumn = (string)($tca['tt_content']['ctrl']['descriptionColumn'] ?? '')) !== '') {
$listOfValuesToRemove[] = $descriptionColumn;
}
// Remove any system field from custom palettes
foreach ($tca['tt_content']['palettes'] as $paletteName => &$paletteConfig) {
if (in_array($paletteName, ['general', 'language', 'access', 'hidden'], true)) {
continue;
}
$showItemSplitted = GeneralUtility::trimExplode(',', $paletteConfig['showitem'], true);
$paletteConfig['showitem'] = implode(',', array_diff($this->removeCustomFieldLabels($showItemSplitted, $listOfValuesToRemove), $listOfValuesToRemove));
}
unset($paletteConfig);
// Process the content types
foreach ($tca['tt_content']['types'] as $type => $typeInformation) {
// Remove any of the special fields from the content type's current showitem
$showItemSplitted = GeneralUtility::trimExplode(',', $typeInformation['showitem'] ?? '', true);
$showItemFiltered = array_diff($this->removeCustomFieldLabels($showItemSplitted, $listOfValuesToRemove), $listOfValuesToRemove);
// Extract all fields of the extended tab to add it at the end
[$showItemList, $extendedParts] = $this->extractExtendedParts($showItemFiltered);
// Add record type field (usually "CType") and colPos either using the "general" palette
// or manually, in case the palette does not exist or does not contain the fields.
$generalPaletteItems = $this->removeCustomFieldLabels(GeneralUtility::trimExplode(',', $tca['tt_content']['palettes']['general']['showitem'] ?? '', true), $listOfValuesToRemove);
if (in_array($typeField, $generalPaletteItems, true) && in_array('colPos', $generalPaletteItems, true)) {
$showItemParts = ['--palette--;;general'];
} else {
$showItemParts = [
$typeField,
'colPos',
];
}
// Because FormEngine will add the general tab automatically, we will not do this here
// However, if the first item in the $showItemList is actually a tab (--div--), we need to
// add if before the "first fields"
if (str_starts_with($showItemList[0] ?? '', '--div--')) {
array_unshift($showItemParts, $showItemList[0]);
unset($showItemList[0]);
}
$showItemParts = array_merge($showItemParts, $showItemList);
// Add language field either using the "language" palette or manually,
// in case the palette does not exist or does not contain the field.
if ($languageField !== '') {
$showItemParts[] = '--div--;core.form.tabs:language';
$languagePaletteItems = $this->removeCustomFieldLabels(GeneralUtility::trimExplode(',', $tca['tt_content']['palettes']['language']['showitem'] ?? '', true), $listOfValuesToRemove);
if (in_array($languageField, $languagePaletteItems, true)
&& ($transOrigPointerField === '' || in_array($transOrigPointerField, $languagePaletteItems, true))
) {
$showItemParts[] = '--palette--;;language';
} else {
$showItemParts[] = $languageField;
if ($transOrigPointerField) {
$showItemParts[] = $transOrigPointerField;
}
}
}
// Add enable fields either using the "hidden" amd "access" palettes or
// manually, in case the palettes do not exist or do not contain the fields.
if ($enablecolumns !== [] || $editlock !== '') {
$showItemParts[] = '--div--;core.form.tabs:access';
if (isset($enablecolumns['disabled'])) {
$hiddenPaletteParts = $this->removeCustomFieldLabels(GeneralUtility::trimExplode(',', $tca['tt_content']['palettes']['hidden']['showitem'] ?? '', true), $listOfValuesToRemove);
if (in_array($enablecolumns['disabled'], $hiddenPaletteParts, true)) {
$showItemParts[] = '--palette--;;hidden';
} else {
$showItemParts[] = $enablecolumns['disabled'];
}
}
if ((isset($enablecolumns['starttime']) || isset($enablecolumns['endtime']) || isset($enablecolumns['fe_group']) || $editlock)) {
$accessPaletteParts = $this->removeCustomFieldLabels(GeneralUtility::trimExplode(',', $tca['tt_content']['palettes']['access']['showitem'] ?? '', true), $listOfValuesToRemove);
if ((!isset($enablecolumns['starttime']) || in_array($enablecolumns['starttime'], $accessPaletteParts, true))
&& (!isset($enablecolumns['endtime']) || in_array($enablecolumns['endtime'], $accessPaletteParts, true))
&& (!isset($enablecolumns['fe_group']) || in_array($enablecolumns['fe_group'], $accessPaletteParts, true))
&& (!$editlock || in_array($editlock, $accessPaletteParts, true))
) {
$showItemParts[] = '--palette--;;access';
} else {
if (isset($enablecolumns['starttime'])) {
$showItemParts[] = $enablecolumns['starttime'];
}
if (isset($enablecolumns['endtime'])) {
$showItemParts[] = $enablecolumns['endtime'];
}
if (isset($enablecolumns['fe_group'])) {
$showItemParts[] = $enablecolumns['fe_group'];
}
if ($editlock) {
$showItemParts[] = $editlock;
}
}
}
}
// Add description column if defined
if ($descriptionColumn !== '') {
$showItemParts[] = '--div--;core.form.tabs:notes,' . $descriptionColumn;
}
// Add extended tab at the end - if it exists
$showItemParts = array_merge($showItemParts, $extendedParts);
// Merge parts together
$tca['tt_content']['types'][$type]['showitem'] = trim(implode(',', $showItemParts), ',');
}
return $tca;
}
private function extractExtendedParts(array $showItemFiltered): array
{
$extendedParts = [];
$addFields = false;
foreach ($showItemFiltered as $key => $part) {
if ($part === '--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:extended' || $part === '--div--;core.form.tabs:extended') {
$extendedParts[] = $part;
$addFields = true;
unset($showItemFiltered[$key]);
} elseif ($addFields) {
if (str_starts_with($part, '--div--')) {
break;
}
$extendedParts[] = $part;
unset($showItemFiltered[$key]);
}
}
return [$showItemFiltered, $extendedParts];
}
private function removeCustomFieldLabels(array $showitemParts, array $fieldList): array
{
if ($fieldList === []) {
return $showitemParts;
}
foreach ($showitemParts as &$showItem) {
// Tabs keep their labels
if (str_starts_with($showItem, '--div--')) {
continue;
}
// Remove label of palette
if (str_starts_with($showItem, '--palette--')) {
$parts = GeneralUtility::trimExplode(';', $showItem, true, 3);
// Palette without label, continue.
if (count($parts) !== 3) {
continue;
}
$paletteName = '--palette--;;' . $parts[2];
if (in_array($paletteName, $fieldList, true)) {
$showItem = $paletteName;
}
continue;
}
// This must be a field.
$parts = GeneralUtility::trimExplode(';', $showItem, true, 2);
$fieldName = $parts[0];
// Just keep the first part => the fieldname in case field is defined in the $fieldList
if (in_array($fieldName, $fieldList, true)) {
$showItem = $fieldName;
}
}
return $showitemParts;
}
protected function addIgnoredPageTypeRestrictionRecords(array $tca): array
{
$allowedRecordTypes = [];
foreach ($tca as $table => $configuration) {
if ($configuration['ctrl']['security']['ignorePageTypeRestriction'] ?? false) {
$allowedRecordTypes[] = $table;
}
}
if ($allowedRecordTypes === []) {
return $tca;
}
$mergedAllowedRecords = array_merge(
$tca['pages']['ctrl']['defaultAllowedRecordTypes'] ?? [],
$allowedRecordTypes
);
$tca['pages']['ctrl']['defaultAllowedRecordTypes'] = array_unique($mergedAllowedRecords);
return $tca;
}
}
@@ -0,0 +1,53 @@
<?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\Core\Configuration\Tca;
/**
* @internal internal data object - only to be used within TYPO3 Core
*/
final readonly class TcaProcessingResult
{
public function __construct(
private array $tca,
/** Accumulate messages, occurred on TCA processing, e.g. by TcaMigration. */
private array $messages = []
) {}
public function getTca(): array
{
return $this->tca;
}
/**
* @return string[]
*/
public function getMessages(): array
{
return $this->messages;
}
public function withTca(array $tca): TcaProcessingResult
{
return new self($tca, $this->messages);
}
public function withAdditionalMessages(string ...$messages): TcaProcessingResult
{
return new self($this->tca, array_merge($this->messages, $messages));
}
}