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