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,400 @@
<?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!
*/
/*
* Inspired by and partially taken from the Neos.Form package (www.neos.io)
*/
namespace TYPO3\CMS\Form\Domain\Finishers;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\Exception\MissingArrayPathException;
use TYPO3\CMS\Core\View\ViewFactoryData;
use TYPO3\CMS\Core\View\ViewFactoryInterface;
use TYPO3\CMS\Extbase\Reflection\ObjectAccess;
use TYPO3\CMS\Form\Domain\Finishers\Exception\FinisherException;
use TYPO3\CMS\Form\Domain\Model\FormElements\StringableFormElementInterface;
use TYPO3\CMS\Form\Domain\Runtime\FormRuntime;
use TYPO3\CMS\Form\Service\TranslationService;
/**
* Finisher base class.
*
* Scope: frontend
* **This class is meant to be sub classed by developers**
*/
abstract class AbstractFinisher implements FinisherInterface, LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* @var string
*/
protected $finisherIdentifier = '';
/**
* @var string
*/
protected $shortFinisherIdentifier = '';
/**
* The options which have been set from the outside. Instead of directly
* accessing them, you should rather use parseOption().
*
* @var array
*/
protected $options = [];
/**
* These are the default options of the finisher.
* Override them in your concrete implementation.
* Default options should not be changed from "outside"
*
* @var array
*/
protected $defaultOptions = [];
/**
* @var FinisherContext
*/
protected $finisherContext;
private ViewFactoryInterface $viewFactory;
private TranslationService $translationService;
public function injectViewFactory(ViewFactoryInterface $viewFactory)
{
$this->viewFactory = $viewFactory;
}
public function injectTranslationService(TranslationService $translationService)
{
$this->translationService = $translationService;
}
/**
* @param string $finisherIdentifier The identifier for this finisher
*/
public function setFinisherIdentifier(string $finisherIdentifier): void
{
$this->finisherIdentifier = $finisherIdentifier;
$this->shortFinisherIdentifier = preg_replace('/Finisher$/', '', $finisherIdentifier) ?? '';
}
public function getFinisherIdentifier(): string
{
return $this->finisherIdentifier;
}
/**
* @param array $options configuration options in the format ['option1' => 'value1', 'option2' => 'value2', ...]
*/
public function setOptions(array $options)
{
$this->options = $options;
}
/**
* Sets a single finisher option (@see setOptions())
*
* @param string $optionName name of the option to be set
* @param mixed $optionValue value of the option
*/
public function setOption(string $optionName, $optionValue)
{
$this->options[$optionName] = $optionValue;
}
/**
* Executes the finisher
*
* @param FinisherContext $finisherContext The Finisher context that contains the current Form Runtime and Response
* @return string|null
*/
final public function execute(FinisherContext $finisherContext)
{
$this->finisherContext = $finisherContext;
if (!$this->isEnabled()) {
return null;
}
try {
return $this->executeInternal();
} catch (FinisherException $e) {
$this->logger->error('Failed to execute finisher', ['exception' => $e]);
$this->finisherContext->cancel();
$formRuntime = $this->finisherContext->getFormRuntime();
$renderingOptions = $formRuntime->getRenderingOptions();
$viewFactoryData = new ViewFactoryData(
templateRootPaths: is_array($renderingOptions['templateRootPaths'] ?? null) ? $renderingOptions['templateRootPaths'] : [],
partialRootPaths: is_array($renderingOptions['partialRootPaths'] ?? null) ? $renderingOptions['partialRootPaths'] : [],
layoutRootPaths: is_array($renderingOptions['layoutRootPaths'] ?? null) ? $renderingOptions['layoutRootPaths'] : [],
request: $this->finisherContext->getRequest(),
);
$view = $this->viewFactory->create($viewFactoryData);
$message = $this->parseOption('errorMessage') ?: $this->translationService->translate('form.finisher.error', null, 'EXT:form/Resources/Private/Language/locallang.xlf');
$view->assign('message', $message);
return $view->render('Finishers/Error');
}
}
/**
* This method is called in the concrete finisher whenever self::execute() is called.
*
* Override and fill with your own implementation!
*
* @throws FinisherException
* @return string|void|null
*/
abstract protected function executeInternal();
/**
* Read the option called $optionName from $this->options, and parse {...}
* as object accessors.
*
* Then translate the value.
*
* If $optionName was not found, the corresponding default option is returned (from $this->defaultOptions)
*
* @param string $optionName
* @return string|array|int|bool|\Closure|callable|null
*/
protected function parseOption(string $optionName)
{
if ($optionName === 'translation') {
return null;
}
try {
$optionValue = ArrayUtility::getValueByPath($this->options, $optionName, '.');
} catch (MissingArrayPathException $exception) {
$optionValue = null;
}
try {
$defaultValue = ArrayUtility::getValueByPath($this->defaultOptions, $optionName, '.');
} catch (MissingArrayPathException $exception) {
$defaultValue = null;
}
if ($optionValue === null && $defaultValue !== null) {
$optionValue = $defaultValue;
}
if ($optionValue === null) {
return null;
}
if (!is_string($optionValue) && !is_array($optionValue)) {
return $optionValue;
}
$formRuntime = $this->finisherContext->getFormRuntime();
$optionValue = $this->substituteRuntimeReferences($optionValue, $formRuntime);
if (is_string($optionValue)) {
$translationOptions = is_array($this->options['translation'] ?? null)
? $this->options['translation']
: [];
$optionValue = $this->translateFinisherOption(
$optionValue,
$formRuntime,
$optionName,
$optionValue,
$translationOptions
);
$optionValue = $this->substituteRuntimeReferences($optionValue, $formRuntime);
}
if (empty($optionValue)) {
if ($defaultValue !== null) {
$optionValue = $defaultValue;
}
}
return $optionValue;
}
/**
* Wraps TranslationService::translateFinisherOption to recursively
* invoke all array items of resolved form state values or nested
* finisher option configuration settings.
*
* @param string|array $subject
* @param FormRuntime $formRuntime
* @param string|array $optionValue
* @return array|string
*/
protected function translateFinisherOption(
$subject,
FormRuntime $formRuntime,
string $optionName,
$optionValue,
array $translationOptions
) {
if (is_array($subject)) {
foreach ($subject as $key => $value) {
$subject[$key] = $this->translateFinisherOption(
$value,
$formRuntime,
$optionName . '.' . $value,
$value,
$translationOptions
);
}
return $subject;
}
return $this->translationService->translateFinisherOption(
$formRuntime,
$this->finisherIdentifier,
$optionName,
$optionValue,
$translationOptions
);
}
/**
* You can encapsulate an option value with {}.
* This enables you to access every gettable property from the
* TYPO3\CMS\Form\Domain\Runtime\FormRuntime.
*
* For example: {formState.formValues.<elementIdentifier>}
* or {<elementIdentifier>}
*
* Both examples are equal to "$formRuntime->getFormState()->getFormValues()[<elementIdentifier>]"
* There is a special option value '{__currentTimestamp}'.
* This will be replaced with the current timestamp.
*
* @param string|array $needle
* @param FormRuntime $formRuntime
* @return mixed
*/
protected function substituteRuntimeReferences($needle, FormRuntime $formRuntime)
{
// neither array nor string, directly return
if (!is_array($needle) && !is_string($needle)) {
return $needle;
}
// resolve (recursively) all array items
if (is_array($needle)) {
$substitutedNeedle = [];
foreach ($needle as $key => $item) {
$key = $this->substituteRuntimeReferences($key, $formRuntime);
$item = $this->substituteRuntimeReferences($item, $formRuntime);
$substitutedNeedle[$key] = $item;
}
return $substitutedNeedle;
}
// substitute one(!) variable in string which either could result
// again in a string or an array representing multiple values
if (preg_match('/^{([^}]+)}$/', $needle, $matches)) {
return $this->resolveRuntimeReference(
$matches[1],
$formRuntime
);
}
// in case string contains more than just one variable or just a static
// value that does not need to be substituted at all, candidates are:
// * "prefix{variable}suffix
// * "{variable-1},{variable-2}"
// * "some static value"
// * mixed cases of the above
return preg_replace_callback(
'/{([^}]+)}/',
function ($matches) use ($formRuntime) {
$value = $this->resolveRuntimeReference(
$matches[1],
$formRuntime
);
// substitute each match by returning the resolved value
if (!is_array($value)) {
return $value;
}
// now the resolve value is an array that shall substitute
// a variable in a string that probably is not the only one
// or is wrapped with other static string content (see above)
// ... which is just not possible
throw new FinisherException(
'Cannot convert array to string',
1519239265
);
},
$needle
);
}
/**
* Resolving property by name from submitted form data.
*
* @return int|string|array
*/
protected function resolveRuntimeReference(string $property, FormRuntime $formRuntime)
{
if ($property === '__currentTimestamp') {
return time();
}
// try to resolve the path '{...}' within the FormRuntime
$value = ObjectAccess::getPropertyPath($formRuntime, $property);
if (is_object($value)) {
$element = $formRuntime->getFormDefinition()->getElementByIdentifier($property);
if (!$element instanceof StringableFormElementInterface) {
throw new FinisherException(
sprintf('Cannot convert object value of "%s" to string', $property),
1574362327
);
}
$value = $element->valueToString($value);
}
if ($value === null) {
// try to resolve the path '{...}' within the FinisherVariableProvider
$value = ObjectAccess::getPropertyPath(
$this->finisherContext->getFinisherVariableProvider(),
$property
);
}
if ($value !== null) {
return $value;
}
// in case no value could be resolved
return '{' . $property . '}';
}
/**
* Returns whether this finisher is enabled
*/
public function isEnabled(): bool
{
return !isset($this->options['renderingOptions']['enabled']) || (bool)$this->parseOption('renderingOptions.enabled') === true;
}
}
@@ -0,0 +1,67 @@
<?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!
*/
/*
* Inspired by and partially taken from the Neos.Form package (www.neos.io)
*/
namespace TYPO3\CMS\Form\Domain\Finishers;
use TYPO3\CMS\Form\Domain\Finishers\Exception\FinisherException;
/**
* A simple finisher that invokes a closure when executed
*
* Usage:
* //...
* $closureFinisher = GeneralUtility::makeInstance(ClosureFinisher::class);
* $closureFinisher->setOption('closure', function($finisherContext) {
* $formRuntime = $finisherContext->getFormRuntime();
* // ...
* });
* $formDefinition->addFinisher($closureFinisher);
* // ...
*
* Scope: frontend
*/
class ClosureFinisher extends AbstractFinisher
{
/**
* @var array
*/
protected $defaultOptions = [
'closure' => null,
];
/**
* Executes this finisher
* @see AbstractFinisher::execute()
*
* @throws FinisherException
*/
protected function executeInternal()
{
$closure = $this->parseOption('closure');
if ($closure === null) {
return;
}
if (!$closure instanceof \Closure) {
throw new FinisherException(sprintf('The option "closure" must be of type Closure, "%s" given.', gettype($closure)), 1332155239);
}
$closure($this->finisherContext);
}
}
@@ -0,0 +1,127 @@
<?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\Domain\Finishers;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\View\ViewFactoryData;
use TYPO3\CMS\Core\View\ViewFactoryInterface;
use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface as ExtbaseConfigurationManagerInterface;
use TYPO3\CMS\Fluid\View\FluidViewAdapter;
use TYPO3\CMS\Form\Domain\Finishers\Exception\FinisherException;
use TYPO3\CMS\Form\ViewHelpers\RenderRenderableViewHelper;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* A finisher that outputs a given text
*
* Options:
*
* - message: A hard-coded message to be rendered
* - contentElementUid: A content element uid to be rendered
*
* Usage:
* //...
* $confirmationFinisher = GeneralUtility::makeInstance(ConfirmationFinisher::class);
* $confirmationFinisher->setOptions(
* [
* 'message' => 'foo',
* ]
* );
* $formDefinition->addFinisher($confirmationFinisher);
* // ...
*
* Scope: frontend
*/
class ConfirmationFinisher extends AbstractFinisher
{
/**
* @var array
*/
protected $defaultOptions = [
'message' => 'The form has been submitted.',
'contentElementUid' => 0,
'typoscriptObjectPath' => 'lib.tx_form.contentElementRendering',
];
public function __construct(
private readonly ExtbaseConfigurationManagerInterface $extbaseConfigurationManager,
private readonly ViewFactoryInterface $viewFactory,
) {}
/**
* @throws FinisherException
*/
protected function executeInternal(): string
{
$options = $this->options;
if (!isset($options['templateName']) || !is_string($options['templateName'])) {
throw new FinisherException(
'The option "templateName" must be set for the ConfirmationFinisher.',
1521573955
);
}
$contentElementUid = $this->parseOption('contentElementUid');
$typoscriptObjectPath = $this->parseOption('typoscriptObjectPath');
$typoscriptObjectPath = is_string($typoscriptObjectPath) ? $typoscriptObjectPath : '';
if (!empty($contentElementUid)) {
$pathSegments = GeneralUtility::trimExplode('.', $typoscriptObjectPath);
$lastSegment = array_pop($pathSegments);
$setup = $this->extbaseConfigurationManager->getConfiguration(ExtbaseConfigurationManagerInterface::CONFIGURATION_TYPE_FULL_TYPOSCRIPT);
foreach ($pathSegments as $segment) {
if (!array_key_exists($segment . '.', $setup)) {
throw new FinisherException(
sprintf('TypoScript object path "%s" does not exist', $typoscriptObjectPath),
1489238980
);
}
$setup = $setup[$segment . '.'];
}
$contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class);
$contentObjectRenderer->setRequest($this->finisherContext->getRequest()->withoutAttribute('extbase'));
$contentObjectRenderer->start([$contentElementUid]);
$contentObjectRenderer->setCurrentVal((string)$contentElementUid);
$message = $contentObjectRenderer->cObjGetSingle($setup[$lastSegment], $setup[$lastSegment . '.'], $lastSegment);
} else {
$message = $this->parseOption('message');
}
$formRuntime = $this->finisherContext->getFormRuntime();
$viewFactoryData = new ViewFactoryData(
templateRootPaths: is_array($options['templateRootPaths'] ?? null) ? $options['templateRootPaths'] : [],
partialRootPaths: is_array($options['partialRootPaths'] ?? null) ? $options['partialRootPaths'] : [],
layoutRootPaths: is_array($options['layoutRootPaths'] ?? null) ? $options['layoutRootPaths'] : [],
request: $this->finisherContext->getRequest(),
);
$view = $this->viewFactory->create($viewFactoryData);
if ($view instanceof FluidViewAdapter) {
$view->getRenderingContext()->getViewHelperVariableContainer()
->addOrUpdate(RenderRenderableViewHelper::class, 'formRuntime', $formRuntime);
}
if (is_array($this->options['variables'] ?? null)) {
$view->assignMultiple($this->options['variables']);
}
$view->assignMultiple([
'form' => $formRuntime,
'finisherVariableProvider' => $this->finisherContext->getFinisherVariableProvider(),
'message' => $message,
'isPreparedMessage' => !empty($contentElementUid),
]);
return $view->render($options['templateName']);
}
}
@@ -0,0 +1,109 @@
<?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\Domain\Finishers;
use TYPO3\CMS\Core\Resource\FileReference;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Extbase\Domain\Model\FileReference as ExtbaseFileReference;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
use TYPO3\CMS\Form\Domain\Model\FormElements\FileUpload;
/**
* This finisher remove the submitted files.
* Use this e.g after the email finisher if you don't want
* to keep the files online.
*
* Scope: frontend
*/
class DeleteUploadsFinisher extends AbstractFinisher
{
/**
* Executes this finisher
* @see AbstractFinisher::execute()
*/
protected function executeInternal()
{
$formRuntime = $this->finisherContext->getFormRuntime();
$uploadFolders = [];
$elements = $formRuntime->getFormDefinition()->getRenderablesRecursively();
foreach ($elements as $element) {
if (!$element instanceof FileUpload) {
continue;
}
$file = $formRuntime[$element->getIdentifier()];
if (!$file) {
continue;
}
if ($file instanceof ExtbaseFileReference) {
$file = $file->getOriginalResource();
}
if ($file instanceof FileReference) {
$this->deleteFileAndCollectFolder($file, $uploadFolders);
} elseif ($file instanceof ObjectStorage) {
foreach ($file as $singleFile) {
if ($singleFile instanceof ExtbaseFileReference) {
$singleFile = $singleFile->getOriginalResource();
}
if ($singleFile instanceof FileReference) {
$this->deleteFileAndCollectFolder($singleFile, $uploadFolders);
}
}
}
}
$this->deleteEmptyUploadFolders($uploadFolders);
}
/**
* Deletes the file and collects its parent folder for later cleanup.
*
* @param array<string, Folder> $uploadFolders
*/
private function deleteFileAndCollectFolder(FileReference $file, array &$uploadFolders): void
{
$folder = $file->getParentFolder();
if ($folder instanceof Folder) {
$uploadFolders[$folder->getCombinedIdentifier()] = $folder;
}
$file->getStorage()->deleteFile($file->getOriginalFile());
}
/**
* note:
* TYPO3\CMS\Form\Mvc\Property\TypeConverter\UploadedFileReferenceConverter::importUploadedResource()
* creates a sub-folder for file uploads (e.g. .../form_<40-chars-hash>/actual.file)
* @param Folder[] $folders
*/
protected function deleteEmptyUploadFolders(array $folders): void
{
foreach ($folders as $folder) {
if ($this->isEmptyFolder($folder)) {
$folder->delete();
}
}
}
protected function isEmptyFolder(Folder $folder): bool
{
return $folder->getFileCount() === 0
&& $folder->getStorage()->countFoldersInFolder($folder) === 0;
}
}
+268
View File
@@ -0,0 +1,268 @@
<?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\Domain\Finishers;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mime\Address;
use TYPO3\CMS\Core\Mail\FluidEmail;
use TYPO3\CMS\Core\Mail\MailerInterface;
use TYPO3\CMS\Core\Mail\TemplatedEmailFactory;
use TYPO3\CMS\Core\Resource\FileInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
use TYPO3\CMS\Form\Domain\Finishers\Exception\FinisherException;
use TYPO3\CMS\Form\Domain\Model\FormElements\FileUpload;
use TYPO3\CMS\Form\Domain\Runtime\FormRuntime;
use TYPO3\CMS\Form\Event\BeforeEmailFinisherInitializedEvent;
use TYPO3\CMS\Form\ViewHelpers\RenderRenderableViewHelper;
/**
* This finisher sends an email to one recipient
*
* Options:
*
* - templateName (mandatory): Template name for the mail body
* - templateRootPaths: root paths for the templates
* - layoutRootPaths: root paths for the layouts
* - partialRootPaths: root paths for the partials
* - variables: associative array of variables which are available inside the Fluid template
*
* The following options control the mail sending. In all of them, placeholders in the form
* of {...} are replaced with the corresponding form value; i.e. {email} as senderAddress
* makes the recipient address configurable.
*
* - subject (mandatory): Subject of the email
* - recipients (mandatory): Email addresses and human-readable names of the recipients
* - senderAddress (mandatory): Email address of the sender
* - senderName: Human-readable name of the sender
* - replyToRecipients: Email addresses and human-readable names of the reply-to recipients
* - carbonCopyRecipients: Email addresses and human-readable names of the copy recipients
* - blindCarbonCopyRecipients: Email addresses and human-readable names of the blind copy recipients
* - title: The title of the email - If not set "subject" is used by default
*
* Scope: frontend
*/
class EmailFinisher extends AbstractFinisher
{
/**
* @var array
*/
protected $defaultOptions = [
'recipientName' => '',
'senderName' => '',
'addHtmlPart' => true,
'attachUploads' => true,
];
public function __construct(
protected readonly EventDispatcherInterface $eventDispatcher,
protected readonly TemplatedEmailFactory $templatedEmailFactory,
protected readonly MailerInterface $mailer,
) {}
/**
* Executes this finisher
* @see AbstractFinisher::execute()
*
* @throws FinisherException
*/
protected function executeInternal(): void
{
$this->options = $this->eventDispatcher
->dispatch(new BeforeEmailFinisherInitializedEvent($this->finisherContext, $this->options))
->getOptions();
// Flexform overrides write strings instead of integers so
// we need to cast the string '0' to false.
if (
isset($this->options['addHtmlPart'])
&& $this->options['addHtmlPart'] === '0'
) {
$this->options['addHtmlPart'] = false;
}
$subject = (string)$this->parseOption('subject');
$recipients = $this->getRecipients('recipients');
$senderAddress = $this->parseOption('senderAddress');
$senderAddress = is_string($senderAddress) ? $senderAddress : '';
$senderName = $this->parseOption('senderName');
$senderName = is_string($senderName) ? $senderName : '';
$replyToRecipients = $this->getRecipients('replyToRecipients');
$carbonCopyRecipients = $this->getRecipients('carbonCopyRecipients');
$blindCarbonCopyRecipients = $this->getRecipients('blindCarbonCopyRecipients');
$addHtmlPart = (bool)$this->parseOption('addHtmlPart');
$attachUploads = $this->parseOption('attachUploads');
$title = (string)$this->parseOption('title') ?: $subject;
if ($subject === '') {
throw new FinisherException('The option "subject" must be set for the EmailFinisher.', 1327060320);
}
if (empty($recipients)) {
throw new FinisherException('The option "recipients" must be set for the EmailFinisher.', 1327060200);
}
if (empty($senderAddress)) {
throw new FinisherException('The option "senderAddress" must be set for the EmailFinisher.', 1327060210);
}
$formRuntime = $this->finisherContext->getFormRuntime();
$mail = $this
->initializeFluidEmail($formRuntime)
->from(new Address($senderAddress, $senderName))
->to(...$recipients)
->subject($subject)
->format($addHtmlPart ? FluidEmail::FORMAT_BOTH : FluidEmail::FORMAT_PLAIN)
->assign('title', $title);
if (!empty($replyToRecipients)) {
$mail->replyTo(...$replyToRecipients);
}
if (!empty($carbonCopyRecipients)) {
$mail->cc(...$carbonCopyRecipients);
}
if (!empty($blindCarbonCopyRecipients)) {
$mail->bcc(...$blindCarbonCopyRecipients);
}
if (is_string($this->options['translation']['language'] ?? null) && $this->options['translation']['language'] !== '') {
$mail->assign('languageKey', $this->options['translation']['language']);
}
$message = $this->parseOption('message');
if (is_string($message) && $message !== '') {
// Remove whitespace between HTML tags to prevent lib.parseFunc_RTE
// from converting newlines into additional blank lines in the email output
$message = preg_replace('/>\s+</', '><', $message);
$placeholderPos = strpos($message, '{formValues}');
if ($placeholderPos !== false) {
$mail->assign('messageBefore', substr($message, 0, $placeholderPos));
$mail->assign('messageAfter', substr($message, $placeholderPos + strlen('{formValues}')));
} else {
// No placeholder - show message only, no form values
$mail->assign('messageBefore', $message);
$mail->assign('messageAfter', '');
$mail->assign('hideFormValues', true);
}
}
if ($attachUploads) {
foreach ($formRuntime->getFormDefinition()->getRenderablesRecursively() as $element) {
if (!$element instanceof FileUpload) {
continue;
}
$file = $formRuntime[$element->getIdentifier()];
if ($file instanceof FileReference) {
$file = $file->getOriginalResource();
}
if ($file instanceof FileInterface) {
$mail->attach($file->getContents(), $file->getName(), $file->getMimeType());
} elseif ($file instanceof ObjectStorage) {
foreach ($file as $singleFile) {
if ($singleFile instanceof FileReference) {
$singleFile = $singleFile->getOriginalResource();
}
if ($singleFile instanceof FileInterface) {
$mail->attach($singleFile->getContents(), $singleFile->getName(), $singleFile->getMimeType());
}
}
}
}
}
try {
$this->mailer->send($mail);
} catch (TransportExceptionInterface $e) {
throw new FinisherException(
'Failed to send the email: ' . $e->getMessage(),
1754047320,
$e
);
}
}
protected function initializeFluidEmail(FormRuntime $formRuntime): FluidEmail
{
$mailMessage = $this->templatedEmailFactory->createWithOverrides(
$this->options['templateRootPaths'] ?? [],
$this->options['layoutRootPaths'] ?? [],
$this->options['partialRootPaths'] ?? [],
$this->finisherContext->getRequest(),
);
if (!isset($this->options['templateName']) || $this->options['templateName'] === '') {
throw new FinisherException('The option "templateName" must be set to use FluidEmail.', 1599834020);
}
// Migrate old template name to default FluidEmail name
if ($this->options['templateName'] === '{@format}.html') {
$this->options['templateName'] = 'Default';
}
$mailMessage
->setTemplate($this->options['templateName'])
->assignMultiple([
'finisherVariableProvider' => $this->finisherContext->getFinisherVariableProvider(),
'form' => $formRuntime,
]);
if (is_array($this->options['variables'] ?? null)) {
$mailMessage->assignMultiple($this->options['variables']);
}
$mailMessage
->getViewHelperVariableContainer()
->addOrUpdate(RenderRenderableViewHelper::class, 'formRuntime', $formRuntime);
return $mailMessage;
}
protected function getRecipients(string $listOption): array
{
$recipients = $this->parseOption($listOption) ?? [];
if (!is_array($recipients) || $recipients === []) {
return [];
}
$addresses = [];
foreach ($recipients as $address => $name) {
// The if is needed to set address and name with TypoScript
if (MathUtility::canBeInterpretedAsInteger($address)) {
if (is_array($name)) {
$address = $name[0] ?? '';
$name = $name[1] ?? '';
} else {
$address = $name;
$name = '';
}
}
$address = trim((string)$address);
if (!GeneralUtility::validEmail($address)) {
// Drop entries without a valid address
continue;
}
$addresses[] = new Address($address, $name);
}
return $addresses;
}
}
@@ -0,0 +1,25 @@
<?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\Domain\Finishers\Exception;
use TYPO3\CMS\Form\Domain\Exception;
/**
* This exception is thrown in Form Finishers
*/
class FinisherException extends Exception {}
@@ -0,0 +1,109 @@
<?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!
*/
/*
* Inspired by and partially taken from the Neos.Form package (www.neos.io)
*/
namespace TYPO3\CMS\Form\Domain\Finishers;
use TYPO3\CMS\Extbase\Mvc\Request;
use TYPO3\CMS\Form\Domain\Runtime\FormRuntime;
/**
* The context that is passed to each finisher when executed.
* It acts like an EventObject that is able to stop propagation.
*
* Scope: frontend
* **This class is NOT meant to be sub classed by developers.**
* @internal
*/
class FinisherContext
{
/**
* If TRUE further finishers won't be invoked
*
* @var bool
*/
protected $cancelled = false;
/**
* A reference to the Form Runtime the finisher belongs to
*/
protected FormRuntime $formRuntime;
/**
* The assigned controller context which might be needed by the finisher.
*/
protected FinisherVariableProvider $finisherVariableProvider;
private Request $request;
/**
* @internal
*/
public function __construct(FormRuntime $formRuntime, Request $request)
{
$this->formRuntime = $formRuntime;
$this->request = $request;
$this->finisherVariableProvider = new FinisherVariableProvider();
}
/**
* Cancels the finisher invocation after the current finisher
*/
public function cancel()
{
$this->cancelled = true;
}
/**
* TRUE if no further finishers should be invoked. Defaults to FALSE
*
* @internal
*/
public function isCancelled(): bool
{
return $this->cancelled;
}
/**
* The Form Runtime that is associated with the current finisher
*/
public function getFormRuntime(): FormRuntime
{
return $this->formRuntime;
}
/**
* The values of the submitted form (after validation and property mapping)
*/
public function getFormValues(): array
{
return $this->formRuntime->getFormState()->getFormValues();
}
public function getFinisherVariableProvider(): FinisherVariableProvider
{
return $this->finisherVariableProvider;
}
public function getRequest(): Request
{
return $this->request;
}
}
@@ -0,0 +1,59 @@
<?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!
*/
/*
* Inspired by and partially taken from the Neos.Form package (www.neos.io)
*/
namespace TYPO3\CMS\Form\Domain\Finishers;
/**
* Finisher that can be attached to a form in order to be invoked
* as soon as the complete form is submitted
*
* Scope: frontend
*/
interface FinisherInterface
{
/**
* Executes the finisher
*
* @param FinisherContext $finisherContext The Finisher context that contains the current Form Runtime and Response
* @return string|null
*/
public function execute(FinisherContext $finisherContext);
public function setFinisherIdentifier(string $finisherIdentifier): void;
/**
* @param array $options configuration options in the format ['option1' => 'value1', 'option2' => 'value2', ...]
*/
public function setOptions(array $options);
/**
* Sets a single finisher option (@see setOptions())
*
* @param string $optionName name of the option to be set
* @param mixed $optionValue value of the option
*/
public function setOption(string $optionName, $optionValue);
/**
* Returns whether this finisher is enabled
*/
public function isEnabled(): bool;
}
@@ -0,0 +1,187 @@
<?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\Domain\Finishers;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\Exception\MissingArrayPathException;
/**
* Store data for usage between the finishers.
*
* Scope: frontend
* **This class is NOT meant to be sub classed by developers.**
* @internal
*/
final class FinisherVariableProvider implements \ArrayAccess, \IteratorAggregate, \Countable
{
/**
* Two-dimensional object array storing the values. The first dimension is the finisher identifier,
* and the second dimension is the identifier for the data the finisher wants to store.
*
* @var array
*/
private $objects = [];
/**
* Add a variable to the finisher container.
*
* @param mixed $value
*/
public function add(string $finisherIdentifier, string $key, $value)
{
$this->addOrUpdate($finisherIdentifier, $key, $value);
}
/**
* Add a variable to the Variable Container.
* In case the value is already inside, it is silently overridden.
*
* @param mixed $value
*/
public function addOrUpdate(string $finisherIdentifier, string $key, $value)
{
if (!array_key_exists($finisherIdentifier, $this->objects)) {
$this->objects[$finisherIdentifier] = [];
}
$this->objects[$finisherIdentifier] = ArrayUtility::setValueByPath(
$this->objects[$finisherIdentifier],
$key,
$value,
'.'
);
}
/**
* Gets a variable which is stored
*
* @param mixed $default
* @return mixed
*/
public function get(string $finisherIdentifier, string $key, $default = null)
{
if ($this->exists($finisherIdentifier, $key)) {
return ArrayUtility::getValueByPath($this->objects[$finisherIdentifier], $key, '.');
}
return $default;
}
/**
* Determine whether there is a variable stored for the given key
*
* @param string $finisherIdentifier
* @param string $key
*/
public function exists($finisherIdentifier, $key): bool
{
try {
ArrayUtility::getValueByPath($this->objects[$finisherIdentifier] ?? [], $key, '.');
} catch (MissingArrayPathException $e) {
return false;
}
return true;
}
/**
* Remove a value from the variable container
*/
public function remove(string $finisherIdentifier, string $key)
{
if ($this->exists($finisherIdentifier, $key)) {
$this->objects[$finisherIdentifier] = ArrayUtility::removeByPath(
$this->objects[$finisherIdentifier],
$key,
'.'
);
}
}
/**
* Clean up for serializing.
*
* @return array
*/
public function __sleep()
{
return ['objects'];
}
/**
* Whether an offset exists
*
* @link https://php.net/manual/en/arrayaccess.offsetexists.php
* @param mixed $offset An offset to check for.
* @return bool TRUE on success or FALSE on failure.
*/
public function offsetExists(mixed $offset): bool
{
return isset($this->objects[$offset]);
}
/**
* Offset to retrieve
*
* @link https://php.net/manual/en/arrayaccess.offsetget.php
* @param mixed $offset The offset to retrieve.
* @return mixed Can return all value types.
*/
public function offsetGet(mixed $offset): mixed
{
return $this->objects[$offset];
}
/**
* Offset to set
*
* @link https://php.net/manual/en/arrayaccess.offsetset.php
* @param mixed $offset The offset to assign the value to.
* @param mixed $value The value to set.
*/
public function offsetSet(mixed $offset, mixed $value): void
{
$this->objects[$offset] = $value;
}
/**
* Offset to unset
*
* @link https://php.net/manual/en/arrayaccess.offsetunset.php
* @param mixed $offset The offset to unset.
*/
public function offsetUnset(mixed $offset): void
{
unset($this->objects[$offset]);
}
public function getIterator(): \Traversable
{
foreach ($this->objects as $offset => $value) {
yield $offset => $value;
}
}
/**
* Count elements of an object
*
* @link https://php.net/manual/en/countable.count.php
* @return int The custom count as an integer.
*/
public function count(): int
{
return count($this->objects);
}
}
@@ -0,0 +1,128 @@
<?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!
*/
/*
* Inspired by and partially taken from the Neos.Form package (www.neos.io)
*/
namespace TYPO3\CMS\Form\Domain\Finishers;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Extbase\Error\Error;
use TYPO3\CMS\Extbase\Error\Message;
use TYPO3\CMS\Extbase\Error\Notice;
use TYPO3\CMS\Extbase\Error\Warning;
use TYPO3\CMS\Extbase\Service\ExtensionService;
use TYPO3\CMS\Form\Domain\Finishers\Exception\FinisherException;
/**
* A simple finisher that adds a message to the FlashMessageContainer
*
* Usage:
* //...
* $flashMessageFinisher = GeneralUtility::makeInstance(FlashMessageFinisher::class);
* $flashMessageFinisher->setOptions(
* [
* 'messageBody' => 'Some message body',
* 'messageTitle' => 'Some message title',
* 'messageArguments' => ['foo' => 'bar'],
* 'severity' => \TYPO3\CMS\Core\Type\ContextualFeedbackSeverity::ERROR
* ]
* );
* $formDefinition->addFinisher($flashMessageFinisher);
* // ...
*
* Scope: frontend
*/
class FlashMessageFinisher extends AbstractFinisher
{
/**
* @var array
*/
protected $defaultOptions = [
'messageBody' => null,
'messageTitle' => '',
'messageArguments' => [],
'messageCode' => null,
'severity' => ContextualFeedbackSeverity::OK,
];
private ExtensionService $extensionService;
private FlashMessageService $flashMessageService;
public function injectFlashMessageService(FlashMessageService $flashMessageService): void
{
$this->flashMessageService = $flashMessageService;
}
public function injectExtensionService(ExtensionService $extensionService): void
{
$this->extensionService = $extensionService;
}
/**
* Executes this finisher
* @see AbstractFinisher::execute()
*
* @throws FinisherException
*/
protected function executeInternal()
{
$messageBody = $this->parseOption('messageBody');
if (!is_string($messageBody)) {
throw new FinisherException(sprintf('The message body must be of type string, "%s" given.', gettype($messageBody)), 1335980069);
}
$messageTitle = $this->parseOption('messageTitle');
$messageArguments = $this->parseOption('messageArguments');
$messageCode = $this->parseOption('messageCode');
$severity = $this->parseOption('severity');
if (MathUtility::canBeInterpretedAsInteger($severity)) {
$severity = ContextualFeedbackSeverity::tryFrom((int)$severity);
}
if (!$severity instanceof ContextualFeedbackSeverity) {
$severity = $this->defaultOptions['severity'];
}
$messageClass = match ($severity) {
ContextualFeedbackSeverity::NOTICE => Notice::class,
ContextualFeedbackSeverity::WARNING => Warning::class,
ContextualFeedbackSeverity::ERROR => Error::class,
default => Message::class,
};
/** @var Message|Notice|Warning|Error $message */
$message = GeneralUtility::makeInstance($messageClass, $messageBody, $messageCode, $messageArguments, $messageTitle);
$flashMessage = new FlashMessage(
$message->render(),
$message->getTitle(),
$severity,
true
);
// todo: this value has to be taken from the request directly in the future
$pluginNamespace = $this->extensionService->getPluginNamespace(
$this->finisherContext->getRequest()->getControllerExtensionName(),
$this->finisherContext->getRequest()->getPluginName()
);
$this->flashMessageService->getMessageQueueByIdentifier('extbase.flashmessages.' . $pluginNamespace)->addMessage($flashMessage);
}
}
@@ -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\Domain\Finishers;
use TYPO3\CMS\Core\Http\PropagateResponseException;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This finisher redirects to another Controller.
*
* Scope: frontend
*/
class RedirectFinisher extends AbstractFinisher
{
/**
* @var array
*/
protected $defaultOptions = [
'pageUid' => 1,
'additionalParameters' => '',
'statusCode' => 303,
'fragment' => '',
];
/**
* Executes this finisher
* @see AbstractFinisher::execute()
*/
protected function executeInternal(): void
{
$pageUid = $this->parseOption('pageUid');
$pageUid = (int)str_replace('pages_', '', (string)$pageUid);
$additionalParameters = $this->parseOption('additionalParameters');
$additionalParameters = is_string($additionalParameters) ? $additionalParameters : '';
$additionalParameters = '&' . ltrim($additionalParameters, '&');
$statusCode = (int)$this->parseOption('statusCode');
$fragment = (string)$this->parseOption('fragment');
$this->finisherContext->cancel();
$this->redirect($pageUid, $additionalParameters, $fragment, $statusCode);
}
/**
* Redirects the request to another page.
*
* Redirect will be sent to the client which then performs another request to the new URI.
*
* NOTE: This method only supports web requests and will thrown an exception
* if used with other request types.
*
* @param int $pageUid Target page uid. If NULL, the current page uid is used
* @param int $statusCode (optional) The HTTP status code for the redirect. Default is "303 See Other"
* @see forward()
*/
protected function redirect(int $pageUid, string $additionalParameters, string $fragment, int $statusCode): never
{
$redirectUri = $this->finisherContext->getRequest()->getAttribute('currentContentObject')->createUrl([
'parameter' => $pageUid,
'additionalParams' => $additionalParameters,
'section' => $fragment,
]);
$this->redirectToUri($redirectUri, $statusCode);
}
/**
* Redirects the web request to another uri.
*
* NOTE: This method only supports web requests and will throw an exception if used with other request types.
*
* @param string $uri A string representation of a URI
* @param int $statusCode (optional) The HTTP status code for the redirect. Default is "303 See Other
* @throws PropagateResponseException
*/
protected function redirectToUri(string $uri, int $statusCode = 303): never
{
$uri = $this->addBaseUriIfNecessary($uri);
$response = new RedirectResponse($uri, $statusCode);
// End processing and dispatching by throwing a PropagateResponseException with our response.
// @todo: Should be changed to *return* a response instead, but this requires the ContentObjectRender
// @todo: to deal with responses instead of strings, if the form is used in a fluid template rendered by the
// @todo: FluidTemplateContentObject and the extbase bootstrap isn't used.
throw new PropagateResponseException($response, 1477070964);
}
/**
* Adds the base uri if not already in place.
*
* @param string $uri The URI
*/
protected function addBaseUriIfNecessary(string $uri): string
{
return GeneralUtility::locationHeaderUrl($uri, $this->finisherContext->getRequest());
}
}
@@ -0,0 +1,407 @@
<?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\Domain\Finishers;
use Doctrine\DBAL\Exception;
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
use TYPO3\CMS\Form\Domain\Finishers\Exception\FinisherException;
use TYPO3\CMS\Form\Domain\Model\FormElements\FormElementInterface;
/**
* This finisher saves the data from a submitted form into
* a database table.
*
* Configuration
* =============
*
* options.table (mandatory)
* -------------
* Save or update values into this table
*
* options.mode (default: insert)
* ------------
* Possible values are 'insert' or 'update'.
*
* insert: will create a new database row with the values from the
* submitted form and/or some predefined values.
* See options.elements and options.databaseFieldMappings
* update: will update a given database row with the values from the
* submitted form and/or some predefined values.
* 'options.whereClause' is then required.
*
* options.whereClause
* -------------------
* This where clause will be used for a database update action
*
* options.elements
* ----------------
* Use this to map form element values to existing database columns.
* Each key within options.elements has to match with a
* form element identifier within your form definition.
* The value for each key within options.elements is an array with
* additional information.
*
* options.elements.<elementIdentifier>.mapOnDatabaseColumn (mandatory)
* --------------------------------------------------------
* The value from the submitted form element with the identifier
* '<elementIdentifier>' will be written into this database column
*
* options.elements.<elementIdentifier>.skipIfValueIsEmpty (default: false)
* ------------------------------------------------------
* Set this to true if the database column should not be written
* if the value from the submitted form element with the identifier
* '<elementIdentifier>' is empty (think about password fields etc.)
*
* options.elements.<elementIdentifier>.hashed (default: false)
* ------------------------------------------------------
* Set this to true if the value from the submitted form element
* should be hashed before writing into the database.
*
* options.elements.<elementIdentifier>.saveFileIdentifierInsteadOfUid (default: false)
* -------------------------------------------------------------------
* This setting only rules for form elements which creates a FAL object
* like FileUpload or ImageUpload.
* By default, the uid of the FAL object will be written into
* the database column. Set this to true if you want to store the
* FAL identifier (1:/user_uploads/some_uploaded_pic.jpg) instead.
*
* options.databaseColumnMappings
* ------------------------------
* Use this to map database columns to static values (which can be
* made dynamic through typoscript overrides of course).
* Each key within options.databaseColumnMappings has to match with a
* existing database column.
* The value for each key within options.databaseColumnMappings is an
* array with additional information.
*
* This mapping is done *before* the options.elements mapping.
* This means if you map a database column to a value through
* options.databaseColumnMappings and map a submitted form element
* value to the same database column, the submitted form element value
* will override the value you set within options.databaseColumnMappings.
*
* options.databaseColumnMappings.<databaseColumnName>.value
* ---------------------------------------------------------
* The value which will be written to the database column.
* You can use the FormRuntime accessor feature to access every
* getable property from the TYPO3\CMS\Form\Domain\Runtime\FormRuntime
* Read the description within
* TYPO3\CMS\Form\Domain\Finishers\AbstractFinisher::parseOption
* In short: use something like {<elementIdentifier>} to get the value
* from the submitted form element with the identifier
* <elementIdentifier>
*
* Don't be confused. If you use the FormRuntime accessor feature within
* options.databaseColumnMappings, the functionality is nearly equal
* to the options.elements configuration.
*
* options.databaseColumnMappings.<databaseColumnName>.skipIfValueIsEmpty (default: false)
* ---------------------------------------------------------------------
* Set this to true if the database column should not be written
* if the value from
* options.databaseColumnMappings.<databaseColumnName>.value is empty.
*
* Example
* =======
*
* finishers:
* -
* identifier: SaveToDatabase
* options:
* table: 'fe_users'
* mode: update
* whereClause:
* uid: 1
* databaseColumnMappings:
* pid:
* value: 1
* elements:
* text-1:
* mapOnDatabaseColumn: 'first_name'
* text-2:
* mapOnDatabaseColumn: 'last_name'
* text-3:
* mapOnDatabaseColumn: 'username'
* advancedpassword-1:
* mapOnDatabaseColumn: 'password'
* skipIfValueIsEmpty: true
* hashed: true
*
* Multiple database operations
* ============================
*
* You can write options as an array to perform multiple database operations.
*
* finishers:
* -
* identifier: SaveToDatabase
* options:
* 1:
* table: 'my_table'
* mode: insert
* databaseColumnMappings:
* some_column:
* value: 'cool'
* 2:
* table: 'my_other_table'
* mode: update
* whereClause:
* pid: 1
* databaseColumnMappings:
* some_other_column:
* value: '{SaveToDatabase.insertedUids.1}'
*
* This would perform 2 database operations.
* One insert and one update.
* You can access the inserted uids with '{SaveToDatabase.insertedUids.<theArrayKeyNumberWithinOptions>}'
* If you perform an insert operation, the value of the inserted database row will be stored
* within the FinisherVariableProvider.
* <theArrayKeyNumberWithinOptions> references to the numeric key within options
* within which the insert operation is executed.
*
* Scope: frontend
*/
class SaveToDatabaseFinisher extends AbstractFinisher
{
/**
* @var array
*/
protected $defaultOptions = [
'table' => null,
'mode' => 'insert',
'whereClause' => [],
'elements' => [],
'databaseColumnMappings' => [],
];
/**
* @var \TYPO3\CMS\Core\Database\Connection
*/
protected $databaseConnection;
/**
* Executes this finisher
* @see AbstractFinisher::execute()
*
* @throws FinisherException
*/
protected function executeInternal(): void
{
$options = [];
if (isset($this->options['table'])) {
$options[] = $this->options;
} else {
$options = $this->options;
}
foreach ($options as $optionKey => $option) {
$this->options = $option;
$this->process($optionKey);
}
}
/**
* Prepare data for saving to database
*/
protected function prepareData(array $elementsConfiguration, array $databaseData): array
{
foreach ($this->getFormValues() as $elementIdentifier => $elementValue) {
if (
($elementValue === null || $elementValue === '')
&& isset($elementsConfiguration[$elementIdentifier])
&& isset($elementsConfiguration[$elementIdentifier]['skipIfValueIsEmpty'])
&& $elementsConfiguration[$elementIdentifier]['skipIfValueIsEmpty'] === true
) {
continue;
}
$element = $this->getElementByIdentifier($elementIdentifier);
if (
!$element
|| !isset($elementsConfiguration[$elementIdentifier])
|| !isset($elementsConfiguration[$elementIdentifier]['mapOnDatabaseColumn'])
) {
continue;
}
if (isset($elementsConfiguration[$elementIdentifier]['saveFileIdentifierInsteadOfUid'])) {
$saveFileIdentifierInsteadOfUid = (bool)$elementsConfiguration[$elementIdentifier]['saveFileIdentifierInsteadOfUid'];
} else {
$saveFileIdentifierInsteadOfUid = false;
}
if ($elementValue instanceof FileReference) {
$elementValue = $this->prepareFileForDatabase($elementValue, $saveFileIdentifierInsteadOfUid);
} elseif ($elementValue instanceof ObjectStorage) {
$fileIdentifiers = [];
foreach ($elementValue as $singleElement) {
if ($singleElement instanceof FileReference) {
$fileIdentifiers[] = $this->prepareFileForDatabase($singleElement, $saveFileIdentifierInsteadOfUid);
}
}
$elementValue = implode(',', $fileIdentifiers);
} elseif (is_array($elementValue)) {
$elementValue = implode(',', $elementValue);
} elseif ($elementValue instanceof \DateTimeInterface) {
$format = $elementsConfiguration[$elementIdentifier]['dateFormat'] ?? 'U';
$elementValue = $elementValue->format($format);
} elseif ($elementValue && ($elementsConfiguration[$elementIdentifier]['hashed'] ?? false) === true) {
$hashInstance = GeneralUtility::makeInstance(PasswordHashFactory::class)->getDefaultHashInstance('FE');
$elementValue = $hashInstance->getHashedPassword($elementValue);
}
$databaseData[$elementsConfiguration[$elementIdentifier]['mapOnDatabaseColumn']] = $elementValue;
}
return $databaseData;
}
/**
* Perform the current database operation
* @throws FinisherException
*/
protected function process(int $iterationCount): void
{
$this->throwExceptionOnInconsistentConfiguration();
$table = $this->parseOption('table');
$table = is_string($table) ? $table : '';
$elementsConfiguration = $this->parseOption('elements');
$elementsConfiguration = is_array($elementsConfiguration) ? $elementsConfiguration : [];
$databaseColumnMappingsConfiguration = $this->parseOption('databaseColumnMappings');
$this->databaseConnection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
$databaseData = [];
foreach ($databaseColumnMappingsConfiguration as $databaseColumnName => $databaseColumnConfiguration) {
$value = $this->parseOption('databaseColumnMappings.' . $databaseColumnName . '.value');
if (
empty($value)
&& ($databaseColumnConfiguration['skipIfValueIsEmpty'] ?? false) === true
) {
continue;
}
$databaseData[$databaseColumnName] = $value;
}
$databaseData = $this->prepareData($elementsConfiguration, $databaseData);
try {
$this->saveToDatabase($databaseData, $table, $iterationCount);
} catch (Exception $e) {
throw new FinisherException(
'Failed to save data to database table: ' . $table . '. Error message:' . $e->getMessage(),
1754050114,
$e
);
}
}
/**
* Save or insert the values from
* $databaseData into the table $table
* @throws Exception
*/
protected function saveToDatabase(array $databaseData, string $table, int $iterationCount): void
{
if (!empty($databaseData)) {
if ($this->parseOption('mode') === 'update') {
$whereClause = $this->parseOption('whereClause');
foreach ($whereClause as $columnName => $columnValue) {
$whereClause[$columnName] = $this->parseOption('whereClause.' . $columnName);
}
$this->databaseConnection->update(
$table,
$databaseData,
$whereClause
);
} else {
$this->databaseConnection->insert($table, $databaseData);
try {
$insertedUid = (int)$this->databaseConnection->lastInsertId();
} catch (Exception) {
// Some database tables like sys_category_record_mm may not
// have an "identity" (uid column). In this case DBAL may
// throw an exception, which we gracefully handle here.
$insertedUid = 0;
}
$this->finisherContext->getFinisherVariableProvider()->add(
$this->shortFinisherIdentifier,
'insertedUids.' . $iterationCount,
$insertedUid
);
}
}
}
/**
* Throws an exception if some inconsistent configuration
* are detected.
*
* @throws FinisherException
*/
protected function throwExceptionOnInconsistentConfiguration(): void
{
if (
$this->parseOption('mode') === 'update'
&& empty($this->parseOption('whereClause'))
) {
throw new FinisherException(
'An empty option "whereClause" is not allowed in update mode.',
1480469086
);
}
}
/**
* Returns the values of the submitted form
*/
protected function getFormValues(): array
{
return $this->finisherContext->getFormValues();
}
/**
* Returns a form element object for a given identifier.
*
* @return FormElementInterface|null
*/
protected function getElementByIdentifier(string $elementIdentifier): ?FormElementInterface
{
return $this
->finisherContext
->getFormRuntime()
->getFormDefinition()
->getElementByIdentifier($elementIdentifier);
}
protected function prepareFileForDatabase(FileReference $fileReference, bool $saveFileIdentifierInsteadOfUid = false): int|string
{
if ($saveFileIdentifierInsteadOfUid) {
$elementValue = $fileReference->getOriginalResource()->getCombinedIdentifier();
} else {
$elementValue = $fileReference->getOriginalResource()->getProperty('uid_local');
}
return $elementValue;
}
}