diff --git a/README.md b/README.md index 1519de82..bce999ea 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,8 @@ Nothing to tell here, it's just [Symfony](https://symfony.com/doc/current/templa ## Further Information - [Usage (Rendering Types, Configuration)](docs/0_Usage.md) - [Headless Mode](docs/1_HeadlessMode.md) -- [SPAM Protection (Honeypot, reCAPTCHA)](docs/03_SpamProtection.md) +- [SPAM Protection](docs/03_SpamProtection.md) + - [Double-Opt-In Feature](docs/03_SpamProtection.md) - [Output Workflows](docs/OutputWorkflow/0_Usage.md) - [API Channel](docs/OutputWorkflow/09_ApiChannel.md) - [Email Channel](docs/OutputWorkflow/10_EmailChannel.md) diff --git a/UPGRADE.md b/UPGRADE.md index a0c89a16..eb33f5f5 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,6 +1,8 @@ # Upgrade Notes ## 5.1.0 +- **[SECURITY FEATURE]** Double-Opt-In Feature, read more about it [here](/docs/.md) + - If you're using a custom form theme, please include the `instructions` type (`{% use '@FormBuilder/form/theme/type/instructions.html.twig' %}`) - **[SECURITY FEATURE]** Add [friendly captcha field](/docs/03_SpamProtection.md#friendly-captcha) - **[SECURITY FEATURE]** Add [cloudflare turnstile](/docs/03_SpamProtection.md#cloudflare-turnstile) - **[BUGFIX]** Use Pimcore AdminUserTranslator for Editable Dialog Box [#450](https://github.com/dachcom-digital/pimcore-formbuilder/issues/450) diff --git a/config/doctrine/model/DoubleOptInSession.orm.yml b/config/doctrine/model/DoubleOptInSession.orm.yml new file mode 100644 index 00000000..c19c79a5 --- /dev/null +++ b/config/doctrine/model/DoubleOptInSession.orm.yml @@ -0,0 +1,49 @@ +FormBuilderBundle\Model\DoubleOptInSession: + type: entity + table: formbuilder_double_opt_in_session + indexes: + token_form: + columns: [ token, form_definition, applied ] + id: + token: + unique: true + column: token + type: uuid + generator: + strategy: CUSTOM + customIdGenerator: + class: Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator + fields: + email: + column: email + type: string + nullable: false + length: 190 + additionalData: + column: additional_data + type: array + nullable: true + dispatchLocation: + column: dispatch_location + type: text + nullable: true + applied: + column: applied + type: boolean + options: + default: 0 + creationDate: + column: creationDate + type: datetime + nullable: false + manyToOne: + formDefinition: + targetEntity: FormBuilderBundle\Model\FormDefinition + orphanRemoval: true + joinColumn: + name: form_definition + referencedColumnName: id + onDelete: CASCADE + uniqueConstraints: + email_form_definition: + columns: [email, form_definition, applied] \ No newline at end of file diff --git a/config/doctrine/model/FormDefinition.orm.yml b/config/doctrine/model/FormDefinition.orm.yml index 22da6f2f..9812401d 100644 --- a/config/doctrine/model/FormDefinition.orm.yml +++ b/config/doctrine/model/FormDefinition.orm.yml @@ -33,10 +33,6 @@ FormBuilderBundle\Model\FormDefinition: modifiedBy: column: modifiedBy type: integer - mailLayout: - column: mailLayout - type: object - nullable: true configuration: column: configuration type: object diff --git a/config/install/sql/install.sql b/config/install/sql/install.sql index 5baecf5f..e95efd8a 100644 --- a/config/install/sql/install.sql +++ b/config/install/sql/install.sql @@ -6,7 +6,6 @@ CREATE TABLE IF NOT EXISTS `formbuilder_forms` ( `modificationDate` datetime NOT NULL, `createdBy` int(11) NOT NULL, `modifiedBy` int(11) NOT NULL, - `mailLayout` longtext COMMENT '(DC2Type:object)', `configuration` longtext COMMENT '(DC2Type:object)', `conditionalLogic` longtext COMMENT '(DC2Type:object)', `fields` longtext COMMENT '(DC2Type:form_builder_fields)', @@ -37,4 +36,20 @@ CREATE TABLE IF NOT EXISTS `formbuilder_output_workflow_channel` ( UNIQUE KEY `ow_name` (`output_workflow`,`name`), KEY `IDX_CEC462362C75DDDC` (`output_workflow`), CONSTRAINT `FK_CEC462362C75DDDC` FOREIGN KEY (`output_workflow`) REFERENCES `formbuilder_output_workflow` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC; \ No newline at end of file +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC; + +CREATE TABLE IF NOT EXISTS `formbuilder_double_opt_in_session` ( + `token` binary(16) NOT NULL COMMENT '(DC2Type:uuid)' PRIMARY KEY, + `form_definition` int NULL, + `email` varchar(190) NOT NULL, + `additional_data` longtext NULL COMMENT '(DC2Type:array)', + `dispatch_location` longtext NULL, + `applied` tinyint(1) DEFAULT 0 NOT null, + `creationDate` datetime NOT NULL, + CONSTRAINT email_form_definition UNIQUE (email, form_definition, applied), + CONSTRAINT FK_88815C4F61F7634C FOREIGN KEY (form_definition) REFERENCES formbuilder_forms (id) ON DELETE CASCADE +); + +create index IDX_88815C4F61F7634C on formbuilder_double_opt_in_session (form_definition); +create index token_form on formbuilder_double_opt_in_session (token, form_definition, applied); + diff --git a/config/install/translations/frontend.csv b/config/install/translations/frontend.csv index 5972ed9b..3ceb777d 100644 --- a/config/install/translations/frontend.csv +++ b/config/install/translations/frontend.csv @@ -36,4 +36,5 @@ "form_builder.dynamic_multi_file.remove","Remove file","Datei entfernen" "form_builder.dynamic_multi_file.global.cannot_destroy_active_instance","This uploader is currently active or has some unprocessed files. in case there are some uploaded files, please remove them first.","Ein Uploader ist derzeit in dieser Sektion aktiv oder es wurden bereits Daten verarbeitet. Falls es bereits hochgeladene Dateien gibt, entfernen Sie diese bitte zuerst." "form_builder.form.container.repeater.min","%label%: You need to add at least %items% items.","%label%: Es werden mindestens %items% Einträge benötigt." -"form_builder.form.container.repeater.max","%label%: Only %items% item(s) allowed.","%label%: Maximal %items% Einträge erlaubt." \ No newline at end of file +"form_builder.form.container.repeater.max","%label%: Only %items% item(s) allowed.","%label%: Maximal %items% Einträge erlaubt." +"form_builder.form.double_opt_in.duplicate_session","Double-Opt-In Session for the given address has already been created.","Ein Zugang wurde für diese Adresse bereits erstellt." diff --git a/config/services/double_opt_in.yaml b/config/services/double_opt_in.yaml new file mode 100644 index 00000000..cdc89d86 --- /dev/null +++ b/config/services/double_opt_in.yaml @@ -0,0 +1,6 @@ +services: + + _defaults: + autowire: true + autoconfigure: true + public: true diff --git a/config/services/forms/forms.yaml b/config/services/forms/forms.yaml index 2784e259..6f821a6e 100644 --- a/config/services/forms/forms.yaml +++ b/config/services/forms/forms.yaml @@ -52,6 +52,11 @@ services: tags: - { name: form.type } + FormBuilderBundle\Form\Type\InstructionsType: + public: false + tags: + - { name: form.type } + FormBuilderBundle\Form\Type\SnippetType: autowire: true public: false @@ -96,3 +101,11 @@ services: tags: - { name: form.type } + + # + # Double-Opt-In + + FormBuilderBundle\Form\Type\DoubleOptIn\DoubleOptInType: + public: false + tags: + - { name: form.type } diff --git a/config/services/manager.yaml b/config/services/manager.yaml index 0c0798bd..5f3f7c7e 100644 --- a/config/services/manager.yaml +++ b/config/services/manager.yaml @@ -15,4 +15,7 @@ services: FormBuilderBundle\Manager\PresetManager: ~ # manager: template (form themes, type templates) - FormBuilderBundle\Manager\TemplateManager: ~ \ No newline at end of file + FormBuilderBundle\Manager\TemplateManager: ~ + + # manager: double-opt-in + FormBuilderBundle\Manager\DoubleOptInManager: ~ \ No newline at end of file diff --git a/config/services/repository.yaml b/config/services/repository.yaml index 57a1f06e..618bff2c 100644 --- a/config/services/repository.yaml +++ b/config/services/repository.yaml @@ -9,4 +9,7 @@ services: FormBuilderBundle\Repository\FormDefinitionRepository: ~ FormBuilderBundle\Repository\OutputWorkflowRepositoryInterface: '@FormBuilderBundle\Repository\OutputWorkflowRepository' - FormBuilderBundle\Repository\OutputWorkflowRepository: ~ \ No newline at end of file + FormBuilderBundle\Repository\OutputWorkflowRepository: ~ + + FormBuilderBundle\Repository\DoubleOptInSessionRepositoryInterface: '@FormBuilderBundle\Repository\DoubleOptInSessionRepository' + FormBuilderBundle\Repository\DoubleOptInSessionRepository: ~ \ No newline at end of file diff --git a/config/services/runtime_data.yaml b/config/services/runtime_data.yaml index 2d76b87a..f1bf4269 100644 --- a/config/services/runtime_data.yaml +++ b/config/services/runtime_data.yaml @@ -9,3 +9,7 @@ services: FormBuilderBundle\Form\RuntimeData\FormRuntimeDataAllocator: ~ FormBuilderBundle\Form\RuntimeData\FormRuntimeDataAllocatorInterface: '@FormBuilderBundle\Form\RuntimeData\FormRuntimeDataAllocator' + + FormBuilderBundle\Form\RuntimeData\Provider\DoubleOptInSessionDataProvider: + tags: + - { name: form_builder.runtime_data_provider } diff --git a/docs/03_SpamProtection.md b/docs/03_SpamProtection.md index 62e88f4d..866c0780 100644 --- a/docs/03_SpamProtection.md +++ b/docs/03_SpamProtection.md @@ -1,5 +1,8 @@ # Spam Protection +## Double-Opt-In +Read more about the double-opt-in feature [here](./04_DoubleOptIn.md). + ## HoneyPot The Honeypot Field is enabled by default. You can disable it via [configuration flags](100_ConfigurationFlags.md). diff --git a/docs/04_DoubleOptIn.md b/docs/04_DoubleOptIn.md new file mode 100644 index 00000000..c6f7f9e8 --- /dev/null +++ b/docs/04_DoubleOptIn.md @@ -0,0 +1,38 @@ +# Double-Opt-In +![image](https://github.com/user-attachments/assets/aa4f1f24-607c-4ed3-aa72-2d9d91fddf12) + +When enabled, a user must confirm its email identity via confirmation before the real form shows up. + +This feature is disabled by default. + +```yaml +form_builder: + + double_opt_in: + + # enable the feature + enabled: true + + # redeem_mode: + # choose between "delete" or "devalue" + # - "delete" (default): The double-opt-in session token gets deleted, after the form submission was successful + # - "devalue": The double-opt-in session token only gets redeemed but not deleted, after the form submission was successful. + redeem_mode: 'delete' + + expiration: + # delete open sessions after 24 hours (default). If you set it to 0, no sessions will be deleted ever. + open_sessions: 24 + # delete redeemed session after x hours (default 0, which means: disabled) + redeemed_sessions: 0 +``` + +## Extending Double-Opt-In Form +By default, the `DoubleOptInType` form type only contains a `emailAddress` field to keep users effort small. +If you want to extend the form, you may want to use a symfony [form extension](https://symfony.com/doc/current/form/create_form_type_extension.html). + +Additional Info: +- `emailAddress` is required and you're not allowed to remove it +- Additional fields will be stored as array in the DoubleOptInSession in `additionalData` + +## Trash-Mail Protection +TBD \ No newline at end of file diff --git a/public/js/extjs/_form/tab/configPanel.js b/public/js/extjs/_form/tab/configPanel.js index 88164aeb..7ed0124c 100755 --- a/public/js/extjs/_form/tab/configPanel.js +++ b/public/js/extjs/_form/tab/configPanel.js @@ -49,6 +49,7 @@ Formbuilder.extjs.formPanel.config = Class.create({ this.formConditionalsStructured = formData.conditional_logic; this.formConditionalsStore = formData.conditional_logic_store; this.formFields = formData.fields; + this.doubleOptIn = formData.double_opt_in; this.availableFormFields = formData.fields_structure; this.availableContainerTypes = formData.container_types; this.availableConstraints = formData.validation_constraints; @@ -500,10 +501,12 @@ Formbuilder.extjs.formPanel.config = Class.create({ return el.submitValue === undefined || el.submitValue === true; }); - for (var i = 0; i < items.length; i++) { - if (typeof items[i].getValue === 'function') { - var val = items[i].getValue(), - fieldName = items[i].name; + Ext.Array.each(items, function (item, index) { + if (typeof item.getValue === 'function') { + + var val = item.getValue(), + fieldName = item.name; + if (fieldName) { if (fieldName === 'name') { @@ -519,6 +522,13 @@ Formbuilder.extjs.formPanel.config = Class.create({ } } } + }.bind(this)); + + // parse form config + this.formConfig = DataObjectParser.transpose(this.formConfig).data(); + + if (this.formConfig.doubleOptIn && this.formConfig.doubleOptIn.enabled === false) { + this.formConfig.doubleOptIn = {enabled: false} } // parse conditional logic to add them later again @@ -632,7 +642,8 @@ Formbuilder.extjs.formPanel.config = Class.create({ getRootPanel: function () { - var methodStore = new Ext.data.ArrayStore({ + var doubleOptInLocalizedField, + methodStore = new Ext.data.ArrayStore({ fields: ['value', 'label'], data: [['post', 'POST'], ['get', 'GET']] }), @@ -658,8 +669,8 @@ Formbuilder.extjs.formPanel.config = Class.create({ listeners: { load: function (store) { store.insert(0, { - id : 'all', - name : t('form_builder_email_csv_export_mail_type_all') + id: 'all', + name: t('form_builder_email_csv_export_mail_type_all') }); } } @@ -675,6 +686,92 @@ Formbuilder.extjs.formPanel.config = Class.create({ ), clBuilder = new Formbuilder.extjs.conditionalLogic.builder(this.formConditionalsStructured, this.formConditionalsStore, this); + if (this.doubleOptIn.enabled === true) { + + doubleOptInLocalizedField = new Formbuilder.extjs.types.localizedField(function (locale) { + + var hrefField = new Formbuilder.extjs.types.href({ + label: t('form_builder_form.double_opt_in.mail_template'), + id: 'doubleOptIn.mailTemplate.' + locale, + config: { + types: ['document'], + subtypes: {document: ['email']} + } + }, + this.formConfig.doubleOptIn && this.formConfig.doubleOptIn.mailTemplate && this.formConfig.doubleOptIn.mailTemplate[locale] + ? this.formConfig.doubleOptIn.mailTemplate[locale] + : null, + null + ); + + return hrefField.getHref(); + + }.bind(this), true); + + this.doubleOptInPanel = new Ext.form.FieldSet({ + title: t('form_builder_form.double_opt_in'), + collapsible: false, + autoHeight: true, + width: '100%', + style: 'margin-top: 20px;', + submitValue: false, + defaults: { + labelWidth: 160 + }, + items: [ + { + xtype: 'checkbox', + name: 'doubleOptIn.enabled', + fieldLabel: t('form_builder_form.double_opt_in.enable'), + inputValue: true, + uncheckedValue: false, + value: this.formConfig.doubleOptIn ? this.formConfig.doubleOptIn.enabled : false, + listeners: { + change: function (cb, value) { + + var containerField = cb.nextSibling(); + + containerField.setHidden(!value); + containerField.query('textfield[name="doubleOptIn.confirmationMessage"]')[0].allowBlank = !value + + }.bind(this) + } + }, + { + xtype: 'container', + hidden: !this.formConfig.doubleOptIn || this.formConfig.doubleOptIn.enabled === false, + items: [ + { + fieldLabel: false, + xtype: 'displayfield', + style: 'display:block !important; margin-bottom:15px !important; font-weight: 300;', + value: t('form_builder_form.double_opt_in.description') + }, + { + xtype: 'textfield', + name: 'doubleOptIn.instructionNote', + fieldLabel: t('form_builder_form.double_opt_in.double_opt_in_instruction_note'), + value: this.formConfig.doubleOptIn ? this.formConfig.doubleOptIn.instructionNote : null, + allowBlank: true, + width: '100%', + inputAttrTpl: ' data-qwidth="250" data-qalign="br-r?" data-qtrackMouse="false" data-qtip="' + t('form_builder_type_field_base.translatable_field') + '"', + }, + { + xtype: 'textfield', + name: 'doubleOptIn.confirmationMessage', + fieldLabel: t('form_builder_form.double_opt_in.confirmation_message'), + value: this.formConfig.doubleOptIn ? this.formConfig.doubleOptIn.confirmationMessage : null, + allowBlank: true, + width: '100%', + inputAttrTpl: ' data-qwidth="250" data-qalign="br-r?" data-qtrackMouse="false" data-qtip="' + t('form_builder_type_field_base.translatable_field') + '"', + }, + doubleOptInLocalizedField.getField() + ] + } + ] + }); + } + this.metaDataPanel = keyValueRepeater.getRepeater(); // add conditional logic field @@ -792,11 +889,10 @@ Formbuilder.extjs.formPanel.config = Class.create({ checked: this.formConfig.useAjax === undefined, value: this.formConfig.useAjax }, - this.metaDataPanel, this.clBuilder, + this.doubleOptInPanel ? this.doubleOptInPanel : null, this.exportPanel - ] }); diff --git a/src/Assembler/FormAssembler.php b/src/Assembler/FormAssembler.php index 1b43661a..c610110c 100644 --- a/src/Assembler/FormAssembler.php +++ b/src/Assembler/FormAssembler.php @@ -6,6 +6,7 @@ use FormBuilderBundle\Event\FormAssembleEvent; use FormBuilderBundle\Form\RuntimeData\FormRuntimeDataAllocatorInterface; use FormBuilderBundle\FormBuilderEvents; +use FormBuilderBundle\Manager\DoubleOptInManager; use FormBuilderBundle\Resolver\FormOptionsResolver; use FormBuilderBundle\Manager\FormDefinitionManager; use FormBuilderBundle\Model\FormDefinitionInterface; @@ -18,6 +19,7 @@ public function __construct( protected EventDispatcherInterface $eventDispatcher, protected FrontendFormBuilder $frontendFormBuilder, protected FormDefinitionManager $formDefinitionManager, + protected DoubleOptInManager $doubleOptInManager, protected FormRuntimeDataAllocatorInterface $formRuntimeDataAllocator ) { } @@ -121,9 +123,13 @@ public function buildForm( $formAttributes = $optionsResolver->getFormAttributes(); $useCsrfProtection = $optionsResolver->useCsrfProtection(); - $formRuntimeDataCollector = $this->formRuntimeDataAllocator->allocate($formDefinition, $systemRuntimeData); + $formRuntimeDataCollector = $this->formRuntimeDataAllocator->allocate($formDefinition, $systemRuntimeData, $headless); $formRuntimeData = $formRuntimeDataCollector->getData(); + if ($this->doubleOptInManager->requiresDoubleOptInForm($formDefinition, $formRuntimeData)) { + return $this->frontendFormBuilder->buildDoubleOptInForm($formDefinition, $formAttributes, $headless, $useCsrfProtection); + } + if ($headless === true) { return $this->frontendFormBuilder->buildHeadlessForm($formDefinition, $formRuntimeData, $formAttributes, $formData, $useCsrfProtection); } diff --git a/src/Builder/ExtJsFormBuilder.php b/src/Builder/ExtJsFormBuilder.php index ccfca159..78069094 100644 --- a/src/Builder/ExtJsFormBuilder.php +++ b/src/Builder/ExtJsFormBuilder.php @@ -62,6 +62,7 @@ public function generateExtJsForm(FormDefinitionInterface $formDefinition): arra $data['fields_structure'] = $this->generateExtJsFormTypesStructure(); $data['fields_template'] = $this->getFormTypeTemplates(); $data['funnel'] = $this->generateFunnelConfiguration(); + $data['double_opt_in'] = $this->generateDoubleOptInConfiguration(); $data['config_store'] = $this->getFormStoreData(); $data['container_types'] = $this->getTranslatedContainerTypes(); $data['validation_constraints'] = $this->getTranslatedValidationConstraints(); @@ -541,6 +542,11 @@ private function generateFunnelConfiguration(): array return $this->configuration->getConfig('funnel'); } + private function generateDoubleOptInConfiguration(): array + { + return $this->configuration->getConfig('double_opt_in'); + } + private function getFormStoreData(): array { $formAttributes = $this->configuration->getConfig('form_attributes'); diff --git a/src/Builder/FrontendFormBuilder.php b/src/Builder/FrontendFormBuilder.php index 2495f1f3..9a4e648f 100644 --- a/src/Builder/FrontendFormBuilder.php +++ b/src/Builder/FrontendFormBuilder.php @@ -4,6 +4,7 @@ use FormBuilderBundle\EventSubscriber\FormBuilderSubscriber; use FormBuilderBundle\Factory\FormDataFactoryInterface; +use FormBuilderBundle\Form\Type\DoubleOptInType; use FormBuilderBundle\Form\Type\DynamicFormType; use FormBuilderBundle\Model\FormDefinitionInterface; use Symfony\Component\Form\FormBuilderInterface; @@ -24,6 +25,60 @@ public function __construct( ) { } + public function buildDoubleOptInForm( + FormDefinitionInterface $formDefinition, + array $formAttributes = [], + bool $isHeadlessForm = false, + bool $useCsrfProtection = true + ): FormInterface { + + $formDefinitionConfig = $formDefinition->getConfiguration(); + $doubleOptInConfig = $formDefinition->getDoubleOptInConfig(); + + $request = !$isHeadlessForm && $this->requestStack->getMainRequest() instanceof Request ? $this->requestStack->getMainRequest() : null; + + if ($formDefinitionConfig['noValidate'] === false) { + $formAttributes['novalidate'] = 'novalidate'; + } + + $formAttributes['class'] = 'formbuilder formbuilder-double-opt-in'; + + if ($formDefinitionConfig['useAjax'] === true && $isHeadlessForm === false) { + $formAttributes['data-ajax-structure-url'] = $this->router->generate('form_builder.controller.ajax.url_structure'); + $formAttributes['class'] = sprintf('%s ajax-form', $formAttributes['class']); + } + + $action = $formDefinitionConfig['action']; + if (!$isHeadlessForm && $request instanceof Request) { + $action = $formDefinitionConfig['action'] === '/' ? $request->getUri() : $formDefinitionConfig['action']; + } + + $builder = $this->formFactory->createNamedBuilder( + $isHeadlessForm === true ? '' : sprintf('formbuilder_double_opt_in_%s', $formDefinition->getId()), + DoubleOptInType::class, + null, + [ + 'action' => $action, + 'method' => $formDefinitionConfig['method'], + 'attr' => $formAttributes, + 'csrf_protection' => $useCsrfProtection, + 'render_conditional_logic_field' => !$isHeadlessForm, + 'render_form_id_field' => !$isHeadlessForm, + 'current_form_id' => $formDefinition->getId(), + 'is_headless_form' => $isHeadlessForm, + 'double_opt_in_instruction_note' => $doubleOptInConfig['instructionNote'] ?? null + ] + ); + + $form = $builder->getForm(); + + if (!$isHeadlessForm && $request instanceof Request) { + $form->handleRequest($request); + } + + return $form; + } + /** * @throws \Exception */ diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index cf182850..a5ead614 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -4,6 +4,7 @@ use FormBuilderBundle\DynamicMultiFile\Adapter\DropZoneAdapter; use FormBuilderBundle\EventSubscriber\SignalStorage\FormDataSignalStorage; +use FormBuilderBundle\Manager\DoubleOptInManager; use FormBuilderBundle\Storage\SessionStorageProvider; use Symfony\Component\Config\Definition\Builder\NodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; @@ -23,6 +24,7 @@ public function getConfigTreeBuilder() ->end(); $rootNode->append($this->buildFunnelNode()); + $rootNode->append($this->buildDoubleOptInNode()); $rootNode->append($this->createPersistenceNode()); $rootNode->append($this->buildFlagsNode()); $rootNode->append($this->buildSpamProductionNode()); @@ -710,4 +712,30 @@ private function buildFunnelNode(): NodeDefinition return $rootNode; } + private function buildDoubleOptInNode(): NodeDefinition + { + $builder = new TreeBuilder('double_opt_in'); + + $rootNode = $builder->getRootNode(); + + $rootNode + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled')->defaultValue(false)->end() + ->enumNode('redeem_mode') + ->values([DoubleOptInManager::REDEEM_MODE_DELETE, DoubleOptInManager::REDEEM_MODE_DEVALUE]) + ->defaultValue(DoubleOptInManager::REDEEM_MODE_DELETE) + ->end() + ->arrayNode('expiration') + ->addDefaultsIfNotSet() + ->children() + ->integerNode('open_sessions')->info('Define expiration (in hours) for open sessions. 0 disables expiration.')->defaultValue(24)->end() + ->integerNode('redeemed_sessions')->info('Define expiration (in hours) for redeemed sessions. 0 disables expiration.')->defaultValue(0)->end() + ->end() + ->end() + ->end(); + + return $rootNode; + } + } diff --git a/src/Event/DoubleOptInSubmissionEvent.php b/src/Event/DoubleOptInSubmissionEvent.php new file mode 100644 index 00000000..126666f5 --- /dev/null +++ b/src/Event/DoubleOptInSubmissionEvent.php @@ -0,0 +1,58 @@ +request; + } + + public function getFormDefinition(): FormDefinitionInterface + { + return $this->formDefinition; + } + + public function getForm(): FormInterface + { + return $this->form; + } + + public function useFlashBag(): bool + { + return $this->useFlashBag; + } + + public function getMessages(): array + { + return $this->messages; + } + + public function addMessage(string $type, mixed $message): void + { + if (empty($message)) { + return; + } + + if (!array_key_exists($type, $this->messages)) { + $this->messages[$type] = []; + } + + $this->messages[$type][] = $message; + } +} diff --git a/src/Event/FormAssembleEvent.php b/src/Event/FormAssembleEvent.php index c82a9e17..3ca724cd 100644 --- a/src/Event/FormAssembleEvent.php +++ b/src/Event/FormAssembleEvent.php @@ -14,7 +14,8 @@ class FormAssembleEvent extends Event public function __construct( protected FormOptionsResolver $formOptionsResolver, protected FormDefinitionInterface $formDefinition, - protected ?FormInterface $form = null + protected ?FormInterface $form = null, + protected bool $headless = false ) { } @@ -42,4 +43,9 @@ public function setFormData(array $formData): void { $this->formData = $formData; } + + public function isHeadless(): bool + { + return $this->headless; + } } diff --git a/src/Event/SubmissionEvent.php b/src/Event/SubmissionEvent.php index c02bbc0b..fab9f5e7 100644 --- a/src/Event/SubmissionEvent.php +++ b/src/Event/SubmissionEvent.php @@ -15,8 +15,8 @@ public function __construct( private readonly Request $request, private readonly ?array $formRuntimeData, private readonly FormInterface $form, - private ?array $funnelRuntimeData = null, - private bool $useFlashBag = true, + private readonly ?array $funnelRuntimeData = null, + private readonly bool $useFlashBag = true, private array $messages = [] ) { } diff --git a/src/EventListener/Core/CleanUpListener.php b/src/EventListener/Core/CleanUpListener.php index e7902528..9c690bcb 100644 --- a/src/EventListener/Core/CleanUpListener.php +++ b/src/EventListener/Core/CleanUpListener.php @@ -3,6 +3,8 @@ namespace FormBuilderBundle\EventListener\Core; use Carbon\Carbon; +use FormBuilderBundle\Manager\DoubleOptInManager; +use FormBuilderBundle\Model\DoubleOptInSessionInterface; use League\Flysystem\FilesystemOperator; use League\Flysystem\StorageAttributes; use Pimcore\Logger; @@ -11,12 +13,19 @@ class CleanUpListener implements TaskInterface { public function __construct( + protected DoubleOptInManager $doubleOptInManager, protected FilesystemOperator $formBuilderChunkStorage, protected FilesystemOperator $formBuilderFilesStorage, ) { } public function execute(): void + { + $this->cleanUpFileStorage(); + $this->cleanUpDoubleOptInSessions(); + } + + protected function cleanUpFileStorage(): void { $minimumModifiedDelta = Carbon::now()->subHour(); @@ -29,6 +38,18 @@ public function execute(): void } } + protected function cleanUpDoubleOptInSessions(): void + { + if (!$this->doubleOptInManager->doubleOptInEnabled()) { + return; + } + + /** @var DoubleOptInSessionInterface $session */ + foreach ($this->doubleOptInManager->getOutDatedDoubleOptInSessions() as $session) { + $this->doubleOptInManager->deleteDoubleOptInSession($session); + } + } + protected function remove(Carbon $minimumModifiedDelta, StorageAttributes $file): void { if (!$minimumModifiedDelta->greaterThan(Carbon::createFromTimestamp($file->lastModified()))) { diff --git a/src/EventListener/Core/RequestListener.php b/src/EventListener/Core/RequestListener.php index e2408604..85071e2e 100644 --- a/src/EventListener/Core/RequestListener.php +++ b/src/EventListener/Core/RequestListener.php @@ -2,6 +2,7 @@ namespace FormBuilderBundle\EventListener\Core; +use FormBuilderBundle\Event\DoubleOptInSubmissionEvent; use FormBuilderBundle\Event\SubmissionEvent; use FormBuilderBundle\Builder\FrontendFormBuilder; use FormBuilderBundle\FormBuilderEvents; @@ -19,6 +20,9 @@ class RequestListener implements EventSubscriberInterface { + private const FORM_TYPE_DEFAULT = 'formbuilder_'; + private const FORM_TYPE_DOUBLE_OPT_IN = 'formbuilder_double_opt_in_'; + public function __construct( protected FrontendFormBuilder $frontendFormBuilder, protected EventDispatcherInterface $eventDispatcher, @@ -36,12 +40,15 @@ public static function getSubscribedEvents(): array public function onKernelRequest(RequestEvent $event): void { + $form = null; + $formRuntimeData = null; + if (!$event->isMainRequest()) { return; } $request = $event->getRequest(); - $formId = $this->findFormIdByRequest($request); + [$formId, $formType] = $this->findFormIdByRequest($request); if ($formId === null) { return; @@ -53,28 +60,45 @@ public function onKernelRequest(RequestEvent $event): void } try { - $formRuntimeData = $this->detectFormRuntimeDataInRequest($event->getRequest(), $formDefinition); - if (null === $formRuntimeData) { - return; + if ($formType === self::FORM_TYPE_DOUBLE_OPT_IN) { + $form = $this->frontendFormBuilder->buildDoubleOptInForm($formDefinition); + } elseif ($formType === self::FORM_TYPE_DEFAULT) { + $formRuntimeData = $this->detectFormRuntimeDataInRequest($event->getRequest(), $formDefinition); + if ($formRuntimeData !== null) { + $form = $this->frontendFormBuilder->buildForm($formDefinition, $formRuntimeData); + } + } else { + throw new \InvalidArgumentException(sprintf('Invalid form type "%s"', $formType)); } - - $form = $this->frontendFormBuilder->buildForm($formDefinition, $formRuntimeData); - } catch (\Exception $e) { + + } catch (\Throwable $e) { $this->generateErroredJsonReturn($event, $e); return; } + if (!$form instanceof FormInterface) { + return; + } + if (!$form->isSubmitted()) { return; } if ($form->isValid() === false) { $this->doneWithError($event, $form); - } else { - $this->doneWithSuccess($event, $form, $formRuntimeData); + + return; + } + + if ($formType === self::FORM_TYPE_DOUBLE_OPT_IN) { + $this->doubleOptInDoneWithSuccess($event, $formDefinition, $form); + + return; } + + $this->doneWithSuccess($event, $form, $formRuntimeData); } protected function doneWithError(RequestEvent $event, FormInterface $form): void @@ -87,7 +111,20 @@ protected function doneWithError(RequestEvent $event, FormInterface $form): void } } - protected function doneWithSuccess(RequestEvent $event, FormInterface $form, $formRuntimeData): void + protected function doubleOptInDoneWithSuccess(RequestEvent $event, FormDefinitionInterface $formDefinition, FormInterface $form): void + { + $request = $event->getRequest(); + $submissionEvent = new DoubleOptInSubmissionEvent($request, $formDefinition, $form); + $this->eventDispatcher->dispatch($submissionEvent, FormBuilderEvents::FORM_DOUBLE_OPT_IN_SUBMIT_SUCCESS); + + $finishResponse = $this->formSubmissionFinisher->finishDoubleOptInWithSuccess($request, $submissionEvent); + + if ($finishResponse instanceof Response) { + $event->setResponse($finishResponse); + } + } + + protected function doneWithSuccess(RequestEvent $event, FormInterface $form, ?array $formRuntimeData): void { $request = $event->getRequest(); $submissionEvent = new SubmissionEvent($request, $formRuntimeData, $form); @@ -117,7 +154,7 @@ protected function generateErroredJsonReturn(RequestEvent $event, ?\Exception $e $event->setResponse($response); } - protected function findFormIdByRequest(Request $request): ?int + protected function findFormIdByRequest(Request $request): ?array { $isProcessed = false; $data = null; @@ -130,24 +167,20 @@ protected function findFormIdByRequest(Request $request): ?int } if ($isProcessed === true) { - return null; + return [null, null]; } if (empty($data)) { - return null; + return [null, null]; } - foreach ($data as $key => $parameters) { - if (!str_contains($key, 'formbuilder_')) { - continue; - } - - if (isset($parameters['formId'])) { - return $parameters['formId']; + foreach ([self::FORM_TYPE_DOUBLE_OPT_IN, self::FORM_TYPE_DEFAULT] as $formType) { + if (null !== $formId = $this->detectFormIdByType($data, $formType)) { + return [$formId, $formType]; } } - return null; + return [null, null]; } protected function detectFormRuntimeDataInRequest(Request $request, FormDefinitionInterface $formDefinition): ?array @@ -178,4 +211,20 @@ protected function detectFormRuntimeDataInRequest(Request $request, FormDefiniti return null; } + + protected function detectFormIdByType(array $values, string $formMatchType = self::FORM_TYPE_DEFAULT): ?int + { + foreach ($values as $key => $parameters) { + + if (!str_contains($key, $formMatchType)) { + continue; + } + + if (isset($parameters['formId'])) { + return $parameters['formId']; + } + } + + return null; + } } diff --git a/src/Exception/DoubleOptInException.php b/src/Exception/DoubleOptInException.php new file mode 100644 index 00000000..6264c748 --- /dev/null +++ b/src/Exception/DoubleOptInException.php @@ -0,0 +1,7 @@ +add($systemRuntimeDataId, $systemRuntimeDataBlock); } - foreach ($this->runtimeDataProviderRegistry->getAll() as $dataProviderIdentifier => $dataProvider) { + /** @var RuntimeDataProviderInterface $dataProvider */ + foreach ($this->runtimeDataProviderRegistry->getAll() as $dataProvider) { + + if ($headless === true && !$dataProvider instanceof HeadlessAwareRuntimeDataProviderInterface) { + continue; + } + if (!$dataProvider->hasRuntimeData($formDefinition)) { continue; } diff --git a/src/Form/RuntimeData/FormRuntimeDataAllocatorInterface.php b/src/Form/RuntimeData/FormRuntimeDataAllocatorInterface.php index e049441a..03f48284 100644 --- a/src/Form/RuntimeData/FormRuntimeDataAllocatorInterface.php +++ b/src/Form/RuntimeData/FormRuntimeDataAllocatorInterface.php @@ -9,5 +9,5 @@ interface FormRuntimeDataAllocatorInterface /** * @throws \Exception */ - public function allocate(FormDefinitionInterface $formDefinition, array $systemRuntimeData): RuntimeDataCollector; + public function allocate(FormDefinitionInterface $formDefinition, array $systemRuntimeData, bool $headless): RuntimeDataCollector; } diff --git a/src/Form/RuntimeData/HeadlessAwareRuntimeDataProviderInterface.php b/src/Form/RuntimeData/HeadlessAwareRuntimeDataProviderInterface.php new file mode 100644 index 00000000..8889c4d5 --- /dev/null +++ b/src/Form/RuntimeData/HeadlessAwareRuntimeDataProviderInterface.php @@ -0,0 +1,7 @@ +configuration->getConfig('double_opt_in'); + + if ($doubleOptInConfig['enabled'] === false) { + return false; + } + + return $this->requestStack->getMainRequest()->query->has(DoubleOptInManager::DOUBLE_OPT_IN_SESSION_QUERY_IDENTIFIER); + } + + public function getRuntimeData(FormDefinitionInterface $formDefinition): ?string + { + return $this->requestStack->getMainRequest()->query->get(DoubleOptInManager::DOUBLE_OPT_IN_SESSION_QUERY_IDENTIFIER); + } +} diff --git a/src/Form/Type/DoubleOptInType.php b/src/Form/Type/DoubleOptInType.php new file mode 100644 index 00000000..aa5552c1 --- /dev/null +++ b/src/Form/Type/DoubleOptInType.php @@ -0,0 +1,74 @@ +vars['instruction_note'] = $options['double_opt_in_instruction_note']; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + if ($options['double_opt_in_instruction_note'] !== null) { + $builder->add('instructionNote', InstructionsType::class, [ + 'instructions' => $options['double_opt_in_instruction_note'], + ]); + } + + $builder->add('emailAddress', EmailType::class, [ + 'label' => 'form_builder.form.double_opt_in.email', + 'constraints' => [ + new NotBlank(), + new Email(), + ] + ]); + + if ($options['render_form_id_field']) { + $builder->add('formId', HiddenType::class, [ + 'mapped' => false, + 'data' => $options['current_form_id'], + ]); + } + + if ($options['render_conditional_logic_field']) { + $builder->add('formCl', HiddenType::class, [ + 'mapped' => false, + 'data' => json_encode([], JSON_THROW_ON_ERROR) + ]); + } + + $builder->add('submit', SubmitType::class, [ + 'label' => 'form_builder.form.double_opt_in.submit', + ]); + } + + public function getBlockPrefix(): string + { + return 'form_builder_double_opt_in'; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'current_form_id' => 0, + 'csrf_protection' => true, + 'render_conditional_logic_field' => true, + 'render_form_id_field' => true, + 'is_headless_form' => false, + 'double_opt_in_instruction_note' => null + ]); + } +} diff --git a/src/Form/Type/InstructionsType.php b/src/Form/Type/InstructionsType.php new file mode 100644 index 00000000..6cb7b7eb --- /dev/null +++ b/src/Form/Type/InstructionsType.php @@ -0,0 +1,48 @@ +setDefaults([ + 'label' => false, + 'mapped' => false, + 'required' => false, + 'instructions' => null + ]); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $vars = array_merge_recursive($view->vars, [ + 'instructions' => $options['instructions'] ?? null, + 'attr' => [ + 'data-field-name' => $view->vars['name'], + 'data-field-id' => $view->vars['id'], + 'class' => 'form-builder-instruction-element' + ] + ]); + + $vars['attr']['class'] = implode(' ', (array) $vars['attr']['class']); + + $view->vars = $vars; + } + + public function getParent(): string + { + return TextType::class; + } + + public function getBlockPrefix(): string + { + return 'form_builder_instruction_type'; + } +} diff --git a/src/FormBuilderEvents.php b/src/FormBuilderEvents.php index bb0d6a01..9821a6c5 100644 --- a/src/FormBuilderEvents.php +++ b/src/FormBuilderEvents.php @@ -46,9 +46,18 @@ final class FormBuilderEvents /** * The FORM_SUBMIT_SUCCESS event occurs when a frontend form submission was successful. + * + * @see \FormBuilderBundle\Event\SubmissionEvent */ public const FORM_SUBMIT_SUCCESS = 'form_builder.submit.success'; + /** + * The FORM_DOUBLE_OPT_IN_SUBMIT_SUCCESS event occurs when a frontend double-opt-in form submission was successful. + * + * @see \FormBuilderBundle\Event\DoubleOptInSubmissionEvent + */ + public const FORM_DOUBLE_OPT_IN_SUBMIT_SUCCESS = 'form_builder.double_opt_in.submit.success'; + /** * The FORM_MAIL_PRE_SUBMIT event occurs before sending an email. * diff --git a/src/Manager/DoubleOptInManager.php b/src/Manager/DoubleOptInManager.php new file mode 100644 index 00000000..78587193 --- /dev/null +++ b/src/Manager/DoubleOptInManager.php @@ -0,0 +1,279 @@ +doubleOptInEnabled($formDefinition) === false) { + return false; + } + + if (!array_key_exists(DoubleOptInSessionDataProvider::DOUBLE_OPT_IN_SESSION_RUNTIME_DATA_IDENTIFIER, $formRuntimeData)) { + return true; + } + + if (null === $sessionToken = $formRuntimeData[DoubleOptInSessionDataProvider::DOUBLE_OPT_IN_SESSION_RUNTIME_DATA_IDENTIFIER]) { + return true; + } + + return !$this->isValidNonAppliedFormAwareSessionToken($formDefinition, $sessionToken); + } + + public function redeemDoubleOptInSessionToken(FormDefinitionInterface $formDefinition, array $formRuntimeData): void + { + if ($this->doubleOptInEnabled($formDefinition) === false) { + return; + } + + if (!array_key_exists(DoubleOptInSessionDataProvider::DOUBLE_OPT_IN_SESSION_RUNTIME_DATA_IDENTIFIER, $formRuntimeData)) { + return; + } + + if (null === $sessionToken = $formRuntimeData[DoubleOptInSessionDataProvider::DOUBLE_OPT_IN_SESSION_RUNTIME_DATA_IDENTIFIER]) { + return; + } + + $doubleOptInSession = $this->doubleOptInSessionRepository->findByNonAppliedFormAwareSessionToken($sessionToken, $formDefinition->getId()); + + if (!$doubleOptInSession instanceof DoubleOptInSessionInterface) { + throw new DoubleOptInException('invalid double-opt-in session'); + } + + $doubleOptInConfig = $this->configuration->getConfig('double_opt_in'); + + if ($doubleOptInConfig['redeem_mode'] === self::REDEEM_MODE_DELETE) { + $this->deleteDoubleOptInSession($doubleOptInSession); + } else { + $this->devalueDoubleOptInSession($doubleOptInSession); + } + } + + public function isValidNonAppliedFormAwareSessionToken(FormDefinitionInterface $formDefinition, ?string $sessionToken): bool + { + $doubleOptInSession = $this->doubleOptInSessionRepository->findByNonAppliedFormAwareSessionToken($sessionToken, $formDefinition->getId()); + + return $doubleOptInSession instanceof DoubleOptInSessionInterface; + } + + /** + * @throws DoubleOptInException + * @throws \Exception + */ + public function processOptInSubmission(DoubleOptInSubmissionEvent $submissionEvent): void + { + $formData = $submissionEvent->getForm()->getData(); + $doubleOptInConfig = $submissionEvent->getFormDefinition()->getDoubleOptInConfig(); + $locale = $submissionEvent->getRequest()->getLocale(); + + $email = $formData['emailAddress'] ?? null; + + if (empty($email)) { + throw new DoubleOptInException('no email address given'); + } + + unset($formData['emailAddress']); + + $dispatchLocation = $submissionEvent->getRequest()->getUri(); + if ($submissionEvent->getRequest()->isXmlHttpRequest()) { + $dispatchLocation = $submissionEvent->getRequest()->headers->get('referer'); + } + + if (empty($dispatchLocation)) { + throw new DoubleOptInException('invalid double-opt-in dispatch location'); + } + + try { + $doubleOptInSession = $this->create( + $submissionEvent->getFormDefinition(), + $email, + $formData, + $dispatchLocation + ); + } catch (UniqueConstraintViolationException) { + throw new DoubleOptInException($this->translator->trans('form_builder.form.double_opt_in.duplicate_session')); + } + + $this->sendDoubleOptInMessage($doubleOptInSession, $doubleOptInConfig, $locale, $email); + + $submissionEvent->addMessage( + 'success', + $this->translator->trans( + $doubleOptInConfig['confirmationMessage'] ?? 'form_builder.double_opt_in_success', + ['%token%' => $doubleOptInSession->getTokenAsString()] + ) + ); + } + + public function create( + FormDefinitionInterface $formDefinition, + string $email, + ?array $additionalData, + string $dispatchLocation + ): DoubleOptInSessionInterface { + $doubleOptInSession = new DoubleOptInSession(); + + $doubleOptInSession->setFormDefinition($formDefinition); + $doubleOptInSession->setEmail($email); + $doubleOptInSession->setAdditionalData($additionalData); + $doubleOptInSession->setDispatchLocation($dispatchLocation); + $doubleOptInSession->setCreationDate(new \DateTime()); + $doubleOptInSession->setApplied(false); + + $this->entityManager->persist($doubleOptInSession); + $this->entityManager->flush(); + + return $doubleOptInSession; + } + + public function deleteDoubleOptInSession(DoubleOptInSessionInterface $doubleOptInSession): void + { + $this->entityManager->remove($doubleOptInSession); + $this->entityManager->flush(); + } + + public function devalueDoubleOptInSession(DoubleOptInSessionInterface $doubleOptInSession): void + { + $doubleOptInSession->setApplied(true); + + $this->entityManager->persist($doubleOptInSession); + $this->entityManager->flush(); + } + + public function doubleOptInEnabled(?FormDefinitionInterface $formDefinition = null): bool + { + $doubleOptInConfig = $this->configuration->getConfig('double_opt_in'); + + if ($doubleOptInConfig['enabled'] === false) { + return false; + } + + if (!$formDefinition instanceof FormDefinitionInterface) { + return true; + } + + return $formDefinition->isDoubleOptInActive() === true; + } + + public function getOutDatedDoubleOptInSessions(): array + { + $doubleOptInConfig = $this->configuration->getConfig('double_opt_in'); + $expiration = $doubleOptInConfig['expiration']; + + $qb = $this->doubleOptInSessionRepository->getQueryBuilder(); + + if ($expiration['open_sessions'] === 0 && $expiration['redeemed_sessions'] === 0) { + return []; + } + + if ($expiration['open_sessions'] > 0) { + + $expiredOpenSessionDate = new \DateTime(); + $expiredOpenSessionDate->modify(sprintf('-%d hour', $expiration['open_sessions'])); + + $qb->orWhere( + $qb->expr()->andX( + $qb->expr()->eq('s.applied', 0), + $qb->expr()->lt('s.creationDate', ':expiredOpenSession'), + ) + ); + + $qb->setParameter('expiredOpenSession', $expiredOpenSessionDate); + } + + if ($expiration['redeemed_sessions'] > 0) { + + $expiredRedeemedSessionDate = new \DateTime(); + $expiredRedeemedSessionDate->modify(sprintf('-%d hour', $expiration['redeemed_sessions'])); + + $qb->orWhere( + $qb->expr()->andX( + $qb->expr()->eq('s.applied', 1), + $qb->expr()->lt('s.creationDate', ':expiredRedeemedSession'), + ) + ); + + $qb->setParameter('expiredRedeemedSession', $expiredRedeemedSessionDate); + } + + return $qb->getQuery()->getResult(); + } + + private function sendDoubleOptInMessage(DoubleOptInSessionInterface $doubleOptInSession, array $doubleOptInConfig, ?string $locale, string $recipient): void + { + $mailTemplate = null; + $mailDocument = null; + $mailTemplates = $doubleOptInConfig['mailTemplate'] ?? []; + + foreach ([$locale, 'default'] as $layoutLocale) { + if (!empty($mailTemplates[$layoutLocale]['id'])) { + $mailTemplate = $mailTemplates[$layoutLocale]['id']; + break; + } + } + + if ($mailTemplate !== null) { + $mailDocument = Email::getById($mailTemplate); + } + + if (!$mailDocument instanceof Email) { + throw new \Exception('No email template found'); + } + + $mail = new Mail(); + $mail->setDocument($mailDocument); + $mail->addTo($recipient); + $mail->setParam('token', $doubleOptInSession->getTokenAsString()); + $mail->setParam('link', $this->generateDoubleOptInSessionAwareLink($doubleOptInSession)); + + $mail->send(); + } + + private function generateDoubleOptInSessionAwareLink(DoubleOptInSessionInterface $doubleOptInSession): string + { + $query = []; + $dispatchLocationUrl = parse_url($doubleOptInSession->getDispatchLocation()); + + if (!empty($dispatchLocationUrl['query'])) { + parse_str($dispatchLocationUrl['query'], $query); + if (array_key_exists(self::DOUBLE_OPT_IN_SESSION_QUERY_IDENTIFIER, $query)) { + unset($query[self::DOUBLE_OPT_IN_SESSION_QUERY_IDENTIFIER]); + } + } + + $query[self::DOUBLE_OPT_IN_SESSION_QUERY_IDENTIFIER] = $doubleOptInSession->getTokenAsString(); + + $dispatchLocationUrl['query'] = http_build_query($query); + + return http_build_url($dispatchLocationUrl); + } +} diff --git a/src/Migrations/Version20240819150642.php b/src/Migrations/Version20240819150642.php new file mode 100644 index 00000000..6e7698a7 --- /dev/null +++ b/src/Migrations/Version20240819150642.php @@ -0,0 +1,37 @@ +container->get(Install::class); + $installer->updateTranslations(); + + $this->addSql('ALTER TABLE formbuilder_forms DROP mailLayout;'); + + $this->addSql('CREATE TABLE formbuilder_double_opt_in_session (token BINARY(16) NOT NULL COMMENT "(DC2Type:uuid)", form_definition INT DEFAULT NULL, email VARCHAR(190) NOT NULL, additional_data LONGTEXT DEFAULT NULL COMMENT "(DC2Type:array)", dispatch_location LONGTEXT DEFAULT NULL, applied TINYINT(1) DEFAULT 0 NOT NULL, creationDate DATETIME NOT NULL, INDEX IDX_88815C4F61F7634C (form_definition), INDEX token_form (token, form_definition, applied), UNIQUE INDEX email_form_definition (email, form_definition, applied), PRIMARY KEY(token)) DEFAULT CHARACTER SET UTF8MB4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB;'); + $this->addSql('ALTER TABLE formbuilder_double_opt_in_session ADD CONSTRAINT FK_88815C4F61F7634C FOREIGN KEY (form_definition) REFERENCES formbuilder_forms (id) ON DELETE CASCADE;'); + + } + + public function down(Schema $schema): void + { + } +} diff --git a/src/Model/DoubleOptInSession.php b/src/Model/DoubleOptInSession.php new file mode 100644 index 00000000..930eaea2 --- /dev/null +++ b/src/Model/DoubleOptInSession.php @@ -0,0 +1,86 @@ +token; + } + + public function getTokenAsString(): string + { + return $this->token->toRfc4122(); + } + + public function getEmail(): string + { + return $this->email; + } + + public function setEmail(string $email): void + { + $this->email = $email; + } + + public function getAdditionalData(): array + { + return $this->additionalData; + } + + public function setAdditionalData(array $additionalData): void + { + $this->additionalData = $additionalData; + } + + public function getDispatchLocation(): string + { + return $this->dispatchLocation; + } + + public function setDispatchLocation(string $dispatchLocation): void + { + $this->dispatchLocation = $dispatchLocation; + } + + public function getCreationDate(): \DateTime + { + return $this->creationDate; + } + + public function setCreationDate(\DateTime $date): void + { + $this->creationDate = $date; + } + + public function getFormDefinition(): FormDefinitionInterface + { + return $this->formDefinition; + } + + public function setFormDefinition(FormDefinitionInterface $formDefinition): void + { + $this->formDefinition = $formDefinition; + } + + public function isApplied(): bool + { + return $this->applied; + } + + public function setApplied(bool $applied): void + { + $this->applied = $applied; + } +} diff --git a/src/Model/DoubleOptInSessionInterface.php b/src/Model/DoubleOptInSessionInterface.php new file mode 100644 index 00000000..d4da84a3 --- /dev/null +++ b/src/Model/DoubleOptInSessionInterface.php @@ -0,0 +1,26 @@ +configuration; } + public function getDoubleOptInConfig(): array + { + return $this->configuration['doubleOptIn'] ?? []; + } + + public function isDoubleOptInActive(): bool + { + $doubleOptInConfig = $this->getDoubleOptInConfig(); + + return ($doubleOptInConfig['enabled'] ?? false) === true; + } + public function getConditionalLogic(): array { return $this->conditionalLogic; diff --git a/src/Model/FormDefinitionInterface.php b/src/Model/FormDefinitionInterface.php index a81a8fc1..b7169178 100644 --- a/src/Model/FormDefinitionInterface.php +++ b/src/Model/FormDefinitionInterface.php @@ -13,6 +13,7 @@ interface FormDefinitionInterface extends SubFieldsAwareInterface 'enctype', 'noValidate', 'useAjax', + 'doubleOptIn', 'attributes' ]; @@ -59,6 +60,10 @@ public function setConfiguration(array $configuration): void; public function getConfiguration(): array; + public function getDoubleOptInConfig(): array; + + public function isDoubleOptInActive(): bool; + public function setConditionalLogic(array $conditionalLogic): void; public function getConditionalLogic(): array; diff --git a/src/OutputWorkflow/FormSubmissionFinisher.php b/src/OutputWorkflow/FormSubmissionFinisher.php index 78d5c06b..269aeda5 100644 --- a/src/OutputWorkflow/FormSubmissionFinisher.php +++ b/src/OutputWorkflow/FormSubmissionFinisher.php @@ -2,11 +2,15 @@ namespace FormBuilderBundle\OutputWorkflow; +use FormBuilderBundle\Event\DoubleOptInSubmissionEvent; use FormBuilderBundle\Event\SubmissionEvent; +use FormBuilderBundle\Exception\DoubleOptInException; use FormBuilderBundle\Exception\OutputWorkflow\GuardOutputWorkflowException; use FormBuilderBundle\Exception\OutputWorkflow\GuardStackedException; use FormBuilderBundle\Form\Data\FormDataInterface; use FormBuilderBundle\Form\FormErrorsSerializerInterface; +use FormBuilderBundle\Manager\DoubleOptInManager; +use FormBuilderBundle\Model\FormDefinitionInterface; use FormBuilderBundle\Model\OutputWorkflowInterface; use FormBuilderBundle\Session\FlashBagManagerInterface; use Symfony\Component\Form\FormInterface; @@ -17,24 +21,14 @@ class FormSubmissionFinisher implements FormSubmissionFinisherInterface { - protected FlashBagManagerInterface $flashBagManager; - protected FormErrorsSerializerInterface $formErrorsSerializer; - protected OutputWorkflowResolverInterface $outputWorkflowResolver; - protected OutputWorkflowDispatcherInterface $outputWorkflowDispatcher; - protected SuccessManagementWorkerInterface $successManagementWorker; - public function __construct( - FlashBagManagerInterface $flashBagManager, - FormErrorsSerializerInterface $formErrorsSerializer, - OutputWorkflowResolverInterface $outputWorkflowResolver, - OutputWorkflowDispatcherInterface $outputWorkflowDispatcher, - SuccessManagementWorkerInterface $successManagementWorker + protected FlashBagManagerInterface $flashBagManager, + protected DoubleOptInManager $doubleOptInManager, + protected FormErrorsSerializerInterface $formErrorsSerializer, + protected OutputWorkflowResolverInterface $outputWorkflowResolver, + protected OutputWorkflowDispatcherInterface $outputWorkflowDispatcher, + protected SuccessManagementWorkerInterface $successManagementWorker ) { - $this->flashBagManager = $flashBagManager; - $this->formErrorsSerializer = $formErrorsSerializer; - $this->outputWorkflowResolver = $outputWorkflowResolver; - $this->outputWorkflowDispatcher = $outputWorkflowDispatcher; - $this->successManagementWorker = $successManagementWorker; } public function finishWithError(Request $request, FormInterface $form): ?Response @@ -57,21 +51,18 @@ public function finishWithSuccess(Request $request, SubmissionEvent $submissionE $outputWorkflow = $this->outputWorkflowResolver->resolve($submissionEvent); if (!$outputWorkflow instanceof OutputWorkflowInterface) { - $errorMessage = 'No valid output workflow found.'; - - return $this->buildErrorResponse($request, $submissionEvent, $errorMessage); + return $this->buildErrorResponse($request, $submissionEvent, 'No valid output workflow found.'); } try { $this->outputWorkflowDispatcher->dispatch($outputWorkflow, $submissionEvent); - } catch (\Exception $e) { - if ($e instanceof GuardOutputWorkflowException) { - $errorMessage = $e->getMessage(); - } elseif ($e instanceof GuardStackedException) { - $errorMessage = implode(', ', $e->getGuardExceptionMessages()); - } else { - $errorMessage = sprintf('Error while dispatching workflow "%s". Message was: %s', $outputWorkflow->getName(), $e->getMessage()); - } + } catch (GuardOutputWorkflowException $e) { + return $this->buildErrorResponse($request, $submissionEvent, $e->getMessage()); + } catch (GuardStackedException $e) { + return $this->buildErrorResponse($request, $submissionEvent, implode(', ', $e->getGuardExceptionMessages())); + } catch (\Throwable $e) { + + $errorMessage = sprintf('Error while dispatching workflow "%s". Message was: %s', $outputWorkflow->getName(), $e->getMessage()); return $this->buildErrorResponse($request, $submissionEvent, $errorMessage); } @@ -87,7 +78,7 @@ public function finishWithSuccess(Request $request, SubmissionEvent $submissionE try { $this->successManagementWorker->process($submissionEvent, $outputWorkflow->getSuccessManagement()); - } catch (\Exception $e) { + } catch (\Throwable $e) { $errorMessage = sprintf('Error while processing success management of workflow "%s". Message was: %s', $outputWorkflow->getName(), $e->getMessage()); return $this->buildErrorResponse($request, $submissionEvent, $errorMessage); @@ -96,59 +87,122 @@ public function finishWithSuccess(Request $request, SubmissionEvent $submissionE return $this->buildSuccessResponse($request, $submissionEvent); } - protected function buildErrorResponse(Request $request, SubmissionEvent $submissionEvent, ?string $errorMessage): ?Response + public function finishDoubleOptInWithSuccess(Request $request, DoubleOptInSubmissionEvent $submissionEvent): ?Response { - return $request->isXmlHttpRequest() - ? $this->generateAjaxFinisherErrorResponse($errorMessage) - : $this->generateRedirectFinisherErrorResponse($submissionEvent, $errorMessage); + try { + $this->doubleOptInManager->processOptInSubmission($submissionEvent); + } catch (DoubleOptInException $e) { + return $this->buildErrorResponse($request, $submissionEvent, $e->getMessage()); + } catch (\Throwable $e) { + return $this->buildErrorResponse($request, $submissionEvent, sprintf('Error while processing double-opt-in: %s', $e->getMessage())); + } + + return $this->buildSuccessResponse($request, $submissionEvent); } - protected function buildSuccessResponse(Request $request, SubmissionEvent $submissionEvent): ?Response + protected function buildErrorResponse(Request $request, SubmissionEvent|DoubleOptInSubmissionEvent $submissionEvent, ?string $errorMessage): ?Response { + $redirectUri = null; + $flashBagPrefix = 'formbuilder'; + + if ($submissionEvent instanceof SubmissionEvent) { + /** @var FormDataInterface $data */ + $data = $submissionEvent->getForm()->getData(); + $formDefinition = $data->getFormDefinition(); + $redirectUri = $submissionEvent->hasRedirectUri() ? $submissionEvent->getRedirectUri() : null; + + } else { + $flashBagPrefix = 'formbuilder_double_opt_in'; + $formDefinition = $submissionEvent->getFormDefinition(); + } + + $arguments = [ + $request, + $formDefinition, + $redirectUri, + $submissionEvent->useFlashBag(), + $flashBagPrefix, + $errorMessage + ]; + return $request->isXmlHttpRequest() - ? $this->generateAjaxFormSuccessResponse($submissionEvent) - : $this->generateRedirectFormSuccessResponse($submissionEvent); + ? $this->generateAjaxFinisherErrorResponse($errorMessage) + : $this->generateRedirectFinisherErrorResponse(...$arguments); } - protected function generateRedirectFormSuccessResponse(SubmissionEvent $submissionEvent): Response + protected function buildSuccessResponse(Request $request, SubmissionEvent|DoubleOptInSubmissionEvent $submissionEvent): ?Response { - $uri = '?send=true'; + $redirectUri = null; + $flashBagPrefix = 'formbuilder'; + $responseMessages = $submissionEvent->getMessages(); + + if ($submissionEvent instanceof SubmissionEvent) { + /** @var FormDataInterface $data */ + $data = $submissionEvent->getForm()->getData(); + $formDefinition = $data->getFormDefinition(); + $redirectUri = $submissionEvent->hasRedirectUri() ? $submissionEvent->getRedirectUri() : null; + } else { + $flashBagPrefix = 'formbuilder_double_opt_in'; + $formDefinition = $submissionEvent->getFormDefinition(); + } - $form = $submissionEvent->getForm(); - /** @var FormDataInterface $data */ - $data = $form->getData(); + $arguments = [ + $formDefinition, + $redirectUri, + $submissionEvent->useFlashBag(), + $flashBagPrefix, + $responseMessages + ]; + + if ($submissionEvent instanceof SubmissionEvent) { + try { + $this->doubleOptInManager->redeemDoubleOptInSessionToken($formDefinition, $submissionEvent->getFormRuntimeData()); + } catch(\Throwable $e) { + return $this->buildErrorResponse($request, $submissionEvent, $e->getMessage()); + } + } - if ($submissionEvent->useFlashBag() === true) { - foreach ($submissionEvent->getMessages() as $type => $eventMessages) { + return $request->isXmlHttpRequest() + ? $this->generateAjaxFormSuccessResponse(...$arguments) + : $this->generateRedirectFormSuccessResponse(...$arguments); + } + + protected function generateRedirectFormSuccessResponse( + FormDefinitionInterface $formDefinition, + ?string $redirectUri, + bool $useFlashBag, + string $flashBagPrefix, + array $responseMessages, + ): Response { + $uri = $redirectUri ?? '?send=true'; + + if ($useFlashBag === true) { + foreach ($responseMessages as $type => $eventMessages) { foreach ($eventMessages as $message) { $messageKey = $type === 'redirect_message' - ? 'formbuilder_redirect_flash_message' - : sprintf('formbuilder_%d_%s', $data->getFormDefinition()->getId(), $type); + ? sprintf('%s_redirect_flash_message', $flashBagPrefix) + : sprintf('%s_%d_%s', $flashBagPrefix, $formDefinition->getId(), $type); $this->flashBagManager->add($messageKey, $message); } } } - if ($submissionEvent->hasRedirectUri()) { - $uri = $submissionEvent->getRedirectUri(); - } - return new RedirectResponse($uri); } - protected function generateAjaxFormSuccessResponse(SubmissionEvent $submissionEvent): Response - { - $redirectUri = null; - if ($submissionEvent->hasRedirectUri()) { - $redirectUri = $submissionEvent->getRedirectUri(); - } - + protected function generateAjaxFormSuccessResponse( + FormDefinitionInterface $formDefinition, + ?string $redirectUri, + bool $useFlashBag, + string $flashBagPrefix, + array $responseMessages, + ): Response { $messages = []; $error = false; - foreach ($submissionEvent->getMessages() as $type => $eventMessages) { + foreach ($responseMessages as $type => $eventMessages) { if ($type === 'error') { $error = true; @@ -156,8 +210,8 @@ protected function generateAjaxFormSuccessResponse(SubmissionEvent $submissionEv foreach ($eventMessages as $message) { - if ($type === 'redirect_message' && $submissionEvent->useFlashBag() === true) { - $this->flashBagManager->add('formbuilder_redirect_flash_message', $message); + if ($type === 'redirect_message' && $useFlashBag === true) { + $this->flashBagManager->add(sprintf('%s_redirect_flash_message', $flashBagPrefix), $message); } $messages[] = ['type' => $type, 'message' => $message]; @@ -173,43 +227,39 @@ protected function generateAjaxFormSuccessResponse(SubmissionEvent $submissionEv protected function generateAjaxFormErrorResponse(FormInterface $form): Response { - $formattedValidationErrors = $this->formErrorsSerializer->getErrors($form); - return new JsonResponse([ 'success' => false, - 'validation_errors' => $formattedValidationErrors, + 'validation_errors' => $this->formErrorsSerializer->getErrors($form), ]); } - protected function generateRedirectFinisherErrorResponse(SubmissionEvent $submissionEvent, array|string $errors): RedirectResponse - { - $uri = '?send=false'; - if ($submissionEvent->hasRedirectUri()) { - $uri = $submissionEvent->getRedirectUri(); - } + protected function generateRedirectFinisherErrorResponse( + Request $request, + FormDefinitionInterface $formDefinition, + ?string $redirectUri, + bool $useFlashBag, + string $flashBagPrefix, + array|string $errors + ): RedirectResponse { + + $uri = $redirectUri ?? '?send=false'; if (!is_array($errors)) { $errors = [$errors]; } - $form = $submissionEvent->getForm(); - /** @var FormDataInterface $data */ - $data = $form->getData(); - - $formDefinition = $data->getFormDefinition(); $formDefinitionConfig = $formDefinition->getConfiguration(); $method = isset($formDefinitionConfig['method']) ? strtoupper($formDefinitionConfig['method']) : 'POST'; if (in_array($method, ['GET', 'HEAD', 'TRACE'])) { - $qs = $submissionEvent->getRequest()->getQueryString(); + $qs = $request->getQueryString(); if (!empty($qs)) { - $uri = !str_contains($uri, '?') ? ($uri . '?' . $qs) : ($uri . '&' . $qs); + $uri = sprintf('%s%s%s', $uri, !str_contains($uri, '?') ? '?' : '&', $qs); } } - $messageKey = sprintf('formbuilder_%s_error', $data->getFormDefinition()->getId()); - - if ($submissionEvent->useFlashBag() === true) { + if ($useFlashBag === true) { + $messageKey = sprintf('%s_%s_error', $flashBagPrefix, $formDefinition->getId()); foreach ($errors as $error) { $this->flashBagManager->add($messageKey, $error); } diff --git a/src/OutputWorkflow/FormSubmissionFinisherInterface.php b/src/OutputWorkflow/FormSubmissionFinisherInterface.php index 158b95d8..07832c30 100644 --- a/src/OutputWorkflow/FormSubmissionFinisherInterface.php +++ b/src/OutputWorkflow/FormSubmissionFinisherInterface.php @@ -2,6 +2,7 @@ namespace FormBuilderBundle\OutputWorkflow; +use FormBuilderBundle\Event\DoubleOptInSubmissionEvent; use FormBuilderBundle\Event\SubmissionEvent; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; @@ -12,4 +13,6 @@ interface FormSubmissionFinisherInterface public function finishWithError(Request $request, FormInterface $form): ?Response; public function finishWithSuccess(Request $request, SubmissionEvent $submissionEvent): ?Response; + + public function finishDoubleOptInWithSuccess(Request $request, DoubleOptInSubmissionEvent $submissionEvent): ?Response; } diff --git a/src/OutputWorkflow/OutputWorkflowDispatcherInterface.php b/src/OutputWorkflow/OutputWorkflowDispatcherInterface.php index 4babec67..3881025e 100644 --- a/src/OutputWorkflow/OutputWorkflowDispatcherInterface.php +++ b/src/OutputWorkflow/OutputWorkflowDispatcherInterface.php @@ -4,6 +4,7 @@ use FormBuilderBundle\Event\SubmissionEvent; use FormBuilderBundle\Exception\OutputWorkflow\GuardException; +use FormBuilderBundle\Exception\OutputWorkflow\GuardStackedException; use FormBuilderBundle\Model\OutputWorkflowInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -13,6 +14,7 @@ interface OutputWorkflowDispatcherInterface /** * @throws \Exception * @throws GuardException + * @throws GuardStackedException */ public function dispatch(OutputWorkflowInterface $outputWorkflow, SubmissionEvent $submissionEvent); diff --git a/src/Repository/DoubleOptInSessionRepository.php b/src/Repository/DoubleOptInSessionRepository.php new file mode 100644 index 00000000..f785f06a --- /dev/null +++ b/src/Repository/DoubleOptInSessionRepository.php @@ -0,0 +1,38 @@ +repository = $entityManager->getRepository(DoubleOptInSession::class); + } + + public function getQueryBuilder(): QueryBuilder + { + return $this->repository->createQueryBuilder('s'); + } + + public function findByNonAppliedFormAwareSessionToken(string $token, int $formDefinitionId): ?DoubleOptInSessionInterface + { + if (!Uuid::isValid($token)) { + return null; + } + + return $this->repository->findOneBy([ + 'token' => Uuid::fromString($token)->toBinary(), + 'formDefinition' => $formDefinitionId, + 'applied' => false + ]); + } +} diff --git a/src/Repository/DoubleOptInSessionRepositoryInterface.php b/src/Repository/DoubleOptInSessionRepositoryInterface.php new file mode 100644 index 00000000..27597a87 --- /dev/null +++ b/src/Repository/DoubleOptInSessionRepositoryInterface.php @@ -0,0 +1,13 @@ + + {{ form.vars.instructions|trans|raw }} + + {% endif %} + {% endapply %} +{% endblock %} \ No newline at end of file diff --git a/translations/admin.de.yml b/translations/admin.de.yml index 6b292da9..6d10f83c 100755 --- a/translations/admin.de.yml +++ b/translations/admin.de.yml @@ -26,6 +26,12 @@ form_builder_form_enctype: 'Enctype' form_builder_form_method: 'Method' form_builder_form_action: 'Action' form_builder_form_ajax_submission: 'Formular via Ajax versenden' +form_builder_form.double_opt_in: 'Double-Opt-In' +form_builder_form.double_opt_in.description: '- Bestätigungstext: %token% als Parameter verfügbar
- Mail-Vorlage: Parameter token, link als Parameter verfügbar' +form_builder_form.double_opt_in.enable: 'Double-Opt-In aktivieren' +form_builder_form.double_opt_in.double_opt_in_instruction_note: 'Double-Opt-In Hinweis' +form_builder_form.double_opt_in.confirmation_message: 'Bestätigungstext' +form_builder_form.double_opt_in.mail_template: 'Mail-Vorlage' form_builder_form_type_invalid: 'Manche Felder besitzen eine ungültige Konfiguration' form_builder_invalid_form_config: 'Die Formularkonfiguration ist fehlerhaft' form_builder_builder_saved_successfully: 'Formular erfolgreich gespeichert' diff --git a/translations/admin.en.yml b/translations/admin.en.yml index a46519bb..7cbc798d 100755 --- a/translations/admin.en.yml +++ b/translations/admin.en.yml @@ -26,6 +26,12 @@ form_builder_form_enctype: 'Enctype' form_builder_form_method: 'Method' form_builder_form_action: 'Action' form_builder_form_ajax_submission: 'Ajax Submission' +form_builder_form.double_opt_in: 'Double-Opt-In' +form_builder_form.double_opt_in.description: '- Confirmation Message: %token% available as parameter
- Mail Template: Parameter token, link available as parameter' +form_builder_form.double_opt_in.enable: 'Enable Double-Opt-In' +form_builder_form.double_opt_in.double_opt_in_instruction_note: 'Double-Opt-In Note' +form_builder_form.double_opt_in.confirmation_message: 'Confirmation Message' +form_builder_form.double_opt_in.mail_template: 'Mail Template' form_builder_form_type_invalid: 'Some form elements are invalid' form_builder_invalid_form_config: 'Some form configuration fields are invalid' form_builder_builder_saved_successfully: 'Form successfully saved'