TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:24 +02:00
commit aad9daaefd
1506 changed files with 94005 additions and 0 deletions
@@ -0,0 +1,140 @@
<?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\Form\Mvc\Configuration;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Form\Mvc\Configuration\ConfigurationManagerInterface as ExtFormConfigurationManagerInterface;
/**
* Extend the ExtbaseConfigurationManager to read YAML configurations.
*
* YAML files are discovered automatically from every active extension's
* Configuration/Form/<SetName>/ directory (see {@see \TYPO3\CMS\Form\DependencyInjection\FormYamlCollectorConfigurator}).
*
* Scope: frontend / backend
* @internal
*/
#[AsAlias(ConfigurationManagerInterface::class, public: true)]
readonly class ConfigurationManager implements ExtFormConfigurationManagerInterface
{
public function __construct(
private YamlSource $yamlSource,
#[Autowire(service: 'cache.assets')]
private FrontendInterface $cache,
private TypoScriptService $typoScriptService,
private FormYamlCollector $formYamlCollector,
) {}
/**
* Load and parse YAML files for the current rendering context.
*
* Files are resolved via auto-discovery: {@see FormYamlCollector} scans every
* active extension's Configuration/Form/<SetName>/ directory and returns
* all paths sorted by priority.
*
* The following post-processing steps are applied to the merged configuration:
*
* * Remove all keys whose values are NULL
* * Sort by array keys if all keys within a nesting level are numerical
* * Resolve possible TypoScript settings in FE mode
*/
public function getYamlConfiguration(array $typoScriptSettings, bool $isFrontend, ?ServerRequestInterface $request = null): array
{
$yamlSettingsFilePaths = $this->formYamlCollector->getPaths();
$cacheKey = strtolower('YamlSettings_form' . md5(json_encode($yamlSettingsFilePaths)));
if ($this->cache->has($cacheKey)) {
$yamlSettings = $this->cache->get($cacheKey);
} else {
$yamlSettings = $this->yamlSource->load($yamlSettingsFilePaths);
$yamlSettings = ArrayUtility::removeNullValuesRecursive($yamlSettings);
$yamlSettings = ArrayUtility::sortArrayWithIntegerKeysRecursive($yamlSettings);
$this->cache->set($cacheKey, $yamlSettings);
}
$this->applySiteSettingsOverrides($yamlSettings, $request);
if (is_array($typoScriptSettings['yamlSettingsOverrides'] ?? null) && !empty($typoScriptSettings['yamlSettingsOverrides'])) {
$yamlSettingsOverrides = $typoScriptSettings['yamlSettingsOverrides'];
if ($isFrontend) {
if ($request === null) {
throw new \RuntimeException('Frontend rendering an ext:form requires the request being hand over', 1760451538);
}
$yamlSettingsOverrides = $this->typoScriptService->resolvePossibleTypoScriptConfiguration($yamlSettingsOverrides, $request);
}
ArrayUtility::mergeRecursiveWithOverrule($yamlSettings, $yamlSettingsOverrides);
}
return $yamlSettings;
}
/**
* Read form template/translation site set settings from the current site
* and merge them into the YAML configuration.
* Non-empty values are added at key 20 so they overlay the base paths (key 10)
* while still allowing higher-priority overrides from form sets or yamlSettingsOverrides.
*/
private function applySiteSettingsOverrides(array &$yamlSettings, ?ServerRequestInterface $request): void
{
$site = $request?->getAttribute('site');
if (!$site instanceof Site) {
return;
}
$siteSettings = $site->getSettings();
$renderingOptionsOverrides = [];
$templateRootPath = (string)$siteSettings->get('form.templates.templateRootPath', '');
if ($templateRootPath !== '') {
$renderingOptionsOverrides['templateRootPaths'][20] = $templateRootPath;
}
$partialRootPath = (string)$siteSettings->get('form.templates.partialRootPath', '');
if ($partialRootPath !== '') {
$renderingOptionsOverrides['partialRootPaths'][20] = $partialRootPath;
}
$layoutRootPath = (string)$siteSettings->get('form.templates.layoutRootPath', '');
if ($layoutRootPath !== '') {
$renderingOptionsOverrides['layoutRootPaths'][20] = $layoutRootPath;
}
$translationFile = (string)$siteSettings->get('form.translation.translationFile', '');
if ($translationFile !== '') {
$renderingOptionsOverrides['translation']['translationFiles'][20] = $translationFile;
}
if ($renderingOptionsOverrides !== []) {
$overrides = [
'prototypes' => [
'standard' => [
'formElementsDefinition' => [
'Form' => [
'renderingOptions' => $renderingOptionsOverrides,
],
],
],
],
];
ArrayUtility::mergeRecursiveWithOverrule($yamlSettings, $overrides);
}
}
}
@@ -0,0 +1,29 @@
<?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\Form\Mvc\Configuration;
use Psr\Http\Message\ServerRequestInterface;
/**
* Scope: frontend / backend
* @internal
*/
interface ConfigurationManagerInterface
{
public function getYamlConfiguration(array $typoScriptSettings, bool $isFrontend, ?ServerRequestInterface $request = null): array;
}
+27
View File
@@ -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\Form\Mvc\Configuration;
use TYPO3\CMS\Form\Exception as FormException;
/**
* A generic Form configuration Exception
*
* @internal
*/
class Exception extends FormException {}
@@ -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\Form\Mvc\Configuration\Exception;
use TYPO3\CMS\Form\Mvc\Configuration\Exception;
/**
* Exception for file write errors
* @internal
*/
class FileWriteException 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\Form\Mvc\Configuration\Exception;
use TYPO3\CMS\Form\Mvc\Configuration\Exception;
/**
* A No Such File exception
* @internal
*/
class NoSuchFileException 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\Form\Mvc\Configuration\Exception;
use TYPO3\CMS\Form\Mvc\Configuration\Exception;
/**
* A Parse Error exception
* @internal
*/
class ParseErrorException extends Exception {}
@@ -0,0 +1,69 @@
<?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\Form\Mvc\Configuration;
/**
* Collects all auto-discovered form YAML configuration files across
* all active TYPO3 extensions.
*
* Each extension can provide YAML files under
* EXT:my_extension/Configuration/Form/<SetName>/
* with an accompanying config.yaml that declares name, label and priority.
* The collector is populated by {@see \TYPO3\CMS\Form\DependencyInjection\FormYamlCollectorConfigurator}
* and provides a priority-sorted list of file paths to {@see ConfigurationManager}.
*
* @internal
*/
final class FormYamlCollector
{
/** @var list<FormYamlConfiguration> */
private array $configurations = [];
public function add(FormYamlConfiguration $configuration): void
{
$this->configurations[] = $configuration;
}
/**
* Returns all registered file paths sorted by ascending priority
* (lower = loaded first = acts as base, higher = override).
*
* @return list<string>
*/
public function getPaths(): array
{
$sorted = $this->configurations;
usort($sorted, static fn(FormYamlConfiguration $a, FormYamlConfiguration $b): int => $a->priority <=> $b->priority);
return array_values(array_map(
static fn(FormYamlConfiguration $c): string => $c->path,
$sorted
));
}
/**
* Returns all registered configurations, regardless of priority.
* Useful for diagnostic/debug purposes.
*
* @return list<FormYamlConfiguration>
*/
public function getAllConfigurations(): array
{
return array_values($this->configurations);
}
}
@@ -0,0 +1,47 @@
<?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\Form\Mvc\Configuration;
/**
* Represents a single auto-discovered form YAML configuration file.
*
* Carries the EXT: path, the priority used for merge ordering, and the
* declared set name from config.yaml. A lower priority value is loaded first
* (acts as base); higher priority values are merged on top and can override
* earlier values.
*
* The {@see $setName} field mirrors the "name" key from config.yaml
* (e.g. "my-vendor/my-set") and is used both for the disable mechanism
* ($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['form']['disabledSets']) and
* for diagnostic / logging output.
*
* @internal
*/
final readonly class FormYamlConfiguration
{
/**
* @param string $path EXT: path to the YAML file, e.g. 'EXT:my_extension/Configuration/Form/MySet/config.yaml'
* @param int $priority Load order: lower = earlier = acts as base (default: 100)
* @param string $setName Declared "name" from config.yaml, e.g. "my-vendor/my-set". Empty string if omitted.
*/
public function __construct(
public string $path,
public int $priority,
public string $setName = '',
) {}
}
@@ -0,0 +1,84 @@
<?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\Form\Mvc\Configuration;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\TypoScript\TypoScriptService as CoreTypoScriptService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* Utilities to manage and convert TypoScript
*
* Scope: frontend
*/
readonly class TypoScriptService
{
public function __construct(
protected CoreTypoScriptService $coreTypoScriptService
) {}
/**
* Parse a configuration with ContentObjectRenderer::cObjGetSingle()
* and return the result.
*
* @internal
*/
public function resolvePossibleTypoScriptConfiguration(array $configuration, ServerRequestInterface $request): array
{
$configuration = $this->coreTypoScriptService->convertPlainArrayToTypoScriptArray($configuration);
$contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class);
$contentObjectRenderer->setRequest($request);
// @todo: Setting request to COR is probably important, but setting page record here *may* not be needed in this case?
$contentObjectRenderer->start($request->getAttribute('frontend.page.information')->getPageRecord(), 'pages');
$configuration = $this->resolveTypoScriptConfiguration($configuration, $contentObjectRenderer);
return $this->coreTypoScriptService->convertTypoScriptArrayToPlainArray($configuration);
}
/**
* Parse a configuration with ContentObjectRenderer::cObjGetSingle()
* if there is an array key without and with a dot at the end.
* This sample would be identified as a TypoScript parsable configuration
* part:
*
* [
* 'example' => 'TEXT'
* 'example.' => [
* 'value' => 'some value'
* ]
* ]
*/
protected function resolveTypoScriptConfiguration(array $configuration, ContentObjectRenderer $contentObjectRenderer): array
{
foreach ($configuration as $key => $value) {
$keyWithoutDot = rtrim((string)$key, '.');
if (isset($configuration[$keyWithoutDot]) && isset($configuration[$keyWithoutDot . '.'])) {
$value = $contentObjectRenderer->cObjGetSingle(
$configuration[$keyWithoutDot],
$configuration[$keyWithoutDot . '.'],
$keyWithoutDot
);
$configuration[$keyWithoutDot] = $value;
} elseif (!isset($configuration[$keyWithoutDot]) && isset($configuration[$keyWithoutDot . '.'])) {
$configuration[$keyWithoutDot] = $this->resolveTypoScriptConfiguration($value, $contentObjectRenderer);
}
unset($configuration[$keyWithoutDot . '.']);
}
return $configuration;
}
}
+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!
*/
/*
* Inspired by and partially taken from the Neos.Form package (www.neos.io)
*/
namespace TYPO3\CMS\Form\Mvc\Configuration;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFileAccessPermissionsException;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\FolderInterface;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Form\Mvc\Configuration\Exception\FileWriteException;
use TYPO3\CMS\Form\Mvc\Configuration\Exception\ParseErrorException;
use TYPO3\CMS\Form\Slot\FilePersistenceSlot;
/**
* Configuration source based on YAML files
*
* Scope: frontend / backend
* @internal
*/
readonly class YamlSource
{
public function __construct(
private FilePersistenceSlot $filePersistenceSlot,
private YamlFileLoader $yamlFileLoader,
) {}
/**
* Loads the specified configuration files and returns its merged content
* as an array.
*
* @internal
*/
public function load(array $filesToLoad): array
{
$configuration = [];
foreach ($filesToLoad as $fileToLoad) {
if ($fileToLoad instanceof File) {
$loadedConfiguration = $this->loadFromFile($fileToLoad);
} else {
$loadedConfiguration = $this->loadFromFilePath($fileToLoad);
if (isset($loadedConfiguration['TYPO3']['CMS']['Form'])) {
$namespacedConfiguration = $loadedConfiguration['TYPO3']['CMS']['Form'];
unset($loadedConfiguration['TYPO3']);
$loadedConfiguration = array_replace_recursive($namespacedConfiguration, $loadedConfiguration);
}
}
$configuration = array_replace_recursive($configuration, $loadedConfiguration);
}
return ArrayUtility::convertBooleanStringsToBooleanRecursive($configuration);
}
/**
* Save the specified configuration array to the given file in YAML format.
*
* @param File|string $fileToSave The file to write to.
* @param array $configuration The configuration to save
* @throws FileWriteException if the file could not be written
* @internal
*/
public function save(File|string $fileToSave, array $configuration): void
{
try {
$header = $this->getHeaderFromFile($fileToSave);
} catch (InsufficientFileAccessPermissionsException $e) {
throw new FileWriteException($e->getMessage(), 1512584488, $e);
}
$yaml = Yaml::dump($configuration, 99, 2);
if ($fileToSave instanceof File) {
// @deprecated: Remove in v16 along with the FileFormsToDatabaseUpgradeWizard
try {
$this->filePersistenceSlot->allowInvocation(
FilePersistenceSlot::COMMAND_FILE_SET_CONTENTS,
$this->buildCombinedIdentifier(
$fileToSave->getParentFolder(),
$fileToSave->getName()
),
$this->filePersistenceSlot->getContentSignature(
$header . LF . $yaml
)
);
$fileToSave->setContents($header . LF . $yaml);
} catch (InsufficientFileAccessPermissionsException $e) {
throw new FileWriteException($e->getMessage(), 1512582753, $e);
}
} else {
$byteCount = @file_put_contents($fileToSave, $header . LF . $yaml);
if ($byteCount === false) {
$error = error_get_last();
$errorMessage = $error['message'] ?? 'Check that the file exists and can be written.';
throw new FileWriteException($errorMessage, 1512582929);
}
}
}
/**
* Load YAML configuration from a local file path
*
* @throws ParseErrorException
*/
protected function loadFromFilePath(string $filePath): array
{
try {
$loadedConfiguration = $this->yamlFileLoader->load($filePath);
} catch (\RuntimeException $e) {
throw new ParseErrorException(
sprintf('An error occurred while parsing file "%s": %s', $filePath, $e->getMessage()),
1480195405,
$e
);
}
return $loadedConfiguration;
}
/**
* Load YAML configuration from a FAL file
*
* @throws ParseErrorException
*/
protected function loadFromFile(File $file): array
{
$fileIdentifier = $file->getIdentifier();
$rawYamlContent = $file->getContents();
try {
$loadedConfiguration = Yaml::parse($rawYamlContent);
} catch (ParseException $e) {
throw new ParseErrorException(
sprintf('An error occurred while parsing file "%s": %s', $fileIdentifier, $e->getMessage()),
1574422322,
$e
);
}
return $loadedConfiguration;
}
/**
* Read the header part from the given file. That means, every line
* until the first non comment line is found.
*
* @return string The header of the given YAML file
*/
protected function getHeaderFromFile(File|string $file): string
{
$header = '';
if ($file instanceof File) {
$fileLines = explode(LF, $file->getContents());
} elseif (is_file($file)) {
$fileLines = file($file);
} else {
return '';
}
foreach ($fileLines as $line) {
if (str_starts_with($line, '#')) {
$header .= $line;
} else {
break;
}
}
return $header;
}
/*
* @deprecated: Remove in v16 along with the FileFormsToDatabaseUpgradeWizard
*/
protected function buildCombinedIdentifier(FolderInterface $folder, string $fileName): string
{
return sprintf(
'%d:%s%s',
$folder->getStorage()->getUid(),
$folder->getIdentifier(),
$fileName
);
}
}
@@ -0,0 +1,50 @@
<?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\Form\Mvc\Persistence\Event;
/**
* Listeners are able to modify the loaded form definition
*/
final class AfterFormDefinitionLoadedEvent
{
public function __construct(
private array $formDefinition,
private readonly string $persistenceIdentifier,
private readonly string $cacheKey
) {}
public function getFormDefinition(): array
{
return $this->formDefinition;
}
public function setFormDefinition(array $formDefinition): void
{
$this->formDefinition = $formDefinition;
}
public function getPersistenceIdentifier(): string
{
return $this->persistenceIdentifier;
}
public function getCacheKey(): string
{
return $this->cacheKey;
}
}
+27
View File
@@ -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\Form\Mvc\Persistence;
use TYPO3\CMS\Form\Exception as FormException;
/**
* A generic Form persistence Exception
*
* @internal
*/
class Exception extends FormException {}
@@ -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\Form\Mvc\Persistence\Exception;
use TYPO3\CMS\Form\Mvc\Persistence\Exception;
/**
* @internal
*/
class NoUniqueIdentifierException 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\Form\Mvc\Persistence\Exception;
use TYPO3\CMS\Form\Mvc\Persistence\Exception;
/**
* @internal
*/
class NoUniquePersistenceIdentifierException extends Exception {}
@@ -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\Form\Mvc\Persistence\Exception;
use TYPO3\CMS\Form\Mvc\Persistence\Exception;
/**
* Generic Persistence Manager Exception, to be thrown f.e. if a given form is not loadable
*
* @internal
*/
class PersistenceManagerException extends Exception {}
@@ -0,0 +1,404 @@
<?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\Form\Mvc\Persistence;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Form\Domain\DTO\FormData;
use TYPO3\CMS\Form\Domain\DTO\FormMetadata;
use TYPO3\CMS\Form\Domain\DTO\PersistenceManagerConfiguration;
use TYPO3\CMS\Form\Domain\DTO\SearchCriteria;
use TYPO3\CMS\Form\Domain\DTO\StorageContext;
use TYPO3\CMS\Form\Domain\ValueObject\FormIdentifier;
use TYPO3\CMS\Form\Mvc\Configuration\TypoScriptService;
use TYPO3\CMS\Form\Mvc\Persistence\Event\AfterFormDefinitionLoadedEvent;
use TYPO3\CMS\Form\Mvc\Persistence\Exception\NoUniqueIdentifierException;
use TYPO3\CMS\Form\Mvc\Persistence\Exception\PersistenceManagerException;
use TYPO3\CMS\Form\Service\DatabaseService;
use TYPO3\CMS\Form\Storage\StorageAdapterFactory;
/**
* Form Persistence Manager - Entry point for FormManagerController
*
* This manager acts as a facade that delegates storage operations to appropriate
* storage adapters via the StorageAdapterFactory.
*
* Scope: frontend / backend
* @internal
*/
#[AsAlias(FormPersistenceManagerInterface::class)]
readonly class FormPersistenceManager implements FormPersistenceManagerInterface
{
public function __construct(
private StorageAdapterFactory $storageAdapterFactory,
#[Autowire(service: 'cache.runtime')]
private FrontendInterface $runtimeCache,
private EventDispatcherInterface $eventDispatcher,
private TypoScriptService $typoScriptService,
private DatabaseService $databaseService,
) {}
/**
* Load form definition and apply event listeners and TypoScript overrides
*/
public function load(string $persistenceIdentifier, ?array $typoScriptSettings = null, ?ServerRequestInterface $request = null): array
{
$cacheKey = 'ext-form-load-' . hash('xxh3', $persistenceIdentifier);
if ($this->runtimeCache->has($cacheKey)) {
$formDefinition = $this->runtimeCache->get($cacheKey);
} else {
$formDefinition = $this->loadFromStorage($persistenceIdentifier, $request);
$this->runtimeCache->set($cacheKey, $formDefinition);
}
$formDefinition = $this->eventDispatcher
->dispatch(new AfterFormDefinitionLoadedEvent($formDefinition, $persistenceIdentifier, $cacheKey))
->getFormDefinition();
if ($request !== null && !empty($typoScriptSettings['formDefinitionOverrides'][$formDefinition['identifier']] ?? null)) {
$formDefinitionOverrides = $this->typoScriptService->resolvePossibleTypoScriptConfiguration(
$typoScriptSettings['formDefinitionOverrides'][$formDefinition['identifier']],
$request
);
ArrayUtility::mergeRecursiveWithOverrule($formDefinition, $formDefinitionOverrides);
}
return $formDefinition;
}
/**
* Save form definition to appropriate storage
*
* @throws PersistenceManagerException
*/
public function save(string $persistenceIdentifier, array $formDefinition, array $formSettings, ?string $storageLocation = null): FormIdentifier
{
if (!$this->isAllowedPersistenceIdentifier($persistenceIdentifier)) {
throw new PersistenceManagerException(
sprintf('Save to path "%s" is not allowed.', $persistenceIdentifier),
1477680881
);
}
$identifier = new FormIdentifier($persistenceIdentifier);
$adapter = $this->storageAdapterFactory->getAdapterForIdentifier($persistenceIdentifier);
$formData = FormData::fromArray($formDefinition);
$context = null;
if (MathUtility::canBeInterpretedAsInteger($storageLocation)) {
$context = StorageContext::create((int)$storageLocation);
}
$savedIdentifier = $adapter->write($identifier, $formData, $context);
$this->clearFormCache($savedIdentifier->identifier);
return $savedIdentifier;
}
/**
* Delete form definition from storage
*/
public function delete(string $persistenceIdentifier, array $formSettings): void
{
$identifier = new FormIdentifier($persistenceIdentifier);
$adapter = $this->storageAdapterFactory->getAdapterForIdentifier($persistenceIdentifier);
if (!$adapter->exists($identifier)) {
throw new PersistenceManagerException(
sprintf('The form "%s" does not exist.', $persistenceIdentifier),
1472239535
);
}
$adapter->delete($identifier);
$this->clearFormCache($persistenceIdentifier);
}
/**
* List all form definitions from all available storages
*/
public function listForms(array $formSettings, SearchCriteria $searchCriteria): array
{
$identifiers = [];
$forms = [];
foreach ($this->storageAdapterFactory->getAllAdapters() as $adapter) {
try {
$formDataList = $adapter->findAll($searchCriteria);
foreach ($formDataList as $formData) {
if ($formData->storageType === null) {
$formData = $formData->withStorageType($adapter->getTypeIdentifier());
}
$forms[] = $formData;
if (!isset($identifiers[$formData->identifier])) {
$identifiers[$formData->identifier] = 0;
}
$identifiers[$formData->identifier]++;
}
} catch (\Exception $e) {
continue;
}
}
foreach ($identifiers as $identifier => $count) {
if ($count > 1) {
foreach ($forms as $index => $formMetadata) {
if ($formMetadata->identifier === $identifier) {
$forms[$index] = $formMetadata->withDuplicateIdentifier(true);
}
}
}
}
$allReferencesForFileUid = $this->databaseService->getAllReferencesForFileUid();
$allReferencesForPersistenceIdentifier = $this->databaseService->getAllReferencesForPersistenceIdentifier();
$allReferencesForFormDefinitionUid = $this->databaseService->getAllReferencesForFormDefinitionUid();
foreach ($forms as $index => $formMetadata) {
if (isset($formMetadata->fileUid) && array_key_exists($formMetadata->fileUid, $allReferencesForFileUid)) {
$referenceCount = $allReferencesForFileUid[$formMetadata->fileUid];
} elseif ($formMetadata->persistenceIdentifier && array_key_exists($formMetadata->persistenceIdentifier, $allReferencesForFormDefinitionUid)) {
$referenceCount = $allReferencesForFormDefinitionUid[$formMetadata->persistenceIdentifier];
} elseif ($formMetadata->persistenceIdentifier && array_key_exists($formMetadata->persistenceIdentifier, $allReferencesForPersistenceIdentifier)) {
$referenceCount = $allReferencesForPersistenceIdentifier[$formMetadata->persistenceIdentifier];
} else {
$referenceCount = 0;
}
if ($referenceCount > 0) {
$forms[$index] = $formMetadata->withReferenceCount($referenceCount);
}
}
return $this->sortForms($forms, $formSettings, $searchCriteria->getOrderField(), $searchCriteria->getOrderDirection());
}
/**
* Check if any forms are available
*/
public function hasForms(array $formSettings): bool
{
foreach ($this->storageAdapterFactory->getAllAdapters() as $adapter) {
try {
$forms = $adapter->findAll(new SearchCriteria(limit: 1));
if (!empty($forms)) {
return true;
}
} catch (\Exception) {
continue;
}
}
return false;
}
/**
* Get unique persistence identifier for a new form
*/
public function getUniquePersistenceIdentifier(string $storage, string $formIdentifier, ?string $savePath): string
{
return $this->storageAdapterFactory->getAdapterByType($storage)->getUniquePersistenceIdentifier($formIdentifier, $savePath);
}
/**
* Get unique identifier (not persistence identifier)
*/
public function getUniqueIdentifier(string $identifier): string
{
$originalIdentifier = $identifier;
if ($this->checkForDuplicateIdentifier($identifier)) {
for ($attempts = 1; $attempts < 100; $attempts++) {
$identifier = sprintf('%s_%d', $originalIdentifier, $attempts);
if (!$this->checkForDuplicateIdentifier($identifier)) {
return $identifier;
}
}
$identifier = $originalIdentifier . '_' . time();
if ($this->checkForDuplicateIdentifier($identifier)) {
throw new NoUniqueIdentifierException(
sprintf('Could not find a unique identifier for form identifier "%s" after %d attempts', $identifier, $attempts),
1477688567
);
}
}
return $identifier;
}
/**
* Check if a storage location is allowed
*
* For database storage: storageLocation is a PID
* For file storage: storageLocation is a folder path (e.g., "1:/forms/")
*/
public function isAllowedStorageLocation(string $storageLocation): bool
{
try {
$adapter = $this->storageAdapterFactory->getAdapterForIdentifier($storageLocation);
return $adapter->isAllowedStorageLocation($storageLocation);
} catch (\Exception) {
return false;
}
}
/**
* Check if a persistence identifier is allowed
*
* For database storage: identifier is a UID or NEW*
* For file storage: identifier is a full file path (e.g., "1:/forms/contact.form.yaml")
*/
public function isAllowedPersistenceIdentifier(string $persistenceIdentifier): bool
{
try {
$adapter = $this->storageAdapterFactory->getAdapterForIdentifier($persistenceIdentifier);
return $adapter->isAllowedPersistenceIdentifier($persistenceIdentifier);
} catch (\Exception) {
return false;
}
}
/**
* Check if file has valid extension
*/
public function hasValidFileExtension(string $fileName): bool
{
return str_ends_with($fileName, self::FORM_DEFINITION_FILE_EXTENSION);
}
/**
* Load form definition from storage
*/
private function loadFromStorage(string $persistenceIdentifier, ?ServerRequestInterface $request = null): array
{
try {
$identifier = new FormIdentifier($persistenceIdentifier);
$adapter = $this->storageAdapterFactory->getAdapterForIdentifier($persistenceIdentifier);
return $adapter->read($identifier, $request)->toArray();
} catch (\Exception $e) {
return [
'type' => 'Form',
'identifier' => $persistenceIdentifier,
'label' => $e->getMessage(),
'invalid' => true,
];
}
}
/**
* Check if form with persistence identifier exists
*/
private function exists(string $persistenceIdentifier): bool
{
try {
$identifier = new FormIdentifier($persistenceIdentifier);
$adapter = $this->storageAdapterFactory->getAdapterForIdentifier($persistenceIdentifier);
return $adapter->exists($identifier);
} catch (\Exception) {
return false;
}
}
/**
* Check if a form with given identifier already exists in any storage
*/
private function checkForDuplicateIdentifier(string $identifier): bool
{
foreach ($this->storageAdapterFactory->getAllAdapters() as $adapter) {
try {
if ($adapter->existsByFormIdentifier($identifier)) {
return true;
}
} catch (\Exception) {
continue;
}
}
return false;
}
protected function sortForms(array $forms, array $formSettings, string $orderField = '', ?string $orderDirection = null): array
{
$persistenceConfiguration = PersistenceManagerConfiguration::fromArray($formSettings['persistenceManager'] ?? []);
if ($orderDirection) {
$ascending = $orderDirection === 'asc';
} else {
$ascending = $persistenceConfiguration->sortAscending;
}
$sortMultiplier = $ascending ? 1 : -1;
$keys = $orderField ? [$orderField] : $persistenceConfiguration->sortByKeys;
usort($forms, static function (FormMetadata $a, FormMetadata $b) use ($keys, $sortMultiplier) {
foreach ($keys as $key) {
$aValue = $a->getSortableValue($key);
$bValue = $b->getSortableValue($key);
if ($aValue === null || $bValue === null) {
continue;
}
$diff = (is_int($aValue) && is_int($bValue))
? $aValue - $bValue
: strcasecmp((string)$aValue, (string)$bValue);
if ($diff !== 0) {
return $diff * $sortMultiplier;
}
}
return 0;
});
return $forms;
}
/**
* Clear cache for specific form
*/
private function clearFormCache(string $persistenceIdentifier): void
{
$cacheKey = 'ext-form-load-' . hash('xxh3', $persistenceIdentifier);
$this->runtimeCache->remove($cacheKey);
}
public function getAccessibleStorageAdapters(): array
{
$storageAdapters = [];
foreach ($this->storageAdapterFactory->getAllAdapters() as $adapter) {
if ($adapter->isAccessible() === false) {
continue;
}
$storageAdapters[] = [
'typeIdentifier' => $adapter->getTypeIdentifier(),
'label' => $adapter->getLabel(),
'description' => $adapter->getDescription(),
'iconIdentifier' => $adapter->getIconIdentifier(),
'options' => $adapter->getFormManagerOptions(),
];
}
return $storageAdapters;
}
}
@@ -0,0 +1,100 @@
<?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!
*/
/*
* Inspired by and partially taken from the Neos.Form package (www.neos.io)
*/
namespace TYPO3\CMS\Form\Mvc\Persistence;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Form\Domain\DTO\SearchCriteria;
use TYPO3\CMS\Form\Domain\ValueObject\FormIdentifier;
/**
* The form persistence manager interface
*
* Scope: frontend / backend
*
* @internal
*/
interface FormPersistenceManagerInterface
{
public const FORM_DEFINITION_FILE_EXTENSION = '.form.yaml';
/**
* Load the array form representation identified by $persistenceIdentifier, and return it.
*
* @param ?array $typoScriptSettings FE TS "plugin.tx_form.settings" - Given when rendering a form
* as plugin using FormFrontendController or formvh:render, empty array in all BE usages.
* Intended to override details like labels of single forms.
*/
public function load(string $persistenceIdentifier, ?array $typoScriptSettings = null, ?ServerRequestInterface $request = null): array;
/**
* Save the array form representation identified by $persistenceIdentifier
*/
public function save(string $persistenceIdentifier, array $formDefinition, array $formSettings, ?string $storageLocation = null): FormIdentifier;
/**
* Delete the form representation identified by $persistenceIdentifier
*/
public function delete(string $persistenceIdentifier, array $formSettings): void;
/**
* List all form definitions which can be loaded through this form persistence
* manager.
*
* Returns an associative array with each item containing the keys 'name' (the human-readable name of the form)
* and 'persistenceIdentifier' (the unique identifier for the Form Persistence Manager e.g. the path to the saved form definition).
*
* @return array in the format [['name' => 'Form 01', 'persistenceIdentifier' => 'path1'], [ .... ]]
*/
public function listForms(array $formSettings, SearchCriteria $searchCriteria): array;
/**
* Check if any form definition is available
*/
public function hasForms(array $formSettings): bool;
/**
* This takes a form identifier and returns a unique persistence identifier for it.
*/
public function getUniquePersistenceIdentifier(string $storage, string $formIdentifier, ?string $savePath): string;
public function getUniqueIdentifier(string $identifier): string;
/**
* Check if a storage location (PID for database, folder path for files) is allowed
*
* @param string $storageLocation The storage location (e.g., "123" for PID, "1:/forms/" for file path)
* @return bool True if the storageLocation is allowed
*/
public function isAllowedStorageLocation(string $storageLocation): bool;
/**
* Check if a persistence identifier (UID for database, full file path for files) is allowed
*
* @param string $persistenceIdentifier The persistence identifier (e.g., "456" for UID, "1:/forms/contact.form.yaml" for file)
* @return bool True if the persistence identifier is allowed
*/
public function isAllowedPersistenceIdentifier(string $persistenceIdentifier): bool;
public function hasValidFileExtension(string $fileName): bool;
public function getAccessibleStorageAdapters(): array;
}
+200
View File
@@ -0,0 +1,200 @@
<?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\Form\Mvc;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Error\Result;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
use TYPO3\CMS\Extbase\Property\PropertyMapper;
use TYPO3\CMS\Extbase\Property\PropertyMappingConfiguration;
use TYPO3\CMS\Extbase\Validation\Validator\ConjunctionValidator;
use TYPO3\CMS\Extbase\Validation\Validator\ValidatorInterface;
use TYPO3\CMS\Extbase\Validation\ValidatorResolver;
use TYPO3\CMS\Form\Mvc\Validation\ObjectStorageElementValidatorInterface;
/**
* A processing Rule contains information for property mapping and validation.
*
* Scope: frontend
* **This class is NOT meant to be sub classed by developers.**
* @internal
*/
#[Autoconfigure(public: true, shared: false)]
class ProcessingRule
{
/**
* The target data type the data should be converted to
*
* @var string
*/
protected $dataType;
protected PropertyMappingConfiguration $propertyMappingConfiguration;
protected ConjunctionValidator $validator;
protected Result $processingMessages;
/**
* Constructs this processing rule
* @internal
*/
public function __construct(
protected readonly PropertyMapper $propertyMapper,
ValidatorResolver $validatorResolver,
) {
$this->propertyMappingConfiguration = GeneralUtility::makeInstance(PropertyMappingConfiguration::class);
/** @var ConjunctionValidator $validator */
$validator = $validatorResolver->createValidator(ConjunctionValidator::class);
$this->validator = $validator;
$this->processingMessages = GeneralUtility::makeInstance(Result::class);
}
/**
* @internal
*/
public function getPropertyMappingConfiguration(): PropertyMappingConfiguration
{
return $this->propertyMappingConfiguration;
}
/**
* @internal
*/
public function getDataType(): string
{
return $this->dataType;
}
/**
* @internal
*/
public function setDataType(string $dataType)
{
$this->dataType = $dataType;
}
/**
* Returns the child validators of the ConjunctionValidator that is bound to this processing rule
*
* @internal
*/
public function getValidators(): \SplObjectStorage
{
return $this->validator->getValidators();
}
/**
* @internal
*/
public function addValidator(ValidatorInterface $validator)
{
$this->validator->addValidator($validator);
}
/**
* Initializes a new validator container
*
* @internal
*/
public function removeAllValidators(): void
{
$this->filterValidators(fn() => false);
}
/**
* Filters validators based on a closure
*
* @internal
*/
public function filterValidators(\Closure $filter): void
{
$validatorsToRemove = new \SplObjectStorage();
$validators = $this->getValidators();
foreach ($validators as $validator) {
if (!$filter($validator)) {
$validatorsToRemove->offsetSet($validator);
}
}
$validators->removeAll($validatorsToRemove);
}
/**
* Removes the specified validator.
*
* @param ValidatorInterface $validator The validator to remove
* @throws \TYPO3\CMS\Extbase\Validation\Exception\NoSuchValidatorException
* @internal
*/
public function removeValidator(ValidatorInterface $validator)
{
$this->validator->removeValidator($validator);
}
/**
* @param mixed $value
* @return mixed
* @internal
*/
public function process($value)
{
if ($this->dataType !== null) {
$value = $this->propertyMapper->convert($value, $this->dataType, $this->propertyMappingConfiguration);
$messages = $this->propertyMapper->getMessages();
$this->propertyMapper->resetMessages();
} else {
$messages = GeneralUtility::makeInstance(Result::class);
}
// PropertyMapper::convert() already records the TypeConverter's Error in
// $messages (via doMapping()) and returns null. If errors are present at
// this point, running validators on a null value would add spurious errors
// that mask the real rejection reason — skip them.
if ($messages->hasErrors()) {
$this->processingMessages->merge($messages);
return $value;
}
// For multi-value fields (e.g. multi-file uploads) the converted value
// is an ObjectStorage. By default, validators receive the whole collection
// (backwards compatible). Validators implementing ObjectStorageElementValidatorInterface
// (e.g. MimeTypeValidator, FileSizeValidator) are called once per element instead.
if ($value instanceof ObjectStorage) {
foreach ($this->getValidators() as $validator) {
$targets = $validator instanceof ObjectStorageElementValidatorInterface
? iterator_to_array($value)
: [$value];
foreach ($targets as $target) {
$messages->merge($validator->validate($target));
}
}
} else {
$messages->merge($this->validator->validate($value));
}
$this->processingMessages->merge($messages);
return $value;
}
/**
* @internal
*/
public function getProcessingMessages(): Result
{
return $this->processingMessages;
}
}
@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Form\Mvc\Property\Exception;
use TYPO3\CMS\Extbase\Error\Error;
/**
* A "Type Converter" Exception
*/
final class TypeConverterException extends \TYPO3\CMS\Extbase\Property\Exception\TypeConverterException
{
private ?Error $error = null;
public static function fromError(Error $error): TypeConverterException
{
$exception = new self($error->render(), $error->getCode());
$exception->error = $error;
return $exception;
}
public function getError(): Error
{
if ($this->error === null) {
return new Error($this->getMessage(), $this->getCode(), [$this->getPrevious()]);
}
return $this->error;
}
}
@@ -0,0 +1,143 @@
<?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\Form\Mvc\Property;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
use TYPO3\CMS\Extbase\Validation\ValidatorResolver;
use TYPO3\CMS\Form\Domain\Model\FormElements\FileUpload;
use TYPO3\CMS\Form\Domain\Model\Renderable\RenderableInterface;
use TYPO3\CMS\Form\Domain\Runtime\FormRuntime;
use TYPO3\CMS\Form\Event\AfterFormStateInitializedEvent;
use TYPO3\CMS\Form\Mvc\ProcessingRule;
use TYPO3\CMS\Form\Mvc\Property\TypeConverter\UploadedFileReferenceConverter;
use TYPO3\CMS\Form\Mvc\Validation\MimeTypeValidator;
/**
* Scope: frontend
* @internal
*/
#[AsEventListener(identifier: 'form/property-mapping-configuration')]
class PropertyMappingConfiguration
{
public function __invoke(AfterFormStateInitializedEvent $event): void
{
foreach ($event->formRuntime->getFormDefinition()->getRenderablesRecursively() as $renderable) {
$this->adjustPropertyMappingForFileUploadsAtRuntime($event->formRuntime, $renderable);
}
}
/**
* Adjusts property mapping configuration for file upload elements at runtime.
*
* At this point, form definition properties (from YAML) are fully available,
* unlike in initializeFormElement() which runs before YAML properties are set.
*
* This sets:
* - CONFIGURATION_UPLOAD_SEED: derived from the form session identifier
* for creating storage sub-folders.
* - CONFIGURATION_ALLOW_REMOVAL: from the element's 'allowRemoval' property
* to enable HMAC-signed file deletion.
*
* It also registers the MimeTypeValidator based on the 'allowedMimeTypes'
* property. This must happen here (and not in FileUpload::initializeFormElement())
* because the concrete form definition properties are only available at runtime.
*/
protected function adjustPropertyMappingForFileUploadsAtRuntime(
FormRuntime $formRuntime,
RenderableInterface $renderable
): void {
if (!$renderable instanceof FileUpload
|| $formRuntime->getFormSession() === null
|| !$formRuntime->canProcessFormSubmission()
) {
return;
}
$processingRule = $renderable->getRootForm()
->getProcessingRule($renderable->getIdentifier());
if ($renderable->getProperties()['multiple'] ?? false) {
$processingRule->setDataType(ObjectStorage::class);
}
$this->registerMimeTypeValidator($processingRule, $renderable);
$propertyMappingConfiguration = $processingRule->getPropertyMappingConfiguration();
// Pass all registered validators to the TypeConverter so they can run on the
// PseudoFile *before* the file is written to FAL storage. This prevents invalid
// files (wrong MIME type, oversized, etc.) from ever being persisted.
// All validators are forwarded — not only ObjectStorageElementValidatorInterface
// ones — because that interface only controls per-element fan-out inside
// ProcessingRule::process() for ObjectStorage values; it is unrelated to whether
// a validator is meaningful at the individual-file level. Validators that do not
// handle PseudoFile (e.g. NotEmptyValidator) treat it as a non-null value and
// return valid, which is the correct behaviour at this stage.
$preStorageValidators = iterator_to_array($processingRule->getValidators());
if ($preStorageValidators !== []) {
$propertyMappingConfiguration->setTypeConverterOption(
UploadedFileReferenceConverter::class,
UploadedFileReferenceConverter::CONFIGURATION_PRE_STORAGE_VALIDATORS,
$preStorageValidators
);
}
$propertyMappingConfiguration->setTypeConverterOption(
UploadedFileReferenceConverter::class,
UploadedFileReferenceConverter::CONFIGURATION_UPLOAD_SEED,
$formRuntime->getFormSession()->getIdentifier()
);
$propertyMappingConfiguration->setTypeConverterOption(
UploadedFileReferenceConverter::class,
UploadedFileReferenceConverter::CONFIGURATION_ALLOW_REMOVAL,
(bool)($renderable->getProperties()['allowRemoval'] ?? false)
);
}
/**
* Registers the MimeTypeValidator for the given file upload element based on
* its 'allowedMimeTypes' property.
*
* The validator is only added once, even if this method is called multiple
* times during a request.
*/
protected function registerMimeTypeValidator(
ProcessingRule $processingRule,
FileUpload $renderable
): void {
$allowedMimeTypes = [];
if (is_array($renderable->getProperties()['allowedMimeTypes'] ?? null)) {
$allowedMimeTypes = array_filter($renderable->getProperties()['allowedMimeTypes']);
}
if ($allowedMimeTypes === []) {
return;
}
foreach ($processingRule->getValidators() as $validator) {
if ($validator instanceof MimeTypeValidator) {
return;
}
}
$mimeTypeValidator = GeneralUtility::makeInstance(ValidatorResolver::class)
->createValidator(MimeTypeValidator::class, ['allowedMimeTypes' => $allowedMimeTypes]);
$processingRule->addValidator($mimeTypeValidator);
}
}
@@ -0,0 +1,203 @@
<?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\Form\Mvc\Property\TypeConverter;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Property\PropertyMappingConfigurationInterface;
use TYPO3\CMS\Extbase\Property\TypeConverter\AbstractTypeConverter;
use TYPO3\CMS\Form\Domain\Configuration\ConfigurationService;
use TYPO3\CMS\Form\Domain\Configuration\Exception\PropertyException;
use TYPO3\CMS\Form\Domain\Configuration\FormDefinitionConversionService;
use TYPO3\CMS\Form\Domain\Configuration\FormDefinitionValidationService;
use TYPO3\CMS\Form\Type\FormDefinitionArray;
/**
* Converter for form definition arrays
*
* @internal
*/
class FormDefinitionArrayConverter extends AbstractTypeConverter
{
public function __construct(
protected readonly FormDefinitionValidationService $formDefinitionValidationService,
protected readonly FormDefinitionConversionService $formDefinitionConversionService,
protected readonly ConfigurationService $configurationService,
) {}
/**
* Convert from $source to $targetType, a noop if the source is an array.
* If it is an empty string it will be converted to an empty array.
*
* @param string $source
* @param string $targetType
* @throws PropertyException
*/
public function convertFrom(
$source,
$targetType,
array $convertedChildProperties = [],
?PropertyMappingConfigurationInterface $configuration = null
): FormDefinitionArray {
$rawFormDefinitionArray = json_decode($source, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new PropertyException('Unable to decode JSON source: ' . json_last_error_msg(), 1512578002);
}
// Extend the hmac hashing key with the "per form editor session (load / save)" unique key.
// The form persistence identifier is embedded in the form definition by addHmacData()
// to allow looking up the correct per-form session token.
// @see \TYPO3\CMS\Form\Domain\Configuration\FormDefinitionConversionService::addHmacData
$formPersistenceIdentifier = $rawFormDefinitionArray['_formPersistenceIdentifier'] ?? '';
unset($rawFormDefinitionArray['_formPersistenceIdentifier']);
$sessionToken = $this->retrieveSessionToken($formPersistenceIdentifier);
$prototypeName = $rawFormDefinitionArray['prototypeName'] ?? null;
$identifier = $rawFormDefinitionArray['identifier'] ?? null;
// A modification of the properties "prototypeName" and "identifier" from the root form element
// through the form editor is always forbidden.
try {
if (!$this->formDefinitionValidationService->isPropertyValueEqualToHistoricalValue([$identifier, 'identifier'], $identifier, $rawFormDefinitionArray['_orig_identifier'] ?? [], $sessionToken)) {
throw new PropertyException('Unauthorized modification of "identifier".', 1528538324);
}
if (!$this->formDefinitionValidationService->isPropertyValueEqualToHistoricalValue([$identifier, 'prototypeName'], $prototypeName, $rawFormDefinitionArray['_orig_prototypeName'] ?? [], $sessionToken)) {
throw new PropertyException('Unauthorized modification of "prototype name".', 1528538323);
}
} catch (PropertyException $e) {
throw new PropertyException('Unauthorized modification of "prototype name" or "identifier".', 1528538322);
}
$this->formDefinitionValidationService->validateFormDefinitionProperties($rawFormDefinitionArray, $prototypeName, $sessionToken);
// @todo move all the transformations to FormDefinitionConversionService
$rawFormDefinitionArray = $this->filterEmptyArrays($rawFormDefinitionArray);
$rawFormDefinitionArray = $this->transformMultiValueElementsForFormFramework($rawFormDefinitionArray);
// Get RTE property paths for transformation and sanitization
$rtePropertyPaths = $this->getRtePropertyPaths($prototypeName);
// Transform RTE content using RteHtmlParser before persistence
if ($rtePropertyPaths !== []) {
$rawFormDefinitionArray = $this->formDefinitionConversionService->transformRteContentForPersistence(
$rawFormDefinitionArray,
$rtePropertyPaths
);
}
// Sanitize HTML: RTE fields use HtmlSanitizer, all others use strip_tags
$rawFormDefinitionArray = $this->formDefinitionConversionService->sanitizeHtml($rawFormDefinitionArray, $rtePropertyPaths);
$rawFormDefinitionArray = $this->formDefinitionConversionService->removeHmacData($rawFormDefinitionArray);
// Filter empty arrays again after removeHmacData, as removing _orig_* entries
// can leave previously non-empty arrays (e.g. fluidAdditionalAttributes) empty.
$rawFormDefinitionArray = $this->filterEmptyArrays($rawFormDefinitionArray);
return GeneralUtility::makeInstance(FormDefinitionArray::class, $rawFormDefinitionArray);
}
/**
* Some data which is build by the form editor needs a transformation before
* it can be used by the framework.
* Multivalue elements like select elements produce data like:
*
* [
* _label => 'label'
* _value => 'value'
* ]
*
* This method transforms this into:
*
* [
* 'value' => 'label'
* ]
*
* @param array $input
*/
protected function transformMultiValueElementsForFormFramework(array $input): array
{
$output = [];
foreach ($input as $key => $value) {
if (is_int($key) && is_array($value) && isset($value['_label']) && isset($value['_value'])) {
$key = $value['_value'];
$value = $value['_label'];
}
if (is_array($value)) {
$output[$key] = $this->transformMultiValueElementsForFormFramework($value);
} else {
$output[$key] = $value;
}
}
return $output;
}
/**
* Remove keys from an array if the key value is an empty array
*
* @todo ArrayUtility?
*/
protected function filterEmptyArrays(array $array): array
{
foreach ($array as $key => $value) {
if (!is_array($value)) {
continue;
}
if (empty($value)) {
unset($array[$key]);
continue;
}
$array[$key] = $this->filterEmptyArrays($value);
if (empty($array[$key])) {
unset($array[$key]);
}
}
return $array;
}
protected function retrieveSessionToken(string $formPersistenceIdentifier): string
{
return $this->formDefinitionConversionService->retrieveSessionToken($formPersistenceIdentifier);
}
/**
* Get RTE-enabled property paths from the prototype configuration.
*
* @param string|null $prototypeName The prototype name
* @return array Map of element types to their RTE property paths
*/
protected function getRtePropertyPaths(?string $prototypeName): array
{
if ($prototypeName === null) {
return [];
}
try {
$prototypeConfiguration = $this->configurationService->getPrototypeConfiguration($prototypeName);
return $this->formDefinitionConversionService->extractRtePropertyPaths($prototypeConfiguration);
} catch (\Exception $e) {
// If prototype configuration is not available, return empty array
return [];
}
}
}
@@ -0,0 +1,106 @@
<?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\Form\Mvc\Property\TypeConverter;
use TYPO3\CMS\Core\Type\File\FileInfo;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Form\Mvc\Property\Exception\TypeConverterException;
/**
* Use in `UploadedFileReferenceConverter` handling file uploads.
* `PseudoFile` and `PseudoFileReference` are independent and not associated.
* @internal
*/
class PseudoFile
{
/**
* @var \SplFileInfo
*/
protected $nameFileInfo;
/**
* @var FileInfo
*/
protected $payloadFileInfo;
/**
* @var string
*/
protected $payloadFilePath;
/**
* see https://www.php.net/manual/en/features.file-upload.post-method.php
*
* @param array $uploadInfo as in $_FILES
* @throws TypeConverterException
*/
public function __construct(array $uploadInfo)
{
if (!isset($uploadInfo['tmp_name']) || !isset($uploadInfo['name'])) {
throw new TypeConverterException(
'Could not determine uploaded file',
1602103603
);
}
$this->nameFileInfo = new \SplFileInfo($uploadInfo['name']);
$this->payloadFilePath = $uploadInfo['tmp_name'];
$this->payloadFileInfo = GeneralUtility::makeInstance(FileInfo::class, $uploadInfo['tmp_name']);
}
public function getName(): string
{
return $this->nameFileInfo->getBasename();
}
public function getNameWithoutExtension(): string
{
// `image...png`
return rtrim(
$this->nameFileInfo->getBasename($this->getExtension()),
'.'
);
}
public function getExtension(): string
{
return $this->nameFileInfo->getExtension();
}
public function getSize(): ?int
{
// returns `null` in case size is empty (includes `0`)
// @see \TYPO3\CMS\Core\Resource\AbstractFile::getSize()
return $this->payloadFileInfo->getSize() ?: null;
}
public function getMimeType(): ?string
{
$mimeType = $this->payloadFileInfo->getMimeType();
return is_string($mimeType) ? $mimeType : null;
}
public function getContents(): string
{
return file_get_contents($this->payloadFilePath);
}
public function getSha1(): string
{
return sha1_file($this->payloadFilePath);
}
}
@@ -0,0 +1,86 @@
<?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\Form\Mvc\Property\TypeConverter;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
/**
* Use in `UploadedFileReferenceConverter` handling file uploads.
* `PseudoFile` and `PseudoFileReference` are independent and not associated.
*
* This facade hides (potential) internal properties from being exposed to the
* public during serialization. `sys_file_reference.uid` or `sys_file.uid` are
* the only aspects used externally. In case of missing integrity checks during
* deserialization, both properties would allow direct object reference (IDOR).
*
* @internal
*/
class PseudoFileReference extends FileReference
{
/**
* @var int|null
*/
private $_uid;
/**
* @var int|null
*/
private $_uidLocal;
public function __sleep(): array
{
// in case this is a persisted file reference, use it directly as reference
// as a consequence, in-memory changes are lost and have to be persisted first
// (it seems that in ext:form a `FileReference` was never persisted)
if ($this->uid > 0) {
$this->_uid = (int)$this->uid;
return ['_uid'];
}
if ($this->getOriginalResource()->getUid() > 0) {
$this->_uid = $this->getOriginalResource()->getUid();
return ['_uid'];
}
// in case this is a transient file reference, just expose the associated `sys_file.uid`
// (based on previous comments, this is the most probably case in ext:form)
$this->_uidLocal = $this->getOriginalResource()->getOriginalFile()->getUid();
return ['_uidLocal'];
}
public function __wakeup(): void
{
$factory = GeneralUtility::makeInstance(ResourceFactory::class);
if ($this->_uid > 0) {
$this->originalResource = $factory->getFileReferenceObject($this->_uid);
} elseif ($this->_uidLocal > 0) {
$this->originalResource = $factory->createFileReferenceObject([
'uid_local' => $this->_uidLocal,
'uid_foreign' => 0,
'uid' => 0,
'crop' => null,
]);
} else {
throw new \LogicException(
sprintf('Cannot unserialize %s', static::class),
1613216548
);
}
unset($this->_uid, $this->_uidLocal);
}
}
@@ -0,0 +1,637 @@
<?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\Form\Mvc\Property\TypeConverter;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Crypto\Random;
use TYPO3\CMS\Core\Exception\Crypto\InvalidHashStringException;
use TYPO3\CMS\Core\Http\UploadedFile;
use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior;
use TYPO3\CMS\Core\Resource\Exception\FolderDoesNotExistException;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\FileReference as CoreFileReference;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Resource\ResourceInstructionTrait;
use TYPO3\CMS\Core\Resource\Security\FileNameValidator;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Extbase\Error\Error;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
use TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface;
use TYPO3\CMS\Extbase\Property\PropertyMappingConfigurationInterface;
use TYPO3\CMS\Extbase\Property\TypeConverter\AbstractTypeConverter;
use TYPO3\CMS\Form\Mvc\Property\Exception\TypeConverterException;
use TYPO3\CMS\Form\Security\HashScope;
use TYPO3\CMS\Form\Service\TranslationService;
use TYPO3\CMS\Form\Slot\ResourcePublicationSlot;
/**
* Scope: frontend
* @internal
*/
class UploadedFileReferenceConverter extends AbstractTypeConverter implements LoggerAwareInterface
{
use LoggerAwareTrait;
use ResourceInstructionTrait;
/**
* Folder where the file upload should go to (including storage).
*/
public const CONFIGURATION_UPLOAD_FOLDER = 1;
/**
* How to handle a upload when the name of the uploaded file conflicts.
*/
public const CONFIGURATION_UPLOAD_CONFLICT_MODE = 2;
/**
* Random seed to be used for deriving storage sub-folders.
*/
public const CONFIGURATION_UPLOAD_SEED = 3;
/**
* Whether the user is allowed to remove previously uploaded files.
*/
public const CONFIGURATION_ALLOW_REMOVAL = 5;
/**
* Validators implementing ObjectStorageElementValidatorInterface to run on
* PseudoFile before the file is written to FAL storage.
*/
public const CONFIGURATION_PRE_STORAGE_VALIDATORS = 6;
protected string $defaultUploadFolder = '1:/user_upload/';
/**
* One of 'cancel', 'replace', 'rename'
*/
protected DuplicationBehavior $defaultConflictMode = DuplicationBehavior::RENAME;
/**
* @var PseudoFileReference[]
*/
protected array $convertedResources = [];
protected ResourceFactory $resourceFactory;
protected HashService $hashService;
protected PersistenceManagerInterface $persistenceManager;
protected StorageRepository $storageRepository;
protected TranslationService $translationService;
/**
* @internal
*/
public function injectTranslationService(TranslationService $translationService): void
{
$this->translationService = $translationService;
}
/**
* @internal
*/
public function injectResourceFactory(ResourceFactory $resourceFactory): void
{
$this->resourceFactory = $resourceFactory;
}
/**
* @internal
*/
public function injectHashService(HashService $hashService): void
{
$this->hashService = $hashService;
}
/**
* @internal
*/
public function injectPersistenceManager(PersistenceManagerInterface $persistenceManager): void
{
$this->persistenceManager = $persistenceManager;
}
/**
* @internal
*/
public function injectStorageRepository(StorageRepository $storageRepository): void
{
$this->storageRepository = $storageRepository;
}
/**
* Actually convert from $source to $targetType, taking into account the fully
* built $convertedChildProperties and $configuration.
*
* @param array|UploadedFile|string $source
* @param string $targetType
* @return FileReference|ObjectStorage|Error|null
* @internal
*/
public function convertFrom($source, $targetType, array $convertedChildProperties = [], ?PropertyMappingConfigurationInterface $configuration = null)
{
if ($source === '' || $source === []) {
return null;
}
if ($source instanceof UploadedFile) {
return $this->convertSingleUpload(
$this->convertUploadedFileToUploadInfoArray($source),
$convertedChildProperties,
$configuration,
);
}
$allowRemoval = $configuration?->getConfigurationValue(self::class, self::CONFIGURATION_ALLOW_REMOVAL) ?? false;
$deleteFileIndices = [];
$filesToDelete = [];
if ($allowRemoval) {
[$deleteFileIndices, $filesToDelete] = $this->extractFileDeletionData($source);
$this->deleteUploadedFiles($filesToDelete);
}
unset($source['__deleteFile']);
if ($this->isMultiUploadTarget($source, $targetType)) {
return $this->handleMultiUploadSource($source, $deleteFileIndices, $convertedChildProperties, $configuration);
}
return $this->handleSingleUploadSource($source, $filesToDelete, $allowRemoval, $convertedChildProperties, $configuration);
}
/**
* Determines whether the source should be treated as a multi-file upload.
*
* Multi-upload is detected when:
* - the target type is ObjectStorage, or
* - the source contains '__submittedFiles' with more than one entry, or
* - the source contains numeric keys holding sub-arrays / UploadedFile objects
* (i.e. no flat 'error' key at the top level).
*/
private function isMultiUploadTarget(array $source, string $targetType): bool
{
if (is_a($targetType, ObjectStorage::class, true)) {
return true;
}
// Fallback heuristic for edge cases where targetType is not ObjectStorage
// but the source structure clearly indicates multi-upload.
if (isset($source['__submittedFiles']) && count($source['__submittedFiles']) > 1) {
return true;
}
return !array_key_exists('error', $source) && !isset($source['submittedFile']) && !isset($source['__submittedFiles']);
}
/**
* Handles single file upload: standard upload, existing resource pointer only,
* or new upload replacing a previous file.
*
* '__submittedFiles' contains existing resource pointers from hidden inputs.
* 'error' is PHP's native UPLOAD_ERR_* constant from $_FILES.
*
* @param list<int> $filesToDelete
*/
private function handleSingleUploadSource(
array $source,
array $filesToDelete,
bool $allowRemoval,
array $convertedChildProperties,
?PropertyMappingConfigurationInterface $configuration,
): FileReference|Error|null {
// Extract the submitted file resource pointer from __submittedFiles
$submittedResourcePointer = null;
if (is_array($source['__submittedFiles'] ?? null)) {
$firstSubmitted = reset($source['__submittedFiles']);
$submittedResourcePointer = $firstSubmitted['submittedFile']['resourcePointer'] ?? null;
}
unset($source['__submittedFiles']);
// Build a flat source array for convertSingleUpload compatibility
if ($submittedResourcePointer !== null) {
$source['submittedFile']['resourcePointer'] = $submittedResourcePointer;
}
if ($allowRemoval && $filesToDelete !== []) {
unset($source['submittedFile']['resourcePointer']);
}
// New upload replaces existing file clean up the old one
if (isset($source['submittedFile']['resourcePointer'], $source['error']) && $source['error'] === \UPLOAD_ERR_OK
) {
$this->deletePreviousUpload($source);
unset($source['submittedFile']);
}
return $this->convertSingleUpload($source, $convertedChildProperties, $configuration);
}
/**
* @param list<int> $deleteFileIndices
*/
private function handleMultiUploadSource(
array $source,
array $deleteFileIndices,
array $convertedChildProperties,
?PropertyMappingConfigurationInterface $configuration,
): ObjectStorage|Error {
$files = new ObjectStorage();
// Extract existing file resource pointers from the dedicated sub-key.
// These are stored separately to avoid index collision with new UploadedFile
// objects during array_replace_recursive() in RequestBuilder.
$submittedFiles = [];
if (is_array($source['__submittedFiles'] ?? null)) {
$submittedFiles = $source['__submittedFiles'];
}
unset($source['__submittedFiles']);
// Process existing files (resource pointers from previous uploads)
$existingFileIndex = 0;
foreach ($submittedFiles as $file) {
if (in_array($existingFileIndex, $deleteFileIndices, true)) {
$existingFileIndex++;
continue;
}
if (is_array($file)) {
$convertedFile = $this->convertSingleUpload(
$file,
$convertedChildProperties,
$configuration,
);
if ($convertedFile instanceof Error) {
return $convertedFile;
}
if ($convertedFile !== null) {
$files->attach($convertedFile);
}
}
$existingFileIndex++;
}
// Process new file uploads
foreach ($source as $file) {
if ($file instanceof UploadedFile || is_array($file)) {
$convertedFile = $this->convertSingleUpload(
$file instanceof UploadedFile ? $this->convertUploadedFileToUploadInfoArray($file) : $file,
$convertedChildProperties,
$configuration,
);
if ($convertedFile instanceof Error) {
return $convertedFile;
}
if ($convertedFile !== null) {
$files->attach($convertedFile);
}
}
}
return $files;
}
/**
* Core conversion: upload info array → FileReference, Error, or null.
*/
private function convertSingleUpload(
array $source,
array $convertedChildProperties,
?PropertyMappingConfigurationInterface $configuration,
): FileReference|Error|null {
$resourcePublicationSlot = GeneralUtility::makeInstance(ResourcePublicationSlot::class);
if (!isset($source['error']) || $source['error'] === \UPLOAD_ERR_NO_FILE) {
if (isset($source['submittedFile']['resourcePointer'])) {
try {
$resourcePointer = $this->hashService->validateAndStripHmac(
$source['submittedFile']['resourcePointer'],
HashScope::ResourcePointer->prefix(),
);
if (str_starts_with($resourcePointer, 'file:')) {
$fileUid = (int)substr($resourcePointer, 5);
$resource = $this->createFileReferenceFromFalFileObject(
$this->resourceFactory->getFileObject($fileUid),
);
} else {
$resource = $this->createFileReferenceFromFalFileReferenceObject(
$this->resourceFactory->getFileReferenceObject((int)$resourcePointer),
(int)$resourcePointer,
);
}
$resourcePublicationSlot->add($resource->getOriginalResource()->getOriginalFile());
return $resource;
} catch (\InvalidArgumentException) {
// No file uploaded and resource pointer is invalid discard.
}
}
return null;
}
if ($source['error'] !== \UPLOAD_ERR_OK) {
return GeneralUtility::makeInstance(Error::class, $this->getUploadErrorMessage($source['error']), 1471715915);
}
if (isset($this->convertedResources[$source['tmp_name']])) {
return $this->convertedResources[$source['tmp_name']];
}
if ($configuration === null) {
throw new \InvalidArgumentException('Argument $configuration must not be null', 1589183114);
}
try {
$resource = $this->importUploadedResource($source, $configuration);
$resourcePublicationSlot->add($resource->getOriginalResource()->getOriginalFile());
} catch (TypeConverterException $e) {
return $e->getError();
} catch (\Exception $e) {
return GeneralUtility::makeInstance(Error::class, $e->getMessage(), $e->getCode());
}
$this->convertedResources[$source['tmp_name']] = $resource;
return $resource;
}
/**
* Deletes a previously uploaded file referenced by submittedFile.resourcePointer.
*/
private function deletePreviousUpload(array $source): void
{
if (!isset($source['submittedFile']['resourcePointer'])) {
return;
}
try {
$resourcePointer = $this->hashService->validateAndStripHmac(
$source['submittedFile']['resourcePointer'],
HashScope::ResourcePointer->prefix(),
);
$fileUid = str_starts_with($resourcePointer, 'file:')
? (int)substr($resourcePointer, 5)
: null;
if ($fileUid !== null) {
$this->deleteUploadedFiles([$fileUid]);
}
} catch (InvalidHashStringException) {
// Invalid resource pointer nothing to delete.
}
}
/**
* Extracts and validates file deletion data from the source array.
*
* @return array{0: list<int>, 1: list<int>} Array containing [deleteFileIndices, filesToDelete]
*/
private function extractFileDeletionData(array $source): array
{
$deleteFileIndices = [];
$filesToDelete = [];
if (!array_key_exists('__deleteFile', $source) || !is_array($source['__deleteFile'])) {
return [$deleteFileIndices, $filesToDelete];
}
foreach ($source['__deleteFile'] as $signedValue) {
try {
$deleteData = $this->hashService->validateAndStripHmac(
$signedValue,
HashScope::DeleteFile->prefix()
);
$deleteData = json_decode($deleteData, true, 512, JSON_THROW_ON_ERROR);
if (isset($deleteData['fileIndex'])) {
$deleteFileIndices[] = (int)$deleteData['fileIndex'];
}
if (isset($deleteData['fileUid'])) {
$filesToDelete[] = (int)$deleteData['fileUid'];
}
} catch (InvalidHashStringException $e) {
$this->logger?->warning(
'Invalid file deletion request: HMAC validation failed.',
['exception' => $e]
);
} catch (\JsonException $e) {
$this->logger?->warning(
'Invalid file deletion request: JSON decoding failed.',
['exception' => $e]
);
}
}
return [$deleteFileIndices, $filesToDelete];
}
/**
* Deletes uploaded files from the server and cleans up empty upload folders.
*
* @param list<int> $fileUids
*/
private function deleteUploadedFiles(array $fileUids): void
{
foreach ($fileUids as $fileUid) {
try {
$file = $this->resourceFactory->getFileObject($fileUid);
$parentFolder = $file->getParentFolder();
$file->delete();
$this->deleteEmptyUploadFolder($parentFolder);
} catch (\Exception $e) {
$this->logger?->warning(
'Could not delete uploaded file with uid {fileUid}.',
['fileUid' => $fileUid, 'exception' => $e]
);
}
}
}
/**
* Deletes the upload folder if it's empty and was created by the form framework.
*/
private function deleteEmptyUploadFolder(?Folder $folder): void
{
if ($folder === null) {
return;
}
if (!str_starts_with($folder->getName(), 'form_')) {
return;
}
if ($folder->getFileCount() === 0
&& $folder->getStorage()->countFoldersInFolder($folder) === 0
) {
$folder->delete();
}
}
/**
* Import a resource and respect configuration given for properties
*/
protected function importUploadedResource(
array $uploadInfo,
PropertyMappingConfigurationInterface $configuration
): PseudoFileReference {
if (!GeneralUtility::makeInstance(FileNameValidator::class)->isValid($uploadInfo['name'])) {
throw new TypeConverterException('Uploading files with PHP file extensions is not allowed!', 1471710357);
}
// `CONFIGURATION_UPLOAD_SEED` is expected to be defined
// if it's not given any random seed is generated, instead of throwing an exception
$seed = $configuration->getConfigurationValue(self::class, self::CONFIGURATION_UPLOAD_SEED)
?: GeneralUtility::makeInstance(Random::class)->generateRandomHexString(40);
$uploadFolderId = $configuration->getConfigurationValue(self::class, self::CONFIGURATION_UPLOAD_FOLDER) ?: $this->defaultUploadFolder;
$conflictMode = DuplicationBehavior::tryFrom($configuration->getConfigurationValue(self::class, self::CONFIGURATION_UPLOAD_CONFLICT_MODE)) ?? $this->defaultConflictMode;
$pseudoFile = GeneralUtility::makeInstance(PseudoFile::class, $uploadInfo);
$preStorageValidators = $configuration->getConfigurationValue(self::class, self::CONFIGURATION_PRE_STORAGE_VALIDATORS) ?? [];
foreach ($preStorageValidators as $validator) {
$validationResult = $validator->validate($pseudoFile);
if ($validationResult->hasErrors()) {
$firstError = current($validationResult->getErrors());
throw TypeConverterException::fromError($firstError);
}
}
$uploadFolder = $this->provideUploadFolder($uploadFolderId);
// current folder name, derived from public random seed (`formSession`)
$currentName = 'form_' . $this->hashService->hmac($seed, self::class);
// sub-folder in $uploadFolder with 160 bit of derived entropy (.../form_<40-chars-hash>/actual.file)
$uploadFolder = $this->provideTargetFolder($uploadFolder, $currentName);
// allow skipping the consistency check, since custom validators have already been executed
$this->skipResourceConsistencyCheckForUploads($uploadFolder->getStorage(), $uploadInfo);
/** @var File $uploadedFile */
$uploadedFile = $uploadFolder->addUploadedFile($uploadInfo, $conflictMode);
$resourcePointer = isset($uploadInfo['submittedFile']['resourcePointer']) && !str_contains($uploadInfo['submittedFile']['resourcePointer'], 'file:')
? (int)$this->hashService->validateAndStripHmac($uploadInfo['submittedFile']['resourcePointer'], HashScope::ResourcePointer->prefix())
: null;
$fileReferenceModel = $this->createFileReferenceFromFalFileObject($uploadedFile, $resourcePointer);
return $fileReferenceModel;
}
protected function createFileReferenceFromFalFileObject(
File $file,
?int $resourcePointer = null
): PseudoFileReference {
$fileReference = $this->resourceFactory->createFileReferenceObject(
[
'uid_local' => $file->getUid(),
'uid_foreign' => StringUtility::getUniqueId('NEW_'),
'uid' => StringUtility::getUniqueId('NEW_'),
'crop' => null,
]
);
return $this->createFileReferenceFromFalFileReferenceObject($fileReference, $resourcePointer);
}
/**
* In case no $resourcePointer is given a new file reference domain object
* will be returned. Otherwise the file reference is reconstituted from
* storage and will be updated(!) with the provided $falFileReference.
*/
protected function createFileReferenceFromFalFileReferenceObject(
CoreFileReference $falFileReference,
?int $resourcePointer = null
): PseudoFileReference {
if ($resourcePointer === null) {
$fileReference = GeneralUtility::makeInstance(PseudoFileReference::class);
} else {
$fileReference = $this->persistenceManager->getObjectByIdentifier($resourcePointer, PseudoFileReference::class, false);
}
$fileReference->setOriginalResource($falFileReference);
return $fileReference;
}
/**
* Returns a human-readable message for the given PHP file upload error
* constant.
*/
protected function getUploadErrorMessage(int $errorCode): string
{
$logMessage = match ($errorCode) {
\UPLOAD_ERR_INI_SIZE => 'The uploaded file exceeds the upload_max_filesize directive in php.ini.',
\UPLOAD_ERR_FORM_SIZE => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.',
\UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially uploaded.',
\UPLOAD_ERR_NO_FILE => 'No file was uploaded.',
\UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder.',
\UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.',
\UPLOAD_ERR_EXTENSION => 'File upload stopped by extension.',
default => 'Unknown upload error.',
};
$this->logger?->error($logMessage);
$translationKey = match ($errorCode) {
\UPLOAD_ERR_INI_SIZE, \UPLOAD_ERR_FORM_SIZE => 'upload.error.150530345',
\UPLOAD_ERR_PARTIAL => 'upload.error.150530346',
\UPLOAD_ERR_NO_FILE => 'upload.error.150530347',
default => 'upload.error.150530348',
};
return $this->translationService->translate(
$translationKey,
null,
'EXT:form/Resources/Private/Language/locallang.xlf'
);
}
/**
* Ensures that upload folder exists, creates it if it does not.
*/
protected function provideUploadFolder(string $uploadFolderIdentifier): Folder
{
try {
return $this->resourceFactory->getFolderObjectFromCombinedIdentifier($uploadFolderIdentifier);
} catch (FolderDoesNotExistException $exception) {
[$storageId, $storagePath] = explode(':', $uploadFolderIdentifier, 2);
$storage = $this->storageRepository->getStorageObject($storageId);
$folderNames = GeneralUtility::trimExplode('/', $storagePath, true);
$uploadFolder = $this->provideTargetFolder($storage->getRootLevelFolder(), ...$folderNames);
$this->provideFolderInitialization($uploadFolder);
return $uploadFolder;
}
}
/**
* Ensures that particular target folder exists, creates it if it does not.
*/
protected function provideTargetFolder(Folder $parentFolder, string $folderName): Folder
{
return $parentFolder->hasFolder($folderName)
? $parentFolder->getSubfolder($folderName)
: $parentFolder->createFolder($folderName);
}
/**
* Creates empty index.html file to avoid directory indexing,
* in case it does not exist yet.
*/
protected function provideFolderInitialization(Folder $parentFolder): void
{
if (!$parentFolder->hasFile('index.html')) {
$parentFolder->createFile('index.html');
}
}
protected function convertUploadedFileToUploadInfoArray(UploadedFile $uploadedFile): array
{
return [
'name' => $uploadedFile->getClientFilename(),
'tmp_name' => $uploadedFile->getTemporaryFileName(),
'size' => $uploadedFile->getSize(),
'error' => $uploadedFile->getError(),
'type' => $uploadedFile->getClientMediaType(),
];
}
}
+73
View File
@@ -0,0 +1,73 @@
<?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!
*/
/*
* Inspired by and partially taken from the Neos.Form package (www.neos.io)
*/
namespace TYPO3\CMS\Form\Mvc\Validation;
use TYPO3\CMS\Extbase\Validation\Validator\AbstractValidator;
/**
* Validator for countable types
*
* Scope: frontend
* @internal
*/
final class CountValidator extends AbstractValidator
{
/**
* @var array
*/
protected $supportedOptions = [
'minimum' => [0, 'The minimum count to accept', 'integer'],
'maximum' => [PHP_INT_MAX, 'The maximum count to accept', 'integer'],
];
/**
* The given value is valid if it is an array or \Countable that contains the specified amount of elements.
*/
public function isValid(mixed $value): void
{
if (!is_array($value) && !($value instanceof \Countable)) {
$this->addError(
$this->translateErrorMessage(
'validation.error.1475002976',
'form'
),
1475002976
);
return;
}
$minimum = (int)$this->options['minimum'];
$maximum = (int)$this->options['maximum'];
$count = count($value);
if ($count < $minimum || $count > $maximum) {
$this->addError(
$this->translateErrorMessage(
'validation.error.1475002994',
'form',
[$minimum, $maximum]
),
1475002994,
[$this->options['minimum'], $this->options['maximum']]
);
}
}
}
@@ -0,0 +1,165 @@
<?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\Form\Mvc\Validation;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use TYPO3\CMS\Extbase\Validation\Validator\AbstractValidator;
use TYPO3\CMS\Form\Utility\DateRangeValidatorPatterns;
/**
* Validator for date ranges
*
* Scope: frontend
*/
final class DateRangeValidator extends AbstractValidator implements LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* @var array
*/
protected $supportedOptions = [
'minimum' => ['', 'The minimum date formatted as Y-m-d or a relative date expression (e.g. "today", "-18 years")', 'string'],
'maximum' => ['', 'The maximum date formatted as Y-m-d or a relative date expression (e.g. "today", "-18 years")', 'string'],
'format' => ['Y-m-d', 'The format of the minimum and maximum option', 'string'],
];
/**
* @param mixed $value The value that should be validated
*/
public function isValid(mixed $value): void
{
$options = $this->validateOptions();
if ($options === null) {
return;
}
if (!($value instanceof \DateTime)) {
$this->addError(
$this->translateErrorMessage(
'validation.error.1521293685',
'form',
[gettype($value)]
),
1521293685
);
return;
}
$minimum = $options['minimum'];
$maximum = $options['maximum'];
$format = $options['format'];
$value->modify('midnight');
if ($minimum instanceof \DateTime && $value < $minimum) {
$formattedMinimum = $minimum->format($format);
$this->addError(
$this->translateErrorMessage(
'validation.error.1521293687',
'form',
[$formattedMinimum]
),
1521293687,
[$formattedMinimum]
);
}
if ($maximum instanceof \DateTime && $value > $maximum) {
$formattedMaximum = $maximum->format($format);
$this->addError(
$this->translateErrorMessage(
'validation.error.1521293686',
'form',
[$formattedMaximum]
),
1521293686,
[$formattedMaximum]
);
}
}
/**
* Checks if this validator is correctly configured.
*
* Returns the resolved options array on success, or null if a date
* option is misconfigured. In the latter case a generic validation
* error is added for the end user and the technical details are logged.
*/
private function validateOptions(): ?array
{
$options = $this->options;
if (!empty($this->options['minimum'])) {
$minimum = $this->parseDate($this->options['minimum']);
if ($minimum === null) {
$this->logger->error('DateRangeValidator: The option "minimum" ({value}) could not be converted to DateTime. Use format "{format}" or a relative expression (e.g. "today", "-18 years").', [
'value' => $this->options['minimum'],
'format' => $this->options['format'],
]);
$this->addError(
$this->translateErrorMessage(
'validation.error.1748345955',
'form'
),
1748345955
);
return null;
}
$minimum->modify('midnight');
$options['minimum'] = $minimum;
}
if (!empty($this->options['maximum'])) {
$maximum = $this->parseDate($this->options['maximum']);
if ($maximum === null) {
$this->logger->error('DateRangeValidator: The option "maximum" ({value}) could not be converted to DateTime. Use format "{format}" or a relative expression (e.g. "today", "-18 years").', [
'value' => $this->options['maximum'],
'format' => $this->options['format'],
]);
$this->addError(
$this->translateErrorMessage(
'validation.error.1748345955',
'form'
),
1748345955
);
return null;
}
$maximum->modify('midnight');
$options['maximum'] = $maximum;
}
return $options;
}
/**
* Parse a date string as absolute format first, then fall back to relative expressions.
*
* Supports:
* - Absolute dates matching the configured format (e.g. "2025-03-17")
* - Any relative date expression accepted by PHP's DateTime parser
* (e.g. "today", "-18 years", "last sunday", "first day of next month")
*/
private function parseDate(string $value): ?\DateTime
{
$date = \DateTime::createFromFormat($this->options['format'], $value);
if ($date instanceof \DateTime) {
return $date;
}
return DateRangeValidatorPatterns::parseRelativeDateExpression($value);
}
}
+52
View File
@@ -0,0 +1,52 @@
<?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\Form\Mvc\Validation;
use TYPO3\CMS\Extbase\Validation\Validator\AbstractValidator;
/**
* Validator for empty values.
*
* Scope: frontend
*/
final class EmptyValidator extends AbstractValidator
{
/**
* This validator always needs to be executed even if the given value is empty.
* See AbstractValidator::validate()
*
* @var bool
*/
protected $acceptsEmptyValues = true;
/**
* Checks if the given property ($propertyValue) is empty (NULL, empty string, empty array or empty object).
*/
public function isValid(mixed $value): void
{
if (!empty($value)) {
$this->addError(
$this->translateErrorMessage(
'validation.error.1476396435',
'form'
),
1476396435
);
}
}
}
@@ -0,0 +1,22 @@
<?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\Form\Mvc\Validation\Exception;
use TYPO3\CMS\Form\Exception;
class InvalidValidationOptionsException 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\Form\Mvc\Validation;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Extbase\Validation\Validator\AbstractValidator;
use TYPO3\CMS\Form\Mvc\Property\TypeConverter\PseudoFile;
use TYPO3\CMS\Form\Mvc\Validation\Exception\InvalidValidationOptionsException;
/**
* Validator for countable types
*
* Scope: frontend
* @internal
*/
final class FileSizeValidator extends AbstractValidator implements ObjectStorageElementValidatorInterface
{
/**
* @var array
*/
protected $supportedOptions = [
'minimum' => ['0B', 'The minimum file size to accept', 'string'],
'maximum' => [PHP_INT_MAX . 'B', 'The maximum file size to accept', 'string'],
];
/**
* The given value is valid
*
* @param mixed $resource
*/
public function isValid(mixed $resource): void
{
$this->validateOptions();
if ($resource instanceof FileReference) {
$fileSize = $resource->getOriginalResource()->getSize();
} elseif ($resource instanceof File) {
$fileSize = $resource->getSize();
} elseif ($resource instanceof PseudoFile) {
$fileSize = $resource->getSize();
} else {
$this->addError(
$this->translateErrorMessage(
'validation.error.1505303626',
'form'
),
1505303626
);
return;
}
$minFileSize = GeneralUtility::getBytesFromSizeMeasurement($this->options['minimum']);
$maxFileSize = GeneralUtility::getBytesFromSizeMeasurement($this->options['maximum']);
$labels = ' Bytes| Kilobyte| Megabyte| Gigabyte';
if ($fileSize < $minFileSize) {
$formattedMinFileSize = GeneralUtility::formatSize($minFileSize, $labels);
$this->addError(
$this->translateErrorMessage(
'validation.error.1505305752',
'form',
[$formattedMinFileSize]
),
1505305752,
[$formattedMinFileSize]
);
}
if ($fileSize > $maxFileSize) {
$formattedMaxFileSize = GeneralUtility::formatSize($maxFileSize, $labels);
$this->addError(
$this->translateErrorMessage(
'validation.error.1505305753',
'form',
[$formattedMaxFileSize]
),
1505305753,
[$formattedMaxFileSize]
);
}
}
/**
* Checks if this validator is correctly configured
*
* @throws InvalidValidationOptionsException if the configured validation options are incorrect
*/
private function validateOptions(): void
{
if (!preg_match('/^(\d*\.?\d+)(B|K|M|G)$/i', $this->options['minimum'])) {
throw new InvalidValidationOptionsException('The option "minimum" has an invalid format. Valid formats are something like this: "10B|K|M|G".', 1505304205);
}
if (!preg_match('/^(\d*\.?\d+)(B|K|M|G)$/i', $this->options['maximum'])) {
throw new InvalidValidationOptionsException('The option "maximum" has an invalid format. Valid formats are something like this: "10B|K|M|G".', 1505304206);
}
}
}
@@ -0,0 +1,115 @@
<?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\Form\Mvc\Validation;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\MimeTypeDetector;
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Extbase\Validation\Validator\AbstractValidator;
use TYPO3\CMS\Form\Mvc\Property\TypeConverter\PseudoFile;
use TYPO3\CMS\Form\Mvc\Validation\Exception\InvalidValidationOptionsException;
/**
* Validator for mime types
*
* Scope: frontend
*/
final class MimeTypeValidator extends AbstractValidator implements ObjectStorageElementValidatorInterface
{
/**
* @var array
*/
protected $supportedOptions = [
'allowedMimeTypes' => [null, 'Allowed mime types (using */* IANA media types)', 'array', true],
];
/**
* The given $value is valid if it is a FileReference of the
* configured type (one of the IANA media types)
*
* Note: a value of NULL or empty string ('') is considered valid
*
* @param mixed $resource The resource that should be validated
*/
public function isValid(mixed $resource): void
{
$this->validateOptions();
if ($resource instanceof FileReference) {
$mimeType = $resource->getOriginalResource()->getMimeType();
$fileExtension = $resource->getOriginalResource()->getExtension();
} elseif ($resource instanceof File) {
$mimeType = $resource->getMimeType();
$fileExtension = $resource->getExtension();
} elseif ($resource instanceof PseudoFile) {
$mimeType = $resource->getMimeType();
$fileExtension = $resource->getExtension();
} else {
$this->addError(
$this->translateErrorMessage(
'validation.error.1471708997',
'form'
),
1471708997
);
return;
}
$allowedMimeTypes = $this->options['allowedMimeTypes'];
if (!in_array($mimeType, $allowedMimeTypes, true)) {
$this->addError(
$this->translateErrorMessage(
'validation.error.1471708998',
'form',
[$mimeType]
),
1471708998,
[$mimeType]
);
} else {
// The mime-type which was detected by FAL matches, but the file name does not match.
// Example: myfile.txt is actually a PDF file (defined by mime-type), but .txt is not associated
// for application/pdf, so this is not valid. The file extension of the uploaded file must match
// the mime-type for this file.
$assumedMimesTypeOfFileExtension = (new MimeTypeDetector())->getMimeTypesForFileExtension($fileExtension);
if (empty(array_intersect($allowedMimeTypes, $assumedMimesTypeOfFileExtension))) {
$this->addError(
$this->translateErrorMessage(
'validation.error.1613126216',
'form',
[$fileExtension]
),
1613126216,
[$fileExtension]
);
}
}
}
/**
* Checks if this validator is correctly configured
*
* @throws InvalidValidationOptionsException if the configured validation options are incorrect
*/
private function validateOptions(): void
{
if (!is_array($this->options['allowedMimeTypes'] ?? null) || $this->options['allowedMimeTypes'] === []) {
throw new InvalidValidationOptionsException('The option "allowedMimeTypes" must be an array with at least one item.', 1471713296);
}
}
}
@@ -0,0 +1,32 @@
<?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\Form\Mvc\Validation;
/**
* Marker interface for validators that operate on individual elements
* of a collection rather than on the collection itself.
*
* When ProcessingRule encounters an ObjectStorage value, validators are
* by default called with the whole collection (preserving backwards
* compatibility). Validators implementing this interface are called
* once per element instead.
*
* Example: A MimeTypeValidator validates each file individually, while
* a CountValidator checks the total number of items in the collection.
*/
interface ObjectStorageElementValidatorInterface {}