TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
Reference in New Issue
Block a user