From b95483e0869c9b7bb4e63a00ae806478688d3da0 Mon Sep 17 00:00:00 2001 From: Shine Software Date: Mon, 24 Nov 2025 20:18:01 +0100 Subject: [PATCH 1/8] Add Turnstile functionality and fallback support - Implemented methods in Turnstile block to check if Turnstile is enabled, retrieve site key, and manage form settings. - Enhanced turnstile.phtml to utilize new block methods for configuration and added fallback for themes without Knockout.js. - Introduced fallback.js to handle rendering when Knockout.js is unavailable. - Updated CSS for improved styling of Turnstile elements. - Included fallback.js in the layout for frontend integration. --- Block/Turnstile.php | 51 +++++++ view/adminhtml/web/css/turnstile.css | 5 + view/base/templates/turnstile.phtml | 49 +++++-- view/base/web/js/fallback.js | 197 +++++++++++++++++++++++++++ view/frontend/layout/default.xml | 1 + view/frontend/web/css/turnstile.css | 5 + 6 files changed, 300 insertions(+), 8 deletions(-) create mode 100644 view/base/web/js/fallback.js diff --git a/Block/Turnstile.php b/Block/Turnstile.php index 60fa167..9b11d9d 100644 --- a/Block/Turnstile.php +++ b/Block/Turnstile.php @@ -85,4 +85,55 @@ public function getId(): string { return 'cloudflare-turnstile-' . $this->filter->translitUrl($this->getAction()); } + + /** + * Check if Turnstile is enabled on frontend + * + * @return bool + */ + public function isEnabled(): bool + { + return $this->config->isEnabledOnFront(); + } + + /** + * Retrieve sitekey + * + * @return string + */ + public function getSiteKey(): string + { + return $this->config->getSiteKey(); + } + + /** + * Check if the current action/form is enabled + * + * @return bool + */ + public function isFormEnabled(): bool + { + $forms = $this->config->getFrontendForms(); + return in_array($this->getAction(), $forms); + } + + /** + * Retrieve theme from config if not overridden + * + * @return string + */ + public function getThemeFromConfig(): string + { + return $this->getTheme() ?: $this->config->getFrontendTheme(); + } + + /** + * Retrieve size from config if not overridden + * + * @return string + */ + public function getSizeFromConfig(): string + { + return $this->getSize() ?: $this->config->getFrontendSize(); + } } diff --git a/view/adminhtml/web/css/turnstile.css b/view/adminhtml/web/css/turnstile.css index e0bce46..3a70bc2 100644 --- a/view/adminhtml/web/css/turnstile.css +++ b/view/adminhtml/web/css/turnstile.css @@ -5,4 +5,9 @@ .cloudflare-turnstile { font-weight: bold; color: #c00; + margin-top: 1rem; +} + +.cloudflare-turnstile .cf-turnstile { + margin-top: 0; } diff --git a/view/base/templates/turnstile.phtml b/view/base/templates/turnstile.phtml index 9d58505..c0bf0ff 100644 --- a/view/base/templates/turnstile.phtml +++ b/view/base/templates/turnstile.phtml @@ -1,18 +1,50 @@ - - -
+isEnabled(); +$sitekey = $block->getSiteKey(); +$action = $block->getAction(); +$isFormEnabled = $block->isFormEnabled(); +$theme = $block->getThemeFromConfig(); +$size = $block->getSizeFromConfig(); +$elementId = $block->getId(); + +// Early return if not enabled +if (!$isEnabled || !$isFormEnabled || !$sitekey) { + return; +} +?> + +
+ +
+ + diff --git a/view/base/web/js/fallback.js b/view/base/web/js/fallback.js new file mode 100644 index 0000000..15b63ef --- /dev/null +++ b/view/base/web/js/fallback.js @@ -0,0 +1,197 @@ +/** + * Copyright (C) 2023 Pixel Développement + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/** + * Fallback for themes without Knockout.js (e.g., Hyvä Theme) + * This script only activates if Knockout.js is not available or fails to render + */ +(function() { + 'use strict'; + + /** + * Initialize fallback for a specific container + * + * @param {string} elementId - The ID of the container element + */ + function initFallback(elementId) { + const container = document.getElementById(elementId); + const fallbackContainer = document.getElementById('cf-turnstile-fallback-' + elementId); + + if (!container || !fallbackContainer) { + return; + } + + // Check if Knockout.js is available + function isKnockoutAvailable() { + // Check if ko is defined globally or if Magento UI components are available + return typeof window.ko !== 'undefined' || + (typeof window.require !== 'undefined' && window.require.specified && window.require.specified('ko')); + } + + // Check if Knockout has rendered the widget + function hasKnockoutRendered() { + // Look for the widget rendered by Knockout (it should have a cf-turnstile class but not our fallback ID) + const koWidget = container.querySelector('.cf-turnstile:not([id*="cf-turnstile-fallback-"])'); + // Also check if there's a cf-turnstile-response input (which means the widget was rendered) + const form = container.closest('form'); + const hasResponseInput = form && form.querySelector('input[name="cf-turnstile-response"]'); + return koWidget !== null || hasResponseInput !== null; + } + + // Initialize fallback after checking if Knockout is working + function init() { + // Only use fallback if Knockout is not available or didn't render + if (isKnockoutAvailable()) { + // Knockout is available, wait a bit to see if it renders + setTimeout(function() { + if (!hasKnockoutRendered()) { + // Knockout is available but didn't render, use fallback + activateFallback(); + } + }, 1000); // Wait 1 second for Knockout to initialize and render + } else { + // Knockout is not available, use fallback immediately + activateFallback(); + } + } + + // Activate the fallback widget + function activateFallback() { + // Make sure we don't activate if Knockout already rendered + if (hasKnockoutRendered()) { + return; + } + + fallbackContainer.style.display = 'block'; + + // Configuration from data attributes + const config = { + sitekey: container.getAttribute('data-sitekey'), + theme: container.getAttribute('data-theme') || 'auto', + size: container.getAttribute('data-size') || 'normal', + action: container.getAttribute('data-action') + }; + + // Load Cloudflare Turnstile script if not already loaded + function loadTurnstileScript() { + if (window.turnstile) { + renderWidget(); + return; + } + + // Check if script is already being loaded + if (document.querySelector('script[src*="challenges.cloudflare.com/turnstile"]')) { + // Wait for script to load + const checkInterval = setInterval(function() { + if (window.turnstile) { + clearInterval(checkInterval); + renderWidget(); + } + }, 100); + + // Timeout after 10 seconds + setTimeout(function() { + clearInterval(checkInterval); + if (!window.turnstile) { + console.error('Cloudflare Turnstile: Script failed to load'); + fallbackContainer.innerText = 'Unable to load security verification. Please refresh the page.'; + } + }, 10000); + + return; + } + + // Load the script + const script = document.createElement('script'); + script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; + script.async = true; + script.defer = true; + script.onload = function() { + renderWidget(); + }; + script.onerror = function() { + console.error('Cloudflare Turnstile: Failed to load script'); + fallbackContainer.innerText = 'Unable to load security verification. Please refresh the page.'; + }; + document.head.appendChild(script); + } + + // Render the widget + function renderWidget() { + if (!window.turnstile || !window.turnstile.render) { + console.error('Cloudflare Turnstile: turnstile object not available'); + fallbackContainer.innerText = 'Unable to initialize security verification.'; + return; + } + + // Double check that Knockout didn't render in the meantime + if (hasKnockoutRendered()) { + fallbackContainer.style.display = 'none'; + return; + } + + try { + const widgetId = window.turnstile.render(fallbackContainer, { + sitekey: config.sitekey, + theme: config.theme, + size: config.size, + action: config.action + }); + + if (typeof widgetId === 'undefined') { + console.error('Cloudflare Turnstile: Failed to render widget'); + fallbackContainer.innerText = 'Unable to secure the form.'; + } else { + // Store widget ID for potential reset + fallbackContainer.setAttribute('data-widget-id', widgetId); + } + } catch (error) { + console.error('Cloudflare Turnstile: Error rendering widget', error); + fallbackContainer.innerText = 'Unable to secure the form.'; + } + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', loadTurnstileScript); + } else { + // DOM is already ready + loadTurnstileScript(); + } + } + + // Start checking after DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + } + + // Auto-initialize all containers with class 'cloudflare-turnstile' that have data attributes + function autoInit() { + const containers = document.querySelectorAll('.cloudflare-turnstile[data-sitekey]'); + containers.forEach(function(container) { + if (container.id) { + initFallback(container.id); + } + }); + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', autoInit); + } else { + autoInit(); + } + + // Export function for manual initialization if needed + window.CloudflareTurnstileFallback = { + init: initFallback + }; +})(); + diff --git a/view/frontend/layout/default.xml b/view/frontend/layout/default.xml index a5e8862..ced4c0d 100644 --- a/view/frontend/layout/default.xml +++ b/view/frontend/layout/default.xml @@ -10,6 +10,7 @@ + + + diff --git a/view/base/web/js/fallback.js b/view/base/web/js/fallback.js index 15b63ef..dcef0cc 100644 --- a/view/base/web/js/fallback.js +++ b/view/base/web/js/fallback.js @@ -24,6 +24,12 @@ if (!container || !fallbackContainer) { return; } + + // Only initialize when the render mode explicitly requires the fallback + const renderMode = container.getAttribute('data-render-mode') || 'knockout'; + if (renderMode !== 'fallback') { + return; + } // Check if Knockout.js is available function isKnockoutAvailable() { @@ -69,13 +75,25 @@ fallbackContainer.style.display = 'block'; // Configuration from data attributes + // getAttribute returns null if attribute doesn't exist, so we need to handle that + const getDataAttribute = function(element, attr) { + const value = element.getAttribute(attr); + return (value !== null && value !== undefined) ? String(value) : null; + }; + const config = { - sitekey: container.getAttribute('data-sitekey'), - theme: container.getAttribute('data-theme') || 'auto', - size: container.getAttribute('data-size') || 'normal', - action: container.getAttribute('data-action') + sitekey: getDataAttribute(container, 'data-sitekey'), + theme: getDataAttribute(container, 'data-theme') || 'auto', + size: getDataAttribute(container, 'data-size') || 'normal', + action: getDataAttribute(container, 'data-action') || 'default' }; + // Validate sitekey before proceeding + if (!config.sitekey || typeof config.sitekey !== 'string' || config.sitekey.trim() === '') { + fallbackContainer.innerText = 'Unable to secure the form. The site key is missing.'; + return; + } + // Load Cloudflare Turnstile script if not already loaded function loadTurnstileScript() { if (window.turnstile) { @@ -97,7 +115,6 @@ setTimeout(function() { clearInterval(checkInterval); if (!window.turnstile) { - console.error('Cloudflare Turnstile: Script failed to load'); fallbackContainer.innerText = 'Unable to load security verification. Please refresh the page.'; } }, 10000); @@ -114,7 +131,6 @@ renderWidget(); }; script.onerror = function() { - console.error('Cloudflare Turnstile: Failed to load script'); fallbackContainer.innerText = 'Unable to load security verification. Please refresh the page.'; }; document.head.appendChild(script); @@ -123,7 +139,6 @@ // Render the widget function renderWidget() { if (!window.turnstile || !window.turnstile.render) { - console.error('Cloudflare Turnstile: turnstile object not available'); fallbackContainer.innerText = 'Unable to initialize security verification.'; return; } @@ -135,22 +150,28 @@ } try { - const widgetId = window.turnstile.render(fallbackContainer, { - sitekey: config.sitekey, - theme: config.theme, - size: config.size, - action: config.action - }); + // Validate and ensure all values are strings (sitekey already validated above) + const renderConfig = { + sitekey: String(config.sitekey).trim(), + theme: String(config.theme || 'auto').trim(), + size: String(config.size || 'normal').trim(), + action: String(config.action || 'default').trim() + }; + + // Final validation - ensure sitekey is not empty + if (!renderConfig.sitekey || renderConfig.sitekey === '') { + throw new Error('Sitekey is empty or invalid'); + } + + const widgetId = window.turnstile.render(fallbackContainer, renderConfig); if (typeof widgetId === 'undefined') { - console.error('Cloudflare Turnstile: Failed to render widget'); fallbackContainer.innerText = 'Unable to secure the form.'; } else { // Store widget ID for potential reset fallbackContainer.setAttribute('data-widget-id', widgetId); } } catch (error) { - console.error('Cloudflare Turnstile: Error rendering widget', error); fallbackContainer.innerText = 'Unable to secure the form.'; } } @@ -174,7 +195,7 @@ // Auto-initialize all containers with class 'cloudflare-turnstile' that have data attributes function autoInit() { - const containers = document.querySelectorAll('.cloudflare-turnstile[data-sitekey]'); + const containers = document.querySelectorAll('.cloudflare-turnstile[data-render-mode="fallback"]'); containers.forEach(function(container) { if (container.id) { initFallback(container.id); diff --git a/view/base/web/js/view/component.js b/view/base/web/js/view/component.js index 32a1c94..38cebe8 100644 --- a/view/base/web/js/view/component.js +++ b/view/base/web/js/view/component.js @@ -31,7 +31,8 @@ define( 'sitekey': '', 'forms': [], 'size': 'normal', - 'theme': 'auto' + 'theme': 'auto', + 'renderingMode': 'knockout' }, action: 'default', size: '', // Override config value if not empty @@ -39,6 +40,7 @@ define( widgetId: null, autoRendering: true, element: null, + renderingMode: 'knockout', /** * Initialize @@ -49,6 +51,8 @@ define( if (typeof window[this.configSource] !== 'undefined' && window[this.configSource].config) { this.config = window[this.configSource].config; } + + this.renderingMode = this.config.renderingMode || 'knockout'; }, /** @@ -68,7 +72,10 @@ define( load: function (element) { this.element = element; - if (!this.config.sitekey) { + // Extract sitekey value from observable if needed + const sitekey = this.getValue(this.config.sitekey); + + if (!sitekey) { this.element.innerText = $.mage.__('Unable to secure the form. The site key is missing.'); } else { this.beforeRender(); @@ -78,17 +85,51 @@ define( } }, + /** + * Get value from observable or return value directly + * + * @param {*} value + * @returns {*} + */ + getValue: function (value) { + if (typeof ko !== 'undefined' && ko.isObservable(value)) { + return ko.unwrap(value); + } + return value; + }, + /** * Render widget */ render: function () { if (this.element) { - const widgetId = turnstile.render(this.element, { - sitekey: this.config.sitekey, - theme: this.theme || this.config.theme, - size: this.size || this.config.size, - action: this.action - }); + // Extract values from observables if needed + let sitekey = this.getValue(this.config.sitekey); + const theme = String(this.getValue(this.theme || this.config.theme) || 'auto').trim(); + const size = String(this.getValue(this.size || this.config.size) || 'normal').trim(); + const action = String(this.getValue(this.action) || 'default').trim(); + + // Validate and convert sitekey to string + if (!sitekey) { + this.element.innerText = $.mage.__('Unable to secure the form. The site key is missing.'); + return; + } + + sitekey = String(sitekey).trim(); + + if (sitekey === '' || sitekey === 'null' || sitekey === 'undefined') { + this.element.innerText = $.mage.__('Unable to secure the form. The site key is invalid.'); + return; + } + + const renderConfig = { + sitekey: sitekey, + theme: theme, + size: size, + action: action + }; + + const widgetId = turnstile.render(this.element, renderConfig); if (typeof widgetId === 'undefined') { this.element.innerText = $.mage.__('Unable to secure the form'); } else { diff --git a/view/frontend/web/css/turnstile.css b/view/frontend/web/css/turnstile.css index 1d3bbe6..b118465 100644 --- a/view/frontend/web/css/turnstile.css +++ b/view/frontend/web/css/turnstile.css @@ -4,6 +4,11 @@ margin-top: 1rem; } -.cloudflare-turnstile .cf-turnstile { +.cloudflare-turnstile .cf-turnstile, +.cloudflare-turnstile .cf-turnstile-manual { margin-top: 0; } + +.cf-turnstile-manual { + min-height: 65px; +} From 6d998011485a24f44d5843221829144a64266481 Mon Sep 17 00:00:00 2001 From: Shine Software Date: Tue, 25 Nov 2025 15:01:34 +0100 Subject: [PATCH 4/8] Update package name and improve error handling in Turnstile component - Changed package name from shinesoftware/magento-cloudflare-turnstile to pixelopen/cloudflare-turnstile-bundle. - Refactored error handling in component.js to ensure the error message is displayed only when the site key is missing. - Cleaned up formatting in turnstile.phtml for better readability. --- composer.json | 2 +- view/base/templates/turnstile.phtml | 2 +- view/base/web/js/view/component.js | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 851000e..0b09f2f 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "shinesoftware/magento-cloudflare-turnstile", + "name": "pixelopen/cloudflare-turnstile-bundle", "description": "Protect your store from spam messages and spam user accounts with Cloudflare Turnstile", "require": { "php": "^8", diff --git a/view/base/templates/turnstile.phtml b/view/base/templates/turnstile.phtml index c5928f1..52eeb10 100644 --- a/view/base/templates/turnstile.phtml +++ b/view/base/templates/turnstile.phtml @@ -27,7 +27,7 @@ if (!$isEnabled || !$isFormEnabled || !$sitekey) { } ?> -
Date: Tue, 25 Nov 2025 15:16:48 +0100 Subject: [PATCH 5/8] Refactor getValue method to use optional chaining for improved safety - Updated the getValue method in component.js to utilize optional chaining when checking if the value is an observable, enhancing code robustness. --- view/base/web/js/view/component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/view/base/web/js/view/component.js b/view/base/web/js/view/component.js index ac0b028..05523a3 100644 --- a/view/base/web/js/view/component.js +++ b/view/base/web/js/view/component.js @@ -93,7 +93,7 @@ define( * @returns {*} */ getValue: function (value) { - if (ko && ko.isObservable(value)) { + if (ko?.isObservable(value)) { return ko.unwrap(value); } return value; From f53c6617335c46e748b820a623920315efb4f3c6 Mon Sep 17 00:00:00 2001 From: Shine Software Date: Tue, 25 Nov 2025 15:22:12 +0100 Subject: [PATCH 6/8] Update package name to pixelopen/magento-cloudflare-turnstile in composer.json --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 0b09f2f..aabf2ae 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "pixelopen/cloudflare-turnstile-bundle", + "name": "pixelopen/magento-cloudflare-turnstile", "description": "Protect your store from spam messages and spam user accounts with Cloudflare Turnstile", "require": { "php": "^8", @@ -23,4 +23,4 @@ "role": "Developer" } ] -} +} \ No newline at end of file From 14bfd25baeb4876a45a4cb68b06e5495e0805fe6 Mon Sep 17 00:00:00 2001 From: Shine Software Date: Tue, 25 Nov 2025 15:25:37 +0100 Subject: [PATCH 7/8] Downgrade version to 100.3.1 in composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index aabf2ae..75d3706 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "magento/framework": "*" }, "type": "magento2-module", - "version": "100.4.0", + "version": "100.3.1", "autoload": { "files": [ "registration.php" From 49ca50a4296ea7e6e6f283cc07de386eb983ff57 Mon Sep 17 00:00:00 2001 From: Shine Software Date: Tue, 25 Nov 2025 15:33:51 +0100 Subject: [PATCH 8/8] Refactor CSS for Turnstile components - Adjusted CSS rules for `.cf-turnstile-manual` and `.cloudflare-turnstile .cf-turnstile` to improve layout consistency. - Ensured proper margin and minimum height settings for better visual alignment in both admin and frontend styles. --- view/adminhtml/web/css/turnstile.css | 8 ++++---- view/frontend/web/css/turnstile.css | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/view/adminhtml/web/css/turnstile.css b/view/adminhtml/web/css/turnstile.css index d57b6bf..4f9f078 100644 --- a/view/adminhtml/web/css/turnstile.css +++ b/view/adminhtml/web/css/turnstile.css @@ -8,11 +8,11 @@ margin-top: 1rem; } -.cloudflare-turnstile .cf-turnstile, -.cloudflare-turnstile .cf-turnstile-manual { +.cf-turnstile-manual { margin-top: 0; + min-height: 65px; } -.cf-turnstile-manual { - min-height: 65px; +.cloudflare-turnstile .cf-turnstile { + margin-top: 0; } diff --git a/view/frontend/web/css/turnstile.css b/view/frontend/web/css/turnstile.css index b118465..e1bdc79 100644 --- a/view/frontend/web/css/turnstile.css +++ b/view/frontend/web/css/turnstile.css @@ -4,11 +4,11 @@ margin-top: 1rem; } -.cloudflare-turnstile .cf-turnstile, -.cloudflare-turnstile .cf-turnstile-manual { +.cf-turnstile-manual { margin-top: 0; + min-height: 65px; } -.cf-turnstile-manual { - min-height: 65px; +.cloudflare-turnstile .cf-turnstile { + margin-top: 0; }