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,559 @@
<?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\Upgrades;
use Psr\Log\LoggerInterface;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use TYPO3\CMS\Core\Attribute\UpgradeWizard;
use TYPO3\CMS\Core\Resource\Exception\FolderDoesNotExistException;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\Filter\FileExtensionFilter;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Resource\ResourceStorage;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Upgrades\ConfirmableInterface;
use TYPO3\CMS\Core\Upgrades\Confirmation;
use TYPO3\CMS\Core\Upgrades\DatabaseUpdatedPrerequisite;
use TYPO3\CMS\Core\Upgrades\UpgradeWizardInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
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\Repository\FormDefinitionRepository;
use TYPO3\CMS\Form\Mvc\Configuration\Exception\NoSuchFileException;
use TYPO3\CMS\Form\Mvc\Configuration\YamlSource;
use TYPO3\CMS\Form\Mvc\Persistence\Exception\PersistenceManagerException;
use TYPO3\CMS\Form\Mvc\Persistence\FormPersistenceManagerInterface;
use TYPO3\CMS\Form\Service\FormTransferService;
/**
* Migrate file-based form definitions (YAML) to database storage.
*
* This wizard reads all form definitions from the paths configured in
* `persistenceManager.allowedFileMounts` (deprecated since v14.2),
* imports them into the form_definition database table,
* and updates all tt_content references (FlexForm persistenceIdentifier) to
* point to the new database records.
*
* After successful migration, the original YAML files are deleted from
* file storage to avoid duplicates.
*
* When removing this class in v16, also remove all classes and methods tagged with
* `@deprecated Remove in v16 along with the FileFormsToDatabaseUpgradeWizard`.
*
* @since 14.2
* @internal
*/
#[UpgradeWizard('formFileFormsToDatabaseMigration')]
final readonly class FileFormsToDatabaseUpgradeWizard implements UpgradeWizardInterface, ConfirmableInterface
{
public function __construct(
private FormDefinitionRepository $formDefinitionRepository,
private FormTransferService $formTransferService,
private LoggerInterface $logger,
private YamlSource $yamlSource,
private ResourceFactory $resourceFactory,
private PersistenceConfigurationService $storageConfiguration,
private StorageRepository $storageRepository,
) {}
public function getTitle(): string
{
return 'Migrate file-based forms to database storage';
}
public function getDescription(): string
{
$forms = $this->getFileBasedForms();
$count = count($forms);
if ($count === 0) {
return 'No file-based form definitions found.';
}
$description = sprintf(
'Found %d file-based form definition(s) that will be migrated to database storage:',
$count
);
foreach ($forms as $form) {
$description .= LF . sprintf(
' • %s (%s)',
$form->name,
$form->persistenceIdentifier ?? $form->identifier
);
}
$description .= LF . LF . 'After migration, all tt_content references will be updated automatically.';
$description .= LF . LF . 'Note: Only references in tt_content (CType form_formframework) are updated '
. 'automatically. If your installation uses form persistence identifiers in custom '
. 'database tables or FlexForm fields outside tt_content, these references must be '
. 'updated manually.';
$description .= LF . LF . 'The original YAML files WILL BE DELETED after successful migration. '
. 'Please ensure backups exist. Failures will be logged and can be found in their '
. 'configured log locations after execution, and should be reviewed.';
return $description;
}
public function getConfirmation(): Confirmation
{
return new Confirmation(
'Migrate forms to database?',
'This will move all filemount-based form definitions into the database '
. 'and update content element references in tt_content. '
. 'References in custom database tables or FlexForm fields outside tt_content '
. 'are NOT updated automatically and must be migrated manually. '
. 'The original YAML files will be deleted after successful migration. '
. 'YAML files in extension directories are not affected. '
. 'Please make sure you have a backup before proceeding.',
false,
'Yes, migrate forms to database',
'No, keep file-based storage'
);
}
public function updateNecessary(): bool
{
return $this->getFileBasedForms() !== [];
}
public function executeUpdate(): bool
{
$forms = $this->getFileBasedForms();
if ($forms === []) {
return true;
}
$success = true;
$migrationMap = [];
$migratedFiles = [];
foreach ($forms as $form) {
$persistenceIdentifier = $form->persistenceIdentifier ?? $form->identifier;
try {
$formData = $this->readForm($persistenceIdentifier);
} catch (\Exception $e) {
$this->logger->error('Failed to load form definition from "{identifier}": {message}', [
'identifier' => $persistenceIdentifier,
'message' => $e->getMessage(),
]);
$success = false;
continue;
}
// Check if this form was already migrated (by identifier)
$existingUid = $this->formDefinitionRepository->findUidByFormIdentifier($formData->identifier);
if ($existingUid !== null) {
$this->logger->info('Form "{identifier}" already exists in database, skipping.', [
'identifier' => $formData->identifier,
]);
// Still track mapping for reference updates
$migrationMap[$persistenceIdentifier] = $existingUid;
$migratedFiles[] = $persistenceIdentifier;
continue;
}
// Write to database using raw insert (no DataHandler required,
// so this works in Install Tool context without a backend user)
try {
$newUid = $this->formDefinitionRepository->addRaw(0, $formData);
} catch (\Exception $e) {
$this->logger->error('Database insert failed for form "{identifier}": {message}', [
'identifier' => $formData->identifier,
'message' => $e->getMessage(),
]);
$newUid = null;
}
if ($newUid === null) {
$this->logger->error('Failed to insert form "{identifier}" into database.', [
'identifier' => $formData->identifier,
'persistenceIdentifier' => $persistenceIdentifier,
]);
$success = false;
continue;
}
$migrationMap[$persistenceIdentifier] = $newUid;
$migratedFiles[] = $persistenceIdentifier;
$this->logger->info('Migrated form "{identifier}" from "{file}" to database UID {uid}.', [
'identifier' => $formData->identifier,
'file' => $persistenceIdentifier,
'uid' => $newUid,
]);
}
// Update tt_content FlexForm references
if ($migrationMap !== []) {
// Convert int UIDs to string for the service (persistenceIdentifier values are always strings)
$stringMap = array_map(strval(...), $migrationMap);
$referencesUpdated = $this->formTransferService->updateContentElementReferences($stringMap);
$this->logger->info('Updated {count} content element reference(s).', [
'count' => $referencesUpdated,
]);
}
// Delete original YAML files only when all forms were migrated successfully.
// If any migration failed, keep all files to allow re-running the wizard.
if ($success && $migratedFiles !== []) {
$deletedCount = $this->deleteOriginalFiles($migratedFiles);
$this->logger->info('Deleted {count} of {total} original YAML file(s).', [
'count' => $deletedCount,
'total' => count($migratedFiles),
]);
} elseif (!$success && $migratedFiles !== []) {
$this->logger->warning(
'Some forms could not be migrated. Original YAML files were kept to allow re-running the wizard.'
);
}
return $success;
}
public function getPrerequisites(): array
{
return [
DatabaseUpdatedPrerequisite::class,
];
}
/**
* Find all YAML form definitions in configured file mounts.
*
* @return list<FormMetadata>
*/
private function getFileBasedForms(): array
{
try {
$results = [];
foreach ($this->retrieveYamlFilesFromStorageFolders() as $file) {
$formMetadata = $this->loadMetaData($file);
if (!$this->looksLikeAFormDefinition($formMetadata)) {
continue;
}
if (!$this->hasValidFileExtension($file->getCombinedIdentifier())) {
continue;
}
$results[] = $formMetadata;
}
return $results;
} catch (\Exception $e) {
$this->logger->warning('Could not list file-based forms: {message}', [
'message' => $e->getMessage(),
]);
return [];
}
}
/**
* Delete original YAML files from file storage after successful migration.
*
* Files that cannot be deleted (e.g. due to permissions) are logged but
* do not cause the overall migration to fail — the database records are
* already the authoritative source at this point.
*
* @param list<string> $persistenceIdentifiers Combined identifiers (e.g. "1:/form_definitions/contact.form.yaml")
* @return int Number of successfully deleted files
*/
private function deleteOriginalFiles(array $persistenceIdentifiers): int
{
$deletedCount = 0;
foreach ($persistenceIdentifiers as $persistenceIdentifier) {
try {
$this->deleteForm($persistenceIdentifier);
$deletedCount++;
$this->logger->info('Deleted original YAML file "{identifier}".', [
'identifier' => $persistenceIdentifier,
]);
} catch (\Exception $e) {
$this->logger->warning('Could not delete original YAML file "{identifier}": {message}', [
'identifier' => $persistenceIdentifier,
'message' => $e->getMessage(),
]);
}
}
return $deletedCount;
}
private function readForm(string $identifier): FormData
{
$file = $this->retrieveFileByPersistenceIdentifier($identifier);
$formDefinition = $this->yamlSource->load([$file]);
$this->generateErrorsIfFormDefinitionIsValidButHasInvalidFileExtension($formDefinition, $identifier);
return FormData::fromArray($formDefinition);
}
private function deleteForm(string $identifier): void
{
if (!$this->hasValidFileExtension($identifier)) {
throw new PersistenceManagerException(sprintf('The file "%s" could not be removed.', $identifier), 1472239534);
}
if (!$this->exists($identifier)) {
throw new PersistenceManagerException(sprintf('The file "%s" could not be removed.', $identifier), 1764879545);
}
[$storageUid, $fileIdentifier] = explode(':', $identifier, 2);
$storage = $this->getStorageByUid((int)$storageUid);
$file = $storage->getFile($fileIdentifier);
if (!$storage->checkFileActionPermission('delete', $file)) {
throw new PersistenceManagerException(sprintf('No delete access to file "%s".', $identifier), 1472239516);
}
$storage->deleteFile($file);
}
/**
* @throws PersistenceManagerException
* @throws NoSuchFileException
*/
private function retrieveFileByPersistenceIdentifier(string $identifier): File
{
$this->ensureValidPersistenceIdentifier($identifier);
try {
$file = $this->resourceFactory->retrieveFileOrFolderObject($identifier);
} catch (\Exception) {
// Top level catch to ensure useful following exception handling, because FAL throws top level exceptions.
$file = null;
}
if ($file === null) {
throw new NoSuchFileException(sprintf('YAML file "%s" could not be loaded', $identifier), 1524684442);
}
if (!$file->getStorage()->checkFileActionPermission('read', $file)) {
throw new PersistenceManagerException(sprintf('No read access to file "%s".', $identifier), 1471630578);
}
return $file;
}
/**
* @throws PersistenceManagerException
*/
private function ensureValidPersistenceIdentifier(string $identifier): void
{
if (pathinfo($identifier, PATHINFO_EXTENSION) !== 'yaml') {
throw new PersistenceManagerException(sprintf('The file "%s" could not be loaded.', $identifier), 1477679819);
}
}
/**
* @throws PersistenceManagerException
*/
private function generateErrorsIfFormDefinitionIsValidButHasInvalidFileExtension(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), 1780660703);
}
}
/**
* Check if array looks like a form definition
*/
private function looksLikeAFormDefinitionArray(array $data): bool
{
return !empty($data['identifier']) && trim($data['type'] ?? '') === 'Form';
}
private function hasValidFileExtension(string $identifier): bool
{
return str_ends_with($identifier, FormPersistenceManagerInterface::FORM_DEFINITION_FILE_EXTENSION);
}
private function exists(string $identifier): bool
{
$exists = false;
if ($this->hasValidFileExtension($identifier) && $this->pathIsIntendedAsFileMountPath($identifier)) {
[$storageUid, $fileIdentifier] = explode(':', $identifier, 2);
$storage = $this->getStorageByUid((int)$storageUid);
$exists = $storage->hasFile($fileIdentifier);
}
return $exists;
}
/**
* Returns a ResourceStorage for a given uid
*
* @throws PersistenceManagerException
*/
private function getStorageByUid(int $storageUid): ResourceStorage
{
$storage = $this->storageRepository->findByUid($storageUid);
if (!$storage?->isBrowsable()) {
throw new PersistenceManagerException(sprintf('Could not access storage with uid "%d".', $storageUid), 1471630581);
}
return $storage;
}
private function pathIsIntendedAsFileMountPath(string $path): bool
{
if (empty($path)) {
return false;
}
[$storageUid, $pathIdentifier] = explode(':', $path, 2);
if (empty($storageUid) || empty($pathIdentifier)) {
return false;
}
return MathUtility::canBeInterpretedAsInteger($storageUid);
}
/**
* Retrieves yaml files from storage folders for further processing.
* At this time it's not determined yet, whether these files contain form data.
*
* @return File[]
*/
private function retrieveYamlFilesFromStorageFolders(): array
{
$filesFromStorageFolders = [];
$fileExtensionFilter = GeneralUtility::makeInstance(FileExtensionFilter::class);
$fileExtensionFilter->setAllowedFileExtensions(['yaml']);
foreach ($this->getAccessibleFormStorageFolders() as $folder) {
$storage = $folder->getStorage();
$storage->setFileAndFolderNameFilters([
[$fileExtensionFilter, 'filterFileList'],
]);
$files = $folder->getFiles(0, 0, Folder::FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS, true);
array_push($filesFromStorageFolders, ...array_values($files));
$storage->resetFileAndFolderNameFiltersToDefault();
}
return $filesFromStorageFolders;
}
/**
* Return a list of all accessible file mountpoints for the
* current backend user.
*
* Only registered mount points from
* persistenceManager.allowedFileMounts
* are listed.
*
* @return Folder[]
*/
private function getAccessibleFormStorageFolders(): array
{
$storageFolders = [];
$allowedFileMounts = $this->storageConfiguration->getPersistenceManagerConfiguration()->allowedFileMounts;
if (empty($allowedFileMounts)) {
return $storageFolders;
}
foreach ($allowedFileMounts as $allowedFileMount) {
$allowedFileMount = rtrim($allowedFileMount, '/') . '/';
[$storageUid, $fileMountPath] = explode(':', $allowedFileMount, 2);
try {
$storage = $this->getStorageByUid((int)$storageUid);
} catch (PersistenceManagerException) {
continue;
}
$isStorageFileMount = false;
$parentFolder = $storage->getRootLevelFolder(false);
foreach ($storage->getFileMounts() as $storageFileMount) {
$storageFileMountFolder = $storageFileMount['folder'];
// Normally should use ResourceStorage::isWithinFolder() to check if the configured file mount path is within
// a storage file mount but this requires a valid Folder object and thus a directory which already exists.
// And the folder could simply not exist yet.
if (str_starts_with($fileMountPath, $storageFileMountFolder->getIdentifier())) {
$isStorageFileMount = true;
$parentFolder = $storageFileMountFolder;
}
}
// Get storage folder object, create it if missing
try {
$fileMountFolder = $storage->getFolder($fileMountPath);
} catch (InsufficientFolderAccessPermissionsException) {
continue;
} catch (FolderDoesNotExistException) {
if ($isStorageFileMount) {
$fileMountPath = substr(
$fileMountPath,
strlen($parentFolder->getIdentifier())
);
}
try {
$fileMountFolder = $storage->createFolder($fileMountPath, $parentFolder);
} catch (InsufficientFolderAccessPermissionsException) {
continue;
}
}
$storageFolders[$allowedFileMount] = $fileMountFolder;
}
return $storageFolders;
}
private function loadMetaData(File $file): FormMetadata
{
$persistenceIdentifier = $file->getCombinedIdentifier();
$rawYamlContent = $file->getContents();
try {
$yaml = $this->extractMetaDataFromCouldBeFormDefinition($rawYamlContent);
$this->generateErrorsIfFormDefinitionIsValidButHasInvalidFileExtension($yaml, $persistenceIdentifier);
return FormMetadata::createFromYaml(
$yaml,
$persistenceIdentifier,
$file->getUid()
);
} catch (\Exception $e) {
return FormMetadata::createInvalid($persistenceIdentifier, $e->getMessage());
}
}
private 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;
}
private function looksLikeAFormDefinition(FormMetadata $formMetadata): bool
{
return !empty($formMetadata->identifier) && trim($formMetadata->type) === 'Form';
}
}