TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:24 +02:00
commit aad9daaefd
1506 changed files with 94005 additions and 0 deletions
@@ -0,0 +1,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(),
];
}
}