commit c2c3078f6dc1ebf965b74b9155c9c5c1b0673665
Author: Melvin Achterhuis <melvin@weprovide.com>
Date:   Wed Sep 28 20:52:59 2022 +0200

    Initial commit

diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3701ec3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,26 @@
+## A plugin for [Shopware 6](https://github.com/shopware/platform)
+
+**Proof-of-concept**: Integrates CloudFlare Turnstile with Shopware 6.
+
+Create a free account to claim your keys: https://www.cloudflare.com/en-gb/lp/turnstile/
+
+*Config:* 
+![](https://i.imgur.com/qutsRPd.png)
+
+Known issues:
+* Not working when form in modal
+* No alert when captcha invalid
+* Missing translations for German
+* ..
+
+## Requirements
+
+| Version 	  | Requirements               	|
+|------------|----------------------------	|
+| 0.1.0    	 | Shopware 6.4 >=	            |
+
+## License
+
+Plugin's Icon by [flaticon](https://www.flaticon.com).
+
+The plugin is released under MIT. For a full overview check the [LICENSE](./LICENSE) file.
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..df44615
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,28 @@
+{
+    "name": "melvinachterhuis/turnstile-shopware6-plugin",
+    "description": "CloudFlare Turnstile Captcha",
+    "version": "0.1.0",
+    "type": "shopware-platform-plugin",
+    "license": "MIT",
+    "authors": [
+        {
+            "name": "Melvin Achterhuis"
+        }
+    ],
+    "require": {
+        "shopware/core": "6.4.*",
+        "ext-curl": "*"
+    },
+    "autoload": {
+        "psr-4": {
+            "Melv\\Turnstile\\": "src/"
+        }
+    },
+    "extra": {
+        "shopware-plugin-class": "Melv\\Turnstile\\MelvTurnstile",
+        "label": {
+            "de-DE": "CloudFlare Turnstile Captcha",
+            "en-GB": "CloudFlare Turnstile Captcha"
+        }
+    }
+}
diff --git a/src/MelvTurnstile.php b/src/MelvTurnstile.php
new file mode 100644
index 0000000..4d5d712
--- /dev/null
+++ b/src/MelvTurnstile.php
@@ -0,0 +1,9 @@
+<?php declare(strict_types=1);
+
+namespace Melv\Turnstile;
+
+use Shopware\Core\Framework\Plugin;
+
+class MelvTurnstile extends Plugin
+{
+}
diff --git a/src/Migration/Migration1664374217addTurnStileCaptcha.php b/src/Migration/Migration1664374217addTurnStileCaptcha.php
new file mode 100644
index 0000000..bcd3c2c
--- /dev/null
+++ b/src/Migration/Migration1664374217addTurnStileCaptcha.php
@@ -0,0 +1,80 @@
+<?php declare(strict_types=1);
+
+namespace Melv\Turnstile\Migration;
+
+use Doctrine\DBAL\Connection;
+use Shopware\Core\Defaults;
+use Shopware\Core\Framework\Migration\MigrationStep;
+
+class Migration1664374217addTurnStileCaptcha extends MigrationStep
+{
+    private const CONFIG_KEY = 'core.basicInformation.activeCaptchasV2';
+
+    private array $captchaItems = [
+        'honeypot' => [
+            'name' => 'Honeypot',
+            'isActive' => false,
+        ],
+        'basicCaptcha' => [
+            'name' => 'basicCaptcha',
+            'isActive' => false,
+        ],
+        'googleReCaptchaV2' => [
+            'name' => 'googleReCaptchaV2',
+            'isActive' => false,
+            'config' => [
+                'siteKey' => '',
+                'secretKey' => '',
+                'invisible' => false,
+            ],
+        ],
+        'googleReCaptchaV3' => [
+            'name' => 'googleReCaptchaV3',
+            'isActive' => false,
+            'config' => [
+                'siteKey' => '',
+                'secretKey' => '',
+                'thresholdScore' => 0.5,
+            ],
+        ],
+        'cloudFlareTurnstile' => [
+            'name' => 'cloudFlareTurnstile',
+            'isActive' => false,
+            'config' => [
+                'siteKey' => '',
+                'secretKey' => ''
+            ]
+        ]
+    ];
+
+
+    public function getCreationTimestamp(): int
+    {
+        return 1664374217;
+    }
+
+    public function update(Connection $connection): void
+    {
+        //TODO: Can we prevent overriding current CAPTCHA settings?
+        $configId = $connection->fetchColumn('SELECT id FROM system_config WHERE configuration_key = :key AND updated_at IS NULL', [
+            'key' => self::CONFIG_KEY,
+        ]);
+
+        if (!$configId) {
+            return;
+        }
+
+        $connection->update('system_config', [
+            'configuration_key' => self::CONFIG_KEY,
+            'configuration_value' => json_encode(['_value' => $this->captchaItems]),
+            'created_at' => (new \DateTime())->format(Defaults::STORAGE_DATE_TIME_FORMAT),
+        ], [
+            'id' => $configId,
+        ]);
+    }
+
+    public function updateDestructive(Connection $connection): void
+    {
+        // implement update destructive
+    }
+}
diff --git a/src/Resources/app/administration/src/main.js b/src/Resources/app/administration/src/main.js
new file mode 100644
index 0000000..e53e55e
--- /dev/null
+++ b/src/Resources/app/administration/src/main.js
@@ -0,0 +1 @@
+import './module/sw-settings-basic-information/component/sw-settings-captcha-select-v2';
\ No newline at end of file
diff --git a/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/index.js b/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/index.js
new file mode 100644
index 0000000..a27ac0b
--- /dev/null
+++ b/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/index.js
@@ -0,0 +1,12 @@
+import template from './sw-settings-captcha-select-v2.html.twig';
+import enGB from './snippet/en-GB.json';
+import deDE from './snippet/de-DE.json';
+
+const { Component, Locale } = Shopware;
+
+Locale.extend('en-GB', enGB);
+Locale.extend('de-DE', deDE);
+
+Component.override('sw-settings-captcha-select-v2', {
+	template,
+});
\ No newline at end of file
diff --git a/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/snippet/de-DE.json b/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/snippet/de-DE.json
new file mode 100644
index 0000000..40bd863
--- /dev/null
+++ b/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/snippet/de-DE.json
@@ -0,0 +1,12 @@
+{
+    "sw-settings-basic-information": {
+        "captcha": {
+            "label": {
+                "cloudFlareTurnstile": "CloudFlare Turnstile",
+                "cloudFlareTurnstileSiteKey": "CloudFlare Turnstile site key",
+                "cloudFlareTurnstileSecretKey": "CloudFlare Turnstile secret key",
+                "cloudFlareTurnstileDescription": "Turnstile is CloudFlare's CAPTCHA alternative. It automatically chooses from a rotating suite of non-intrusive browser challenges based on telemetry and client behavior exhibited during a session."
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/snippet/en-GB.json b/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/snippet/en-GB.json
new file mode 100644
index 0000000..40bd863
--- /dev/null
+++ b/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/snippet/en-GB.json
@@ -0,0 +1,12 @@
+{
+    "sw-settings-basic-information": {
+        "captcha": {
+            "label": {
+                "cloudFlareTurnstile": "CloudFlare Turnstile",
+                "cloudFlareTurnstileSiteKey": "CloudFlare Turnstile site key",
+                "cloudFlareTurnstileSecretKey": "CloudFlare Turnstile secret key",
+                "cloudFlareTurnstileDescription": "Turnstile is CloudFlare's CAPTCHA alternative. It automatically chooses from a rotating suite of non-intrusive browser challenges based on telemetry and client behavior exhibited during a session."
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/sw-settings-captcha-select-v2.html.twig b/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/sw-settings-captcha-select-v2.html.twig
new file mode 100644
index 0000000..faa021f
--- /dev/null
+++ b/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/sw-settings-captcha-select-v2.html.twig
@@ -0,0 +1,36 @@
+<!-- eslint-disable-next-line sw-deprecation-rules/no-twigjs-blocks -->
+{% block sw_settings_captcha_select_v2_google_recaptcha_v2 %}
+    {% parent() %}
+    {% block sw_settings_captcha_select_v2_cloudflare_turnstile %}
+        <sw-container
+                v-if="currentValue.cloudFlareTurnstile.isActive"
+                class="sw-settings-captcha-select-v2__cloudflare-turnstile"
+        >
+
+            <!-- eslint-disable-next-line sw-deprecation-rules/no-twigjs-blocks -->
+            {% block sw_settings_captcha_select_v2_cloudflare_turnstile_description %}
+                <p class="sw-settings-captcha-select-v2__description sw-settings-captcha-select-v2__cloudflare-turnstile-description">
+                    {{ $tc('sw-settings-basic-information.captcha.label.cloudFlareTurnstileDescription') }}
+                </p>
+            {% endblock %}
+
+            <!-- eslint-disable-next-line sw-deprecation-rules/no-twigjs-blocks -->
+            {% block sw_settings_captcha_select_v2_cloudflare_turnstile_site_key %}
+                <sw-text-field
+                        v-model="currentValue.cloudFlareTurnstile.config.siteKey"
+                        name="cloudFlareTurnstileSiteKey"
+                        :label="$tc('sw-settings-basic-information.captcha.label.cloudFlareTurnstileSiteKey')"
+                />
+            {% endblock %}
+
+            <!-- eslint-disable-next-line sw-deprecation-rules/no-twigjs-blocks -->
+            {% block sw_settings_captcha_select_v2_cloudflare_turnstile_secret_key %}
+                <sw-text-field
+                        v-model="currentValue.cloudFlareTurnstile.config.secretKey"
+                        name="cloudFlareTurnstileSecretKey"
+                        :label="$tc('sw-settings-basic-information.captcha.label.cloudFlareTurnstileSecretKey')"
+                />
+            {% endblock %}
+        </sw-container>
+    {% endblock %}
+{% endblock %}
\ No newline at end of file
diff --git a/src/Resources/config/plugin.png b/src/Resources/config/plugin.png
new file mode 100644
index 0000000..879cb7a
Binary files /dev/null and b/src/Resources/config/plugin.png differ
diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml
new file mode 100644
index 0000000..463bbf8
--- /dev/null
+++ b/src/Resources/config/services.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" ?>
+
+<container xmlns="http://symfony.com/schema/dic/services"
+           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+           xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
+
+    <services>
+        <service id="Melv\Turnstile\Storefront\Framework\Captcha\CloudFlareTurnstile">
+            <argument type="service" id="shopware.captcha.client"/>
+            <tag name="shopware.storefront.captcha" priority="50"/>
+        </service>
+    </services>
+</container>
\ No newline at end of file
diff --git a/src/Resources/public/administration/js/melv-turnstile.js b/src/Resources/public/administration/js/melv-turnstile.js
new file mode 100644
index 0000000..8989788
--- /dev/null
+++ b/src/Resources/public/administration/js/melv-turnstile.js
@@ -0,0 +1 @@
+!function(e){var t={};function n(l){if(t[l])return t[l].exports;var r=t[l]={i:l,l:!1,exports:{}};return e[l].call(r.exports,r,r.exports,n),r.l=!0,r.exports}n.m=e,n.c=t,n.d=function(e,t,l){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:l})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var l=Object.create(null);if(n.r(l),Object.defineProperty(l,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var r in e)n.d(l,r,function(t){return e[t]}.bind(null,r));return l},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/bundles/melvturnstile/",n(n.s="wNoc")}({"+t2p":function(e){e.exports=JSON.parse('{"sw-settings-basic-information":{"captcha":{"label":{"cloudFlareTurnstile":"CloudFlare Turnstile","cloudFlareTurnstileSiteKey":"CloudFlare Turnstile site key","cloudFlareTurnstileSecretKey":"CloudFlare Turnstile secret key","cloudFlareTurnstileDescription":"Turnstile is CloudFlare\'s CAPTCHA alternative. It automatically chooses from a rotating suite of non-intrusive browser challenges based on telemetry and client behavior exhibited during a session."}}}}')},"R+yN":function(e){e.exports=JSON.parse('{"sw-settings-basic-information":{"captcha":{"label":{"cloudFlareTurnstile":"CloudFlare Turnstile","cloudFlareTurnstileSiteKey":"CloudFlare Turnstile site key","cloudFlareTurnstileSecretKey":"CloudFlare Turnstile secret key","cloudFlareTurnstileDescription":"Turnstile is CloudFlare\'s CAPTCHA alternative. It automatically chooses from a rotating suite of non-intrusive browser challenges based on telemetry and client behavior exhibited during a session."}}}}')},wNoc:function(e,t,n){"use strict";n.r(t);var l=n("+t2p"),r=n("R+yN"),s=Shopware,i=s.Component,c=s.Locale;c.extend("en-GB",l),c.extend("de-DE",r),i.override("sw-settings-captcha-select-v2",{template:'\n{% block sw_settings_captcha_select_v2_google_recaptcha_v2 %}\n    {% parent() %}\n    {% block sw_settings_captcha_select_v2_cloudflare_turnstile %}\n        <sw-container\n                v-if="currentValue.cloudFlareTurnstile.isActive"\n                class="sw-settings-captcha-select-v2__cloudflare-turnstile"\n        >\n\n            \n            {% block sw_settings_captcha_select_v2_cloudflare_turnstile_description %}\n                <p class="sw-settings-captcha-select-v2__description sw-settings-captcha-select-v2__cloudflare-turnstile-description">\n                    {{ $tc(\'sw-settings-basic-information.captcha.label.cloudFlareTurnstileDescription\') }}\n                </p>\n            {% endblock %}\n\n            \n            {% block sw_settings_captcha_select_v2_cloudflare_turnstile_site_key %}\n                <sw-text-field\n                        v-model="currentValue.cloudFlareTurnstile.config.siteKey"\n                        name="cloudFlareTurnstileSiteKey"\n                        :label="$tc(\'sw-settings-basic-information.captcha.label.cloudFlareTurnstileSiteKey\')"\n                />\n            {% endblock %}\n\n            \n            {% block sw_settings_captcha_select_v2_cloudflare_turnstile_secret_key %}\n                <sw-text-field\n                        v-model="currentValue.cloudFlareTurnstile.config.secretKey"\n                        name="cloudFlareTurnstileSecretKey"\n                        :label="$tc(\'sw-settings-basic-information.captcha.label.cloudFlareTurnstileSecretKey\')"\n                />\n            {% endblock %}\n        </sw-container>\n    {% endblock %}\n{% endblock %}'})}});
\ No newline at end of file
diff --git a/src/Resources/snippet/storefront.de-DE.json b/src/Resources/snippet/storefront.de-DE.json
new file mode 100644
index 0000000..a585d78
--- /dev/null
+++ b/src/Resources/snippet/storefront.de-DE.json
@@ -0,0 +1,7 @@
+{
+    "captcha": {
+        "cloudFlareTurnstile": {
+            "dataProtectionInformation": "This site is protected by Turnstile and the CloudFlare <a href=\"https://www.cloudflare.com/en-gb/privacypolicy/\">Privacy Policy</a> and <a href=\"https://www.cloudflare.com/en-gb/website-terms/\">Terms of Service</a> apply."
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Resources/snippet/storefront.en-GB.json b/src/Resources/snippet/storefront.en-GB.json
new file mode 100644
index 0000000..a585d78
--- /dev/null
+++ b/src/Resources/snippet/storefront.en-GB.json
@@ -0,0 +1,7 @@
+{
+    "captcha": {
+        "cloudFlareTurnstile": {
+            "dataProtectionInformation": "This site is protected by Turnstile and the CloudFlare <a href=\"https://www.cloudflare.com/en-gb/privacypolicy/\">Privacy Policy</a> and <a href=\"https://www.cloudflare.com/en-gb/website-terms/\">Terms of Service</a> apply."
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Resources/views/storefront/component/captcha/cloudFlareTurnstile.html.twig b/src/Resources/views/storefront/component/captcha/cloudFlareTurnstile.html.twig
new file mode 100644
index 0000000..faf59a0
--- /dev/null
+++ b/src/Resources/views/storefront/component/captcha/cloudFlareTurnstile.html.twig
@@ -0,0 +1,9 @@
+{% block component_captcha_cloudflare_turnstile %}
+    <div class="cf-turnstile mb-2"
+         data-sitekey="{{ config('core.basicInformation.activeCaptchasV2.cloudFlareTurnstile.config.siteKey') }}">
+
+        <div class="data-protection-information cf_turnstile-protection-information mb-2">
+            {{ "captcha.cloudFlareTurnstile.dataProtectionInformation"|trans|sw_sanitize }}
+        </div>
+    </div>
+{% endblock %}
diff --git a/src/Resources/views/storefront/component/recaptcha.html.twig b/src/Resources/views/storefront/component/recaptcha.html.twig
new file mode 100644
index 0000000..19e187f
--- /dev/null
+++ b/src/Resources/views/storefront/component/recaptcha.html.twig
@@ -0,0 +1,11 @@
+{% sw_extends '@Storefront/storefront/component/recaptcha.html.twig' %}
+
+{% block component_head_javascript_recaptcha %}
+    {{ parent() }}
+    {% block component_head_javascript_turnstile %}
+        {% set turnstileActive = config('core.basicInformation.activeCaptchasV2.cloudFlareTurnstile.isActive') %}
+        {% if turnstileActive %}
+            <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
+        {% endif %}
+    {% endblock %}
+{% endblock %}
\ No newline at end of file
diff --git a/src/Storefront/Framework/Captcha/CloudFlareTurnstile.php b/src/Storefront/Framework/Captcha/CloudFlareTurnstile.php
new file mode 100644
index 0000000..f1bbb02
--- /dev/null
+++ b/src/Storefront/Framework/Captcha/CloudFlareTurnstile.php
@@ -0,0 +1,77 @@
+<?php declare(strict_types=1);
+
+namespace Melv\Turnstile\Storefront\Framework\Captcha;
+
+use GuzzleHttp\ClientInterface;
+use Psr\Http\Client\ClientExceptionInterface;
+use Shopware\Core\Framework\Feature;
+use Shopware\Storefront\Framework\Captcha\AbstractCaptcha;
+use Symfony\Component\HttpFoundation\Request;
+
+class CloudFlareTurnstile extends AbstractCaptcha
+{
+    public const CAPTCHA_NAME = 'cloudFlareTurnstile';
+    //Injected by CF in form
+    public const CAPTCHA_REQUEST_PARAMETER = 'cf-turnstile-response';
+    private const CLOUDFLARE_CAPTCHA_VERIFY_ENDPOINT = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
+
+    private ClientInterface $client;
+
+    /**
+     * @internal
+     */
+    public function __construct(ClientInterface $client)
+    {
+        $this->client = $client;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function isValid(Request $request /* , array $captchaConfig */): bool
+    {
+        if (\func_num_args() < 2 || !\is_array(func_get_arg(1))) {
+            Feature::triggerDeprecationOrThrow(
+                'v6.5.0.0',
+                'Method `isValid()` in `CloudFlareTurnstile` expects passing the `$captchaConfig` as array as the second parameter in v6.5.0.0.'
+            );
+        }
+
+        if (!$request->get(self::CAPTCHA_REQUEST_PARAMETER)) {
+            return false;
+        }
+
+        $captchaConfig = \func_get_args()[1] ?? [];
+
+        $secretKey = !empty($captchaConfig['config']['secretKey']) ? $captchaConfig['config']['secretKey'] : null;
+
+        if (!\is_string($secretKey)) {
+            return false;
+        }
+
+        try {
+            $response = $this->client->request('POST', self::CLOUDFLARE_CAPTCHA_VERIFY_ENDPOINT, [
+                'form_params' => [
+                    'secret' => $secretKey,
+                    'response' => $request->get(self::CAPTCHA_REQUEST_PARAMETER),
+                    'remoteip' => $request->getClientIp(),
+                ],
+            ]);
+
+            $responseRaw = $response->getBody()->getContents();
+            $response = json_decode($responseRaw, true);
+
+            return $response && (bool) $response['success'];
+        } catch (ClientExceptionInterface $exception) {
+            return false;
+        }
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getName(): string
+    {
+        return self::CAPTCHA_NAME;
+    }
+}