From c5754ba0b511fe159d9e397c5b72de5510d376e4 Mon Sep 17 00:00:00 2001
From: Bill Seremetis <bill@seremetis.net>
Date: Wed, 28 May 2025 16:39:02 +0300
Subject: [PATCH] Honeypot on cached pages with JS

---
 config/install/honeypot.settings.yml |  1 +
 config/schema/honeypot.schema.yml    |  3 ++
 honeypot.install                     | 10 ++++++
 honeypot.libraries.yml               |  7 ++++
 js/honeypot_timestamp.js             | 54 ++++++++++++++++++++++++++++
 src/Form/HoneypotSettingsForm.php    | 39 +++++++++++++++++---
 src/HoneypotService.php              | 16 ++++++---
 7 files changed, 122 insertions(+), 8 deletions(-)
 create mode 100644 honeypot.libraries.yml
 create mode 100644 js/honeypot_timestamp.js

diff --git a/config/install/honeypot.settings.yml b/config/install/honeypot.settings.yml
index e70bdc2..a740c9a 100644
--- a/config/install/honeypot.settings.yml
+++ b/config/install/honeypot.settings.yml
@@ -8,6 +8,7 @@ protect_all_forms: false
 log: false
 element_name: 'url'
 time_limit: 5
+use_js_for_cached_pages: false
 expire: 300
 form_settings:
   user_register_form: false
diff --git a/config/schema/honeypot.schema.yml b/config/schema/honeypot.schema.yml
index ec37c01..2519b58 100644
--- a/config/schema/honeypot.schema.yml
+++ b/config/schema/honeypot.schema.yml
@@ -19,6 +19,9 @@ honeypot.settings:
     time_limit:
       type: integer
       label: 'Honeypot time limit'
+    use_js_for_cached_pages:
+      type: boolean
+      label: 'Use Javascript protection'
     expire:
       type: integer
       label: 'Expiration'
