From 02ea938784752e4edf9bccbb17a60fcadd8ae266 Mon Sep 17 00:00:00 2001 From: "y2ksoft@gmail.com" Date: Thu, 22 Jun 2023 14:27:50 +1200 Subject: [PATCH] commit --- .gitignore | 2 + README.md | 185 +++++---- composer.json | 32 +- lang/de.yml | 2 +- lang/en.yml | 6 +- lang/fr.yml | 6 +- lang/ru.yml | 4 +- public/_resources/.htaccess | 10 + public/_resources/.method | 1 + public/_resources/client/dist | 1 + .../vendor/silverstripe/admin/client/dist | 1 + .../vendor/silverstripe/admin/client/lang | 1 + .../vendor/silverstripe/admin/thirdparty | 1 + .../silverstripe/framework/client/images | 1 + .../silverstripe/framework/client/styles | 1 + .../vendor/silverstripe/spamprotection/images | 1 + src/Forms/TurnstileCaptchaField.php | 351 +++++++++++++++++- src/Forms/TurnstileCaptchaProtector.php | 18 +- .../Forms/TurnstileCaptchaField.ss | 11 + 19 files changed, 547 insertions(+), 88 deletions(-) create mode 100644 .gitignore create mode 100644 public/_resources/.htaccess create mode 100644 public/_resources/.method create mode 120000 public/_resources/client/dist create mode 120000 public/_resources/vendor/silverstripe/admin/client/dist create mode 120000 public/_resources/vendor/silverstripe/admin/client/lang create mode 120000 public/_resources/vendor/silverstripe/admin/thirdparty create mode 120000 public/_resources/vendor/silverstripe/framework/client/images create mode 120000 public/_resources/vendor/silverstripe/framework/client/styles create mode 120000 public/_resources/vendor/silverstripe/spamprotection/images diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b7ef35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/vendor +composer.lock diff --git a/README.md b/README.md index 13e0b8d..efcb307 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,143 @@ -# Silverstripe CMS supported module skeleton +Turnstile Smart CAPTCHA +================= -A useful skeleton to more easily create a [Silverstripe CMS Module](https://docs.silverstripe.org/en/developer_guides/extending/modules/) that conform to the -[Module Standard](https://docs.silverstripe.org/en/developer_guides/extending/modules/#module-standard). +Adds a "spam protection" field to SilverStripe userforms using Cloudflare's +[smart CAPTCHA](https://developers.cloudflare.com/turnstile) service. -This README contains descriptions of the parts of this module base you should customise to meet you own module needs. -For example, the module name in the H1 above should be you own module name, and the description text you are reading now -is where you should provide a good short explanation of what your module does. +## Requirements +* SilverStripe 5.x +* [SilverStripe Spam Protection + 3.x](https://github.com/silverstripe/silverstripe-spamprotection/) +* PHP CURL -Where possible we have included default text that can be included as is into your module and indicated in -other places where you need to customise it - -Below is a template of the sections of your `README.md` you should ideally include to met the Module Standard -and help others make use of your modules. - -## Steps to prepare this module for your own use +## Installation +``` +composer require silverstripe-terraformers/turnstileCaptcha +``` -Ensure you read the -['publishing a module'](https://docs.silverstripe.org/en/developer_guides/extending/how_tos/publish_a_module/) guide -and update your module's `composer.json` to designate your code as a Silversripe CMS module. +After installing the module via composer or manual install you must set the spam +protector to NocaptchaProtector, this needs to be set in your site's config file +normally this is mysite/\_config/config.yml. +```yml +SilverStripe\SpamProtection\Extension\FormSpamProtectionExtension: + default_spam_protector: Terraformers\TurnstileCaptcha\Forms\TurnstileCaptchaProtector +``` -- Clone this repository into a folder -- Add your name/organisation to `LICENSE.md` -- Update this README with information about your module. Ensure sections that aren't relevant are deleted and -placeholders are edited where relevant -- Review the README files in the various provided directories. You should ultimately delete these README files when you have added your code -- Update the module's `composer.json` with your requirements and package name -- Update (or remove) `package.json` with your requirements and package name. Run `yarn install` (or remove `yarn.lock`) to -ensure dependencies resolve correctly -- Clear the git history by running `rm -rf .git && git init` -- Add and push to a VCS repository -- Either [publish](https://getcomposer.org/doc/02-libraries.md#publishing-to-packagist) the module on packagist.org, or add a [custom repository](https://getcomposer.org/doc/02-libraries.md#publishing-to-a-vcs) to your main `composer.json` -- Require the module in your main `composer.json` -- If you need to build your css or js and are using components, injector, scss variables, etc from `silverstripe/admin`: - - Ensure that `silverstripe/admin` is installed with `composer install --prefer-source` instead of the default `--prefer-dist` (you can use `composer reinstall silverstripe/admin --prefer-source` if you already installed it) - - If you are relying on additional dependencies from `silverstripe/admin` instead of adding them as dependencies in your `package.json` file, you need to install third party dependencies in `silverstripe/admin` by running `yarn install` in the `vendor/silverstripe/admin/` directory. -- Start developing your module! +Finally, add the "spam protection" field to your form by calling +``enableSpamProtection()`` on the form object. +```php +$form->enableSpamProtection(); +``` -## License +## Configuration +There are multiple configuration options for the field, you must set the +site_key and the secret_key which you can get from the [reCAPTCHA +page](https://www.google.com/recaptcha). These configuration options must be +added to your site's yaml config typically this is mysite/\_config/config.yml. +```yml +Terraformers\TurnstileCaptcha\Forms\TurnstileCaptchaField: + site_key: "YOUR_SITE_KEY" #Your site key (required) + secret_key: "YOUR_SECRET_KEY" #Your secret key (required) + verify_ssl: true #Allows you to disable php-curl's SSL peer verification by setting this to false (optional, defaults to true) + default_theme: "light" #Default theme color (optional, light or dark, defaults to light) + default_handle_submit: true #Default setting for whether nocaptcha should handle form submission. See "Handling form submission" below. + proxy_server: "" #Your proxy server address (optional) + proxy_port: "" #Your proxy server address port (optional) + proxy_auth: "" #Your proxy server authentication information (optional) +``` -See [License](LICENSE.md) +## Adding field labels -This module template defaults to using the "BSD-3-Clause" license. The BSD-3 license is one of the most -permissive open-source license and is used by most Silverstripe CMS module. +If you want to add a field label or help text to the Captcha field you can do so +like this: -To publish your module under a different license: +```php +$form->enableSpamProtection() + ->fields()->fieldByName('Captcha') + ->setTitle("Spam protection") + ->setDescription("Please tick the box to prove you're a human and help us stop spam."); +``` -- update the [`license.md`](LICENSE.md) file -- update the `license' key in your [`composer.json`](composer.json). +### Commenting Module +When your using the +[silverstripe/comments](https://github.com/silverstripe/silverstripe-comments) +module you must add the following (per their documentation) to your \_config.php +in order to use Terraformers\TurnstileCaptcha on comment forms. -You can use [choosealicense.com](https://choosealicense.com) to help you pick a suitable license for your project. +```php +CommentingController::add_extension('CommentSpamProtection'); +``` -You do not need to keep this section in your README file - the `LICENSE.md` file is sufficient. +## Retrieving the Verify Response -## Installation +If you wish to manually retrieve the Site Verify response in you form action use +the `getVerifyResponse()` method -Replace `silverstripe-module/skeleton` in the command below with the composer name of your module. +```php +function doSubmit($data, $form) { + $captchaResponse = $form->Fields()->fieldByName('Captcha')->getVerifyResponse(); -```sh -composer require silverstripe-module/skeleton + // $captchaResponse = array (size=5) [ + // 'success' => boolean true + // 'challenge_ts' => string '2020-09-08T20:48:34Z' (length=20) + // 'hostname' => string 'localhost' (length=9) + // 'score' => float 0.9 + // 'action' => string 'submit' (length=6) + // ]; +} ``` -**Note:** When you have completed your module, submit it to Packagist or add it as a VCS repository to your -project's composer.json, pointing to the private repository URL. +## Handling form submission +By default, the javascript included with this module will add a submit event handler to your form. -## Documentation +If you need to handle form submissions in a special way (for example to support front-end validation), +you can choose to handle form submit events yourself. -- [Documentation readme](docs/en/README.md) - -Add links into your `docs/` folder here unless your module only requires minimal documentation -in that case, add here and remove the docs folder. You might use this as a quick table of content if you -mhave multiple documentation pages. +This can be configured site-wide using the Config API +```yml +Terraformers\TurnstileCaptcha\Forms\TurnstileCaptchaField: + default_handle_submit: false +``` -## Example configuration +Or on a per form basis: +```php +$captchaField = $form->Fields()->fieldByName('Captcha'); +$captchaField->setHandleSubmitEvents(false); +``` -If your module makes use of the config API in Silverstripe CMS it's a good idea to provide an example config -here that will get the module working out of the box and expose the user to the possible configuration options. -Though note that in many cases simply linking to the documentation is enough. +With this configuration no event handlers will be added by this module to your form. Instead, a +function will be provided called `nocaptcha_handleCaptcha` which you can call from your code +when you're ready to submit your form. It has the following signature: +```js +function nocaptcha_handleCaptcha(form, callback) +``` +`form` must be the form element, and `callback` should be a function that finally submits the form, +though it is optional. + +In the simplest case, you can use it like this: +```js +document.addEventListener("DOMContentLoaded", function(event) { + // where formID is the element ID for your form + const form = document.getElementById(formID); + const submitListener = function(event) { + event.preventDefault(); + let valid = true; + /* Your validation logic here */ + if (valid) { + nocaptcha_handleCaptcha(form, form.submit.bind(form)); + } + }; + form.addEventListener('submit', submitListener); +}); +``` -Provide a syntax-highlighted code examples where possible. +## Reporting an issue -```yaml -Page: - config_option: true - another_config: - - item1 - - item2 -``` +When you're reporting an issue please ensure you specify what version of +SilverStripe you are using i.e. 3.1.3, 3.2beta, master etc. Also be sure to +include any JavaScript or PHP errors you receive, for PHP errors please ensure +you include the full stack trace. Also please include how you produced the +issue. You may also be asked to provide some of the classes to aid in +re-producing the issue. Stick with the issue, remember that you seen the issue +not the maintainer of the module so it may take allot of questions to arrive at +a fix or answer. diff --git a/composer.json b/composer.json index 6abd10a..f9a89b3 100644 --- a/composer.json +++ b/composer.json @@ -1,24 +1,28 @@ { - "name": "silverstripe-module/skeleton", - "description": "A skeleton for Silverstripe CMS modules.", + "name": "silverstripe-terraformers/turnstile-captcha", + "description": "Silverstripe CMS Turnstile Captcha Spam Protection Field", "type": "silverstripe-vendormodule", "keywords": [ "silverstripe", - "CMS" + "recaptcha", + "spamprotection", + "turnstile" ], "license": "BSD-3-Clause", + "authors": [ + { + "name": "Dev Sundar", + "email": "y2ksoft@gmail.com" + } + ], "require": { + "php": "^8.1", "silverstripe/framework": "^5.0", - "silverstripe/admin": "^2.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.5", - "squizlabs/php_codesniffer": "^3.7" + "silverstripe/spamprotection": "^3 | ^4" }, "autoload": { "psr-4": { - "SilverStripeModule\\Skeleton\\": "src/", - "SilverStripeModule\\Skeleton\\Tests\\": "tests/php/" + "Terraformers\\TurnstileCaptcha\\": "src/" } }, "extra": { @@ -27,5 +31,11 @@ ] }, "minimum-stability": "dev", - "prefer-stable": true + "prefer-stable": true, + "config": { + "allow-plugins": { + "composer/installers": true, + "silverstripe/vendor-plugin": true + } + } } diff --git a/lang/de.yml b/lang/de.yml index ecdfe47..298fe40 100644 --- a/lang/de.yml +++ b/lang/de.yml @@ -1,4 +1,4 @@ de: - UndefinedOffset\NoCaptcha\Forms\NocaptchaField: + Terraformers\TurnstileCaptcha\Forms\CaptchaField: NOSCRIPT: "Sie müssen JavaScript aktivieren um dieses Formular zu übermitteln" VALIDATE_ERROR: "Spam-Schutz konnte nicht geprüft werden" diff --git a/lang/en.yml b/lang/en.yml index 86ce759..d1e09e2 100644 --- a/lang/en.yml +++ b/lang/en.yml @@ -1,4 +1,4 @@ en: - UndefinedOffset\NoCaptcha\Forms\NocaptchaField: - NOSCRIPT: "You must enable JavaScript to submit this form" - VALIDATE_ERROR: "Captcha could not be validated" \ No newline at end of file + Terraformers\TurnstileCaptcha\Forms\CaptchaField: + NOSCRIPT: "You must enable JavaScript to submit this form" + VALIDATE_ERROR: "Captcha could not be validated" diff --git a/lang/fr.yml b/lang/fr.yml index 71ed4ff..ac1762d 100644 --- a/lang/fr.yml +++ b/lang/fr.yml @@ -1,4 +1,4 @@ fr: - UndefinedOffset\NoCaptcha\Forms\NocaptchaField: - NOSCRIPT: "Vous devez activer JavaScript pour soumettre ce formulaire" - VALIDATE_ERROR: "Captcha n'a pas pu être validé" \ No newline at end of file + Terraformers\TurnstileCaptcha\Forms\CaptchaField: + NOSCRIPT: "Vous devez activer JavaScript pour soumettre ce formulaire" + VALIDATE_ERROR: "Captcha n'a pas pu être validé" diff --git a/lang/ru.yml b/lang/ru.yml index 238d15a..2731257 100644 --- a/lang/ru.yml +++ b/lang/ru.yml @@ -1,4 +1,4 @@ ru: - UndefinedOffset\NoCaptcha\Forms\NocaptchaField: - EMPTY: "Укажите ответ на капчу, если вы её не видите вам необходимо включить JavaScript." + Terraformers\TurnstileCaptcha\Forms\CaptchaField: + NOSCRIPT: "Укажите ответ на капчу, если вы её не видите вам необходимо включить JavaScript." VALIDATE_ERROR: "Ошибка проверки капчи - попробуйте ещё раз." diff --git a/public/_resources/.htaccess b/public/_resources/.htaccess new file mode 100644 index 0000000..b151b54 --- /dev/null +++ b/public/_resources/.htaccess @@ -0,0 +1,10 @@ +# Block .method file + + Require all denied + + +# Prevent file listings + + DirectoryIndex disabled + DirectorySlash On + diff --git a/public/_resources/.method b/public/_resources/.method new file mode 100644 index 0000000..4d18c3e --- /dev/null +++ b/public/_resources/.method @@ -0,0 +1 @@ +auto \ No newline at end of file diff --git a/public/_resources/client/dist b/public/_resources/client/dist new file mode 120000 index 0000000..3469128 --- /dev/null +++ b/public/_resources/client/dist @@ -0,0 +1 @@ +../../../client/dist \ No newline at end of file diff --git a/public/_resources/vendor/silverstripe/admin/client/dist b/public/_resources/vendor/silverstripe/admin/client/dist new file mode 120000 index 0000000..b244eb9 --- /dev/null +++ b/public/_resources/vendor/silverstripe/admin/client/dist @@ -0,0 +1 @@ +../../../../../../vendor/silverstripe/admin/client/dist \ No newline at end of file diff --git a/public/_resources/vendor/silverstripe/admin/client/lang b/public/_resources/vendor/silverstripe/admin/client/lang new file mode 120000 index 0000000..5c65d73 --- /dev/null +++ b/public/_resources/vendor/silverstripe/admin/client/lang @@ -0,0 +1 @@ +../../../../../../vendor/silverstripe/admin/client/lang \ No newline at end of file diff --git a/public/_resources/vendor/silverstripe/admin/thirdparty b/public/_resources/vendor/silverstripe/admin/thirdparty new file mode 120000 index 0000000..dd78eb0 --- /dev/null +++ b/public/_resources/vendor/silverstripe/admin/thirdparty @@ -0,0 +1 @@ +../../../../../vendor/silverstripe/admin/thirdparty \ No newline at end of file diff --git a/public/_resources/vendor/silverstripe/framework/client/images b/public/_resources/vendor/silverstripe/framework/client/images new file mode 120000 index 0000000..4f5528f --- /dev/null +++ b/public/_resources/vendor/silverstripe/framework/client/images @@ -0,0 +1 @@ +../../../../../../vendor/silverstripe/framework/client/images \ No newline at end of file diff --git a/public/_resources/vendor/silverstripe/framework/client/styles b/public/_resources/vendor/silverstripe/framework/client/styles new file mode 120000 index 0000000..882c6ce --- /dev/null +++ b/public/_resources/vendor/silverstripe/framework/client/styles @@ -0,0 +1 @@ +../../../../../../vendor/silverstripe/framework/client/styles \ No newline at end of file diff --git a/public/_resources/vendor/silverstripe/spamprotection/images b/public/_resources/vendor/silverstripe/spamprotection/images new file mode 120000 index 0000000..fe51583 --- /dev/null +++ b/public/_resources/vendor/silverstripe/spamprotection/images @@ -0,0 +1 @@ +../../../../../vendor/silverstripe/spamprotection/images \ No newline at end of file diff --git a/src/Forms/TurnstileCaptchaField.php b/src/Forms/TurnstileCaptchaField.php index 5528dc0..833a2da 100644 --- a/src/Forms/TurnstileCaptchaField.php +++ b/src/Forms/TurnstileCaptchaField.php @@ -1,8 +1,355 @@ title = $title; + + $this->_captchaTheme = self::config()->default_theme; + $this->handleSubmitEvents = self::config()->default_handle_submit; + } + + /** + * Adds in the requirements for the field + * @param array $properties Array of properties for the form element (not used) + * @return string Rendered field template + */ + public function Field($properties = array()) + { + $siteKey = $this->getSiteKey(); + $secretKey = $this->_secretKey ? $this->_secretKey : self::config()->secret_key; + + if (empty($siteKey) || empty($secretKey)) { + user_error('You must configure Nocaptcha.site_key and Nocaptcha.secret_key, you can retrieve these at https://google.com/recaptcha', E_USER_ERROR); + } + + Requirements::javascript( + 'https://challenges.cloudflare.com/turnstile/v0/api.js?hl=' . Locale::getPrimaryLanguage(i18n::get_locale()) . ($this->config()->js_onload_callback ? '&onload=' . $this->config()->js_onload_callback : ''), + [ + 'async' => true, + 'defer' => true, + ] + ); + return parent::Field($properties); + } + + + /** + * Validates the captcha against the Recaptcha API + * + * @param Validator $validator Validator to send errors to + * @return bool Returns boolean true if valid false if not + */ + public function validate($validator) + { + + $request = Controller::curr()->getRequest(); + $recaptchaResponse = $request->requestVar('cf-turnstile-response'); + + if (!isset($recaptchaResponse)) { + $validator->validationError($this->name, _t( + 'Terraformers\\TurnstileCaptcha\\Forms\\CaptchaField.EMPTY', + 'if you do not see the captcha you must enable JavaScript'), + 'validation'); + return false; + } + + if (!function_exists('curl_init')) { + user_error('You must enable php-curl to use this field', E_USER_ERROR); + return false; + } + + $secret_key = $this->_secretKey ?: self::config()->secret_key; + $url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + $ch = curl_init($url); + $proxy_server = $this->_proxyServer ?: self::config()->proxy_server; + if (!empty($proxy_server)) { + curl_setopt($ch, CURLOPT_PROXY, $proxy_server); + + $proxy_auth = $this->_proxyAuth ?: self::config()->proxy_auth; + if (!empty($proxy_auth)) { + curl_setopt($ch, CURLOPT_PROXYUSERPWD, $proxy_auth); + } + + $proxy_port = $this->_proxyPort ?: self::config()->proxy_port; + if (!empty($proxy_port)) { + curl_setopt($ch, CURLOPT_PROXYPORT, $proxy_port); + } + } + + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, self::config()->verify_ssl); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_USERAGENT, str_replace(',', '/', 'SilverStripe')); + curl_setopt( + $ch, + CURLOPT_POSTFIELDS, + http_build_query([ + 'secret' => $secret_key, + 'response' => $recaptchaResponse, + 'remoteip' => $request->getIP(), + ]) + ); + $response = json_decode(curl_exec($ch), true); + + if (is_array($response)) { + $this->verifyResponse = $response; + + if (!array_key_exists('success', $response) || !$response['success']) { + $validator->validationError($this->name, _t( + 'Terraformers\\TurnstileCaptcha\\Forms\\CaptchaField.EMPTY', + '_Please answer the captcha, + if you do not see the captcha you must enable JavaScript'), + 'validation'); + return false; + } + + } else { + $validator->validationError($this->name, _t( + 'Terraformers\\TurnstileCaptcha\\Forms\\CaptchaField.VALIDATE_ERROR', + '_Captcha could not be validated'), + 'validation'); + $logger = Injector::inst()->get(LoggerInterface::class); + $logger->error( + 'Captcha validation failed as request was not successful.' + ); + return false; + } + + return true; + } + + /** + * Sets whether form submit events are handled directly by this module. + * + * @param boolean $value + * @return TurnstileCaptchaField + */ + public function setHandleSubmitEvents(bool $value): TurnstileCaptchaField + { + $this->handleSubmitEvents = $value; + return $this; + } + + /** + * Get whether form submit events are handled directly by this module. + * + * @return boolean + */ + public function getHandleSubmitEvents(): bool + { + return $this->handleSubmitEvents; + } + + /** + * Sets the theme for this captcha + * @param string $value Theme to set it to, currently the api supports light and dark + * @return TurnstileCaptchaField + */ + public function setTheme(string $value): TurnstileCaptchaField + { + $this->_captchaTheme = $value; + + return $this; + } + + /** + * Gets the theme for this captcha + * @return string + */ + public function getCaptchaTheme(): string + { + return $this->_captchaTheme; + } + + /** + * Gets the site key configured via NocaptchaField.site_key this is used in the template + * @return string + */ + public function getSiteKey(): string + { + return $this->_siteKey ? $this->_siteKey : self::config()->site_key; + } + + /** + * Setter for _siteKey to allow injector config to override the value + */ + public function setSiteKey($key) + { + $this->_siteKey = $key; + } + + /** + * Setter for _secretKey to allow injector config to override the value + */ + public function setSecretKey($key) + { + $this->_secretKey = $key; + } + + /** + * Setter for _proxyServer to allow injector config to override the value + */ + public function setProxyServer($server) + { + $this->_proxyServer = $server; + } + + /** + * Setter for _proxyAuth to allow injector config to override the value + */ + public function setProxyAuth($auth) + { + $this->_proxyAuth = $auth; + } + + /** + * Setter for _proxyPort to allow injector config to override the value + */ + public function setProxyPort($port) + { + $this->_proxyPort = $port; + } + + /** + * Gets the form's id + * @return ?string + */ + public function getFormID(): ?string + { + return ($this->form ? $this->getTemplateHelper()->generateFormID($this->form) : null); + } + + /** + * @return array + */ + public function getVerifyResponse(): array + { + return $this->verifyResponse; + } } diff --git a/src/Forms/TurnstileCaptchaProtector.php b/src/Forms/TurnstileCaptchaProtector.php index 82504fe..469c507 100644 --- a/src/Forms/TurnstileCaptchaProtector.php +++ b/src/Forms/TurnstileCaptchaProtector.php @@ -2,7 +2,23 @@ namespace Terraformers\TurnstileCaptcha\Forms; -class TurnstileCaptchaProtector +use SilverStripe\SpamProtection\SpamProtector; + +class TurnstileCaptchaProtector implements SpamProtector { + /** + * @param $name + * @param $title + * @param $value + * @return TurnstileCaptchaField + */ + public function getFormField($name = "Recaptcha2Field", $title = "Captcha", $value = null): TurnstileCaptchaField + { + return TurnstileCaptchaField::create($name, $title); + } + + public function setFieldMapping($fieldMapping) + { + } } diff --git a/templates/Terraformers/TurnstileCaptcha/Forms/TurnstileCaptchaField.ss b/templates/Terraformers/TurnstileCaptcha/Forms/TurnstileCaptchaField.ss index e69de29..01e1c24 100644 --- a/templates/Terraformers/TurnstileCaptcha/Forms/TurnstileCaptchaField.ss +++ b/templates/Terraformers/TurnstileCaptcha/Forms/TurnstileCaptchaField.ss @@ -0,0 +1,11 @@ +
+ +
+ +