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,173 @@
<?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\Storage;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Form\Domain\DTO\FormMetadata;
use TYPO3\CMS\Form\Domain\DTO\SearchCriteria;
use TYPO3\CMS\Form\Domain\ValueObject\FormIdentifier;
use TYPO3\CMS\Form\Mvc\Persistence\Exception\NoUniquePersistenceIdentifierException;
use TYPO3\CMS\Form\Mvc\Persistence\Exception\PersistenceManagerException;
use TYPO3\CMS\Form\Mvc\Persistence\FormPersistenceManagerInterface;
/**
* Abstract helper class for file-based form persistence
*
* Provides shared utility methods for file-based storage adapters.
* Concrete storage adapters must implement StorageAdapterInterface.
*
* @internal
*/
abstract class AbstractFileStorageAdapter
{
public const FORM_DEFINITION_FILE_EXTENSION = FormPersistenceManagerInterface::FORM_DEFINITION_FILE_EXTENSION;
protected ?StorageRepository $storageRepository = null;
public function injectStorageRepository(StorageRepository $storageRepository): void
{
$this->storageRepository = $storageRepository;
}
protected function hasValidFileExtension(string $identifier): bool
{
return str_ends_with($identifier, self::FORM_DEFINITION_FILE_EXTENSION);
}
abstract public function exists(FormIdentifier $identifier): bool;
abstract public function existsByFormIdentifier(string $formIdentifier): bool;
abstract public function findAll(SearchCriteria $criteria): array;
/**
* Build a user-friendly storageLocation label for display
* Each storage adapter implements this to provide appropriate storageLocation information
*/
abstract protected function buildStorageLocationLabel(string $persistenceIdentifier): string;
/**
* This takes a form identifier and returns a unique persistence identifier for it.
* By default, this is just similar to the identifier. But if a form with the same persistence identifier already
* exists a suffix is appended until the persistence identifier is unique.
*
* @param string $formIdentifier lowerCamelCased form identifier
* @param string $storageLocation Path where the form should be saved (e.g., "1:/forms/")
* @return string unique form persistence identifier (e.g., "1:/forms/contact.form.yaml")
* @throws NoUniquePersistenceIdentifierException
* @throws PersistenceManagerException
*/
public function getUniquePersistenceIdentifier(string $formIdentifier, string $storageLocation): string
{
$storageLocation = rtrim($storageLocation, '/') . '/';
$formPersistenceIdentifier = $storageLocation . $formIdentifier . self::FORM_DEFINITION_FILE_EXTENSION;
if (!$this->exists(new FormIdentifier($formPersistenceIdentifier))) {
return $formPersistenceIdentifier;
}
for ($attempts = 1; $attempts < 100; $attempts++) {
$formPersistenceIdentifier = $storageLocation . sprintf('%s_%d', $formIdentifier, $attempts) . self::FORM_DEFINITION_FILE_EXTENSION;
if (!$this->exists(new FormIdentifier($formPersistenceIdentifier))) {
return $formPersistenceIdentifier;
}
}
$formPersistenceIdentifier = $storageLocation . sprintf('%s_%d', $formIdentifier, time()) . self::FORM_DEFINITION_FILE_EXTENSION;
if (!$this->exists(new FormIdentifier($formPersistenceIdentifier))) {
return $formPersistenceIdentifier;
}
throw new NoUniquePersistenceIdentifierException(
sprintf('Could not find a unique persistence identifier for form identifier "%s" after %d attempts', $formIdentifier, $attempts),
1764879439
);
}
protected function extractMetaDataFromCouldBeFormDefinition(string $maybeRawFormDefinition): array
{
$metaDataProperties = ['identifier', 'type', 'label', 'prototypeName'];
$metaData = [];
foreach (explode(LF, $maybeRawFormDefinition) as $line) {
if (empty($line) || $line[0] === ' ') {
continue;
}
$parts = explode(':', $line, 2);
$key = trim($parts[0]);
if (!($parts[1] ?? null) || !in_array($key, $metaDataProperties, true)) {
continue;
}
if ($key === 'label') {
try {
$parsedLabelLine = Yaml::parse($line);
$value = $parsedLabelLine['label'] ?? '';
} catch (ParseException) {
$value = '';
}
} else {
$value = trim($parts[1], " '\"\r");
}
$metaData[$key] = $value;
}
return $metaData;
}
/**
* @throws PersistenceManagerException
*/
protected function generateErrorsIfFormDefinitionIsInvalidOrHasInvalidFileExtension(array $formDefinition, string $identifier): void
{
if (!$this->looksLikeAFormDefinitionArray($formDefinition) || !$this->hasValidFileExtension($identifier)) {
throw new PersistenceManagerException(sprintf('Form definition "%s" does not end with ".form.yaml".', $identifier), 1531160649);
}
}
/**
* Check if array looks like a form definition
*/
protected function looksLikeAFormDefinitionArray(array $data): bool
{
return !empty($data['identifier']) && trim($data['type'] ?? '') === 'Form';
}
protected function looksLikeAFormDefinition(FormMetadata $formMetadata): bool
{
return !empty($formMetadata->identifier) && trim($formMetadata->type) === 'Form';
}
/**
* Check if form data matches search criteria
*/
protected function matchesCriteria(FormMetadata $formMetadata, SearchCriteria $criteria): bool
{
if ($criteria->searchTerm) {
$searchIn = strtolower(
$formMetadata->name . ' '
. $formMetadata->identifier . ' '
. $formMetadata->prototypeName . ' '
. ($formMetadata->persistenceIdentifier ?? '')
);
if (!str_contains($searchIn, strtolower($criteria->searchTerm))) {
return false;
}
}
return true;
}
}
+352
View File
@@ -0,0 +1,352 @@
<?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\Storage;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Http\ApplicationType;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
use TYPO3\CMS\Form\Domain\DTO\FormData;
use TYPO3\CMS\Form\Domain\DTO\FormMetadata;
use TYPO3\CMS\Form\Domain\DTO\SearchCriteria;
use TYPO3\CMS\Form\Domain\DTO\StorageContext;
use TYPO3\CMS\Form\Domain\Repository\FormDefinitionRepository;
use TYPO3\CMS\Form\Domain\ValueObject\FormIdentifier;
use TYPO3\CMS\Form\Mvc\Persistence\Exception\PersistenceManagerException;
use TYPO3\CMS\Form\Storage\Permission\DatabasePermissionChecker;
/**
* Storage adapter for database-based form persistence
*
* Scope: frontend / backend
* @internal
*/
final readonly class DatabaseStorageAdapter implements StorageAdapterInterface
{
public function __construct(
private FormDefinitionRepository $repository,
private DatabasePermissionChecker $permissionChecker,
private JsonObjectKeyOrderPreserver $jsonObjectKeyOrderPreserver,
) {}
public function getTypeIdentifier(): string
{
return 'database';
}
public function supports(string $identifier): bool
{
return str_starts_with($identifier, 'NEW') || MathUtility::canBeInterpretedAsInteger($identifier);
}
public function getPriority(): int
{
return 100;
}
public function getLabel(): string
{
return 'formManager.storage.database.label';
}
public function getDescription(): string
{
return 'formManager.storage.database.description';
}
public function getIconIdentifier(): string
{
return 'content-database';
}
public function getUniquePersistenceIdentifier(string $formIdentifier, string $storageLocation): string
{
return StringUtility::getUniqueId('NEW');
}
/**
* @throws PersistenceManagerException
*/
public function read(FormIdentifier $identifier, ?ServerRequestInterface $request = null): FormData
{
$uid = $this->extractUidFromIdentifier($identifier);
$record = $this->repository->findByUid($uid);
if (!$record) {
throw new PersistenceManagerException(
sprintf('The form with uid "%s" could not be loaded.', $uid),
1767199422
);
}
$applicationType = $request !== null ? ApplicationType::fromRequest($request) : null;
// Skip permission checks in frontend context: Forms must be readable without a
// backend user session, so no backend permission checks are applied for frontend
// requests. In all other contexts (e.g. backend), permission checks are enforced.
if (!$applicationType?->isFrontend()) {
$this->permissionChecker->assertReadAccessForRecord($uid, $record);
}
try {
$formDefinitionArray = json_decode($record['configuration'] ?? '', true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new PersistenceManagerException(
sprintf('The form definition for uid "%s" is invalid: %s', $uid, $e->getMessage()),
1767199423,
$e
);
}
if (!is_array($formDefinitionArray)) {
throw new PersistenceManagerException(
sprintf('The form definition for uid "%s" is invalid.', $uid),
1767199444
);
}
$formDefinitionArray = $this->jsonObjectKeyOrderPreserver->restore($formDefinitionArray);
$formDefinitionArray['identifier'] = $record['identifier'];
return FormData::fromArray($formDefinitionArray);
}
/**
* @throws PersistenceManagerException
*/
public function write(FormIdentifier $identifier, FormData $data, ?StorageContext $context = null): FormIdentifier
{
if (!$this->exists($identifier)) {
$pid = 0;
if (!$this->permissionChecker->hasWritePermission($pid)) {
throw new PersistenceManagerException(
'Access denied: You do not have permission to create a form.',
1767199435
);
}
$uid = $this->repository->add($identifier->identifier, $pid, $data);
if (!$uid) {
throw new PersistenceManagerException(
'Failed to create form definition in database.',
1767199424
);
}
return new FormIdentifier((string)$uid);
}
$uid = $this->extractUidFromIdentifier($identifier);
$record = $this->repository->findByUid($uid);
if (!$record) {
throw new PersistenceManagerException(
sprintf('The form with uid "%s" could not be found.', $uid),
1767199425
);
}
$this->permissionChecker->assertWriteAccessForRecord($uid, $record);
$result = $this->repository->update($uid, $data);
if (!$result) {
throw new PersistenceManagerException(
sprintf('Failed to update form definition with uid "%s".', $uid),
1767199426
);
}
return $identifier;
}
/**
* @throws PersistenceManagerException
*/
public function delete(FormIdentifier $identifier): void
{
$uid = $this->extractUidFromIdentifier($identifier);
$record = $this->repository->findByUid($uid);
if (!$record) {
throw new PersistenceManagerException(
sprintf('The form with uid "%s" could not be found.', $uid),
1767199431
);
}
$this->permissionChecker->assertWriteAccessForRecord($uid, $record);
$success = $this->repository->remove($uid);
if (!$success) {
throw new PersistenceManagerException(
sprintf('Failed to delete form definition with uid "%s".', $uid),
1767199427
);
}
}
/**
* @throws PersistenceManagerException
*/
public function exists(FormIdentifier $identifier): bool
{
if (str_starts_with($identifier->identifier, 'NEW')) {
return false;
}
$uid = $this->extractUidFromIdentifier($identifier);
$record = $this->repository->findByUid($uid);
if ($record === null) {
return false;
}
$pid = (int)($record['pid'] ?? -1);
return $this->permissionChecker->hasReadPermission($pid);
}
public function existsByFormIdentifier(string $formIdentifier): bool
{
return $this->repository->existsByFormIdentifier($formIdentifier);
}
/**
* Find all form definitions for listing.
*
* Uses findAllForListing() which only selects metadata columns (uid, pid, identifier, label)
* instead of the full configuration JSON. This avoids loading and parsing potentially large
* JSON blobs just for the form listing view.
*/
public function findAll(SearchCriteria $criteria): array
{
$rows = $this->repository->findAllForListing($criteria);
$results = [];
foreach ($rows as $row) {
if ($row['uid'] === null) {
continue;
}
$pageId = (int)($row['pid'] ?? 0);
$uid = (int)$row['uid'];
if (!$this->permissionChecker->hasReadPermission($pageId)) {
continue;
}
$persistenceIdentifier = (string)$uid;
$hasWritePermission = $this->permissionChecker->hasWritePermission($pageId);
$metadata = new FormMetadata(
identifier: $row['identifier'] ?? '',
type: 'Form',
name: $row['label'] ?? $row['identifier'] ?? '',
prototypeName: 'standard',
persistenceIdentifier: $persistenceIdentifier,
readOnly: !$hasWritePermission,
removable: $hasWritePermission,
fileUid: null,
storageLocation: $this->getStorageLocationLabel(),
);
$results[] = $metadata;
}
return $results;
}
public function getFormManagerOptions(): array
{
if (!$this->permissionChecker->hasWritePermission(0)) {
return [];
}
return [
'allowedStorageLocations' => [
[
'value' => '0',
'label' => $this->getStorageLocationLabel(),
],
],
];
}
public function isAccessible(): bool
{
return $this->permissionChecker->hasWritePermission(0);
}
public function isAllowedStorageLocation(string $storageLocation): bool
{
if (MathUtility::canBeInterpretedAsInteger($storageLocation)) {
return (int)$storageLocation === 0;
}
return false;
}
public function isAllowedPersistenceIdentifier(string $persistenceIdentifier): bool
{
if (str_starts_with($persistenceIdentifier, 'NEW')) {
return true;
}
if (!MathUtility::canBeInterpretedAsInteger($persistenceIdentifier)) {
return false;
}
if (!$this->isAccessible()) {
return false;
}
$uid = (int)$persistenceIdentifier;
$record = $this->repository->findByUid($uid);
return $record !== null;
}
/**
* @throws PersistenceManagerException
*/
private function extractUidFromIdentifier(FormIdentifier $identifier): int
{
if (!MathUtility::canBeInterpretedAsInteger($identifier->identifier)) {
throw new PersistenceManagerException(
sprintf('Invalid database identifier "%s". Expected numeric UID.', $identifier->identifier),
1767199428
);
}
return (int)$identifier->identifier;
}
private function getStorageLocationLabel(): string
{
$languageService = $this->getLanguageService();
return $languageService?->sL('LLL:EXT:form/Resources/Private/Language/Database.xlf:' . $this->getLabel()) ?: 'Database';
}
private function getLanguageService(): ?LanguageService
{
return $GLOBALS['LANG'] ?? null;
}
}
+354
View File
@@ -0,0 +1,354 @@
<?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\Storage;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
use TYPO3\CMS\Form\Domain\Configuration\PersistenceConfigurationService;
use TYPO3\CMS\Form\Domain\DTO\FormData;
use TYPO3\CMS\Form\Domain\DTO\FormMetadata;
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\Exception\NoSuchFileException;
use TYPO3\CMS\Form\Mvc\Configuration\YamlSource;
use TYPO3\CMS\Form\Mvc\Persistence\Exception\PersistenceManagerException;
/**
* Storage adapter for extension-based form persistence
*
* @internal
*/
class ExtensionStorageAdapter extends AbstractFileStorageAdapter implements StorageAdapterInterface
{
public function __construct(
protected readonly YamlSource $yamlSource,
protected readonly ResourceFactory $resourceFactory,
protected readonly PersistenceConfigurationService $storageConfiguration,
#[Autowire(service: 'cache.runtime')]
protected readonly FrontendInterface $runtimeCache,
) {}
public function getTypeIdentifier(): string
{
return 'extension';
}
public function supports(string $identifier): bool
{
return PathUtility::isExtensionPath($identifier);
}
public function getPriority(): int
{
// High priority - extension paths should be checked early
return 75;
}
public function getLabel(): string
{
return 'formManager.storage.extension.label';
}
public function getDescription(): string
{
return 'formManager.storage.extension.description';
}
public function getIconIdentifier(): string
{
return 'content-extension';
}
public function read(FormIdentifier $identifier, ?ServerRequestInterface $request = null): FormData
{
$this->ensureValidPersistenceIdentifier($identifier->identifier);
$file = $identifier->identifier;
$formDefinition = $this->yamlSource->load([$file]);
$this->generateErrorsIfFormDefinitionIsInvalidOrHasInvalidFileExtension($formDefinition, $identifier->identifier);
return FormData::fromArray($formDefinition);
}
public function write(FormIdentifier $identifier, FormData $data, ?StorageContext $context = null): FormIdentifier
{
if (!$this->hasValidFileExtension($identifier->identifier)) {
throw new PersistenceManagerException(sprintf('The file "%s" could not be saved.', $identifier->identifier), 1764879569);
}
if (!$this->storageConfiguration->isAllowedToSaveToExtensionPaths()) {
throw new PersistenceManagerException('Save to extension paths is not allowed.', 1764879520);
}
if (!$this->isFileWithinAccessibleExtensionFolders($identifier->identifier)) {
throw new PersistenceManagerException(
sprintf('The file "%s" could not be saved. Please check your configuration option "persistenceManager.allowedExtensionPaths"', $identifier->identifier),
1484073571
);
}
$fileToSave = GeneralUtility::getFileAbsFileName($identifier->identifier);
try {
$this->yamlSource->save($fileToSave, $data->toArray());
} catch (\Exception $e) {
throw new PersistenceManagerException(
sprintf('The file "%s" could not be saved: %s', $identifier->identifier, $e->getMessage()),
1764879589,
$e
);
}
return $identifier;
}
public function delete(FormIdentifier $identifier): void
{
if (!$this->hasValidFileExtension($identifier->identifier)) {
throw new PersistenceManagerException(sprintf('The file "%s" could not be removed.', $identifier->identifier), 1764879609);
}
if (!$this->exists($identifier)) {
throw new PersistenceManagerException(sprintf('The file "%s" could not be removed.', $identifier->identifier), 1764879543);
}
if (!$this->storageConfiguration->isAllowedToDeleteFromExtensionPaths()) {
throw new PersistenceManagerException(sprintf('The file "%s" could not be removed.', $identifier->identifier), 1472239536);
}
if (!$this->isFileWithinAccessibleExtensionFolders($identifier->identifier)) {
$message = sprintf('The file "%s" could not be removed. Please check your configuration option "persistenceManager.allowedExtensionPaths"', $identifier->identifier);
throw new PersistenceManagerException($message, 1484073878);
}
$fileToDelete = GeneralUtility::getFileAbsFileName($identifier->identifier);
unlink($fileToDelete);
}
public function exists(FormIdentifier $identifier): bool
{
$exists = false;
if ($this->hasValidFileExtension($identifier->identifier)) {
if ($this->isFileWithinAccessibleExtensionFolders($identifier->identifier)) {
$exists = file_exists(GeneralUtility::getFileAbsFileName($identifier->identifier));
}
}
return $exists;
}
public function existsByFormIdentifier(string $formIdentifier): bool
{
foreach ($this->retrieveYamlFilesFromExtensionFolders() as $identifier) {
$formMetadata = $this->loadMetaData($identifier);
if ($this->looksLikeAFormDefinition($formMetadata) && $formMetadata->identifier === $formIdentifier) {
return true;
}
}
return false;
}
public function findAll(SearchCriteria $criteria): array
{
$results = [];
foreach ($this->retrieveYamlFilesFromExtensionFolders() as $identifier) {
$formMetadata = $this->loadMetaData($identifier);
if (!$this->looksLikeAFormDefinition($formMetadata)) {
continue;
}
if (!$this->hasValidFileExtension($identifier)) {
continue;
}
$readOnly = !$this->storageConfiguration->isAllowedToSaveToExtensionPaths();
$formMetadata = $formMetadata->withReadOnly($readOnly);
$removable = $this->storageConfiguration->isAllowedToDeleteFromExtensionPaths();
$formMetadata = $formMetadata->withRemovable($removable);
if (!$this->matchesCriteria($formMetadata, $criteria)) {
continue;
}
$results[] = $formMetadata;
}
return $results;
}
/**
* Return a list of all accessible extension folders
*
* Only registered mount points from
* persistenceManager.allowedExtensionPaths
* are listed.
*/
public function getAccessibleExtensionFolders(): array
{
$cacheKey = 'ext-form-accessibleExtensionFolders';
if ($this->runtimeCache->has($cacheKey)) {
return $this->runtimeCache->get($cacheKey);
}
$extensionFolders = [];
$allowedExtensionPaths = $this->storageConfiguration->getAllowedExtensionPaths();
if (empty($allowedExtensionPaths)) {
$this->runtimeCache->set($cacheKey, $extensionFolders);
return $extensionFolders;
}
foreach ($allowedExtensionPaths as $allowedExtensionPath) {
if (!PathUtility::isExtensionPath($allowedExtensionPath)) {
continue;
}
$allowedExtensionFullPath = GeneralUtility::getFileAbsFileName($allowedExtensionPath);
if (!file_exists($allowedExtensionFullPath)) {
continue;
}
$allowedExtensionPath = rtrim($allowedExtensionPath, '/') . '/';
$extensionFolders[$allowedExtensionPath] = $allowedExtensionFullPath;
}
$this->runtimeCache->set($cacheKey, $extensionFolders);
return $extensionFolders;
}
/**
* Retrieves yaml files from extension folders for further processing.
* At this time it's not determined yet, whether these files contain form data.
*
* @return string[]
*/
protected function retrieveYamlFilesFromExtensionFolders(): array
{
$filesFromExtensionFolders = [];
foreach ($this->getAccessibleExtensionFolders() as $relativePath => $fullPath) {
foreach (new \DirectoryIterator($fullPath) as $fileInfo) {
if ($fileInfo->getExtension() !== 'yaml') {
continue;
}
$filesFromExtensionFolders[] = $relativePath . $fileInfo->getFilename();
}
}
return $filesFromExtensionFolders;
}
protected function loadMetaData(string $fileOrIdentifier): FormMetadata
{
$this->ensureValidPersistenceIdentifier($fileOrIdentifier);
$persistenceIdentifier = $fileOrIdentifier;
$rawYamlContent = false;
$absoluteFilePath = GeneralUtility::getFileAbsFileName($fileOrIdentifier);
if ($absoluteFilePath !== '' && file_exists($absoluteFilePath)) {
$rawYamlContent = file_get_contents($absoluteFilePath);
}
try {
if ($rawYamlContent === false) {
throw new NoSuchFileException(sprintf('YAML file "%s" could not be loaded', $persistenceIdentifier), 1524684462);
}
$yaml = $this->extractMetaDataFromCouldBeFormDefinition($rawYamlContent);
$this->generateErrorsIfFormDefinitionIsInvalidOrHasInvalidFileExtension($yaml, $persistenceIdentifier);
return FormMetadata::createFromYaml(
$yaml,
$persistenceIdentifier,
)->withStorageLocation($this->buildStorageLocationLabel($persistenceIdentifier));
} catch (\Exception $e) {
return FormMetadata::createInvalid($persistenceIdentifier, $e->getMessage());
}
}
protected function isAccessibleExtensionFolder(string $folderName): bool
{
$folderName = rtrim($folderName, '/') . '/';
return array_key_exists($folderName, $this->getAccessibleExtensionFolders());
}
protected function isFileWithinAccessibleExtensionFolders(string $fileName): bool
{
$pathInfo = PathUtility::pathinfo($fileName, PATHINFO_DIRNAME);
$dirName = rtrim($pathInfo, '/') . '/';
return array_key_exists($dirName, $this->getAccessibleExtensionFolders());
}
/**
* @throws PersistenceManagerException
*/
protected function ensureValidPersistenceIdentifier(string $identifier): void
{
if (pathinfo($identifier, PATHINFO_EXTENSION) !== 'yaml') {
throw new PersistenceManagerException(sprintf('The file "%s" could not be loaded.', $identifier), 1764879628);
}
if (PathUtility::isExtensionPath($identifier)
&& !$this->isFileWithinAccessibleExtensionFolders($identifier)
) {
throw new PersistenceManagerException(
sprintf('The file "%s" could not be loaded. Please check your configuration option "persistenceManager.allowedExtensionPaths"', $identifier),
1484071985
);
}
}
/**
* Check if a storage location (extension folder) is allowed
*/
public function isAllowedStorageLocation(string $storageLocation): bool
{
// For extension storage, storageLocation is a folder path within allowed extensions
return $this->isAccessibleExtensionFolder($storageLocation);
}
/**
* Check if a persistence identifier (full file path) is allowed
*/
public function isAllowedPersistenceIdentifier(string $persistenceIdentifier): bool
{
// For extension storage, persistence identifier is a full file path (EXT:...)
return $this->hasValidFileExtension($persistenceIdentifier)
&& $this->isFileWithinAccessibleExtensionFolders($persistenceIdentifier);
}
public function getFormManagerOptions(): array
{
$preparedAccessibleFormStorageFolders = [];
if ($this->storageConfiguration->isAllowedToSaveToExtensionPaths()) {
foreach ($this->getAccessibleExtensionFolders() as $relativePath => $fullPath) {
$preparedAccessibleFormStorageFolders[] = [
'label' => $relativePath,
'value' => $relativePath,
];
}
}
return [
'allowedStorageLocations' => $preparedAccessibleFormStorageFolders,
];
}
public function isAccessible(): bool
{
return $this->storageConfiguration->isAllowedToSaveToExtensionPaths() && !empty($this->getAccessibleExtensionFolders());
}
/**
* Build a user-friendly storage location label
* Format: "extension_key/Configuration/Forms/file.form.yaml"
*/
protected function buildStorageLocationLabel(string $persistenceIdentifier): string
{
return $persistenceIdentifier;
}
}
@@ -0,0 +1,110 @@
<?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\Storage;
/**
* Workaround for form_definition.configuration being persisted through a
* native SQL JSON column (TCA type=json).
*
* Multi-value form elements (SingleSelect, MultiSelect, RadioButton,
* MultiCheckbox, ...) store their "options" as an associative map
* (value => label) keyed by option value. json_encode() of such a map
* produces a JSON *object*, and MySQL's native JSON column type does not
* guarantee that a JSON object's member order survives a write/read round
* trip. This is documented, spec-compliant behavior (RFC 8259: object
* member order "has no significance"). JSON *array* element order, by
* contrast, is reliably preserved by MySQL.
*
* MariaDB's JSON type is a plain LONGTEXT alias with a
* CHECK(JSON_VALID(...)) constraint, so it happens to preserve the exact
* text (and therefore object member order) byte-for-byte, which is why
* this only reproduces on real MySQL.
*
* Practical effect without this workaround: reordering a select element's
* options in the form editor visibly "works" right after saving, but
* reverts to the previous order on the next reload, once MySQL has
* renormalized the JSON object.
*
* protect() wraps every "options" map in an array-based structure before
* persisting, so the intended order survives via a JSON array instead of
* relying on object member order. restore() reverses this again after
* json_decode() on read, using the explicit order list rather than the
* member order MySQL happened to return the object in.
*
* Note: "options" is matched by key name rather than by resolving each
* renderable's prototype configuration for declared multi-value
* properties (as FormEditorController does for the editor UI). This is a
* deliberate simplification. It also protects option order inside
* variant overrides for free, without needing prototype/DI wiring in the
* persistence layer and is safe even where it over-applies, since
* protect()/restore() are lossless no-ops on any "options" map that
* doesn't need reordering.
*
* @internal
*/
final readonly class JsonObjectKeyOrderPreserver
{
private const string MARKER = '__jsonKeyOrderProtected';
public function protect(array $formDefinition): array
{
$output = $formDefinition;
foreach ($formDefinition as $key => $value) {
if (!is_array($value)) {
continue;
}
if ($key === 'options' && !array_is_list($value)) {
$output[$key] = [
self::MARKER => true,
'order' => array_keys($value),
'values' => $value,
];
continue;
}
$output[$key] = $this->protect($value);
}
return $output;
}
public function restore(array $formDefinition): array
{
$output = $formDefinition;
foreach ($formDefinition as $key => $value) {
if (!is_array($value)) {
continue;
}
if ($key === 'options' && ($value[self::MARKER] ?? false) === true) {
$order = $value['order'] ?? [];
$values = $value['values'] ?? [];
$restored = [];
foreach ($order as $optionKey) {
if (array_key_exists($optionKey, $values)) {
$restored[$optionKey] = $values[$optionKey];
unset($values[$optionKey]);
}
}
// Defensive fallback for entries the order list doesn't cover
// (should not normally happen, keeps old/foreign data intact).
$output[$key] = $restored + $values;
continue;
}
$output[$key] = $this->restore($value);
}
return $output;
}
}
@@ -0,0 +1,152 @@
<?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\Storage\Permission;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Form\Domain\Repository\FormDefinitionRepository;
use TYPO3\CMS\Form\Mvc\Persistence\Exception\PersistenceManagerException;
/**
* Permission checker for database-stored form definitions
*
* Encapsulates all backend user permission checks for the database storage adapter:
* - TCA table existence checks
* - Table read/write access
* - Page-level access (web mounts, page permissions)
*
* @internal
*/
final readonly class DatabasePermissionChecker
{
public function __construct(
private TcaSchemaFactory $tcaSchemaFactory,
) {}
/**
* Check if the current backend user has read permissions for the given page
*/
public function hasReadPermission(int $pageId): bool
{
if (!$this->hasBackendUser()) {
return false;
}
return $this->tcaSchemaFactory->has(FormDefinitionRepository::TABLE_NAME)
&& $this->hasTableReadAccess()
&& $this->hasPageAccess($pageId);
}
/**
* Assert that the current backend user has read permissions for the page
* a given form record is stored on.
*
* @throws PersistenceManagerException
*/
public function assertReadAccessForRecord(int $uid, ?array $record): void
{
$pid = (int)($record['pid'] ?? throw new PersistenceManagerException(
sprintf('The form with uid "%d" has no valid pid.', $uid),
1774364028
));
if (!$this->hasReadPermission($pid)) {
throw new PersistenceManagerException(
sprintf('Access denied: You do not have permission to access forms on page "%d".', $pid),
1774364031
);
}
}
/**
* Check if the current backend user has write permissions for the given page
*/
public function hasWritePermission(int $pageId): bool
{
if (!$this->hasBackendUser()) {
return false;
}
return $this->tcaSchemaFactory->has(FormDefinitionRepository::TABLE_NAME)
&& $this->hasTableWriteAccess()
&& $this->hasPageAccess($pageId);
}
/**
* Assert that the current backend user has write permissions for the page
* a given form record is stored on.
*
* @throws PersistenceManagerException
*/
public function assertWriteAccessForRecord(int $uid, ?array $record): void
{
$pid = (int)($record['pid'] ?? throw new PersistenceManagerException(
sprintf('The form with uid "%d" has no valid pid.', $uid),
1767199436
));
if (!$this->hasWritePermission($pid)) {
throw new PersistenceManagerException(
sprintf('Access denied: You do not have permission to persist forms on page "%d".', $pid),
1767199442
);
}
}
private function hasPageAccess(int $pageId): bool
{
$backendUser = $this->getBackendUser();
if ($backendUser->isAdmin()) {
return true;
}
if ($pageId <= 0) {
return true;
}
$pageRow = BackendUtility::getRecord('pages', $pageId);
if ($pageRow === null) {
return false;
}
// For all other pages, check web mount and page permissions
if ($backendUser->isInWebMount($pageId) === null) {
return false;
}
return $backendUser->doesUserHaveAccess($pageRow, Permission::PAGE_SHOW);
}
private function hasTableReadAccess(): bool
{
return $this->getBackendUser()->check('tables_select', FormDefinitionRepository::TABLE_NAME);
}
private function hasTableWriteAccess(): bool
{
return $this->getBackendUser()->check('tables_modify', FormDefinitionRepository::TABLE_NAME);
}
private function hasBackendUser(): bool
{
return ($GLOBALS['BE_USER'] ?? null) instanceof BackendUserAuthentication;
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,31 @@
<?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\Storage\Security;
/**
* Represents the DataHandler operations that FormDefinitionPersistenceGuard
* can authorise for the form_definition table.
*
* @internal
*/
enum FormDefinitionPersistenceCommand
{
case Create;
case Update;
case Delete;
}
@@ -0,0 +1,146 @@
<?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\Storage\Security;
use TYPO3\CMS\Core\Crypto\HashAlgo;
use TYPO3\CMS\Core\Crypto\HashService;
/**
* Guards direct DataHandler access to the form_definition table.
*
* FormDefinitionRepository grants a per-invocation token before each
* DataHandler call. FormDefinitionDataHandlerHook verifies and consumes that
* token; unauthorised DataHandler operations are rejected. This prevents
* backend users from bypassing form persistence validation by writing directly
* to the table (e.g. list module, impexp).
*
* Each token covers the command, the record identifier, and an HMAC of all
* field-pairs (ksort-ordered), so neither the operation nor any individual
* field value can be tampered with independently.
*
* @internal
*/
final class FormDefinitionPersistenceGuard
{
private array $allowedInvocations = [];
public function __construct(private readonly HashService $hashService) {}
/**
* Allows a single DataHandler invocation for the given command and record.
* Write commands (create, update) must supply the exact field-pairs that
* will be passed to DataHandler; delete passes null.
*
* Returns false if an identical invocation is already pending (duplicate).
*/
public function allowInvocation(
FormDefinitionPersistenceCommand $command,
string|int $identifier,
?array $fields = null,
): bool {
if ($this->findInvocationIndex($command, $identifier) !== null) {
return false;
}
$item = [
'command' => $command,
'identifier' => $identifier,
];
if ($fields !== null) {
$processed = $this->processFields($fields);
$item['names'] = $processed['names'];
$item['hmac'] = $processed['hmac'];
}
$this->allowedInvocations[] = $item;
return true;
}
/**
* Returns true if a matching invocation has been granted and not yet consumed.
* The provided fields must produce the same sorted key list and HMAC as
* the fields that were registered via allowInvocation().
*/
public function isInvocationAllowed(
FormDefinitionPersistenceCommand $command,
string|int $identifier,
?array $fields = null,
): bool {
$index = $this->findInvocationIndex($command, $identifier);
if ($index === null) {
return false;
}
if ($fields === null) {
return true;
}
$item = $this->allowedInvocations[$index];
$processed = $this->processFields($fields);
return $item['names'] === $processed['names'] && $item['hmac'] === $processed['hmac'];
}
/**
* Consumes a matching invocation (removes it from the pending list).
* Called both by the hook after successful verification (single-use
* enforcement) and by the repository's finally block (cleanup).
*/
public function consumeInvocation(
FormDefinitionPersistenceCommand $command,
string|int $identifier,
?array $fields = null,
): void {
$index = $this->findInvocationIndex($command, $identifier);
if ($index === null) {
return;
}
if ($fields === null) {
unset($this->allowedInvocations[$index]);
return;
}
$item = $this->allowedInvocations[$index];
$processed = $this->processFields($fields);
if ($item['names'] === $processed['names'] && $item['hmac'] === $processed['hmac']) {
unset($this->allowedInvocations[$index]);
}
}
private function findInvocationIndex(FormDefinitionPersistenceCommand $command, string|int $identifier): ?int
{
foreach ($this->allowedInvocations as $index => $invocation) {
if ($invocation['command'] === $command && $invocation['identifier'] === $identifier) {
return $index;
}
}
return null;
}
/**
* Sorts fields alphabetically and returns an array with keys 'names' and 'hmac'.
*
* @return array{names: list<string>, hmac: string}
*/
private function processFields(array $fields): array
{
ksort($fields);
return [
'names' => array_keys($fields),
'hmac' => $this->hashService->hmac(
json_encode($fields, JSON_THROW_ON_ERROR),
FormDefinitionPersistenceGuard::class,
HashAlgo::SHA3_384
),
];
}
}
+151
View File
@@ -0,0 +1,151 @@
<?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\Storage;
/**
* Factory for finding storage adapters using Chain of Responsibility pattern
*
* This factory manages storage adapters and finds the appropriate adapter
* for a given persistence identifier by asking each adapter if it can handle
* the identifier (via supports() method).
*
* Adapters are checked in priority order (highest priority first), allowing
* extensions to provide custom adapters that override core adapters.
*
* @internal
*/
final readonly class StorageAdapterFactory
{
/**
* @var list<StorageAdapterInterface>
*/
private array $adapters;
/**
* @param iterable<StorageAdapterInterface> $adapters
*/
public function __construct(iterable $adapters)
{
$this->adapters = $this->sortAdaptersByPriority($adapters);
}
/**
* Get storage adapter that can handle the given persistence identifier
*
* Uses Chain of Responsibility pattern to find the first adapter
* (in priority order) that supports the given identifier.
*
* @param string $identifier Persistence identifier (e.g., "EXT:my_extension/Forms/contact.form.yaml", "1:/forms/contact.form.yaml")
* @throws \RuntimeException if no adapter can handle the identifier
*/
public function getAdapterForIdentifier(string $identifier): StorageAdapterInterface
{
foreach ($this->adapters as $adapter) {
if ($adapter->supports($identifier)) {
return $adapter;
}
}
throw new \RuntimeException(
sprintf(
'No storage adapter found that can handle identifier "%s". Registered adapters: %s',
$identifier,
implode(', ', array_map(fn($a) => $a->getTypeIdentifier(), $this->adapters))
),
1731672000
);
}
/**
* Get adapter by type identifier
*
* @param string $typeIdentifier Type identifier (e.g., 'extension', 'filemount')
* @throws \InvalidArgumentException if no adapter with this type identifier exists
*/
public function getAdapterByType(string $typeIdentifier): StorageAdapterInterface
{
foreach ($this->adapters as $adapter) {
if ($adapter->getTypeIdentifier() === $typeIdentifier) {
return $adapter;
}
}
throw new \InvalidArgumentException(
sprintf(
'No storage adapter found with type identifier "%s". Available types: %s',
$typeIdentifier,
implode(', ', array_map(fn($a) => $a->getTypeIdentifier(), $this->adapters))
),
1731672002
);
}
/**
* Check if an adapter with the given type identifier exists
*/
public function hasAdapterType(string $typeIdentifier): bool
{
foreach ($this->adapters as $adapter) {
if ($adapter->getTypeIdentifier() === $typeIdentifier) {
return true;
}
}
return false;
}
/**
* Get all registered storage adapters
*
* @return list<StorageAdapterInterface>
*/
public function getAllAdapters(): array
{
return $this->adapters;
}
/**
* Get all registered storage type identifiers
*
* @return list<string>
*/
public function getRegisteredTypeIdentifiers(): array
{
return array_map(
fn(StorageAdapterInterface $adapter) => $adapter->getTypeIdentifier(),
$this->adapters
);
}
/**
* Sort adapters by priority (highest first)
*
* @param iterable<StorageAdapterInterface> $adapters
* @return list<StorageAdapterInterface>
*/
private function sortAdaptersByPriority(iterable $adapters): array
{
$sortedAdapters = [...$adapters];
usort(
$sortedAdapters,
fn(StorageAdapterInterface $a, StorageAdapterInterface $b) => $b->getPriority() <=> $a->getPriority()
);
return $sortedAdapters;
}
}
+186
View File
@@ -0,0 +1,186 @@
<?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\Storage;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Form\Domain\DTO\FormData;
use TYPO3\CMS\Form\Domain\DTO\FormMetadata;
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\Persistence\Exception\NoUniquePersistenceIdentifierException;
use TYPO3\CMS\Form\Mvc\Persistence\Exception\PersistenceManagerException;
/**
* Storage adapter interface for form persistence
*
* Storage adapters are responsible for reading, writing, and finding form definitions
* from various storage backends (file mounts, extensions, database, etc.).
*
* Adapters are selected using the Chain of Responsibility pattern:
* - Each adapter declares what identifiers it can handle via supports()
* - Factory iterates through adapters by priority until one matches
* - This allows third-party extensions to register custom storage adapters
*
* @internal
*/
interface StorageAdapterInterface
{
/**
* Get the human-readable label for this storage type
*
* @return string translation key
*/
public function getLabel(): string;
/**
* Get the description for this storage type
*
* @return string translation key
*/
public function getDescription(): string;
/**
* Get the icon identifier for this storage type
*
* @return string icon identifier
*/
public function getIconIdentifier(): string;
/**
* Get unique identifier for this storage type
*
* Used for metadata, display, and debugging purposes.
* Examples: 'extension', 'filemount', 'database'
*
* @return string Unique type identifier (lowercase, alphanumeric + underscore)
*/
public function getTypeIdentifier(): string;
/**
* Check if this adapter can handle the given persistence identifier
*
* @param string $identifier Persistence identifier (e.g., "EXT:my_extension/Forms/contact.form.yaml", "1:/forms/contact.form.yaml")
* @return bool True if this adapter can handle the identifier
*/
public function supports(string $identifier): bool;
/**
* Get options for the form manager interface
*/
public function getFormManagerOptions(): array;
/**
* Check if this storage is currently accessible
*/
public function isAccessible(): bool;
/**
* Get priority for capability checking
*
* Higher priority adapters are checked first.
* Allows extensions to override core adapters by providing higher priority.
*
* Suggested ranges:
* - 0-49: Low priority / fallback adapters
* - 50-99: Normal priority (file mounts, database)
* - 100+: High priority (extension paths, specific handlers)
*
* @return int Priority (higher = checked first)
*/
public function getPriority(): int;
/**
* Read form definition from storage
*
* @throws \TYPO3\CMS\Form\Mvc\Persistence\Exception\PersistenceManagerException
*/
public function read(FormIdentifier $identifier, ?ServerRequestInterface $request = null): FormData;
/**
* Write form definition to storage
*
* @param StorageContext|null $context Additional storage context (e.g., PID for database storage)
* @return FormIdentifier The identifier of the saved form (might differ for new forms in database storage)
* @throws PersistenceManagerException
*/
public function write(FormIdentifier $identifier, FormData $data, ?StorageContext $context = null): FormIdentifier;
/**
* Delete form definition from storage
*
* @throws \TYPO3\CMS\Form\Mvc\Persistence\Exception\PersistenceManagerException
*/
public function delete(FormIdentifier $identifier): void;
/**
* Check if form definition exists in storage
*/
public function exists(FormIdentifier $identifier): bool;
/**
* Check if a form with the given form identifier (not persistence identifier) exists
*
* This is used for efficient duplicate identifier checking without loading all forms.
* The form identifier is the logical name (e.g., "contact-form"), not the persistence
* identifier (e.g., UID or file path).
*
* @param string $formIdentifier The form identifier to check (e.g., "contact-form")
* @return bool True if a form with this identifier exists in this storage
*/
public function existsByFormIdentifier(string $formIdentifier): bool;
/**
* Find all form definitions matching the search criteria
*
* @return array<FormMetadata>
*/
public function findAll(SearchCriteria $criteria): array;
/**
* Get unique persistence identifier for a new form in this storage
*
* @param string $formIdentifier The form identifier (e.g., "contact-form")
* @param string $storageLocation The save path (e.g., "1:/forms/" for filemount, pid for database)
* @return string Unique persistence identifier
* @throws NoUniquePersistenceIdentifierException
*/
public function getUniquePersistenceIdentifier(string $formIdentifier, string $storageLocation): string;
/**
* Check if a storage location is allowed for this adapter
*
* For database storage: storageLocation is a PID
* For file storage: storageLocation is a folder path (e.g., "1:/forms/")
*
* @param string $storageLocation The storage location to check
* @return bool True if the storage location is allowed
*/
public function isAllowedStorageLocation(string $storageLocation): bool;
/**
* Check if a persistence identifier is allowed for this adapter
*
* 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")
*
* @param string $persistenceIdentifier The persistence identifier to check
* @return bool True if the persistence identifier is allowed
*/
public function isAllowedPersistenceIdentifier(string $persistenceIdentifier): bool;
}