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,197 @@
<?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\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Form\Service\CleanupFormUploadsService;
/**
* CLI command to clean up old file upload folders created by the TYPO3 Form Framework.
*
* When users upload files via ext:form, the files are stored in `form_<hash>` sub-folders.
* Over time these folders accumulate — both from completed and incomplete form submissions.
* Since files are not moved upon submission, there is no way to distinguish between
* the two. This command removes form upload folders older than a configurable retention period.
*
* Usage examples:
* # Dry-run: list form upload folders older than 2 weeks (default)
* bin/typo3 form:cleanup:uploads 1:/user_upload/ --dry-run
*
* # Delete folders older than 48 hours in specific upload folders
* bin/typo3 form:cleanup:uploads 1:/user_upload/ 2:/custom_uploads/ --retention-period=48
*
* # Force deletion without confirmation (e.g. for scheduler)
* bin/typo3 form:cleanup:uploads 1:/user_upload/ --force
*/
#[AsCommand('form:cleanup:uploads', 'Remove old form file upload folders based on retention period.')]
class CleanupFormUploadsCommand extends Command
{
private const int DEFAULT_RETENTION_PERIOD_HOURS = 336;
public function __construct(
private readonly CleanupFormUploadsService $cleanupService,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->setHelp(
'Removes old form upload folders (form_<hash>) that were created by file uploads '
. 'in ext:form.' . LF . LF
. 'Since uploaded files are not moved when a form is submitted, the command cannot ' . LF
. 'distinguish between folders from completed and abandoned submissions. It uses ' . LF
. 'the folder modification time and a configurable retention period to decide ' . LF
. 'which folders to remove.' . LF . LF
. 'You must specify at least one upload folder to scan. Each form element can configure ' . LF
. 'a different saveToFileMount; pass all relevant folders as arguments.' . LF . LF
. 'Use --verbose for detailed output about each folder found.'
)
->addArgument(
'upload-folder',
InputArgument::REQUIRED | InputArgument::IS_ARRAY,
'Combined folder identifier(s) to scan (e.g. "1:/user_upload/").',
)
->addOption(
'retention-period',
'r',
InputOption::VALUE_REQUIRED,
'Minimum age in hours before a form upload folder is considered for removal.',
(string)self::DEFAULT_RETENTION_PERIOD_HOURS,
)
->addOption(
'dry-run',
null,
InputOption::VALUE_NONE,
'Only list expired folders without deleting them.',
)
->addOption(
'force',
'f',
InputOption::VALUE_NONE,
'Skip the confirmation question. Automatically set when using --no-interaction.',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$retentionHours = (int)$input->getOption('retention-period');
if ($retentionHours < 1) {
$io->error('The retention period must be at least 1 hour.');
return Command::FAILURE;
}
$maximumAgeSeconds = $retentionHours * 3600;
/** @var list<string> $uploadFolders */
$uploadFolders = $input->getArgument('upload-folder');
$isDryRun = (bool)$input->getOption('dry-run');
$io->section(sprintf(
'Scanning %s for form upload folders older than %d hour(s)',
'folders: ' . implode(', ', $uploadFolders),
$retentionHours,
));
$expiredFolders = $this->cleanupService->getExpiredFolders($maximumAgeSeconds, $uploadFolders);
if ($expiredFolders === []) {
$io->success('No expired form upload folders found. Nothing to do.');
return Command::SUCCESS;
}
if ($output->isVerbose()) {
foreach ($expiredFolders as $folder) {
$age = time() - $folder->getModificationTime();
$ageHours = round($age / 3600, 1);
$fileCount = $folder->getFileCount();
$io->writeln(sprintf(
' [FOLDER] %s (age: %s hours, files: %d)',
$folder->getCombinedIdentifier(),
$ageHours,
$fileCount,
));
}
}
$totalFiles = 0;
foreach ($expiredFolders as $folder) {
$totalFiles += $folder->getFileCount();
}
$io->writeln(sprintf(
'Found <options=bold>%d folder(s)</> containing <options=bold>%d file(s)</>.',
count($expiredFolders),
$totalFiles,
));
if ($isDryRun) {
$io->note('Dry-run mode: no folders were deleted.');
return Command::SUCCESS;
}
// Do not ask for confirmation when running the command in EXT:scheduler
if (!$input->isInteractive()) {
$input->setOption('force', true);
}
if (!$input->getOption('force')) {
/** @var QuestionHelper $questionHelper */
$questionHelper = $this->getHelper('question');
$question = new ConfirmationQuestion(
sprintf(
'Are you sure you want to delete %d folder(s) with %d file(s)? [default: no] ',
count($expiredFolders),
$totalFiles,
),
false,
);
if (!$questionHelper->ask($input, $output, $question)) {
$io->note('Aborted by user.');
return Command::SUCCESS;
}
}
$result = $this->cleanupService->deleteFolders($expiredFolders);
if ($result['deleted'] > 0) {
$io->success(sprintf('Successfully deleted %d form upload folder(s).', $result['deleted']));
}
if ($result['failed'] > 0) {
$io->warning(sprintf('Failed to delete %d folder(s).', $result['failed']));
if ($output->isVerbose()) {
foreach ($result['errors'] as $error) {
$io->writeln(sprintf(' [ERROR] %s: %s', $error['folder'], $error['message']));
}
}
}
return $result['failed'] > 0 ? Command::FAILURE : Command::SUCCESS;
}
}
@@ -0,0 +1,285 @@
<?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\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Core\SystemEnvironmentBuilder;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\Http\ServerRequest;
use TYPO3\CMS\Form\Service\FormTransferService;
/**
* CLI command to transfer form definitions between storage backends.
*
* Usage examples:
* # Transfer all forms from extension storage to database
* bin/typo3 form:definition:transfer --source=extension --target=database
*
* # Transfer a specific form
* bin/typo3 form:definition:transfer --source=extension --target=database --form-identifier=contact
*
* # Move forms (transfer + delete source)
* bin/typo3 form:definition:transfer --source=filemount --target=database --move
*
* # Dry-run: preview what would be transferred
* bin/typo3 form:definition:transfer --source=extension --target=database --dry-run
*
* # Transfer to a specific target location (PID for database)
* bin/typo3 form:definition:transfer --source=extension --target=database --target-location=42
*/
#[AsCommand('form:definition:transfer', 'Transfer form definitions between storage backends')]
class TransferFormDefinitionCommand extends Command
{
public function __construct(
private readonly FormTransferService $formTransferService,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->setHelp(
'Transfers form definitions from one storage backend to another.' . LF . LF
. 'Available storage types depend on the installed adapters. Core provides:' . LF
. ' - database: Database storage (default target)' . LF
. ' - extension: Extension paths (EXT:...)' . LF
. ' - filemount: File mount storage (deprecated since v14.2)' . LF . LF
. 'Target location (--target-location / -l) per storage type:' . LF
. ' - database: Always "0" (fixed; forms are stored at the root level).' . LF
. ' - extension: An EXT: path configured in "persistenceManager.allowedExtensionPaths",' . LF
. ' with "persistenceManager.allowSaveToExtensionPaths: true" set.' . LF
. ' e.g. --target-location="EXT:my_extension/Resources/Private/Forms/"' . LF . LF
. 'Use --dry-run to preview which forms would be transferred.' . LF
. 'Use --move to delete the source form after successful transfer.' . LF . LF
. 'After a successful transfer, content element references in "tt_content" are automatically' . LF
. 'updated to point to the new storage location. No other tables are updated.'
)
->addOption(
'source',
null,
InputOption::VALUE_REQUIRED,
'Source storage type identifier (e.g., "extension", "filemount", "database").',
)
->addOption(
'target',
null,
InputOption::VALUE_REQUIRED,
'Target storage type identifier (e.g., "database", "extension").',
)
->addOption(
'target-location',
'l',
InputOption::VALUE_REQUIRED,
'Target storage location. For "database": always "0". For "extension": EXT: path from allowedExtensionPaths.',
'0',
)
->addOption(
'form-identifier',
'f',
InputOption::VALUE_REQUIRED,
'Transfer only the form with this identifier. If omitted, all forms from the source are transferred.',
)
->addOption(
'move',
'm',
InputOption::VALUE_NONE,
'Delete the source form after successful transfer (move operation).',
)
->addOption(
'dry-run',
null,
InputOption::VALUE_NONE,
'Only list forms that would be transferred without making changes.',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
// @todo: ConfigurationManager triggered by PersistenceConfigurationService needs a Request
$request = (new ServerRequest('https://localhost/', 'GET'));
$request = $request->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE)
->withAttribute('normalizedParams', NormalizedParams::createFromRequest($request));
$GLOBALS['TYPO3_REQUEST'] = $request;
$io = new SymfonyStyle($input, $output);
$sourceType = $input->getOption('source');
$targetType = $input->getOption('target');
$targetLocation = $input->getOption('target-location');
$formIdentifier = $input->getOption('form-identifier');
$isMove = (bool)$input->getOption('move');
$isDryRun = (bool)$input->getOption('dry-run');
if ($sourceType === null || $targetType === null) {
$io->error('Both --source and --target options are required.');
$io->note(sprintf(
'Available storage types: %s',
implode(', ', $this->formTransferService->getAvailableStorageTypes()),
));
return Command::FAILURE;
}
if (!$this->formTransferService->hasStorageType($sourceType)) {
$io->error(sprintf(
'Unknown source storage type "%s". Available types: %s',
$sourceType,
implode(', ', $this->formTransferService->getAvailableStorageTypes()),
));
return Command::FAILURE;
}
if (!$this->formTransferService->hasStorageType($targetType)) {
$io->error(sprintf(
'Unknown target storage type "%s". Available types: %s',
$targetType,
implode(', ', $this->formTransferService->getAvailableStorageTypes()),
));
return Command::FAILURE;
}
if ($sourceType === $targetType && $formIdentifier === null) {
$io->error('Source and target storage types are identical. Use --form-identifier to transfer a specific form, or choose different storage types.');
return Command::FAILURE;
}
$targetAdapter = $this->formTransferService->getAdapter($targetType);
if (!$targetAdapter->isAllowedStorageLocation($targetLocation)) {
$hint = match ($targetType) {
'database' => 'For database storage the only valid location is "0" (default). The option can be omitted.',
'extension' => 'For extension storage, provide an EXT: path that is registered in "persistenceManager.allowedExtensionPaths"' . LF
. 'and ensure "persistenceManager.allowSaveToExtensionPaths: true" is set in your form YAML setup.' . LF
. 'Example: --target-location="EXT:my_extension/Resources/Private/Forms/"',
default => 'Check the storage adapter documentation for valid location formats.',
};
$io->error(sprintf(
'The target location "%s" is not valid for the "%s" storage adapter.' . LF . '%s',
$targetLocation,
$targetType,
$hint,
));
return Command::FAILURE;
}
$sourceForms = $this->formTransferService->listSourceForms($sourceType, $formIdentifier);
if ($sourceForms === []) {
$message = $formIdentifier !== null
? sprintf('No form with identifier "%s" found in "%s" storage.', $formIdentifier, $sourceType)
: sprintf('No forms found in "%s" storage.', $sourceType);
$io->warning($message);
return Command::SUCCESS;
}
$operation = $isMove ? 'move' : 'transfer';
$io->section(sprintf(
'Found %d form(s) to %s from "%s" to "%s"',
count($sourceForms),
$operation,
$sourceType,
$targetType,
));
if ($isDryRun) {
$rows = [];
foreach ($sourceForms as $form) {
$rows[] = [
$form->identifier,
$form->name,
$form->persistenceIdentifier ?? '-',
'<comment>would ' . $operation . '</comment>',
];
}
$io->table(['Identifier', 'Name', 'Source', 'Status'], $rows);
$io->note('Dry-run mode: no forms were transferred.');
return Command::SUCCESS;
}
$transferred = 0;
$failed = 0;
$results = [];
$migrationMap = [];
foreach ($sourceForms as $form) {
try {
$result = $this->formTransferService->transferForm(
$form,
$sourceType,
$targetType,
$targetLocation,
$isMove,
);
$status = '<info>success</info>';
if ($isMove && $result->sourceDeleted) {
$status = '<info>moved</info>';
} elseif ($isMove && $result->deletionError !== null) {
$status = '<info>transferred</info>, <comment>source deletion failed: ' . $result->deletionError . '</comment>';
}
$results[] = [
$result->formIdentifier,
$result->formName,
$result->sourceIdentifier,
$result->targetIdentifier,
$status,
];
$migrationMap[$result->sourceIdentifier] = $result->targetIdentifier;
$transferred++;
} catch (\Exception $e) {
$results[] = [
$form->identifier,
$form->name,
$form->persistenceIdentifier ?? '-',
'-',
'<error>' . $e->getMessage() . '</error>',
];
$failed++;
if ($output->isVerbose()) {
$io->error(sprintf('Failed to transfer "%s": %s', $form->identifier, $e->getMessage()));
}
}
}
$io->table(['Identifier', 'Name', 'Source', 'Target', 'Status'], $results);
if ($migrationMap !== []) {
$referencesUpdated = $this->formTransferService->updateContentElementReferences($migrationMap);
if ($referencesUpdated > 0) {
$io->note(sprintf('Updated %d content element reference(s).', $referencesUpdated));
}
}
if ($transferred > 0) {
$verb = $isMove ? 'moved' : 'transferred';
$io->success(sprintf('Successfully %s %d form(s) from "%s" to "%s".', $verb, $transferred, $sourceType, $targetType));
}
if ($failed > 0) {
$io->warning(sprintf('Failed to transfer %d form(s).', $failed));
}
return $failed > 0 ? Command::FAILURE : Command::SUCCESS;
}
}