diff --git a/config/optional/core.entity_form_display.taxonomy_term.captcha_widgets.default.yml b/config/optional/core.entity_form_display.taxonomy_term.captcha_widgets.default.yml new file mode 100644 index 0000000..889e12b --- /dev/null +++ b/config/optional/core.entity_form_display.taxonomy_term.captcha_widgets.default.yml @@ -0,0 +1,64 @@ +langcode: en +status: true +dependencies: + config: + - field.field.taxonomy_term.captcha_widgets.field_captcha_type + - field.field.taxonomy_term.captcha_widgets.field_site_key + - taxonomy.vocabulary.captcha_widgets + module: + - path + - text +id: taxonomy_term.captcha_widgets.default +targetEntityType: taxonomy_term +bundle: captcha_widgets +mode: default +content: + description: + type: text_textarea + weight: 4 + region: content + settings: + rows: 5 + placeholder: '' + third_party_settings: { } + field_captcha_type: + type: options_select + weight: 1 + region: content + settings: { } + third_party_settings: { } + field_site_key: + type: string_textfield + weight: 3 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + name: + type: string_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + path: + type: path + weight: 5 + region: content + settings: { } + third_party_settings: { } + simple_sitemap: + weight: 10 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 6 + region: content + settings: + display_label: true + third_party_settings: { } +hidden: { } diff --git a/config/optional/core.entity_view_display.taxonomy_term.captcha_widgets.default.yml b/config/optional/core.entity_view_display.taxonomy_term.captcha_widgets.default.yml new file mode 100644 index 0000000..e9cd673 --- /dev/null +++ b/config/optional/core.entity_view_display.taxonomy_term.captcha_widgets.default.yml @@ -0,0 +1,39 @@ +langcode: en +status: true +dependencies: + config: + - field.field.taxonomy_term.captcha_widgets.field_captcha_type + - field.field.taxonomy_term.captcha_widgets.field_site_key + - taxonomy.vocabulary.captcha_widgets + module: + - options + - text +id: taxonomy_term.captcha_widgets.default +targetEntityType: taxonomy_term +bundle: captcha_widgets +mode: default +content: + description: + type: text_default + label: hidden + settings: { } + third_party_settings: { } + weight: 0 + region: content + field_captcha_type: + type: list_default + label: above + settings: { } + third_party_settings: { } + weight: 2 + region: content + field_site_key: + type: string + label: above + settings: + link_to_entity: false + third_party_settings: { } + weight: 1 + region: content +hidden: + search_api_excerpt: true diff --git a/config/optional/field.field.taxonomy_term.captcha_widgets.field_captcha_type.yml b/config/optional/field.field.taxonomy_term.captcha_widgets.field_captcha_type.yml new file mode 100644 index 0000000..11aec33 --- /dev/null +++ b/config/optional/field.field.taxonomy_term.captcha_widgets.field_captcha_type.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.taxonomy_term.field_captcha_type + - taxonomy.vocabulary.captcha_widgets + module: + - options +id: taxonomy_term.captcha_widgets.field_captcha_type +field_name: field_captcha_type +entity_type: taxonomy_term +bundle: captcha_widgets +label: 'Captcha type' +description: '' +required: true +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: list_string diff --git a/config/optional/field.field.taxonomy_term.captcha_widgets.field_site_key.yml b/config/optional/field.field.taxonomy_term.captcha_widgets.field_site_key.yml new file mode 100644 index 0000000..efa2780 --- /dev/null +++ b/config/optional/field.field.taxonomy_term.captcha_widgets.field_site_key.yml @@ -0,0 +1,18 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.taxonomy_term.field_site_key + - taxonomy.vocabulary.captcha_widgets +id: taxonomy_term.captcha_widgets.field_site_key +field_name: field_site_key +entity_type: taxonomy_term +bundle: captcha_widgets +label: 'Site key' +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/config/optional/field.storage.taxonomy_term.field_captcha_type.yml b/config/optional/field.storage.taxonomy_term.field_captcha_type.yml new file mode 100644 index 0000000..a178c31 --- /dev/null +++ b/config/optional/field.storage.taxonomy_term.field_captcha_type.yml @@ -0,0 +1,29 @@ +langcode: en +status: true +dependencies: + module: + - options + - taxonomy +id: taxonomy_term.field_captcha_type +field_name: field_captcha_type +entity_type: taxonomy_term +type: list_string +settings: + allowed_values: + - + value: google_recaptcha_v3 + label: 'Google reCAPTCHA v3' + - + value: google_recaptcha_v2 + label: 'Google reCAPTCHA v2' + - + value: cloudfare_turnstile + label: 'Cloudfare Turnstile' + allowed_values_function: '' +module: options +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/optional/field.storage.taxonomy_term.field_site_key.yml b/config/optional/field.storage.taxonomy_term.field_site_key.yml new file mode 100644 index 0000000..ed26eed --- /dev/null +++ b/config/optional/field.storage.taxonomy_term.field_site_key.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + module: + - taxonomy +id: taxonomy_term.field_site_key +field_name: field_site_key +entity_type: taxonomy_term +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/optional/taxonomy.vocabulary.captcha_widgets.yml b/config/optional/taxonomy.vocabulary.captcha_widgets.yml new file mode 100644 index 0000000..4891d46 --- /dev/null +++ b/config/optional/taxonomy.vocabulary.captcha_widgets.yml @@ -0,0 +1,7 @@ +langcode: en +status: true +dependencies: { } +name: 'CAPTCHA widgets' +vid: captcha_widgets +description: '' +weight: 0 diff --git a/tests/behat/features/webform.feature b/tests/behat/features/webform.feature index 2a69978..3704d1d 100644 --- a/tests/behat/features/webform.feature +++ b/tests/behat/features/webform.feature @@ -10,3 +10,26 @@ Feature: Webform "Content Rating" exists. And save screenshot And I see the text "Was this page helpful" + + @api @nosuggest @javascript + Scenario: Webform "Tide webform CAPTCHA" exists. + Given I am logged in as a user with the "administrator" role + And captcha_widgets terms: + | name | parent | tid | field_captcha_type | field_site_key | + | Test Site 1 | 0 | 10001 | google_recaptcha_v3 | abcd | + When I visit "admin/structure/webform/manage/contact/settings" + And I click on the detail "Third party settings" + Then I see the text "Tide webform CAPTCHA" + Then I see the text "Enable captcha" + Then I see the text "Captcha type" + Then I select "Google reCAPTCHA v3" from "Captcha type" + And I wait for AJAX to finish + Then I select "Test Site 1" from "Site key" + Then I fill in "Score threshold (reCAPTCHA v3)?" with "0.1" + Then I check "Enable captcha" + And I press "Save" + Then I visit "admin/structure/webform/manage/contact/export" + And I see the text "captcha_type: google_recaptcha_v3" + And I see the text "score_threshold: 0.1" + And I see the text "site_key: abcd" + And save screenshot diff --git a/tide_webform.install b/tide_webform.install index e791e26..0370804 100644 --- a/tide_webform.install +++ b/tide_webform.install @@ -25,3 +25,30 @@ function tide_webform_update_10001() { $config->set('element.excluded_elements', $excluded_elements_value); $config->save(); } + +/** + * Import configs for Captcha feature. + */ +function tide_webform_update_10002() { + $configs = [ + 'taxonomy.vocabulary.captcha_widgets' => 'taxonomy_vocabulary', + 'field.storage.taxonomy_term.field_site_key' => 'field_storage_config', + 'field.storage.taxonomy_term.field_captcha_type' => 'field_storage_config', + 'field.field.taxonomy_term.captcha_widgets.field_site_key' => 'field_config', + 'field.field.taxonomy_term.captcha_widgets.field_captcha_type' => 'field_config', + 'core.entity_view_display.taxonomy_term.captcha_widgets.default' => 'entity_view_display', + 'core.entity_form_display.taxonomy_term.captcha_widgets.default' => 'entity_form_display', + ]; + + \Drupal::moduleHandler()->loadInclude('tide_core', 'inc', 'includes/helpers'); + $config_location = [\Drupal::service('extension.list.module')->getPath('tide_webform') . '/config/optional']; + foreach ($configs as $config_name => $type) { + $config_read = _tide_read_config($config_name, $config_location, TRUE); + $storage = \Drupal::entityTypeManager()->getStorage($type); + $id = $storage->getIDFromConfigName($config_name, $storage->getEntityType()->getConfigPrefix()); + if ($storage->load($id) == NULL) { + $config_entity = $storage->createFromStorageRecord($config_read); + $config_entity->save(); + } + } +} diff --git a/tide_webform.module b/tide_webform.module index ab61b20..4755aed 100644 --- a/tide_webform.module +++ b/tide_webform.module @@ -12,6 +12,7 @@ use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\BaseFieldDefinition; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\taxonomy\Entity\Term; use Drupal\webform\Entity\Webform; use Drupal\webform\Entity\WebformSubmission; use Drupal\webform\WebformSubmissionForm; @@ -438,3 +439,191 @@ function tide_webform_webform_element_default_properties_alter(array &$propertie function tide_webform_webform_update(EntityInterface $entity) { \Drupal::cache()->invalidate('webform_text_fields_default_maxlength'); } + +/** + * Implements hook_webform_third_party_settings_form_alter(). + */ +function tide_webform_webform_third_party_settings_form_alter(array &$form, FormStateInterface $form_state) { + $webform = $form_state->getFormObject()->getEntity(); + $third_party_settings = $webform->getThirdPartySettings('tide_webform_captcha'); + $user_input = $form_state->getUserInput(); + $taxonomy_storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term'); + $captcha_type = NULL; + $allowed_values = []; + try { + $field_definition = \Drupal::entityTypeManager()->getStorage('field_storage_config')->load('taxonomy_term.field_captcha_type'); + if ($field_definition) { + $allowed_values = $field_definition->getSetting('allowed_values'); + } + else { + throw new \Exception('Field storage configuration not found for taxonomy_term.field_captcha_type'); + } + } + catch (\Exception $e) { + \Drupal::messenger()->addWarning('Unable to load CAPTCHA type options. Please contact the site administrator.'); + \Drupal::logger('tide_webform') + ->error('Error loading CAPTCHA type options for webform @webform: @message', + ['@webform' => $webform->id(), '@message' => $e->getMessage()]); + return; + } + if (isset($third_party_settings['captcha_type'])) { + $captcha_type = $third_party_settings['captcha_type']; + } + if (isset($user_input['third_party_settings']['tide_webform_captcha']['captcha_type'])) { + $captcha_type = $user_input['third_party_settings']['tide_webform_captcha']['captcha_type']; + } + $query = $taxonomy_storage->getQuery() + ->accessCheck(TRUE) + ->condition('vid', 'captcha_widgets') + ->condition('field_captcha_type', $captcha_type); + $tids = $query->execute(); + $terms = $taxonomy_storage->loadMultiple($tids); + $options = array_map(function (Term $term) { + return $term->label() . ' (' . $term->id() . ')'; + }, $terms); + + $form['third_party_settings']['tide_webform_captcha'] = [ + '#type' => 'fieldset', + '#title' => t('Tide webform CAPTCHA'), + '#open' => TRUE, + ]; + + $form['third_party_settings']['tide_webform_captcha']['enable_captcha'] = [ + '#type' => 'checkbox', + '#default_value' => !empty($third_party_settings['enable_captcha']) ? $third_party_settings['enable_captcha'] : NULL, + '#title' => t('Enable captcha'), + ]; + + $form['third_party_settings']['tide_webform_captcha']['captcha_type'] = [ + '#type' => 'select', + '#title' => t('Captcha type'), + '#options' => [ + 'all' => '-Select-', + ] + $allowed_values, + '#default_value' => !empty($third_party_settings['captcha_type']) ? $third_party_settings['captcha_type'] : NULL, + '#ajax' => [ + 'callback' => '_tide_webform_captcha_type_dropdown_callback', + 'wrapper' => 'captcha-type-dropdown-container', + ], + ]; + $score_threshold = NULL; + if (isset($third_party_settings['score_threshold']) && $third_party_settings['score_threshold'] === 0.0) { + $score_threshold = '0.0'; + } + elseif (!empty($third_party_settings['score_threshold'])) { + $score_threshold = (string) $third_party_settings['score_threshold']; + } + + $form['third_party_settings']['tide_webform_captcha']['score_threshold'] = [ + '#type' => 'textfield', + '#title' => t('Score threshold (reCAPTCHA v3)'), + '#size' => 2, + '#maxlength' => 3, + '#number_type' => 'decimal', + '#element_validate' => ['_tide_webform_threshold_validate'], + '#states' => [ + 'visible' => [ + ':input[name="third_party_settings[tide_webform_captcha][captcha_type]"]' => ['value' => 'google_recaptcha_v3'], + ], + ], + '#default_value' => $score_threshold, + '#description' => 'Enter a value between 0.0 and 1.0. Use only one decimal place (e.g., 0.0, 0.5, 1.0).', + ]; + + $form['third_party_settings']['tide_webform_captcha']['captcha_type_dropdown_container'] = [ + '#type' => 'fieldset', + '#attributes' => ['id' => 'captcha-type-dropdown-container'], + ]; + + $form['third_party_settings']['tide_webform_captcha']['captcha_type_dropdown_container']['captcha_details'] = [ + '#type' => \Drupal::moduleHandler()->moduleExists('select2') ? 'select2' : 'select', + '#title' => ('Site key'), + '#target_type' => 'taxonomy_term', + '#options' => $options, + '#tags' => TRUE, + '#select2' => [ + 'allowClear' => TRUE, + 'dropdownAutoWidth' => FALSE, + 'width' => '20%', + 'closeOnSelect' => FALSE, + ], + '#default_value' => !empty($third_party_settings['captcha_details']['term_id']) ? $third_party_settings['captcha_details']['term_id'] : NULL, + '#selection_settings' => [ + 'target_bundles' => ['captcha_widgets'], + ], + ]; + + $form['#validate'][] = '_tide_webform_captcha_form_validate'; +} + +/** + * Captcha form validate. + */ +function _tide_webform_captcha_form_validate(&$form, FormStateInterface $form_state) { + $settings = &$form_state->getValue(['third_party_settings', 'tide_webform_captcha']); + $captcha_details = $settings['captcha_type_dropdown_container']['captcha_details'] ?? NULL; + + if (is_array($captcha_details) && isset($captcha_details[0]['target_id'])) { + $term_id = $captcha_details[0]['target_id']; + } + elseif (is_numeric($captcha_details)) { + $term_id = $captcha_details; + } + else { + $term_id = NULL; + } + + if ($term_id) { + $term = Term::load($term_id); + $settings['captcha_details'] = [ + 'site_key' => $term->get('field_site_key')->value, + 'term_id' => (int) $term_id, + 'captcha_id' => _tide_webform_get_captcha_id($term), + ]; + } + else { + $settings['captcha_details'] = NULL; + } + + unset($settings['captcha_type_dropdown_container']); +} + +/** + * Captcha options callback. + */ +function _tide_webform_captcha_type_dropdown_callback($form, FormStateInterface $form_state) { + return $form['third_party_settings']['tide_webform_captcha']['captcha_type_dropdown_container']; +} + +/** + * Threshold element validation. + */ +function _tide_webform_threshold_validate($element, FormStateInterface $form_state) { + $number = $form_state->getUserInput()['third_party_settings']['tide_webform_captcha']['score_threshold']; + if ($number === 0 || $number === '' || $number === NULL) { + $form_state->setValue([ + 'third_party_settings', + 'tide_webform_captcha', + 'score_threshold', + ], NULL); + return; + } + if (preg_match('/^(0(\.[0-9])?|1(\.0)?)$/', (string) $number)) { + $form_state->setValue([ + 'third_party_settings', + 'tide_webform_captcha', + 'score_threshold', + ], (float) $number); + } + else { + $form_state->setError($element, t('Enter a value between 0.0 and 1.0. Use only one decimal place (e.g., 0.0, 0.5, 1.0).')); + } +} + +/** + * Gets captcha id. + */ +function _tide_webform_get_captcha_id(Term $term) { + $uri = $term->toLink()->getUrl()->toString(); + return pathinfo(rtrim($uri, '/'), PATHINFO_BASENAME); +}