diff --git a/honeypot.install b/honeypot.install
index dcf29f0..5ede7f4 100644
--- a/honeypot.install
+++ b/honeypot.install
@@ -76,3 +76,13 @@ function honeypot_uninstall() {
 function honeypot_update_last_removed() {
   return 8104;
 }
+
+/**
+ * Add default value for use_js_for_cached_pages.
+ */
+function honeypot_update_8105() {
+  $config_factory = \Drupal::configFactory();
+  $config = $config_factory->getEditable('honeypot.settings');
+  $config->set('use_js_for_cached_pages', FALSE);
+  $config->save(TRUE);
+}
diff --git a/honeypot.libraries.yml b/honeypot.libraries.yml
new file mode 100644
index 0000000..f9c6001
--- /dev/null
+++ b/honeypot.libraries.yml
@@ -0,0 +1,7 @@
+timestamp:
+  js:
+    js/honeypot_timestamp.js: {}
+  dependencies:
+    - core/jquery
+    - core/once
+    - core/drupalSettings
diff --git a/js/honeypot_timestamp.js b/js/honeypot_timestamp.js
new file mode 100644
index 0000000..61b188d
--- /dev/null
+++ b/js/honeypot_timestamp.js
@@ -0,0 +1,54 @@
+/**
+ * @file
+ */
+(function ($, Drupal, drupalSettings) {
+
+  'use strict';
+
+  Drupal.honeypot = {};
+
+  Drupal.honeypot.page_load_timestamp = new Date();
+
+  Drupal.behaviors.honeypot_timestamp = {
+
+
+    attach: function (context, settings) {
+      var obj = this;
+      var $honeypotTime = $('form.honeypot-timestamp-js').find('input[name="honeypot_time"]');
+
+      $(once('honeypot-timestamp', 'form.honeypot-timestamp-js')).bind('submit', function () {
+        $honeypotTime.attr('value', obj.getIntervalTimestamp());
+      });
+    },
+
+    getIntervalTimestamp: function () {
+      var now = new Date();
+      var interval = Math.floor((now - Drupal.honeypot.page_load_timestamp) / 1000);
+      return 'js_token:' + drupalSettings.honeypot.identifier + '|' + interval;
+    }
+  };
+
+  if (Drupal.Ajax && Drupal.Ajax.prototype) {
+    if (typeof Drupal.Ajax.prototype.beforeSubmit != 'undefined') {
+      Drupal.Ajax.prototype.honeypotOriginalBeforeSubmit = Drupal.Ajax.prototype.beforeSubmit;
+    }
+
+    Drupal.Ajax.prototype.beforeSubmit = function (form_values, element, options) {
+
+      if (this.$form && this.$form.hasClass('honeypot-timestamp-js')) {
+        $.each(form_values, function(key, el) {
+          // Inject the right interval timestamp.
+          if (el.name == 'honeypot_time' && el.value == 'no_js_available') {
+            form_values[key].value = Drupal.behaviors.honeypot_timestamp.getIntervalTimestamp();
+          }
+        });
+      }
+
+      if (typeof Drupal.Ajax.prototype.honeypotOriginalBeforeSubmit != 'undefined') {
+        // Call the original function in case someone else has overridden it.
+        return Drupal.Ajax.prototype.honeypotOriginalBeforeSubmit(form_values, element, options);
+      }
+    }
+  }
+
+}(jQuery, Drupal, drupalSettings));
diff --git a/src/Form/HoneypotSettingsForm.php b/src/Form/HoneypotSettingsForm.php
index 485c256..0d3d656 100644
--- a/src/Form/HoneypotSettingsForm.php
+++ b/src/Form/HoneypotSettingsForm.php
@@ -3,6 +3,7 @@
 namespace Drupal\honeypot\Form;
 
 use Drupal\Component\Utility\Html;
+use Drupal\Core\Cache\CacheBackendInterface;
 use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\Config\TypedConfigManagerInterface;
 use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
@@ -40,7 +41,14 @@ class HoneypotSettingsForm extends ConfigFormBase {
   protected $entityTypeBundleInfo;
 
   /**
-   * Constructs a HoneypotSettings form.
+   * A cache backend interface.
+   *
+   * @var \Drupal\Core\Cache\CacheBackendInterface
+   */
+  protected $cache;
+
+  /**
+   * Constructs a settings controller.
    *
    * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
    *   The factory for configuration objects.
@@ -52,12 +60,15 @@ class HoneypotSettingsForm extends ConfigFormBase {
    *   The entity type manager.
    * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
    *   The entity type bundle info service.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+   *   The cache backend interface.
    */
-  public function __construct(ConfigFactoryInterface $config_factory, TypedConfigManagerInterface $typedConfigManager, ModuleHandlerInterface $module_handler, EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $entity_type_bundle_info) {
+  public function __construct(ConfigFactoryInterface $config_factory, TypedConfigManagerInterface $typedConfigManager, ModuleHandlerInterface $module_handler, EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $entity_type_bundle_info, CacheBackendInterface $cache_backend) {
     parent::__construct($config_factory, $typedConfigManager);
     $this->moduleHandler = $module_handler;
     $this->entityTypeManager = $entity_type_manager;
     $this->entityTypeBundleInfo = $entity_type_bundle_info;
+    $this->cache = $cache_backend;
   }
 
   /**
@@ -69,7 +80,8 @@ class HoneypotSettingsForm extends ConfigFormBase {
       $container->get('config.typed'),
       $container->get('module_handler'),
       $container->get('entity_type.manager'),
-      $container->get('entity_type.bundle.info')
+      $container->get('entity_type.bundle.info'),
+      $container->get('cache.default')
     );
   }
 
@@ -130,7 +142,23 @@ class HoneypotSettingsForm extends ConfigFormBase {
       '#size' => 5,
       '#field_suffix' => $this->t('seconds'),
     ];
-    $form['configuration']['time_limit']['#description'] .= '<br />' . $this->t('<strong>Page caching will be disabled if there is a form protected by time limit on the page.</strong>');
+
+    if (!$honeypot_config->get('use_js_for_cached_pages')) {
+      $form['configuration']['time_limit']['#description'] .= '<br />' . $this->t('<strong>Page caching will be disabled if there is a form protected by time limit on the page.</strong>');
+    }
+
+    $form['configuration']['use_js_for_cached_pages'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Use Javascript protection for cacheable pages. (experimental)'),
+      '#description' => $this->t('Uses Javascript to preserve Page caching'),
+      '#default_value' => $honeypot_config->get('use_js_for_cached_pages'),
+      '#states' => [
+        'invisible' => [
+          'input[name="time_limit"]' => ['value' => 0],
+        ],
+      ],
+    ];
+    $form['configuration']['use_js_for_cached_pages']['#description'] .= '<br />' . t('<strong>Warning: Users who have Javascript disabled will need to confirm their form submission on the next page (if the Honeypot-enabled form is on a cacheable page).</strong>');
 
     $form['configuration']['expire'] = [
       '#type' => 'number',
@@ -285,6 +313,9 @@ class HoneypotSettingsForm extends ConfigFormBase {
     // Save the honeypot forms from $form_state into a 'form_settings' array.
     $config->set('form_settings', $form_state->getValue('form_settings'))->save();
 
+    // Clear the honeypot protected forms cache.
+    $this->cache->delete('honeypot_protected_forms');
+
     parent::submitForm($form, $form_state);
   }
 
diff --git a/src/HoneypotService.php b/src/HoneypotService.php
index 99e9e33..b2b952d 100644
--- a/src/HoneypotService.php
+++ b/src/HoneypotService.php
@@ -203,10 +203,18 @@ class HoneypotService implements HoneypotServiceInterface {
 
       // Disable page caching to make sure timestamp isn't cached.
       if ($this->account->id() == 0) {
-        // @todo D8 - Use DIC?
-        // @see https://www.drupal.org/node/1539454
-        // Should this now set 'omit_vary_cookie' instead?
-        $this->killSwitch->trigger();
+        if ($this->config->get('use_js_for_cached_pages')) {
+          // This value will be overwritten by JS in Drupal.behaviors.honeypot_timestamp.
+          $form['honeypot_time']['#default_value'] = 'no_js_available';
+          $form['honeypot_time']['#attached']['library'][] = 'honeypot/timestamp';
+          $form['#attached']['drupalSettings']['honeypot']['identifier'] = $identifier;
+          $form['#attributes']['class'][] = 'honeypot-timestamp-js';
+        }
+        else {
+          // @todo Use dependency injection? See: http://drupal.org/node/1539454
+          // Should this now set 'omit_vary_cookie' instead?
+          $this->killSwitch->trigger();
+        }
       }
     }
 
-- 
GitLab