diff --git a/composer.json b/composer.json index d6c655b60..4a69398e7 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "drupal/metatag": "^2.2.0", "drupal/paragraphs": "^1.19.0", "drupal/pathauto": "^1.14.0", + "drupal/recaptcha": "^3.4", "drupal/redirect": "^1.12.0", "drupal/rename_admin_paths": "^3.0@beta", "drupal/securitytxt": "^1.4", diff --git a/composer.lock b/composer.lock index bf42ffd41..0fc62aae3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d3ce2f58155f0d32b6f34a731a4b4d89", + "content-hash": "5018a5fd0e2f8034548d15826c72b4ec", "packages": [ { "name": "asm89/stack-cors", @@ -1218,6 +1218,86 @@ "issues": "https://www.drupal.org/project/issues/admin_toolbar" } }, + { + "name": "drupal/captcha", + "version": "2.0.9", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/captcha.git", + "reference": "2.0.9" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/captcha-2.0.9.zip", + "reference": "2.0.9", + "shasum": "15b2ba18fab75ea88bfa8f75fb1be09f7cd52cbb" + }, + "require": { + "drupal/core": "^9.5 || ^10 || ^11" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "2.0.9", + "datestamp": "1753701287", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + }, + "branch-alias": { + "dev-8.x-1.x": "1.x-dev" + } + }, + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "anybody", + "homepage": "https://www.drupal.org/user/291091" + }, + { + "name": "elachlan", + "homepage": "https://www.drupal.org/user/1021502" + }, + { + "name": "grevil", + "homepage": "https://www.drupal.org/user/3668491" + }, + { + "name": "japerry", + "homepage": "https://www.drupal.org/user/45640" + }, + { + "name": "naveenvalecha", + "homepage": "https://www.drupal.org/user/2665733" + }, + { + "name": "podarok", + "homepage": "https://www.drupal.org/user/116002" + }, + { + "name": "robloach", + "homepage": "https://www.drupal.org/user/61114" + }, + { + "name": "thomas.frobieter", + "homepage": "https://www.drupal.org/user/409335" + }, + { + "name": "wundo", + "homepage": "https://www.drupal.org/user/25523" + } + ], + "description": "The CAPTCHA module provides this feature to virtually any user facing web form on a Drupal site.", + "homepage": "https://www.drupal.org/project/captcha", + "support": { + "source": "https://git.drupalcode.org/project/captcha", + "issues": "https://www.drupal.org/project/issues/captcha" + } + }, { "name": "drupal/conditional_fields", "version": "4.0.0-alpha6", @@ -2537,6 +2617,93 @@ "documentation": "https://www.drupal.org/docs/8/modules/pathauto" } }, + { + "name": "drupal/recaptcha", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/recaptcha.git", + "reference": "8.x-3.4" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/recaptcha-8.x-3.4.zip", + "reference": "8.x-3.4", + "shasum": "95fa7ac5dd064ea6a1c14fc4881778bf68200598" + }, + "require": { + "drupal/captcha": "^1.15 || ^2.0", + "drupal/core": "^10 || ^11", + "google/recaptcha": "^1.3" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "8.x-3.4", + "datestamp": "1723563033", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + } + }, + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "hass", + "homepage": "https://www.drupal.org/u/hass" + }, + { + "name": "See other contributors", + "homepage": "https://www.drupal.org/node/147903/committers" + }, + { + "name": "diolan", + "homepage": "https://www.drupal.org/user/2336786" + }, + { + "name": "hass", + "homepage": "https://www.drupal.org/user/85918" + }, + { + "name": "id.medion", + "homepage": "https://www.drupal.org/user/2542592" + }, + { + "name": "kim.pepper", + "homepage": "https://www.drupal.org/user/370574" + }, + { + "name": "larowlan", + "homepage": "https://www.drupal.org/user/395439" + }, + { + "name": "liam morland", + "homepage": "https://www.drupal.org/user/493050" + }, + { + "name": "robloach", + "homepage": "https://www.drupal.org/user/61114" + }, + { + "name": "wundo", + "homepage": "https://www.drupal.org/user/25523" + }, + { + "name": "yseki", + "homepage": "https://www.drupal.org/user/1523064" + } + ], + "description": "Protect your website from spam and abuse while letting real people pass through with ease.", + "homepage": "https://www.drupal.org/project/recaptcha", + "support": { + "source": "https://git.drupalcode.org/project/recaptcha.git", + "issues": "https://www.drupal.org/project/issues/recaptcha" + } + }, { "name": "drupal/redirect", "version": "1.12.0", @@ -3429,6 +3596,58 @@ }, "time": "2025-08-12T10:13:48+00:00" }, + { + "name": "google/recaptcha", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/google/recaptcha.git", + "reference": "56522c261d2e8c58ba416c90f81a4cd9f2ed89b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/google/recaptcha/zipball/56522c261d2e8c58ba416c90f81a4cd9f2ed89b9", + "reference": "56522c261d2e8c58ba416c90f81a4cd9f2ed89b9", + "shasum": "" + }, + "require": { + "php": ">=8" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.14", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "ReCaptcha\\": "src/ReCaptcha" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Client library for reCAPTCHA, a free service that protects websites from spam and abuse.", + "homepage": "https://www.google.com/recaptcha/", + "keywords": [ + "Abuse", + "captcha", + "recaptcha", + "spam" + ], + "support": { + "forum": "https://groups.google.com/forum/#!forum/recaptcha", + "issues": "https://github.com/google/recaptcha/issues", + "source": "https://github.com/google/recaptcha" + }, + "time": "2025-06-26T22:21:57+00:00" + }, { "name": "grasmash/expander", "version": "3.0.1", @@ -8179,7 +8398,7 @@ }, "prefer-stable": true, "prefer-lowest": false, - "platform": [], - "platform-dev": [], + "platform": {}, + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/vendor/composer/InstalledVersions.php b/vendor/composer/InstalledVersions.php index 51e734a77..6d29bff66 100644 --- a/vendor/composer/InstalledVersions.php +++ b/vendor/composer/InstalledVersions.php @@ -32,6 +32,11 @@ class InstalledVersions */ private static $installed; + /** + * @var bool + */ + private static $installedIsLocalDir; + /** * @var bool|null */ @@ -309,6 +314,12 @@ public static function reload($data) { self::$installed = $data; self::$installedByVendor = array(); + + // when using reload, we disable the duplicate protection to ensure that self::$installed data is + // always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not, + // so we have to assume it does not, and that may result in duplicate data being returned when listing + // all installed packages for example + self::$installedIsLocalDir = false; } /** @@ -322,19 +333,27 @@ private static function getInstalled() } $installed = array(); + $copiedLocalDir = false; if (self::$canGetVendors) { + $selfDir = strtr(__DIR__, '\\', '/'); foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) { + $vendorDir = strtr($vendorDir, '\\', '/'); if (isset(self::$installedByVendor[$vendorDir])) { $installed[] = self::$installedByVendor[$vendorDir]; } elseif (is_file($vendorDir.'/composer/installed.php')) { /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ $required = require $vendorDir.'/composer/installed.php'; - $installed[] = self::$installedByVendor[$vendorDir] = $required; - if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) { - self::$installed = $installed[count($installed) - 1]; + self::$installedByVendor[$vendorDir] = $required; + $installed[] = $required; + if (self::$installed === null && $vendorDir.'/composer' === $selfDir) { + self::$installed = $required; + self::$installedIsLocalDir = true; } } + if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) { + $copiedLocalDir = true; + } } } @@ -350,7 +369,7 @@ private static function getInstalled() } } - if (self::$installed !== array()) { + if (self::$installed !== array() && !$copiedLocalDir) { $installed[] = self::$installed; } diff --git a/vendor/composer/LICENSE b/vendor/composer/LICENSE index 62ecfd8d0..f27399a04 100644 --- a/vendor/composer/LICENSE +++ b/vendor/composer/LICENSE @@ -1,3 +1,4 @@ + Copyright (c) Nils Adermann, Jordi Boggiano Permission is hereby granted, free of charge, to any person obtaining a copy @@ -17,3 +18,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/vendor/composer/autoload_psr4.php b/vendor/composer/autoload_psr4.php index d56cf1f52..67f8f6cff 100644 --- a/vendor/composer/autoload_psr4.php +++ b/vendor/composer/autoload_psr4.php @@ -43,6 +43,7 @@ 'Symfony\\Component\\Console\\' => array($vendorDir . '/symfony/console'), 'Symfony\\Bridge\\PsrHttpMessage\\' => array($vendorDir . '/symfony/psr-http-message-bridge'), 'Robo\\' => array($vendorDir . '/consolidation/robo/src'), + 'ReCaptcha\\' => array($vendorDir . '/google/recaptcha/src/ReCaptcha'), 'QueryPath\\' => array($vendorDir . '/gravitypdf/querypath/src'), 'QueryPathTests\\' => array($vendorDir . '/gravitypdf/querypath/tests/QueryPath'), 'Psy\\' => array($vendorDir . '/psy/psysh/src'), diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php index 378ce29d1..cb53dc6ef 100644 --- a/vendor/composer/autoload_static.php +++ b/vendor/composer/autoload_static.php @@ -86,6 +86,7 @@ class ComposerStaticInit969219525714a33a1696abd06bf370ac 'R' => array ( 'Robo\\' => 5, + 'ReCaptcha\\' => 10, ), 'Q' => array ( @@ -316,6 +317,10 @@ class ComposerStaticInit969219525714a33a1696abd06bf370ac array ( 0 => __DIR__ . '/..' . '/consolidation/robo/src', ), + 'ReCaptcha\\' => + array ( + 0 => __DIR__ . '/..' . '/google/recaptcha/src/ReCaptcha', + ), 'QueryPath\\' => array ( 0 => __DIR__ . '/..' . '/gravitypdf/querypath/src', diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json index fda13e17f..a25241f5d 100644 --- a/vendor/composer/installed.json +++ b/vendor/composer/installed.json @@ -1265,6 +1265,89 @@ }, "install-path": "../../web/modules/contrib/admin_toolbar" }, + { + "name": "drupal/captcha", + "version": "2.0.9", + "version_normalized": "2.0.9.0", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/captcha.git", + "reference": "2.0.9" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/captcha-2.0.9.zip", + "reference": "2.0.9", + "shasum": "15b2ba18fab75ea88bfa8f75fb1be09f7cd52cbb" + }, + "require": { + "drupal/core": "^9.5 || ^10 || ^11" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "2.0.9", + "datestamp": "1753701287", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + }, + "branch-alias": { + "dev-8.x-1.x": "1.x-dev" + } + }, + "installation-source": "dist", + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "anybody", + "homepage": "https://www.drupal.org/user/291091" + }, + { + "name": "elachlan", + "homepage": "https://www.drupal.org/user/1021502" + }, + { + "name": "grevil", + "homepage": "https://www.drupal.org/user/3668491" + }, + { + "name": "japerry", + "homepage": "https://www.drupal.org/user/45640" + }, + { + "name": "naveenvalecha", + "homepage": "https://www.drupal.org/user/2665733" + }, + { + "name": "podarok", + "homepage": "https://www.drupal.org/user/116002" + }, + { + "name": "robloach", + "homepage": "https://www.drupal.org/user/61114" + }, + { + "name": "thomas.frobieter", + "homepage": "https://www.drupal.org/user/409335" + }, + { + "name": "wundo", + "homepage": "https://www.drupal.org/user/25523" + } + ], + "description": "The CAPTCHA module provides this feature to virtually any user facing web form on a Drupal site.", + "homepage": "https://www.drupal.org/project/captcha", + "support": { + "source": "https://git.drupalcode.org/project/captcha", + "issues": "https://www.drupal.org/project/issues/captcha" + }, + "install-path": "../../web/modules/contrib/captcha" + }, { "name": "drupal/conditional_fields", "version": "4.0.0-alpha6", @@ -2637,6 +2720,96 @@ }, "install-path": "../../web/modules/contrib/pathauto" }, + { + "name": "drupal/recaptcha", + "version": "3.4.0", + "version_normalized": "3.4.0.0", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/recaptcha.git", + "reference": "8.x-3.4" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/recaptcha-8.x-3.4.zip", + "reference": "8.x-3.4", + "shasum": "95fa7ac5dd064ea6a1c14fc4881778bf68200598" + }, + "require": { + "drupal/captcha": "^1.15 || ^2.0", + "drupal/core": "^10 || ^11", + "google/recaptcha": "^1.3" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "8.x-3.4", + "datestamp": "1723563033", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + } + }, + "installation-source": "dist", + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "hass", + "homepage": "https://www.drupal.org/u/hass" + }, + { + "name": "See other contributors", + "homepage": "https://www.drupal.org/node/147903/committers" + }, + { + "name": "diolan", + "homepage": "https://www.drupal.org/user/2336786" + }, + { + "name": "hass", + "homepage": "https://www.drupal.org/user/85918" + }, + { + "name": "id.medion", + "homepage": "https://www.drupal.org/user/2542592" + }, + { + "name": "kim.pepper", + "homepage": "https://www.drupal.org/user/370574" + }, + { + "name": "larowlan", + "homepage": "https://www.drupal.org/user/395439" + }, + { + "name": "liam morland", + "homepage": "https://www.drupal.org/user/493050" + }, + { + "name": "robloach", + "homepage": "https://www.drupal.org/user/61114" + }, + { + "name": "wundo", + "homepage": "https://www.drupal.org/user/25523" + }, + { + "name": "yseki", + "homepage": "https://www.drupal.org/user/1523064" + } + ], + "description": "Protect your website from spam and abuse while letting real people pass through with ease.", + "homepage": "https://www.drupal.org/project/recaptcha", + "support": { + "source": "https://git.drupalcode.org/project/recaptcha.git", + "issues": "https://www.drupal.org/project/issues/recaptcha" + }, + "install-path": "../../web/modules/contrib/recaptcha" + }, { "name": "drupal/redirect", "version": "1.12.0", @@ -3565,6 +3738,61 @@ }, "install-path": "../enshrined/svg-sanitize" }, + { + "name": "google/recaptcha", + "version": "1.3.1", + "version_normalized": "1.3.1.0", + "source": { + "type": "git", + "url": "https://github.com/google/recaptcha.git", + "reference": "56522c261d2e8c58ba416c90f81a4cd9f2ed89b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/google/recaptcha/zipball/56522c261d2e8c58ba416c90f81a4cd9f2ed89b9", + "reference": "56522c261d2e8c58ba416c90f81a4cd9f2ed89b9", + "shasum": "" + }, + "require": { + "php": ">=8" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.14", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^10" + }, + "time": "2025-06-26T22:21:57+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3.x-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "ReCaptcha\\": "src/ReCaptcha" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Client library for reCAPTCHA, a free service that protects websites from spam and abuse.", + "homepage": "https://www.google.com/recaptcha/", + "keywords": [ + "Abuse", + "captcha", + "recaptcha", + "spam" + ], + "support": { + "forum": "https://groups.google.com/forum/#!forum/recaptcha", + "issues": "https://github.com/google/recaptcha/issues", + "source": "https://github.com/google/recaptcha" + }, + "install-path": "../google/recaptcha" + }, { "name": "grasmash/expander", "version": "3.0.1", diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php index 52467da24..04281d94c 100644 --- a/vendor/composer/installed.php +++ b/vendor/composer/installed.php @@ -3,7 +3,7 @@ 'name' => 'drupal/recommended-project', 'pretty_version' => 'dev-main', 'version' => 'dev-main', - 'reference' => 'a59c9db4a0a2a0db79b28cb64cb1d6e68e301592', + 'reference' => 'c463fdd43b63186ef0cde946405ce0e78c365632', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -178,6 +178,15 @@ 'aliases' => array(), 'dev_requirement' => false, ), + 'drupal/captcha' => array( + 'pretty_version' => '2.0.9', + 'version' => '2.0.9.0', + 'reference' => '2.0.9', + 'type' => 'drupal-module', + 'install_path' => __DIR__ . '/../../web/modules/contrib/captcha', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'drupal/conditional_fields' => array( 'pretty_version' => '4.0.0-alpha6', 'version' => '4.0.0.0-alpha6', @@ -475,10 +484,19 @@ 'aliases' => array(), 'dev_requirement' => false, ), + 'drupal/recaptcha' => array( + 'pretty_version' => '3.4.0', + 'version' => '3.4.0.0', + 'reference' => '8.x-3.4', + 'type' => 'drupal-module', + 'install_path' => __DIR__ . '/../../web/modules/contrib/recaptcha', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'drupal/recommended-project' => array( 'pretty_version' => 'dev-main', 'version' => 'dev-main', - 'reference' => 'a59c9db4a0a2a0db79b28cb64cb1d6e68e301592', + 'reference' => 'c463fdd43b63186ef0cde946405ce0e78c365632', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -598,6 +616,15 @@ 0 => '*', ), ), + 'google/recaptcha' => array( + 'pretty_version' => '1.3.1', + 'version' => '1.3.1.0', + 'reference' => '56522c261d2e8c58ba416c90f81a4cd9f2ed89b9', + 'type' => 'library', + 'install_path' => __DIR__ . '/../google/recaptcha', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'grasmash/expander' => array( 'pretty_version' => '3.0.1', 'version' => '3.0.1.0', @@ -823,8 +850,8 @@ 'psr/container-implementation' => array( 'dev_requirement' => false, 'provided' => array( - 0 => '^1.0', - 1 => '1.1|2.0', + 0 => '1.1|2.0', + 1 => '^1.0', ), ), 'psr/event-dispatcher' => array( diff --git a/vendor/google/recaptcha/LICENSE b/vendor/google/recaptcha/LICENSE new file mode 100644 index 000000000..d147b35b3 --- /dev/null +++ b/vendor/google/recaptcha/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2019, Google Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/google/recaptcha/README.md b/vendor/google/recaptcha/README.md new file mode 100644 index 000000000..025979411 --- /dev/null +++ b/vendor/google/recaptcha/README.md @@ -0,0 +1,147 @@ +# reCAPTCHA PHP client library + +[![Build Status](https://travis-ci.org/google/recaptcha.svg)](https://travis-ci.org/google/recaptcha) +[![Coverage Status](https://coveralls.io/repos/github/google/recaptcha/badge.svg)](https://coveralls.io/github/google/recaptcha) +[![Latest Stable Version](https://poser.pugx.org/google/recaptcha/v/stable.svg)](https://packagist.org/packages/google/recaptcha) +[![Total Downloads](https://poser.pugx.org/google/recaptcha/downloads.svg)](https://packagist.org/packages/google/recaptcha) + +reCAPTCHA is a free CAPTCHA service that protects websites from spam and abuse. +This is a PHP library that wraps up the server-side verification step required +to process responses from the reCAPTCHA service. This client supports both v2 +and v3. + +- reCAPTCHA: https://www.google.com/recaptcha +- This repo: https://github.com/google/recaptcha +- Hosted demo: https://recaptcha-demo.appspot.com/ +- Version: 1.3.1 +- License: BSD, see [LICENSE](LICENSE) + +## Installation + +### Composer (recommended) + +Use [Composer](https://getcomposer.org) to install this library from Packagist: +[`google/recaptcha`](https://packagist.org/packages/google/recaptcha) + +Run the following command from your project directory to add the dependency: + +```sh +composer require google/recaptcha "^1.3" +``` + +Alternatively, add the dependency directly to your `composer.json` file: + +```json +"require": { + "google/recaptcha": "^1.3" +} +``` + +### Support for earlier versions of PHP + +The 1.3 release moves to PHP 8 and up. For earlier versions, you will need to +stay with the 1.2 releases. + +### Direct download + +Download the [ZIP file](https://github.com/google/recaptcha/archive/master.zip) +and extract into your project. An autoloader script is provided in +`src/autoload.php` which you can require into your script. For example: + +```php +require_once '/path/to/recaptcha/src/autoload.php'; +$recaptcha = new \ReCaptcha\ReCaptcha($secret); +``` + +The classes in the project are structured according to the +[PSR-4](https://www.php-fig.org/psr/psr-4/) standard, so you can also use your +own autoloader or require the needed files directly in your code. + +## Usage + +First obtain the appropriate keys for the type of reCAPTCHA you wish to +integrate for v2 at https://www.google.com/recaptcha/admin or v3 at +https://g.co/recaptcha/v3. + +Then follow the [integration guide on the developer +site](https://developers.google.com/recaptcha/intro) to add the reCAPTCHA +functionality into your frontend. + +This library comes in when you need to verify the user's response. On the PHP +side you need the response from the reCAPTCHA service and secret key from your +credentials. Instantiate the `ReCaptcha` class with your secret key, specify any +additional validation rules, and then call `verify()` with the reCAPTCHA +response (usually in `$_POST['g-recaptcha-response']` or the response from +`grecaptcha.execute()` in JS which is in `$gRecaptchaResponse` in the example) +and user's IP address. For example: + +```php +setExpectedHostname('recaptcha-demo.appspot.com') + ->verify($gRecaptchaResponse, $remoteIp); +if ($resp->isSuccess()) { + // Verified! +} else { + $errors = $resp->getErrorCodes(); +} +``` + +The following methods are available: + +- `setExpectedHostname($hostname)`: ensures the hostname matches. You must do + this if you have disabled "Domain/Package Name Validation" for your + credentials. +- `setExpectedApkPackageName($apkPackageName)`: if you're verifying a response + from an Android app. Again, you must do this if you have disabled + "Domain/Package Name Validation" for your credentials. +- `setExpectedAction($action)`: ensures the action matches for the v3 API. +- `setScoreThreshold($threshold)`: set a score threshold for responses from the + v3 API +- `setChallengeTimeout($timeoutSeconds)`: set a timeout between the user passing + the reCAPTCHA and your server processing it. + +Each of the `set`\*`()` methods return the `ReCaptcha` instance so you can chain +them together. For example: + +```php +setExpectedHostname('recaptcha-demo.appspot.com') + ->setExpectedAction('homepage') + ->setScoreThreshold(0.5) + ->verify($gRecaptchaResponse, $remoteIp); + +if ($resp->isSuccess()) { + // Verified! +} else { + $errors = $resp->getErrorCodes(); +} +``` + +You can find the constants for the libraries error codes in the `ReCaptcha` +class constants, e.g. `ReCaptcha::E_HOSTNAME_MISMATCH` + +For more details on usage and structure, see [ARCHITECTURE](ARCHITECTURE.md). + +### Examples + +You can see examples of each reCAPTCHA type in [examples/](examples/). You can +run the examples locally by using the Composer script: + +```sh +composer run-script serve-examples +``` + +This makes use of the in-built PHP dev server to host the examples at +http://localhost:8080/ + +These are also hosted on Google AppEngine Flexible environment at +https://recaptcha-demo.appspot.com/. This is configured by +[`app.yaml`](./app.yaml) which you can also use to [deploy to your own AppEngine +project](https://cloud.google.com/appengine/docs/flexible/php/download). + +## Contributing + +No one ever has enough engineers, so we're very happy to accept contributions +via Pull Requests. For details, see [CONTRIBUTING](CONTRIBUTING.md) diff --git a/vendor/google/recaptcha/app.yaml b/vendor/google/recaptcha/app.yaml new file mode 100644 index 000000000..b6ccaf18b --- /dev/null +++ b/vendor/google/recaptcha/app.yaml @@ -0,0 +1,8 @@ +runtime: php +env: flex + +skip_files: +- tests + +runtime_config: + document_root: examples diff --git a/vendor/google/recaptcha/composer.json b/vendor/google/recaptcha/composer.json new file mode 100644 index 000000000..7f199fca0 --- /dev/null +++ b/vendor/google/recaptcha/composer.json @@ -0,0 +1,39 @@ +{ + "name": "google/recaptcha", + "description": "Client library for reCAPTCHA, a free service that protects websites from spam and abuse.", + "type": "library", + "keywords": ["recaptcha", "captcha", "spam", "abuse"], + "homepage": "https://www.google.com/recaptcha/", + "license": "BSD-3-Clause", + "support": { + "forum": "https://groups.google.com/forum/#!forum/recaptcha", + "source": "https://github.com/google/recaptcha" + }, + "require": { + "php": ">=8" + }, + "require-dev": { + "phpunit/phpunit": "^10", + "friendsofphp/php-cs-fixer": "^3.14", + "php-coveralls/php-coveralls": "^2.5" + }, + "autoload": { + "psr-4": { + "ReCaptcha\\": "src/ReCaptcha" + } + }, + "extra": { + "branch-alias": { + "dev-master": "1.3.x-dev" + } + }, + "scripts": { + "lint": "PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer -vvv fix --using-cache=no --dry-run .", + "lint-fix": "PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer -vvv fix --using-cache=no .", + "test": "XDEBUG_MODE=coverage vendor/bin/phpunit", + "serve-examples": "@php -S localhost:8080 -t examples" + }, + "config": { + "process-timeout": 0 + } +} diff --git a/vendor/google/recaptcha/src/ReCaptcha/ReCaptcha.php b/vendor/google/recaptcha/src/ReCaptcha/ReCaptcha.php new file mode 100644 index 000000000..463a5e579 --- /dev/null +++ b/vendor/google/recaptcha/src/ReCaptcha/ReCaptcha.php @@ -0,0 +1,275 @@ +secret = $secret; + $this->requestMethod = (is_null($requestMethod)) ? new RequestMethod\Post() : $requestMethod; + } + + /** + * Calls the reCAPTCHA siteverify API to verify whether the user passes + * CAPTCHA test and additionally runs any specified additional checks + * + * @param string $response The user response token provided by reCAPTCHA, verifying the user on your site. + * @param string $remoteIp The end user's IP address. + * @return Response Response from the service. + */ + public function verify($response, $remoteIp = null) + { + // Discard empty solution submissions + if (empty($response)) { + $recaptchaResponse = new Response(false, array(self::E_MISSING_INPUT_RESPONSE)); + return $recaptchaResponse; + } + + $params = new RequestParameters($this->secret, $response, $remoteIp, self::VERSION); + $rawResponse = $this->requestMethod->submit($params); + $initialResponse = Response::fromJson($rawResponse); + $validationErrors = array(); + + if (isset($this->hostname) && strcasecmp($this->hostname, $initialResponse->getHostname()) !== 0) { + $validationErrors[] = self::E_HOSTNAME_MISMATCH; + } + + if (isset($this->apkPackageName) && strcasecmp($this->apkPackageName, $initialResponse->getApkPackageName()) !== 0) { + $validationErrors[] = self::E_APK_PACKAGE_NAME_MISMATCH; + } + + if (isset($this->action) && strcasecmp($this->action, $initialResponse->getAction()) !== 0) { + $validationErrors[] = self::E_ACTION_MISMATCH; + } + + if (isset($this->threshold) && $this->threshold > $initialResponse->getScore()) { + $validationErrors[] = self::E_SCORE_THRESHOLD_NOT_MET; + } + + if (isset($this->timeoutSeconds)) { + $challengeTs = strtotime($initialResponse->getChallengeTs()); + + if ($challengeTs > 0 && time() - $challengeTs > $this->timeoutSeconds) { + $validationErrors[] = self::E_CHALLENGE_TIMEOUT; + } + } + + if (empty($validationErrors)) { + return $initialResponse; + } + + return new Response( + false, + array_merge($initialResponse->getErrorCodes(), $validationErrors), + $initialResponse->getHostname(), + $initialResponse->getChallengeTs(), + $initialResponse->getApkPackageName(), + $initialResponse->getScore(), + $initialResponse->getAction() + ); + } + + /** + * Provide a hostname to match against in verify() + * This should be without a protocol or trailing slash, e.g. www.google.com + * + * @param string $hostname Expected hostname + * @return ReCaptcha Current instance for fluent interface + */ + public function setExpectedHostname($hostname) + { + $this->hostname = $hostname; + return $this; + } + + /** + * Provide an APK package name to match against in verify() + * + * @param string $apkPackageName Expected APK package name + * @return ReCaptcha Current instance for fluent interface + */ + public function setExpectedApkPackageName($apkPackageName) + { + $this->apkPackageName = $apkPackageName; + return $this; + } + + /** + * Provide an action to match against in verify() + * This should be set per page. + * + * @param string $action Expected action + * @return ReCaptcha Current instance for fluent interface + */ + public function setExpectedAction($action) + { + $this->action = $action; + return $this; + } + + /** + * Provide a threshold to meet or exceed in verify() + * Threshold should be a float between 0 and 1 which will be tested as response >= threshold. + * + * @param float $threshold Expected threshold + * @return ReCaptcha Current instance for fluent interface + */ + public function setScoreThreshold($threshold) + { + $this->threshold = floatval($threshold); + return $this; + } + + /** + * Provide a timeout in seconds to test against the challenge timestamp in verify() + * + * @param int $timeoutSeconds Maximum time (seconds) elapsed since the challenge timestamp + * @return ReCaptcha Current instance for fluent interface + */ + public function setChallengeTimeout($timeoutSeconds) + { + $this->timeoutSeconds = $timeoutSeconds; + return $this; + } +} diff --git a/vendor/google/recaptcha/src/ReCaptcha/RequestMethod.php b/vendor/google/recaptcha/src/ReCaptcha/RequestMethod.php new file mode 100644 index 000000000..bd2a94902 --- /dev/null +++ b/vendor/google/recaptcha/src/ReCaptcha/RequestMethod.php @@ -0,0 +1,49 @@ +curl = (is_null($curl)) ? new Curl() : $curl; + $this->siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : $siteVerifyUrl; + } + + /** + * Submit the cURL request with the specified parameters. + * + * @param RequestParameters $params Request parameters + * @return string Body of the reCAPTCHA response + */ + public function submit(RequestParameters $params) + { + $handle = $this->curl->init($this->siteVerifyUrl); + + $options = array( + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $params->toQueryString(), + CURLOPT_HTTPHEADER => array( + 'Content-Type: application/x-www-form-urlencoded' + ), + CURLINFO_HEADER_OUT => false, + CURLOPT_HEADER => false, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_SSL_VERIFYPEER => true + ); + $this->curl->setoptArray($handle, $options); + + $response = $this->curl->exec($handle); + $this->curl->close($handle); + + if ($response !== false) { + return $response; + } + + return '{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}'; + } +} diff --git a/vendor/google/recaptcha/src/ReCaptcha/RequestMethod/Post.php b/vendor/google/recaptcha/src/ReCaptcha/RequestMethod/Post.php new file mode 100644 index 000000000..a4ff716fb --- /dev/null +++ b/vendor/google/recaptcha/src/ReCaptcha/RequestMethod/Post.php @@ -0,0 +1,88 @@ +siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : $siteVerifyUrl; + } + + /** + * Submit the POST request with the specified parameters. + * + * @param RequestParameters $params Request parameters + * @return string Body of the reCAPTCHA response + */ + public function submit(RequestParameters $params) + { + $options = array( + 'http' => array( + 'header' => "Content-type: application/x-www-form-urlencoded\r\n", + 'method' => 'POST', + 'content' => $params->toQueryString(), + // Force the peer to validate (not needed in 5.6.0+, but still works) + 'verify_peer' => true, + ), + ); + $context = stream_context_create($options); + $response = file_get_contents($this->siteVerifyUrl, false, $context); + + if ($response !== false) { + return $response; + } + + return '{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}'; + } +} diff --git a/vendor/google/recaptcha/src/ReCaptcha/RequestMethod/Socket.php b/vendor/google/recaptcha/src/ReCaptcha/RequestMethod/Socket.php new file mode 100644 index 000000000..236bd5f5d --- /dev/null +++ b/vendor/google/recaptcha/src/ReCaptcha/RequestMethod/Socket.php @@ -0,0 +1,112 @@ +handle = fsockopen($hostname, $port, $errno, $errstr, (is_null($timeout) ? ini_get("default_socket_timeout") : $timeout)); + + if ($this->handle != false && $errno === 0 && $errstr === '') { + return $this->handle; + } + return false; + } + + /** + * fwrite + * + * @see http://php.net/fwrite + * @param string $string + * @param int $length + * @return int | bool + */ + public function fwrite($string, $length = null) + { + return fwrite($this->handle, $string, (is_null($length) ? strlen($string) : $length)); + } + + /** + * fgets + * + * @see http://php.net/fgets + * @param int $length + * @return string + */ + public function fgets($length = null) + { + return fgets($this->handle, $length); + } + + /** + * feof + * + * @see http://php.net/feof + * @return bool + */ + public function feof() + { + return feof($this->handle); + } + + /** + * fclose + * + * @see http://php.net/fclose + * @return bool + */ + public function fclose() + { + return fclose($this->handle); + } +} diff --git a/vendor/google/recaptcha/src/ReCaptcha/RequestMethod/SocketPost.php b/vendor/google/recaptcha/src/ReCaptcha/RequestMethod/SocketPost.php new file mode 100644 index 000000000..0aa72af15 --- /dev/null +++ b/vendor/google/recaptcha/src/ReCaptcha/RequestMethod/SocketPost.php @@ -0,0 +1,110 @@ +socket = (is_null($socket)) ? new Socket() : $socket; + $this->siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : $siteVerifyUrl; + } + + /** + * Submit the POST request with the specified parameters. + * + * @param RequestParameters $params Request parameters + * @return string Body of the reCAPTCHA response + */ + public function submit(RequestParameters $params) + { + $errno = 0; + $errstr = ''; + $urlParsed = parse_url($this->siteVerifyUrl); + + if (false === $this->socket->fsockopen('ssl://' . $urlParsed['host'], 443, $errno, $errstr, 30)) { + return '{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}'; + } + + $content = $params->toQueryString(); + + $request = "POST " . $urlParsed['path'] . " HTTP/1.0\r\n"; + $request .= "Host: " . $urlParsed['host'] . "\r\n"; + $request .= "Content-Type: application/x-www-form-urlencoded\r\n"; + $request .= "Content-length: " . strlen($content) . "\r\n"; + $request .= "Connection: close\r\n\r\n"; + $request .= $content . "\r\n\r\n"; + + $this->socket->fwrite($request); + $response = ''; + + while (!$this->socket->feof()) { + $response .= $this->socket->fgets(4096); + } + + $this->socket->fclose(); + + if (0 !== strpos($response, 'HTTP/1.0 200 OK')) { + return '{"success": false, "error-codes": ["'.ReCaptcha::E_BAD_RESPONSE.'"]}'; + } + + $parts = preg_split("#\n\s*\n#Uis", $response); + + return $parts[1]; + } +} diff --git a/vendor/google/recaptcha/src/ReCaptcha/RequestParameters.php b/vendor/google/recaptcha/src/ReCaptcha/RequestParameters.php new file mode 100644 index 000000000..e9ba45354 --- /dev/null +++ b/vendor/google/recaptcha/src/ReCaptcha/RequestParameters.php @@ -0,0 +1,111 @@ +secret = $secret; + $this->response = $response; + $this->remoteIp = $remoteIp; + $this->version = $version; + } + + /** + * Array representation. + * + * @return array Array formatted parameters. + */ + public function toArray() + { + $params = array('secret' => $this->secret, 'response' => $this->response); + + if (!is_null($this->remoteIp)) { + $params['remoteip'] = $this->remoteIp; + } + + if (!is_null($this->version)) { + $params['version'] = $this->version; + } + + return $params; + } + + /** + * Query string representation for HTTP request. + * + * @return string Query string formatted parameters. + */ + public function toQueryString() + { + return http_build_query($this->toArray(), '', '&'); + } +} diff --git a/vendor/google/recaptcha/src/ReCaptcha/Response.php b/vendor/google/recaptcha/src/ReCaptcha/Response.php new file mode 100644 index 000000000..8a5d3aabe --- /dev/null +++ b/vendor/google/recaptcha/src/ReCaptcha/Response.php @@ -0,0 +1,218 @@ +success = $success; + $this->hostname = $hostname; + $this->challengeTs = $challengeTs; + $this->apkPackageName = $apkPackageName; + $this->score = $score; + $this->action = $action; + $this->errorCodes = $errorCodes; + } + + /** + * Is success? + * + * @return boolean + */ + public function isSuccess() + { + return $this->success; + } + + /** + * Get error codes. + * + * @return array + */ + public function getErrorCodes() + { + return $this->errorCodes; + } + + /** + * Get hostname. + * + * @return string + */ + public function getHostname() + { + return $this->hostname; + } + + /** + * Get challenge timestamp + * + * @return string + */ + public function getChallengeTs() + { + return $this->challengeTs; + } + + /** + * Get APK package name + * + * @return string + */ + public function getApkPackageName() + { + return $this->apkPackageName; + } + /** + * Get score + * + * @return float + */ + public function getScore() + { + return $this->score; + } + + /** + * Get action + * + * @return string + */ + public function getAction() + { + return $this->action; + } + + public function toArray() + { + return array( + 'success' => $this->isSuccess(), + 'hostname' => $this->getHostname(), + 'challenge_ts' => $this->getChallengeTs(), + 'apk_package_name' => $this->getApkPackageName(), + 'score' => $this->getScore(), + 'action' => $this->getAction(), + 'error-codes' => $this->getErrorCodes(), + ); + } +} diff --git a/vendor/google/recaptcha/src/autoload.php b/vendor/google/recaptcha/src/autoload.php new file mode 100644 index 000000000..7947a1050 --- /dev/null +++ b/vendor/google/recaptcha/src/autoload.php @@ -0,0 +1,69 @@ + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/web/modules/contrib/captcha/README.md b/web/modules/contrib/captcha/README.md new file mode 100755 index 000000000..c21f7be75 --- /dev/null +++ b/web/modules/contrib/captcha/README.md @@ -0,0 +1,93 @@ +# CAPTCHA + +A CAPTCHA is a challenge-response test most often placed within web forms to +determine whether the user is human. The purpose of CAPTCHA is to block form +submissions by spambots, which are automated scripts that post spam content +everywhere they can. The CAPTCHA module provides this feature to virtually any +user facing web form on a Drupal site. + +For a full description of the module, visit the +[project page](https://www.drupal.org/project/captcha) + +Submit bug reports and feature suggestions, or track changes in the +[issue queue](https://www.drupal.org/project/issues/captcha) + + +## Table of contents + +- Requirements +- Conflicts/Known issues +- Installation +- Configuration +- Development +- Maintainers + + +## Requirements + +This module requires no modules outside of Drupal core. + + +## Conflicts/Known issues + +CAPTCHA and page caching do not work together currently. However, the CAPTCHA +module does support the Drupal core page caching mechanism: it just disables the +caching of the pages where it has to put its challenges. + +If you use other caching mechanisms, it is possible that CAPTCHA's won't work, +and you get error messages like 'CAPTCHA validation error: unknown CAPTCHA +session ID'. + + +## Installation + +Install as you would normally install a contributed Drupal module. For further +information, see +[Installing Drupal Modules](https://www.drupal.org/docs/extending-drupal/installing-drupal-modules). + + +## Configuration + +The configuration page is at `admin/config/people/captcha`, +where you can configure the CAPTCHA module +and enable challenges for the desired forms. +You can also tweak the image CAPTCHA to your liking. + +1. Navigate to Administration > Extend and enable the module. +1. Navigate to Administration > Configuration > People > Captcha module + settings to administer how and when Captcha is used. +1. Select the challenge type you want for each of the listed forms. +1. Select "Add a description to the CAPTCHA" to add a configurable + description to explain the purpose of the CAPTCHA to the visitor. +1. For Default CAPTCHA validation, define how the response should be + processed by default. Note that the modules that provide the actual + challenges can override or ignore this. +1. Save configuration. + + +## Development + +You can disable captcha in your local or test environment by adding the +following line to `settings.php`: +``` +$settings['disable_captcha'] = TRUE; +``` + + +## Maintainers + +- Fabiano Sant'Ana - [wundo](https://www.drupal.org/u/wundo) +- Julian Pustkuchen - [Anybody](https://www.drupal.org/u/Anybody) +- Jakob Perry - [japerry](https://www.drupal.org/u/japerry) +- Rob Loach - [RobLoach](https://www.drupal.org/u/RobLoach) +- soxofaan - [soxofaan](https://www.drupal.org/u/soxofaan) +- Joshua Sedler - [Grevil](https://www.drupal.org/u/Grevil) +- Thomas Frobieter - [thomas.frobieter](https://www.drupal.org/u/thomas.frobieter) +- Lachlan Ennis - [elachlan](https://www.drupal.org/u/elachlan) +- Naveen Valecha - [naveenvalecha](https://www.drupal.org/u/naveenvalecha) +- Andrii Podanenko - [podarok](https://www.drupal.org/u/podarok) + +Supporting organizations: + +- Chuva Inc. - [Chuva Inc](https://www.drupal.org/chuva-inc) +- webks GmbH - [DROWL.de](https://www.DROWL.de) diff --git a/web/modules/contrib/captcha/captcha.api.php b/web/modules/contrib/captcha/captcha.api.php new file mode 100644 index 000000000..d3f553407 --- /dev/null +++ b/web/modules/contrib/captcha/captcha.api.php @@ -0,0 +1,197 @@ + 'textfield', + '#title' => t('Enter "foo"'), + '#required' => TRUE, + ]; + // The CAPTCHA module provides an option for case sensitive and case + // insensitive validation of the responses. If this is not sufficient, + // you can provide your own validation function with the + // 'captcha_validate' field, illustrated by the following example: + $captcha['captcha_validate'] = 'hook_captcha_custom_validation'; + return $captcha; + } + break; + } +} + +/** + * Allow modules to alter a CAPTCHA. + * + * @param array $captcha + * The array returned by hook_captcha(). + * @param array $info + * Array of information about the CAPTCHA. + */ +function hook_captcha_alter(&$captcha, $info) { + if ($info['module'] == 'mymodule') { + $captcha['form']['captcha_response']['#description'] = t('New description.'); + } +} + +/** + * Implements hook_help(). + * + * You should of course implement a function foo_captcha_settings_form() which + * returns the form of your configuration page. + * === Optional: hook_help($section) === + * To offer a description/explanation of your challenge, you can use the + * normal hook_help() system. + * For our simple foo CAPTCHA module this would mean: + */ +function hook_captcha_help($route_name, RouteMatchInterface $route_match) { + switch ($route_name) { + case 'foo_captcha.settings': + return '

' . t('This is a very simple challenge, which requires users to + enter "foo" in a textfield.') . '

'; + } +} + +/** + * A hook_captcha_custom_advance_validation implementation example. + * + * Custom CAPTCHA validation function. Previous example shows the basic usage + * for custom validation with only a $solution and $response argument, which + * should be sufficient for most CAPTCHA modules. More advanced CAPTCHA modules + * can also use extra provided arguments $element and $form_state. + * + * @param string $solution + * The solution for the challenge as reported by hook_captcha('generate',...). + * @param string $response + * The answer given by the user. + * + * @return true + * on success and FALSE on failure. + */ +function hook_captcha_custom_validation($solution, $response) { + return $response == "foo" || $response == "bar"; +} + +/** + * A hook_captcha_custom_advance_validation implementation example. + * + * Custom Advance CAPTCHA validation function. These extra arguments are the + * $element and $form_state arguments of the validation function of the #captcha + * element. See captcha_validate() in captcha.module for more info about this. + * + * @param string $solution + * The solution for the challenge as reported by hook_captcha('generate',...). + * @param string $response + * The answer given by the user. + * @param array $element + * The element argument. + * @param array $form_state + * The form_state argument. + * + * @return true + * on success and FALSE on failure. + */ +function hook_captcha_custom_advance_validation($solution, $response, array $element, array $form_state) { + return $form_state['foo']['#bar'] = 'baz'; +} + +/** + * A hook_captcha_placement_map implementation example. + * + * === Hook into CAPTCHA placement === + * The CAPTCHA module attempts to place the CAPTCHA element in an appropriate + * spot at the bottom of the targeted form, but this automatic detection may be + * insufficient for complex forms. + * The hook_captcha_placement_map hook allows to define the placement of the + * CAPTCHA element as desired. The hook should return an array, mapping form IDs + * to placement arrays, which are associative arrays with the following fields: + * 'path': path (array of path items) of the form's container element in which + * the CAPTCHA element should be inserted. + * 'key': the key of the element before which the CAPTCHA element + * should be inserted. If the field 'key' is undefined or NULL, the CAPTCHA + * will just be appended in the container. + * 'weight': if 'key' is not NULL: should be the weight of the element defined + * by 'key'. If 'key' is NULL and weight is not NULL/unset: set the weight + * property of the CAPTCHA element to this value. + * For example: + * This will place the CAPTCHA element + * in the 'my_fancy_form' form inside the container $form['items']['buttons'], + * just before the element $form['items']['buttons']['sacebutton']. + * in the 'another_form' form at the toplevel of the form, with a weight 34. + */ +function hook_captcha_placement_map() { + return [ + 'my_fancy_form' => [ + 'path' => ['items', 'buttons'], + 'key' => 'savebutton', + ], + 'another_form' => [ + 'path' => [], + 'weight' => 34, + ], + ]; +} diff --git a/web/modules/contrib/captcha/captcha.config_translation.yml b/web/modules/contrib/captcha/captcha.config_translation.yml new file mode 100644 index 000000000..eeb5c2f8b --- /dev/null +++ b/web/modules/contrib/captcha/captcha.config_translation.yml @@ -0,0 +1,5 @@ +captcha.settings: + title: 'CAPTCHA' + base_route_name: captcha_settings + names: + - captcha.settings diff --git a/web/modules/contrib/captcha/captcha.inc b/web/modules/contrib/captcha/captcha.inc new file mode 100755 index 000000000..72bdf9f86 --- /dev/null +++ b/web/modules/contrib/captcha/captcha.inc @@ -0,0 +1,340 @@ +module + * and $captcha_type->captcha_type. + */ +function captcha_set_form_id_setting($form_id, $captcha_type) { + /** @var Drupal\captcha\Entity\CaptchaPoint $captcha_point */ + $captcha_point = CaptchaPoint::load($form_id); + + if ($captcha_point) { + $captcha_point->setCaptchaType($captcha_type); + } + else { + $captcha_point = new CaptchaPoint([ + 'formId' => $form_id, + 'captchaType' => $captcha_type, + // @see https://www.drupal.org/project/drupal/issues/3302838 + // On config pre-save drupal tries to query same uuid, and deprecation + // is generated. So we prefill the uuid for now. + 'uuid' => \Drupal::service('uuid')->generate(), + ], 'captcha_point'); + } + $captcha_point->enable(); + + $captcha_point->save(); +} + +/** + * Get the CAPTCHA setting for a given form_id. + * + * @param string $form_id + * The form_id to query for. + * @param bool $symbolic + * Flag to return as (symbolic) strings instead of object. + * + * @return null|CaptchaPoint + * NULL if no setting is known + * captcha point object with fields 'module' and 'captcha_type'. + * If argument $symbolic is true, returns 'default' or in the + * form 'captcha/Math'. + */ +function captcha_get_form_id_setting($form_id, $symbolic = FALSE) { + /** @var \Drupal\captcha\Entity\CaptchaPoint $captchaPoint */ + $captcha_point = CaptchaPoint::load($form_id); + + if ($symbolic) { + $captcha_point = $captcha_point->getCaptchaType(); + } + + return $captcha_point; +} + +/** + * Helper function for generating a new CAPTCHA session. + * + * @param string $form_id + * The form_id of the form to add a CAPTCHA to. + * @param int $status + * The initial status of the CAPTCHA session. + * + * @return string + * The session ID of the new CAPTCHA session. + */ +function _captcha_generate_captcha_session($form_id = NULL, $status = CaptchaConstants::CAPTCHA_STATUS_UNSOLVED) { + $user = \Drupal::currentUser(); + + // Initialize solution with random data. + $solution = hash('sha256', mt_rand()); + + // Insert an entry and thankfully receive the value + // of the autoincrement field 'csid'. + $captcha_sid = \Drupal::database()->insert('captcha_sessions') + ->fields([ + 'uid' => $user->id(), + 'sid' => session_id(), + 'ip_address' => \Drupal::request()->getClientIp(), + 'timestamp' => \Drupal::time()->getRequestTime(), + 'form_id' => $form_id, + 'solution' => $solution, + 'status' => $status, + 'attempts' => 0, + ]) + ->execute(); + return $captcha_sid; +} + +/** + * Helper function for updating the solution in the CAPTCHA session table. + * + * @param string $captcha_sid + * The CAPTCHA session ID to update. + * @param string $solution + * The new solution to associate with the given CAPTCHA session. + */ +function _captcha_update_captcha_session($captcha_sid, $solution) { + \Drupal::database()->update('captcha_sessions') + ->condition('csid', $captcha_sid) + ->fields([ + 'timestamp' => \Drupal::time()->getRequestTime(), + 'solution' => $solution, + ]) + ->execute(); +} + +/** + * Helper function for checking if CAPTCHA is required for user. + * + * Based on the CAPTCHA persistence setting, the CAPTCHA session + * ID and user session info. + */ +function _captcha_required_for_user($captcha_sid, $form_id) { + // Get the CAPTCHA persistence setting. + $captcha_persistence = \Drupal::config('captcha.settings') + ->get('persistence'); + + // First check: should we always add a CAPTCHA? + if ($captcha_persistence == CaptchaConstants::CAPTCHA_PERSISTENCE_SHOW_ALWAYS) { + return TRUE; + } + + // Get the status of the current CAPTCHA session. + $captcha_session_status = \Drupal::database() + ->select('captcha_sessions', 'cs') + ->fields('cs', ['status']) + ->condition('csid', $captcha_sid) + ->execute() + ->fetchField(); + + // Second check: if the current session is already + // solved: omit further CAPTCHAs. + if ($captcha_session_status == CaptchaConstants::CAPTCHA_STATUS_SOLVED) { + return FALSE; + } + + // Third check: look at the persistence level + // (per form instance, per form or per user). + if ($captcha_persistence == CaptchaConstants::CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL_PER_FORM_INSTANCE) { + return TRUE; + } + else { + $captcha_success_form_ids = isset($_SESSION['captcha_success_form_ids']) ? (array) ($_SESSION['captcha_success_form_ids']) : []; + switch ($captcha_persistence) { + case CaptchaConstants::CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL: + return (count($captcha_success_form_ids) == 0); + + case CaptchaConstants::CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL_PER_FORM_TYPE: + return !isset($captcha_success_form_ids[$form_id]); + } + } + + // We should never get to this point, but to be sure, we return TRUE. + return TRUE; +} + +/** + * Get the CAPTCHA title. + * + * @return string + * CAPTCHA title. + */ +function _captcha_get_title() { + $title = \Drupal::config('captcha.settings')->get('title'); + if ($title === NULL) { + return ''; + } + return Xss::filter($title); +} + +/** + * Get the CAPTCHA description. + * + * @return string + * CAPTCHA description. + */ +function _captcha_get_description() { + $description = \Drupal::config('captcha.settings')->get('description'); + if ($description === NULL) { + return ''; + } + return Xss::filter($description); +} + +/** + * Gets the error message for when a user enters an incorrect CAPTCHA answer. + * + * @return string + * Error message. + */ +function _captcha_get_error_message() { + $error_message = \Drupal::config('captcha.settings')->get('wrong_captcha_response_message'); + if ($error_message) { + return Xss::filter($error_message); + } + return t('The answer you entered for the CAPTCHA was not correct.'); +} + +/** + * Parse or interpret the given captcha_type. + * + * @param string $captcha_type + * representation of the CAPTCHA type, + * e.g. 'default', 'captcha/Math', 'image_captcha/Image'. + * + * @return array + * list($captcha_module, $captcha_type). + */ +function _captcha_parse_captcha_type($captcha_type) { + if ($captcha_type == CaptchaConstants::CAPTCHA_TYPE_DEFAULT) { + $captcha_type = \Drupal::config('captcha.settings') + ->get('default_challenge', CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE); + } + return explode('/', $captcha_type); +} + +/** + * Helper function to get placement information for a given form_id. + */ +function _captcha_get_captcha_placement($form_id, $form) { + // Get CAPTCHA placement map from cache. Two levels of cache: + // static variable in this function and storage in the variables table. + static $placement_map = NULL; + + $write_cache = FALSE; + + // Try first level cache. + if ($placement_map === NULL) { + // If first level cache missed: try second level cache. + if ($cache = \Drupal::cache()->get('captcha_placement_map_cache')) { + $placement_map = $cache->data; + } + else { + // If second level cache missed: initialize the placement map + // and let other modules hook into this with the + // hook_captcha_placement_map hook. + // By default however, probably all Drupal core forms + // are already correctly handled with the best effort guess + // based on the 'actions' element (see below). + $placement_map = \Drupal::moduleHandler() + ->invokeAll('captcha_placement_map'); + $write_cache = TRUE; + } + } + + // Query the placement map. + if (array_key_exists($form_id, $placement_map) && $placement_map[$form_id] !== NULL) { + $placement = $placement_map[$form_id]; + } + // If no placement info is available in placement map: + // make a best effort guess. + else { + // If there is an "actions" button group, a good placement + // is just before that. + if (isset($form['actions']) && isset($form['actions']['#type']) && $form['actions']['#type'] === 'actions') { + $placement = [ + 'path' => [], + 'key' => 'actions', + // #type 'actions' defaults to 100. + 'weight' => (isset($form['actions']['#weight']) ? $form['actions']['#weight'] - 1 : 99), + ]; + } + else { + // Search the form for buttons and guess placement from it. + $buttons = _captcha_search_buttons($form); + if (count($buttons)) { + // Pick first button. + // @todo make this more sofisticated? Use cases needed. + $placement = (isset($buttons[count($buttons) - 1])) ? $buttons[count($buttons) - 1] : $buttons[0]; + } + else { + // Use NULL when no buttons were found. + $placement = NULL; + } + } + + // Store calculated placement in cache. + $placement_map[$form_id] = $placement; + $write_cache = TRUE; + } + + if ($write_cache) { + \Drupal::cache()->set('captcha_placement_map_cache', $placement_map); + } + return $placement; +} + +/** + * Helper function for searching the buttons in a form. + * + * @param array $form + * The form to search button elements in. + * + * @return array + * Array of paths to the buttons. + * A path is an array of keys leading to the button, the last + * item in the path is the weight of the button element + * (or NULL if undefined). + */ +function _captcha_search_buttons(array $form) { + $buttons = []; + + foreach (Element::children($form, FALSE) as $key) { + // Look for submit or button type elements. + if (isset($form[$key]['#type']) && ($form[$key]['#type'] == 'submit' || $form[$key]['#type'] == 'button')) { + $weight = $form[$key]['#weight'] ?? NULL; + $buttons[] = [ + 'path' => [], + 'key' => $key, + 'weight' => $weight, + ]; + } + // Process children recursively. + $children_buttons = _captcha_search_buttons($form[$key]); + foreach ($children_buttons as $b) { + $b['path'] = array_merge([$key], $b['path']); + $buttons[] = $b; + } + } + + return $buttons; +} diff --git a/web/modules/contrib/captcha/captcha.info.yml b/web/modules/contrib/captcha/captcha.info.yml new file mode 100644 index 000000000..aaec67503 --- /dev/null +++ b/web/modules/contrib/captcha/captcha.info.yml @@ -0,0 +1,11 @@ +name: CAPTCHA +type: module +description: Provides the CAPTCHA API for adding challenges to arbitrary forms. +package: Spam control +core_version_requirement: ^9.5 || ^10 || ^11 +configure: captcha_settings + +# Information added by Drupal.org packaging script on 2025-07-28 +version: '2.0.9' +project: 'captcha' +datestamp: 1753701291 diff --git a/web/modules/contrib/captcha/captcha.install b/web/modules/contrib/captcha/captcha.install new file mode 100755 index 000000000..c68361abe --- /dev/null +++ b/web/modules/contrib/captcha/captcha.install @@ -0,0 +1,269 @@ + 'Stores the data about CAPTCHA sessions (solution, IP address, timestamp, ...).', + 'fields' => [ + 'csid' => [ + 'description' => 'CAPTCHA session ID.', + 'type' => 'serial', + 'not null' => TRUE, + ], + 'token' => [ + 'description' => 'One time CAPTCHA token.', + 'type' => 'varchar', + 'length' => 64, + 'not null' => FALSE, + ], + 'uid' => [ + 'description' => "User's {users}.uid.", + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ], + 'sid' => [ + 'description' => "Session ID of the user.", + 'type' => 'varchar', + 'length' => 64, + 'not null' => TRUE, + 'default' => '', + ], + 'ip_address' => [ + 'description' => 'IP address of the visitor.', + 'type' => 'varchar', + 'length' => 128, + 'not null' => FALSE, + ], + 'timestamp' => [ + 'description' => 'A Unix timestamp indicating when the challenge was generated.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ], + 'form_id' => [ + 'description' => 'The form_id of the form where the CAPTCHA is added to.', + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + ], + 'solution' => [ + 'description' => 'Solution of the challenge.', + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + ], + 'status' => [ + 'description' => 'Status of the CAPTCHA session (unsolved, solved, ...)', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ], + 'attempts' => [ + 'description' => 'The number of attempts.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ], + ], + 'primary key' => ['csid'], + 'indexes' => [ + 'csid_ip' => ['csid', 'ip_address'], + ], + ]; + + return $schema; +} + +/** + * Implements hook_requirements(). + */ +function captcha_requirements($phase) { + $requirements = []; + $config = \Drupal::config('captcha.settings'); + + if ($phase == 'runtime' && $config->get('enable_stats')) { + // Show the wrong response counter in the status report. + $requirements['captcha_wrong_response_counter'] = [ + 'title' => \Drupal::translation()->translate('CAPTCHA'), + 'value' => \Drupal::translation()->formatPlural( + \Drupal::state()->get('captcha.wrong_response_counter', 0), + 'Already 1 blocked form submission', + 'Already @count blocked form submissions' + ), + 'severity' => REQUIREMENT_INFO, + ]; + } + return $requirements; +} + +/** + * Implements hook_install(). + */ +function captcha_install() { + + if (!\Drupal::service('config.installer')->isSyncing() && \Drupal::moduleHandler()->moduleExists('node')) { + $form_ids = []; + $label = []; + // Add form_ids of all currently known node types too. + foreach (node_type_get_names() as $type => $name) { + $form_ids[] = 'node_' . $type . '_form'; + $label[] = 'node_' . $type . '_form'; + } + + $captcha_storage = \Drupal::entityTypeManager() + ->getStorage('captcha_point'); + foreach ($form_ids as $index => $form_id) { + $values = [ + 'label' => $label[$index], + 'formId' => $form_id, + 'captchaType' => CaptchaConstants::CAPTCHA_TYPE_DEFAULT, + 'status' => FALSE, + ]; + $captcha_storage->create($values)->save(); + } + } + +} + +/** + * Implements hook_update_N(). + */ +function captcha_update_8901(&$sandbox) { + $entityType = \Drupal::entityTypeManager() + ->getDefinition('captcha_point'); + + if ($entityType) { + \Drupal::entityDefinitionUpdateManager() + ->installEntityType($entityType); + } +} + +/** + * Implements hook_update_N(). + */ +function captcha_update_8902(&$sandbox) { + $query = \Drupal::entityQuery('captcha_point'); + $query->notExists('label'); + $entity_ids = $query->execute(); + + if (!empty($entity_ids) && is_array($entity_ids)) { + foreach ($entity_ids as $entity_id) { + $captcha_point_id = $entity_id; + $captcha_point = \Drupal::entityTypeManager() + ->getStorage('captcha_point') + ->load($captcha_point_id); + $captcha_point->set('label', $captcha_point->getFormId()); + $captcha_point->save(); + } + } +} + +/** + * Implements hook_update_N(). + * + * Handle and delete "add_captcha_description". + */ +function captcha_update_8903(&$sandbox) { + $config_factory = \Drupal::configFactory(); + $config = $config_factory->getEditable('captcha.settings'); + $addDescription = $config->get('add_captcha_description'); + // If description was disabled before, set 'description' to an empty string, + // so it is disabled again in the newest version: + if (!$addDescription) { + $config->set('description', '')->save(); + } + // Delete old config: + $config->clear('add_captcha_description')->save(); +} + +/** + * Several changes. + * + * Remove old configuration keys and map to new ones. + */ +function captcha_update_8904(&$sandbox) { + // Update config: + $config_factory = \Drupal::configFactory(); + $config = $config_factory->getEditable('captcha.settings'); + + $config->set('administration_mode_on_admin_routes', FALSE); + $config->set('enable_globally_on_admin_routes', FALSE); + $config->set('enable_globally', $config->get('enabled_default')); + + if (!empty($config->get('allow_on_admin_pages')) && !empty($config->get('administration_mode'))) { + $config->set('administration_mode_on_admin_routes', TRUE); + } + if (!empty($config->get('allow_on_admin_pages')) && !empty($config->get('enabled_default'))) { + $config->set('enable_globally_on_admin_routes', TRUE); + } + $config + ->clear('allow_on_admin_pages') + ->clear('enabled_default') + ->save(); +} + +/** + * Add the captcha title, if the user has not set it manually yet. + * + * Note, that this is needed, because the update hook introducing the title + * was originally implemented in the wrong install file (image_captcha.install). + * See https://www.drupal.org/project/captcha/issues/3356063 where it was + * wrongly added. + */ +function captcha_update_8905() { + $captchaConfig = \Drupal::configFactory()->getEditable('captcha.settings'); + if ($captchaConfig->get('title') === NULL) { + // Set the title in config, if it wasn't existing before: + $captchaConfig->set('title', 'CAPTCHA')->save(TRUE); + } +} + +/** + * Ensure caches are cleared with new module folder locations. + */ +function captcha_update_8906() { + // Remove test modules if they're on the site. They won't rebuild. + $extension = \Drupal::configFactory()->getEditable('core.extension'); + $modules = $extension->get('module'); + unset($modules['captcha_long_form_id_test']); + unset($modules['captcha_test']); + $extension->set('module', $modules); + $extension->save(); + + // Need to flush all caches due to module paths changing. + drupal_flush_all_caches(); +} + +/** + * Fix invalid config added in captcha_update_8906 (2.0.1) + */ +function captcha_update_8907() { + // Remove test modules if they're on the site. They won't rebuild. + $extension = \Drupal::configFactory()->getEditable('core.extension'); + + // Remove invalid config added in 8906 and do the actual removal. + if ($extension->get('modules') !== NULL) { + $extension->clear('modules'); + + $modules = $extension->get('module'); + unset($modules['captcha_long_form_id_test']); + unset($modules['captcha_test']); + $extension->set('module', $modules); + + $extension->save(); + + // Need to flush all caches due to module paths changing. + drupal_flush_all_caches(); + } +} diff --git a/web/modules/contrib/captcha/captcha.libraries.yml b/web/modules/contrib/captcha/captcha.libraries.yml new file mode 100644 index 000000000..0c2ea204b --- /dev/null +++ b/web/modules/contrib/captcha/captcha.libraries.yml @@ -0,0 +1,5 @@ +base: + version: 1.0 + css: + theme: + css/captcha.css: {} diff --git a/web/modules/contrib/captcha/captcha.links.action.yml b/web/modules/contrib/captcha/captcha.links.action.yml new file mode 100755 index 000000000..2df3643b7 --- /dev/null +++ b/web/modules/contrib/captcha/captcha.links.action.yml @@ -0,0 +1,5 @@ +captcha_point.add: + route_name: 'captcha_point.add' + title: 'Add Captcha Point' + appears_on: + - captcha_point.list diff --git a/web/modules/contrib/captcha/captcha.links.menu.yml b/web/modules/contrib/captcha/captcha.links.menu.yml new file mode 100755 index 000000000..cc80bfdaa --- /dev/null +++ b/web/modules/contrib/captcha/captcha.links.menu.yml @@ -0,0 +1,13 @@ +captcha.settings: + title: 'CAPTCHA settings' + description: 'Administer CAPTCHA settings' + route_name: captcha_settings + parent: user.admin_index + weight: -1 + +captcha.examples: + title: 'CAPTCHA examples' + description: Overview of the available CAPTCHA challenge types with examples' + route_name: captcha_examples + parent: captcha.settings + weight: 0 diff --git a/web/modules/contrib/captcha/captcha.links.task.yml b/web/modules/contrib/captcha/captcha.links.task.yml new file mode 100755 index 000000000..b3b9940a5 --- /dev/null +++ b/web/modules/contrib/captcha/captcha.links.task.yml @@ -0,0 +1,14 @@ +captcha_settings: + route_name: captcha_settings + title: 'CAPTCHA Settings' + base_route: captcha_settings + +captcha_examples: + route_name: captcha_examples + title: 'CAPTCHA Examples' + base_route: captcha_settings + +captcha_points.list: + route_name: captcha_point.list + title: 'Captcha Points' + base_route: captcha_settings diff --git a/web/modules/contrib/captcha/captcha.module b/web/modules/contrib/captcha/captcha.module new file mode 100755 index 000000000..82a14f687 --- /dev/null +++ b/web/modules/contrib/captcha/captcha.module @@ -0,0 +1,779 @@ +' . t('About') . ''; + $output .= '

' . t('"CAPTCHA" is an acronym for "Completely Automated Public Turing test to tell Computers and Humans Apart". It is typically a challenge-response test to determine whether the user is human. The CAPTCHA module is a tool to fight automated submission by malicious users (spamming) of for example comments forms, user registration forms, guestbook forms, etc. You can extend the desired forms with an additional challenge, which should be easy for a human to solve correctly, but hard enough to keep automated scripts and spam bots out.') . '

'; + $output .= '

' . t('Note that the CAPTCHA module interacts with page caching (see performance settings). Because the challenge should be unique for each generated form, the caching of the page it appears on is prevented. Make sure that these forms do not appear on too many pages or you will lose much caching efficiency. For example, if you put a CAPTCHA on the user login block, which typically appears on each page for anonymous visitors, caching will practically be disabled. The comment submission forms are another example. In this case you should set the Location of comment submission form to Display on separate page in the comment settings of the relevant content types for better caching efficiency.', [ + ':performancesettings' => Url::fromRoute('system.performance_settings')->toString(), + ':contenttypes' => \Drupal::moduleHandler()->moduleExists('node') ? Url::fromRoute('entity.node_type.collection')->toString() : '#', + ]) . '

'; + $output .= '

' . t('CAPTCHA is a trademark of Carnegie Mellon University.') . '

'; + return ['#markup' => $output]; + + case 'captcha_settings': + $output = '

' . t('A CAPTCHA can be added to virtually any Drupal form, through adding an affiliated CAPTCHA Point. Some default CAPTCHA Points are already provided in the CAPTCHA Point section, but arbitrary forms can be easily added and managed.') . '

'; + $output .= '

' . t('Users with the Skip CAPTCHA permission won\'t be offered a challenge. Be sure to grant this permission to the trusted users (e.g. site administrators). If you want to test a protected form, be sure to do it as a user without the Skip CAPTCHA permission (e.g. as anonymous user).', [ + ':perm' => Url::fromRoute('user.admin_permissions')->toString(), + ]) . '

'; + $output .= '

' . t('Note that the CAPTCHA module disables page caching of pages that include a CAPTCHA challenge.', [ + ':performancesettings' => Url::fromRoute('system.performance_settings')->toString(), + ]) . '

'; + return ['#markup' => $output]; + } +} + +/** + * Loader for Captcha Point entity. + * + * @param string $id + * Form id string. + * + * @return \Drupal\Core\Entity\EntityInterface + * An instance of an captcha_point entity. + */ +function captcha_point_load($id) { + return CaptchaPoint::load($id); +} + +/** + * Implements hook_theme(). + */ +function captcha_theme() { + $path = \Drupal::service('extension.list.module')->getPath('captcha'); + return [ + 'captcha' => [ + 'render element' => 'element', + 'template' => 'captcha', + 'path' => $path . '/templates', + ], + ]; +} + +/** + * Implements hook_theme_suggestions_HOOK(). + */ +function captcha_theme_suggestions_captcha(array $variables) { + $suggestions = []; + $suggestions[] = 'captcha__' . strtolower($variables['element']['#captcha_type_challenge']); + return $suggestions; +} + +/** + * Implements hook_cron(). + * + * Remove old entries from captcha_sessions table. + */ +function captcha_cron() { + // Get request time. + $request_time = \Drupal::time()->getRequestTime(); + + // Remove challenges older than PHP's session.gc_maxlifetime value. + $connection = Database::getConnection(); + $connection->delete('captcha_sessions') + ->condition('timestamp', $request_time - ini_get('session.gc_maxlifetime'), '<') + ->execute(); +} + +/** + * Theme function for a CAPTCHA element. + * + * Render it in a section element if a description of the CAPTCHA + * is available. Render it as is otherwise. + */ +function template_preprocess_captcha(&$variables, $hook, $info) { + $variables['title'] = _captcha_get_title(); + $variables['description'] = _captcha_get_description(); + + // Add an indicator, if the captcha widget is visible, to + // account for _captcha_required_for_user(). + // See Captcha::preRenderProcess(). + // If the CAPTCHA was solved, hidden inputs have to be preserved to the form + // but the CAPTCHA and its wrappers are not visible. + // This variable exposed this to the theme layer transparently: + $variables['is_visible'] = isset($variables['element']['captcha_widgets']); + // Attach library: + $variables['#attached']['library'][] = 'captcha/base'; +} + +/** + * Implements hook_form_alter(). + * + * This function adds a CAPTCHA to forms for untrusted users + * if needed and adds. CAPTCHA administration links for site + * administrators if this option is enabled. + */ +function captcha_form_alter(array &$form, FormStateInterface $form_state, $form_id) { + $account = \Drupal::currentUser(); + $config = \Drupal::config('captcha.settings'); + $captchaService = \Drupal::service('captcha.helper'); + $adminContext = \Drupal::service('router.admin_context'); + $captcha_point = NULL; + + \Drupal::moduleHandler()->loadInclude('captcha', 'inc'); + + // If the user does not have the "skip CAPTCHA" permission render + // it "normally": + if (!$account->hasPermission('skip CAPTCHA')) { + $query = \Drupal::entityQuery('captcha_point'); + $query->condition('label', $form_id); + $entity_ids = $query->execute(); + + // If empty, see if it is a form provided by default config. + if (empty($entity_ids)) { + $query = \Drupal::entityQuery('captcha_point'); + $query->condition('formId', $form_id); + $entity_ids = $query->execute(); + } + + if (!empty($entity_ids) && is_array($entity_ids)) { + $captcha_point_id = array_pop($entity_ids); + /** @var \Drupal\captcha\CaptchaPointInterface $captcha_point */ + $captcha_point = \Drupal::entityTypeManager() + ->getStorage('captcha_point') + ->load($captcha_point_id); + } + + // If there is no CaptchaPoint for the form_id, try to use the base_form_id. + if (empty($captcha_point) || !$captcha_point->status()) { + $form_object = $form_state->getFormObject(); + if ($form_object instanceof BaseFormIdInterface) { + $base_form_id = $form_object->getBaseFormId(); + if (!empty($base_form_id) && $base_form_id != $form_id) { + /** @var \Drupal\captcha\CaptchaPointInterface $captcha_point */ + $captcha_point = \Drupal::entityTypeManager() + ->getStorage('captcha_point') + ->load($base_form_id); + } + } + } + + // If there is no CaptchaPoint at all, but we want to add a captcha globally + // for every form do that here: + if (empty($captcha_point) && !empty($config->get('enable_globally'))) { + // Only add this captcha on non admin routes or if + // "enable_globally_on_admin_routes" is enabled, also on admin routes: + if ( + !$adminContext->isAdminRoute() + || + ($adminContext->isAdminRoute() && !empty($config->get('enable_globally_on_admin_routes'))) + ) { + // Create captcha point without saving. + /** @var \Drupal\captcha\CaptchaPointInterface $captcha_point */ + $captcha_point = new CaptchaPoint([ + 'formId' => $form_id, + 'captchaType' => $config->get('default_challenge'), + ], 'captcha_point'); + $captcha_point->enable(); + } + } + if (!empty($captcha_point) && $captcha_point->status()) { + // Checking if user's ip is whitelisted. + if (captcha_whitelist_ip_whitelisted()) { + // If form is setup to have captcha, but user's ip is whitelisted, then + // we still have to disable form caching to prevent showing cached form + // for users with not whitelisted ips. + $form['#cache'] = ['max-age' => 0]; + \Drupal::service('page_cache_kill_switch')->trigger(); + } + else { + // Build CAPTCHA form element. + $captcha_element = [ + '#type' => 'captcha', + '#captcha_type' => $captcha_point->getCaptchaType(), + ]; + + // Get placement in form and insert in form. + $captcha_placement = _captcha_get_captcha_placement($form_id, $form); + $captchaService->insertCaptchaElement($form, $captcha_placement, $captcha_element); + } + } + } + // If the user has the "skip CAPTCHA" permission, check, if it should be + // rendered in "administration_mode", which adds administrative informations + // to the captcha: + elseif (!empty($config->get('administration_mode')) && $account->hasPermission('administer CAPTCHA settings')) { + // Add informations if it isn't an admin route OR if + // "administration_mode_on_admin_routes" is set and the current route is + // and admin route: + if ( + !$adminContext->isAdminRoute() + || + ($config->get('administration_mode_on_admin_routes') && $adminContext->isAdminRoute()) + ) { + // Add CAPTCHA administration tools. + /** @var \Drupal\captcha\CaptchaPointInterface $captcha_point */ + $captcha_point = CaptchaPoint::load($form_id); + + // Add admin information to the captcha: + $captcha_element = [ + '#type' => 'details', + '#title' => t('CAPTCHA'), + '#attributes' => [ + 'class' => ['captcha-admin-links'], + ], + '#open' => TRUE, + ]; + + if ($captcha_point !== NULL && $captcha_point->getCaptchaType()) { + $captcha_element['#title'] = $captcha_point->status() ? t('CAPTCHA: challenge "@type" enabled', ['@type' => $captcha_point->getCaptchaType()]) : t('CAPTCHA: challenge "@type" disabled', ['@type' => $captcha_point->getCaptchaType()]); + $captcha_point->status() ? $captchaElementDescription = t('Users without the "skip CAPTCHA" permission will see a CAPTCHA here (general CAPTCHA settings).', + [ + '@settings' => Url::fromRoute('captcha_settings') + ->toString(), + ]) : $captchaElementDescription = t('CAPTCHA disabled, Untrusted users won\'t see the captcha (general CAPTCHA settings).', + ['@settings' => Url::fromRoute('captcha_settings')->toString()] + ); + $captcha_element['#description'] = $captchaElementDescription; + $captcha_element['challenge'] = [ + '#type' => 'item', + '#title' => t('Enabled challenge'), + '#markup' => $captcha_point->toLink(t('change'), 'edit-form', [ + 'query' => \Drupal::destination() + ->getAsArray(), + ])->toString(), + ]; + } + else { + $captcha_element['#title'] = t('CAPTCHA: no challenge enabled'); + $captcha_element['add_captcha'] = [ + '#markup' => Link::fromTextAndUrl( + t('Place a CAPTCHA here for untrusted users.'), + Url::fromRoute('captcha_point.add', [], [ + 'query' => \Drupal::destination() + ->getAsArray() + ['form_id' => $form_id], + ]) + )->toString(), + ]; + } + + // Get placement in form and insert in form. + $captcha_placement = _captcha_get_captcha_placement($form_id, $form); + $captchaService->insertCaptchaElement($form, $captcha_placement, $captcha_element); + } + // If the user has the "skip Captcha" permission, but the + // administration_mode is not enabled, simply do nothing and therefore + // "skip" the CAPTCHA. + } + + // Add a warning about caching on the Performance settings page. + if ($form_id == 'system_performance_settings') { + $form['caching']['captcha'] = [ + '#type' => 'item', + '#title' => t('CAPTCHA'), + '#markup' => '
' . t('Most CAPTCHA methods will disable the caching of pages that contain a CAPTCHA element. Check the different implementations to know more about how it affects caching.') . '
', + ]; + } + + // Disable captcha if override is set. + if (Settings::get('disable_captcha', FALSE) === TRUE) { + $override_notice = [ + '#type' => 'html_tag', + '#tag' => 'strong', + '#value' => t('Captcha is currently disabled via settings.php.'), + ]; + if (isset($form['elements']['captcha'])) { + $form['elements']['captcha'] = $override_notice; + } + if (isset($form['captcha'])) { + $form['captcha'] = $override_notice; + } + } +} + +/** + * CAPTCHA validation function to tests strict equality. + * + * @param string $solution + * The solution of the test. + * @param string $response + * The response to the test. + * + * @return bool + * TRUE when case insensitive equal, FALSE otherwise. + */ +function captcha_validate_strict_equality($solution, $response) { + return $solution === $response; +} + +/** + * CAPTCHA validation function to tests case insensitive equality. + * + * @param string $solution + * The solution of the test. + * @param string $response + * The response to the test. + * + * @return bool + * TRUE when case insensitive equal, FALSE otherwise. + */ +function captcha_validate_case_insensitive_equality($solution, $response) { + if ($solution == NULL || $response == NULL) { + // mb_strtolower wouldn't work on NULL and NULL is never a valid solution. + return FALSE; + } + return mb_strtolower($solution ?: '') === mb_strtolower($response ?: ''); +} + +/** + * CAPTCHA validation function to tests equality while ignoring spaces. + * + * @param string $solution + * The solution of the test. + * @param string $response + * The response to the test. + * + * @return bool + * TRUE when equal (ignoring spaces), FALSE otherwise. + */ +function captcha_validate_ignore_spaces($solution, $response) { + if ($solution == NULL || $response == NULL) { + // preg_replace wouldn't work on NULL and NULL is never a valid solution. + return FALSE; + } + return preg_replace('/\s/', '', $solution) === preg_replace('/\s/', '', $response); +} + +/** + * Validation function to tests case insensitive equality while ignoring spaces. + * + * @param string $solution + * The solution of the test. + * @param string $response + * The response to the test. + * + * @return bool + * TRUE when equal (ignoring spaces), FALSE otherwise. + */ +function captcha_validate_case_insensitive_ignore_spaces($solution, $response) { + if ($solution == NULL || $response == NULL) { + // preg_replace wouldn't work on NULL and NULL is never a valid solution. + return FALSE; + } + return preg_replace('/\s/', '', mb_strtolower($solution ?: '')) === preg_replace('/\s/', '', mb_strtolower($response ?: '')); +} + +/** + * Helper function for getting the posted CAPTCHA info. + * + * This function hides the form processing mess for several use cases an + * browser bug workarounds. + * For example: $element['#post'] can typically be used to get the posted + * form_id and captcha_sid, but in the case of node preview situations + * (with correct CAPTCHA response) that does not work and we can get them from + * $form_state['clicked_button']['#post']. + * However with Internet Explorer 7, the latter does not work either when + * submitting the forms (with only one text field) with the enter key + * (see http://drupal.org/node/534168), in which case we also have to check + * $form_state['buttons']['button']['0']['#post']. + * + * @param array $element + * The CAPTCHA element. + * @param Drupal\Core\Form\FormStateInterface $form_state + * The form state structure to extract the info from. + * @param string $this_form_id + * The form ID of the form we are currently processing + * (which is not necessarily the form that was posted). + * + * @return array + * Array with $posted_form_id and $post_captcha_sid (with NULL values + * if the values could not be found, e.g. for a fresh form). + */ +function _captcha_get_posted_captcha_info(array $element, FormStateInterface $form_state, $this_form_id) { + if ($form_state->has('captcha_info')) { + // We are handling (or rebuilding) an already submitted form, + // so we already determined the posted form ID and CAPTCHA session ID + // for this form (from before submitting). Reuse this info. + $posted_form_id = $form_state->get('captcha_info')['posted_form_id']; + $posted_captcha_sid = $form_state->get('captcha_info')['captcha_sid']; + } + else { + // We have to determine the posted form ID and CAPTCHA session ID + // from the post data. + // Because we possibly use raw post data here, + // we should be extra cautious and filter this data. + $input = &$form_state->getUserInput(); + $posted_form_id = isset($input['form_id']) ? + preg_replace("/[^a-z0-9_-]/", "", (string) $input['form_id']) + : NULL; + $posted_captcha_sid = isset($input['captcha_sid']) ? + (int) $input['captcha_sid'] + : NULL; + $posted_captcha_token = isset($input['captcha_token']) ? + preg_replace("/[^a-zA-Z0-9-_]/", "", (string) $input['captcha_token']) + : NULL; + + if ($posted_form_id == $this_form_id) { + // Check if the posted CAPTCHA token is valid for the posted CAPTCHA + // session ID. Note that we could just check the validity of the CAPTCHA + // token and extract the CAPTCHA session ID from that (without looking at + // the actual posted CAPTCHA session ID). However, here we check both + // the posted CAPTCHA token and session ID: it is a bit more stringent + // and the database query should also be more efficient (because there is + // an index on the CAPTCHA session ID). + if ($posted_captcha_sid != NULL) { + $expected_captcha_token = \Drupal::database() + ->select('captcha_sessions', 'cs') + ->fields('cs', ['token']) + ->condition('csid', $posted_captcha_sid) + ->execute() + ->fetchField(); + + // If we do have a CAPTCHA token mismatch then log it. + try { + if (($expected_captcha_token !== $posted_captcha_token) && empty($input['captcha_cacheable'])) { + throw new \UnexpectedValueException('CAPTCHA session reuse attack detected.'); + } + } + catch (\Exception $e) { + \Drupal::logger('captcha')->debug( + 'CAPTCHA session reuse attack detected on @form_id
Posted CAPTCHA token: @posted_captcha_token
Expected captcha token: @expected_captcha_token', + [ + '@form_id' => $this_form_id, + '@expected_captcha_token' => var_export($expected_captcha_token, TRUE), + '@posted_captcha_token' => var_export($posted_captcha_token, TRUE), + ] + ); + // Invalidate the CAPTCHA session. + $posted_captcha_sid = NULL; + } + } + } + else { + // The CAPTCHA session ID is specific to the posted form. + // Return NULL, so a new session will be generated for this other form. + $posted_captcha_sid = NULL; + } + } + return [$posted_form_id, $posted_captcha_sid]; +} + +/** + * CAPTCHA validation handler. + * + * This function is placed in the main captcha.module file to make sure that + * it is available (even for cached forms, which don't fire + * captcha_form_alter(), and subsequently don't include additional include + * files). + */ +function captcha_validate($element, FormStateInterface &$form_state) { + + $captcha_info = $form_state->get('captcha_info'); + $form_id = $captcha_info['this_form_id']; + + // Get CAPTCHA response. + $captcha_response = $form_state->getValue('captcha_response'); + + // Get CAPTCHA session from CAPTCHA info. + // @todo is this correct in all cases: see comments in previous revisions? + $csid = $captcha_info['captcha_sid']; + + // Bypass captcha validation if access attribute value is false. + if (empty($captcha_info['access'])) { + return FALSE; + } + + // If the form is cacheable where all solution validation is handed off or if + // we found a session with a solution then continue with validation. + $is_cacheable = (bool) $form_state->getValue('captcha_cacheable', FALSE); + + if ($is_cacheable) { + // Completely ignore the captcha_sessions table, + // since the captcha_sid can get reused by the cache. + $solution = FALSE; + $captcha_validate = $element['#captcha_validate']; + if (!function_exists($captcha_validate)) { + // Cacheable CAPTCHAs must provide their own validation function. + $form_state->setErrorByName('captcha', t('CAPTCHA configuration error: Contact the site administrator.')); + \Drupal::logger('CAPTCHA')->error( + 'CAPTCHA configuration error: cacheable CAPTCHA type %challenge did not provide a validation function.', + ['%challenge' => $captcha_info['captcha_type']]); + } + // Check the response with the CAPTCHA validation function. + // Apart from the traditional expected $solution and received $response, + // we also provide the CAPTCHA $element and $form_state + // arrays for more advanced use cases. + if (!$captcha_validate($solution, $captcha_response, $element, $form_state)) { + // Wrong answer. + $form_state->setErrorByName('captcha_response', _captcha_get_error_message()); + // Update wrong response counter. + if (\Drupal::config('captcha.settings')->get('enable_stats', FALSE)) { + Drupal::state()->set('captcha.wrong_response_counter', Drupal::state() + ->get('captcha.wrong_response_counter', 0) + 1); + } + + if (\Drupal::config('captcha.settings') + ->get('log_wrong_responses', FALSE) + ) { + \Drupal::logger('CAPTCHA')->notice( + '%form_id post blocked by CAPTCHA module: challenge %challenge (by module %module).', + [ + '%form_id' => $form_id, + '%challenge' => $captcha_info['captcha_type'], + '%module' => $captcha_info['module'], + ]); + } + } + } + else { + $solution = \Drupal::database() + ->select('captcha_sessions', 'cs') + ->fields('cs', ['solution']) + ->condition('csid', $csid) + ->execute() + ->fetchField(); + + if ($solution !== FALSE) { + // Get CAPTCHA validate function or fall back on strict equality. + $captcha_validate = $element['#captcha_validate']; + if (!function_exists($captcha_validate)) { + $captcha_validate = 'captcha_validate_strict_equality'; + } + // Check the response with the CAPTCHA validation function. + // Apart from the traditional expected $solution and received $response, + // we also provide the CAPTCHA $element and $form_state + // arrays for more advanced use cases. + if ($captcha_validate($solution, $captcha_response, $element, $form_state)) { + + // Get the CAPTCHA persistence setting. + $captcha_persistence = \Drupal::config('captcha.settings') + ->get('persistence'); + + if (in_array($captcha_persistence, + [ + CaptchaConstants::CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL, + CaptchaConstants::CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL_PER_FORM_TYPE, + ])) { + // Only save the success in $_SESSION if it is actually needed for + // further validation in _captcha_required_for_user(). Setting + // this kills the page cache so let's not be cavalier about it. + $_SESSION['captcha_success_form_ids'][$form_id] = $form_id; + } + + // Record success. + \Drupal::database()->update('captcha_sessions') + ->condition('csid', $csid) + ->fields(['status' => CaptchaConstants::CAPTCHA_STATUS_SOLVED]) + ->expression('attempts', 'attempts + 1') + ->execute(); + } + else { + // Wrong answer. + \Drupal::database()->update('captcha_sessions') + ->condition('csid', $csid) + ->expression('attempts', 'attempts + 1') + ->execute(); + + $form_state->setErrorByName('captcha_response', _captcha_get_error_message()); + // Update wrong response counter. + if (\Drupal::config('captcha.settings')->get('enable_stats', FALSE)) { + Drupal::state()->set('captcha.wrong_response_counter', Drupal::state() + ->get('captcha.wrong_response_counter', 0) + 1); + } + + if (\Drupal::config('captcha.settings') + ->get('log_wrong_responses', FALSE) + ) { + \Drupal::logger('CAPTCHA')->notice( + '%form_id post blocked by CAPTCHA module: challenge %challenge (by module %module), user answered "@response", but the solution was "@solution".', + [ + '%form_id' => $form_id, + '@response' => $captcha_response, + '@solution' => $solution, + '%challenge' => $captcha_info['captcha_type'], + '%module' => $captcha_info['module'], + ]); + } + } + } + else { + // If the session is gone and we can't confirm a solution error. + // Note: _captcha_get_posted_captcha_info() validates and triggers session + // rebuilds for re-use attacks during element processing so this should be + // rare if it ever happens. + $form_state->setErrorByName('captcha', t('CAPTCHA validation error: unknown CAPTCHA session ID. Contact the site administrator if this problem persists.')); + \Drupal::logger('CAPTCHA')->warning( + 'CAPTCHA validation error: unknown CAPTCHA session ID (%csid).', + ['%csid' => var_export($csid, TRUE)]); + } + } +} + +/** + * Pre-render callback for additional processing of a CAPTCHA form element. + * + * This encompasses tasks that should happen after the general FAPI processing + * (building, submission and validation) but before rendering + * (e.g. storing the solution). + * + * @param array $element + * The CAPTCHA form element. + * + * @return array + * The manipulated element. + * + * @deprecated in captcha:8.x-1.0 and is removed from captcha:8.x-2.0. + * Use \Drupal\captcha\Element\Captcha::preRenderProcess() instead. + * @see https://www.drupal.org/project/captcha/issues/1949682 + */ +function captcha_pre_render_process(array $element) { + return Captcha::preRenderProcess($element); +} + +/** + * Default implementation of hook_captcha(). + */ +function captcha_captcha($op, $captcha_type = '') { + switch ($op) { + case 'list': + return ['Math']; + + case 'generate': + if ($captcha_type == 'Math') { + $result = []; + $answer = mt_rand(1, 20); + $x = mt_rand(1, $answer); + $y = $answer - $x; + $result['solution'] = "$answer"; + // Build challenge widget. + // Note that we also use t() for the math challenge itself. This makes + // it possible to 'rephrase' the challenge a bit through localization + // or string overrides. + $result['form']['captcha_response'] = [ + '#type' => 'textfield', + '#title' => t('Math question (@x + @y =)', ['@x' => $x, '@y' => $y]), + '#description' => t('Solve this simple math problem and enter the result. E.g. for 1+3, enter 4.'), + '#size' => 4, + '#maxlength' => 2, + '#required' => TRUE, + '#attributes' => [ + 'autocomplete' => 'off', + ], + '#cache' => ['max-age' => 0], + ]; + \Drupal::service('page_cache_kill_switch')->trigger(); + + return $result; + } + elseif ($captcha_type == 'Test') { + // This challenge is not visible through the administrative interface + // as it is not listed in captcha_captcha('list'), + // but it is meant for debugging and testing purposes. + // @todo for Drupal 7 version: This should be done with a mock module, + // but Drupal 6 does not support this (mock modules can not be hidden). + $result = [ + 'solution' => 'Test 123', + 'form' => [], + ]; + $result['form']['captcha_response'] = [ + '#type' => 'textfield', + '#title' => t('Test one two three'), + '#required' => TRUE, + '#cache' => ['max-age' => 0], + ]; + \Drupal::service('page_cache_kill_switch')->trigger(); + + return $result; + } + break; + } +} + +/** + * Parse values of whitelist ip addresses and ranges. + * + * @param string|null $whitelist_ips_value + * Contains list of ip addresses and ranges set one per line. + * + * @return array + * Array of parsed ip addresses and ranges. + */ +function captcha_whitelist_ips_parse_values($whitelist_ips_value) { + $whitelist_ips = [ + CaptchaConstants::CAPTCHA_WHITELIST_IP_RANGE => [], + CaptchaConstants::CAPTCHA_WHITELIST_IP_ADDRESS => [], + ]; + + // Ensure the IPs value is trimmed before moving onward. + $whitelist_ips_value = trim($whitelist_ips_value ?? ""); + + if (empty($whitelist_ips_value)) { + return $whitelist_ips; + } + + $value_rows = explode("\n", $whitelist_ips_value); + foreach ($value_rows as $value_row) { + $value_row = trim($value_row); + if (!empty($value_row) && strpos($value_row, '-') !== FALSE) { + $whitelist_ips[CaptchaConstants::CAPTCHA_WHITELIST_IP_RANGE][] = $value_row; + } + else { + $whitelist_ips[CaptchaConstants::CAPTCHA_WHITELIST_IP_ADDRESS][] = $value_row; + } + } + + return $whitelist_ips; +} + +/** + * Check if ip address is whitelisted. + * + * @param string $ip_address + * Optional. IP address to be checked if it is in whitelist. If no ip value + * provided user's current ip will be used to be verified. + * + * @return bool + * TRUE if requested IP address is whitelisted, FALSE if it is not. + */ +function captcha_whitelist_ip_whitelisted($ip_address = '') { + if (empty($ip_address)) { + $ip_address = Drupal::request()->getClientIp(); + } + + $config = \Drupal::config('captcha.settings'); + $whitelist_ips_value = $config->get('whitelist_ips'); + $whitelist_ips = captcha_whitelist_ips_parse_values($whitelist_ips_value); + + if (in_array($ip_address, $whitelist_ips[CaptchaConstants::CAPTCHA_WHITELIST_IP_ADDRESS])) { + return TRUE; + } + elseif (empty($whitelist_ips[CaptchaConstants::CAPTCHA_WHITELIST_IP_RANGE])) { + return FALSE; + } + + foreach ($whitelist_ips[CaptchaConstants::CAPTCHA_WHITELIST_IP_RANGE] as $ip_range) { + [$ip_lower, $ip_upper] = explode('-', $ip_range, 2); + $ip_lower_dec = (float) sprintf("%u", ip2long($ip_lower)); + $ip_upper_dec = (float) sprintf("%u", ip2long($ip_upper)); + $ip_address_dec = (float) sprintf("%u", ip2long($ip_address)); + if (($ip_address_dec >= $ip_lower_dec) && ($ip_address_dec <= $ip_upper_dec)) { + return TRUE; + } + } + + return FALSE; +} diff --git a/web/modules/contrib/captcha/captcha.permissions.yml b/web/modules/contrib/captcha/captcha.permissions.yml new file mode 100644 index 000000000..f745e2934 --- /dev/null +++ b/web/modules/contrib/captcha/captcha.permissions.yml @@ -0,0 +1,5 @@ +administer CAPTCHA settings: + title: 'Administer CAPTCHA settings' +skip CAPTCHA: + title: 'Skip CAPTCHA' + description: 'Users with this permission will not be offered a CAPTCHA.' diff --git a/web/modules/contrib/captcha/captcha.post_update.php b/web/modules/contrib/captcha/captcha.post_update.php new file mode 100644 index 000000000..8512774f3 --- /dev/null +++ b/web/modules/contrib/captcha/captcha.post_update.php @@ -0,0 +1,13 @@ +.form-item { + flex: 1 1 calc(100% - var(--image-width) - var(--gap-h)); + min-width: 13em; + margin: 0; +} + +.captcha-type-challenge--image .captcha__element>.form-item>label { + margin-top: 0; +} + +.captcha__image-wrapper { + position: relative; + flex: 0 0 var(--image-width); +} + +.captcha__image-wrapper img { + display: block; + max-width: 100%; + border: 1px solid #eee; + padding: .35rem; + margin-bottom: .5rem; +} diff --git a/web/modules/contrib/captcha/modules/image_captcha/css/image_captcha_refresh.css b/web/modules/contrib/captcha/modules/image_captcha/css/image_captcha_refresh.css new file mode 100644 index 000000000..86ea703c8 --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/css/image_captcha_refresh.css @@ -0,0 +1,38 @@ +/* Show "Get new captcha"-link as simple icon */ +.reload-captcha { + display: block; + width: var(--reload-icon-size); + height: var(--reload-icon-size); + background-image: url("data:image/svg+xml,%3Csvg width='100%25' height='100%25' viewBox='0 0 56 56' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Cg transform='matrix(1,0,0,1,-344.937,-314.119)'%3E%3Cpath d='M399.28,314.12L390.155,321.37C385.356,317.346 379.193,314.901 372.499,314.901C357.37,314.901 344.937,327.335 344.937,342.463C344.937,357.591 357.371,369.744 372.499,369.744C384.359,369.744 394.551,362.29 398.405,351.775L388.53,349.369C385.887,355.601 379.763,359.9 372.499,359.9C362.775,359.9 355.061,352.187 355.061,342.462C355.061,332.737 362.774,324.743 372.499,324.743C376.107,324.743 379.422,325.86 382.186,327.743L373.155,334.931L399.28,339.774L399.28,314.118L399.28,314.12Z' style='fill-rule:nonzero;'/%3E%3C/g%3E%3C/svg%3E%0A"); + background-repeat: no-repeat; + font-size: 0px; + text-align: left; + opacity: 0.7; + transition-property: opacity; + transition-duration: 150ms; + transition-timing-function: linear; +} + +.reload-captcha:hover { + opacity: 1; +} + +/* Ensure the reload button spins around once at minimum when clicked */ +.reload-captcha:focus:not(:focus-visible) { + animation: image_captcha_refresh_spin 500ms linear 1; +} + +/* Loading Formatter: Rotate refresh button while loading new CAPTCHA image */ +.captcha--loading .reload-captcha { + animation: image_captcha_refresh_spin 500ms linear infinite; +} + +@keyframes image_captcha_refresh_spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} diff --git a/web/modules/contrib/captcha/modules/image_captcha/fonts/README.txt b/web/modules/contrib/captcha/modules/image_captcha/fonts/README.txt new file mode 100644 index 000000000..452586d74 --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/fonts/README.txt @@ -0,0 +1,7 @@ + +It possible to put your own fonts for the Image CAPTCHA in this folder. +However, this is not the recommended way, as they can get lost easily during +a module update. The recommended way to provide your own fonts is putting them +in the files directory of your Drupal setup or, just like with contributed +modules and themes, in the "libraries" folders sites/all/libraries/fonts +or sites//libraries/fonts. diff --git a/web/modules/contrib/captcha/modules/image_captcha/fonts/Tesox/tesox.ttf b/web/modules/contrib/captcha/modules/image_captcha/fonts/Tesox/tesox.ttf new file mode 100644 index 000000000..31f91d349 Binary files /dev/null and b/web/modules/contrib/captcha/modules/image_captcha/fonts/Tesox/tesox.ttf differ diff --git a/web/modules/contrib/captcha/modules/image_captcha/fonts/Tesox/tesox_readme.txt b/web/modules/contrib/captcha/modules/image_captcha/fonts/Tesox/tesox_readme.txt new file mode 100644 index 000000000..97e2b6286 --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/fonts/Tesox/tesox_readme.txt @@ -0,0 +1,24 @@ + +The Tesox typeface +================== + +The Tesox typeface is created by Stefaan Lippens (also known as soxofaan on +drupal.org, http://drupal.org/user/41478). +It is based on hand drawn characters, converted to a TrueType font with the +FontCapture web service (http://www.fontcapture.com). + +Background +---------- +The Tesox typeface is created specifically for the image CAPTCHA module +for Drupal (http://drupal.org/project/captcha). For a better out-of-the-box +experience it was desired to include one or more typefaces with the CAPTCHA +module package by default. However, this redistribution raised licensing issues. +For example, according the code hosting policy of drupal.org, only GPL licensed +code and resources are allowed in the drupal.org code repository (CVS). +To avoid licensing and redistribution issues, it was decided to create a +dedicated typeface for the image CAPTCHA module from scratch. + +Licencing +--------- +The Tesox typeface is GPLv2 licensed to be compatible with the drupal.org code +hosting and packaging policies, as explained above. diff --git a/web/modules/contrib/captcha/modules/image_captcha/fonts/Tuffy/README.txt b/web/modules/contrib/captcha/modules/image_captcha/fonts/Tuffy/README.txt new file mode 100644 index 000000000..205343fb5 --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/fonts/Tuffy/README.txt @@ -0,0 +1,23 @@ + +This directory contains a subset (Regular and Bold) of the Tuffy typeface +created by Thatcher Ulrich (http://tulrich.com/fonts) and released in the +public domain. + +Original licensing statement of the creator +------------------------------------------- +Here are my dabblings in font design. I have placed them in the Public Domain. +This is all 100% my own work. Usage is totally unrestricted. +If you want to make derivative works for any purpose, please go ahead. + +I welcome comments & constructive criticism. + +Put another way, a la PD-self (http://en.wikipedia.org/wiki/Template:PD-self): + I, the copyright holder of this work, hereby release it into the public + domain. This applies worldwide. + + In case this is not legally possible, + + I grant any entity the right to use this work for any purpose, + without any conditions, unless such conditions are required by law. + +-Thatcher Ulrich http://tulrich.com diff --git a/web/modules/contrib/captcha/modules/image_captcha/fonts/Tuffy/Tuffy.ttf b/web/modules/contrib/captcha/modules/image_captcha/fonts/Tuffy/Tuffy.ttf new file mode 100644 index 000000000..8ea647090 Binary files /dev/null and b/web/modules/contrib/captcha/modules/image_captcha/fonts/Tuffy/Tuffy.ttf differ diff --git a/web/modules/contrib/captcha/modules/image_captcha/fonts/Tuffy/Tuffy_Bold.ttf b/web/modules/contrib/captcha/modules/image_captcha/fonts/Tuffy/Tuffy_Bold.ttf new file mode 100644 index 000000000..9574aab6e Binary files /dev/null and b/web/modules/contrib/captcha/modules/image_captcha/fonts/Tuffy/Tuffy_Bold.ttf differ diff --git a/web/modules/contrib/captcha/modules/image_captcha/image_captcha.admin.inc b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.admin.inc new file mode 100644 index 000000000..a0de45eec --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.admin.inc @@ -0,0 +1,72 @@ +get('image_captcha_fonts_preview_map_cache'); + if (!isset($fonts[$font_token])) { + print 'Bad token'; + exit(); + } + // Get the font path. + $font = $fonts[$font_token]->uri; + // Some sanity checks if the given font is valid. + if (!is_file($font) || !is_readable($font)) { + print 'Bad font'; + exit(); + } + } + + // Settings of the font preview. + $width = 120; + $text = 'AaBbCc123'; + $font_size = 14; + $height = 2 * $font_size; + + // Allocate image resource. + $image = imagecreatetruecolor($width, $height); + if (!$image) { + exit(); + } + // White background and black foreground. + $background_color = imagecolorallocate($image, 255, 255, 255); + $color = imagecolorallocate($image, 0, 0, 0); + imagefilledrectangle($image, 0, 0, $width, $height, $background_color); + + // Draw preview text. + if ($font == 'BUILTIN') { + imagestring($image, 5, 1, .5 * $height - 10, $text, $color); + } + else { + imagettftext($image, $font_size, 0, 1, 1.5 * $font_size, $color, realpath($font), $text); + } + + $response = new Response(); + $response->headers->set('Content-Type', 'image/png'); + // Dump image data to client. + imagepng($image); + // Release image memory. + imagedestroy($image); + unset($image); + + // Close connection. + exit(); +} diff --git a/web/modules/contrib/captcha/modules/image_captcha/image_captcha.info.yml b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.info.yml new file mode 100644 index 000000000..82c600936 --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.info.yml @@ -0,0 +1,13 @@ +name: Image CAPTCHA +type: module +description: Provides an image based CAPTCHA. +package: Spam control +core_version_requirement: ^9.5 || ^10 || ^11 +dependencies: + - captcha:captcha +configure: image_captcha.settings + +# Information added by Drupal.org packaging script on 2025-07-28 +version: '2.0.9' +project: 'captcha' +datestamp: 1753701291 diff --git a/web/modules/contrib/captcha/modules/image_captcha/image_captcha.install b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.install new file mode 100644 index 000000000..462c93db5 --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.install @@ -0,0 +1,95 @@ + \Drupal::translation() + ->translate('Image CAPTCHA requires GD library'), + 'description' => + \Drupal::translation() + ->translate('The Image CAPTCHA module can not be installed because your PHP setup does not provide the GD library, which is required to generate images.', + ['!gddoc' => 'http://www.php.net/manual/en/book.image.php'] + ), + 'severity' => REQUIREMENT_ERROR, + ]; + } + } + return $requirements; +} + +/** + * Implements hook_install(). + */ +function image_captcha_install() { + $config = \Drupal::configFactory()->getEditable('image_captcha.settings'); + $module_path = \Drupal::service('extension.list.module')->getPath('image_captcha'); + $config->set('image_captcha_fonts', [ + hash('sha256', $module_path . '/fonts/Tesox/tesox.ttf'), + hash('sha256', $module_path . '/fonts/Tuffy/Tuffy.ttf'), + ])->save(TRUE); +} + +/** + * Convert existing setting to hashes. + */ +function image_captcha_update_8001() { + $config = \Drupal::configFactory()->getEditable('image_captcha.settings'); + + foreach ($config->get('image_captcha_fonts') as $index => $font) { + if (!empty($font) && strpos($font, '.ttf') !== FALSE) { + $config->set('image_captcha_fonts.' . $index, hash('sha256', $font)); + } + } + $config->save(TRUE); +} + +/** + * Fix possibly broken image_captcha fonts path after 8.x-1.7 update. + */ +function image_captcha_update_9001(&$sandbox) { + // Reset the font path from the correct module directory location: + $config = \Drupal::configFactory()->getEditable('image_captcha.settings'); + $module_path = \Drupal::service('extension.list.module')->getPath('image_captcha'); + $config->set('image_captcha_fonts', [ + hash('sha256', $module_path . '/fonts/Tesox/tesox.ttf'), + hash('sha256', $module_path . '/fonts/Tuffy/Tuffy.ttf'), + ])->save(TRUE); +} + +/** + * Set captcha 'title' default, as the setting did not exist before. + */ +function image_captcha_update_9002(&$sandbox) { + $config = \Drupal::configFactory()->getEditable('image_captcha.settings'); + $config->set('title', 'CAPTCHA')->save(TRUE); +} + +/** + * Remove image_captcha "title" config as it never should have existed. + */ +function image_captcha_update_9003(&$sandbox) { + $imageCaptchaConfig = \Drupal::configFactory()->getEditable('image_captcha.settings'); + $imageCaptchaConfig->clear('title')->save(TRUE); +} diff --git a/web/modules/contrib/captcha/modules/image_captcha/image_captcha.libraries.yml b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.libraries.yml new file mode 100644 index 000000000..14cd986ab --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.libraries.yml @@ -0,0 +1,31 @@ +base: + version: VERSION + css: + theme: + css/image_captcha.css: {} + dependencies: + - core/jquery + - core/drupal + - core/drupalSettings + +image-captcha-refresh: + version: VERSION + js: + js/image_captcha_refresh.js: {} + css: + theme: + css/image_captcha_refresh.css: {} + dependencies: + - core/jquery + - core/drupal + - image_captcha/base + +admin: + version: VERSION + js: + js/admin.image_captcha.js: {} + css: + theme: + css/admin.image_captcha.css: {} + dependencies: + - image_captcha/base diff --git a/web/modules/contrib/captcha/modules/image_captcha/image_captcha.links.menu.yml b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.links.menu.yml new file mode 100644 index 000000000..b2f3731dc --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.links.menu.yml @@ -0,0 +1,6 @@ +image_captcha.settings: + title: 'Image Captcha' + route_name: image_captcha.settings + description: 'Configure Image Captcha Settings.' + parent: captcha.settings + weight: -1 diff --git a/web/modules/contrib/captcha/modules/image_captcha/image_captcha.links.task.yml b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.links.task.yml new file mode 100644 index 000000000..0e18abcbf --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.links.task.yml @@ -0,0 +1,5 @@ +image_captcha.settings: + title: 'Image Captcha' + route_name: image_captcha.settings + base_route: captcha_settings + weight: -1 diff --git a/web/modules/contrib/captcha/modules/image_captcha/image_captcha.module b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.module new file mode 100644 index 000000000..edad41732 --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.module @@ -0,0 +1,367 @@ +' . t('The image CAPTCHA is a popular challenge where a random textual code is obfuscated in an image. The image is generated on the fly for each request, which is rather CPU intensive for the server. Be careful with the size and computation related settings.') . '

'; + return $output; + } +} + +/** + * Getter for fonts to use in the image CAPTCHA. + * + * @return array + * List of font paths. + */ +function _image_captcha_get_enabled_fonts() { + if (ImageCaptchaConstants::IMAGE_CAPTCHA_ERROR_NO_TTF_SUPPORT & _image_captcha_check_setup(FALSE)) { + return ['BUILTIN']; + } + else { + return \Drupal::config('image_captcha.settings') + ->get('image_captcha_fonts'); + } +} + +/** + * Helper function to get font(s). + * + * @return string|array + * URI of file hash or List of font paths. + */ +function _image_captcha_get_font_uri($token = NULL) { + $fonts = [ + 'BUILTIN' => 'BUILTIN', + ]; + $available_fonts = _image_captcha_get_available_fonts_from_directories(); + foreach ($available_fonts as $file_token => $font_info) { + $fonts[$file_token] = $font_info['uri']; + } + return ((!empty($token) && !empty($fonts[$token])) ? $fonts[$token] : $fonts); +} + +/** + * Helper function to get fonts from the given directories. + * + * @param array|null $directories + * (Optional) an array of directories + * to recursively search through, if not given, the default + * directories will be used. + * + * @return array + * Fonts file objects (with fields 'name', + * 'basename' and 'filename'), keyed on the sha256 hash of the font + * path (to have an easy token that can be used in an url + * without en/decoding issues). + */ +function _image_captcha_get_available_fonts_from_directories($directories = NULL) { + // If no fonts directories are given: use the default. + if ($directories === NULL) { + $request = \Drupal::service('request_stack')->getCurrentRequest(); + $directories = [ + \Drupal::service('extension.list.module')->getPath('image_captcha') . '/fonts', + 'sites/all/libraries/fonts', + DrupalKernel::findSitePath($request) . '/libraries/fonts', + ]; + } + // Collect the font information. + $fonts = []; + foreach ($directories as $directory) { + if (is_dir($directory) && is_readable($directory)) { + $files = \Drupal::service('file_system')->scanDirectory($directory, '/\.[tT][tT][fF]$/'); + foreach ($files as $filename => $font) { + $fonts[hash('sha256', $filename)] = (array) $font; + } + } + } + + return $fonts; +} + +/** + * Helper function for checking if the specified fonts are available. + * + * @param array $fonts + * Paths of fonts to check. + * + * @return array + * List($readable_fonts, $problem_fonts). + */ +function _image_captcha_check_fonts(array $fonts) { + $problem_fonts = []; + + foreach ($fonts as $font) { + if ($font != 'BUILTIN' && (!is_file($font) || !is_readable($font))) { + $problem_fonts[] = $font; + } + } + + return $problem_fonts; +} + +/** + * Helper function for splitting an utf8 string correctly in characters. + * + * Assumes the given utf8 string is well formed. + * See http://en.wikipedia.org/wiki/Utf8 for more info. + * + * @param string $str + * UTF8 string to be split. + * + * @return array + * List of characters given string consists of. + */ +function _image_captcha_utf8_split($str) { + $characters = []; + $len = mb_strlen($str); + + for ($i = 0; $i < $len;) { + $chr = ord($str[$i]); + // One byte character (0zzzzzzz). + if (($chr & 0x80) == 0x00) { + $width = 1; + } + else { + // Two byte character (first byte: 110yyyyy). + if (($chr & 0xE0) == 0xC0) { + $width = 2; + } + // Three byte character (first byte: 1110xxxx). + elseif (($chr & 0xF0) == 0xE0) { + $width = 3; + } + // Four byte character (first byte: 11110www). + elseif (($chr & 0xF8) == 0xF0) { + $width = 4; + } + else { + \Drupal::logger('CAPTCHA') + ->error('Encountered an illegal byte while splitting an utf8 string in characters.'); + return $characters; + } + } + + $characters[] = mb_substr($str, $i, $width); + $i += $width; + } + + return $characters; +} + +/** + * Helper function for checking the setup of the Image CAPTCHA. + * + * The image CAPTCHA requires at least the GD PHP library. + * Support for TTF is recommended and the enabled + * font files should be readable. + * This functions checks these things. + * + * @param bool $check_fonts + * Whether or not the enabled fonts should be checked. + * + * @return int + * Status code: bitwise 'OR' of status flags like + * IMAGE_CAPTCHA_ERROR_NO_GDLIB, IMAGE_CAPTCHA_ERROR_NO_TTF_SUPPORT, + * IMAGE_CAPTCHA_ERROR_TTF_FILE_READ_PROBLEM. + */ +function _image_captcha_check_setup($check_fonts = TRUE) { + $status = 0; + // Check if we can use the GD library. + // We need at least the imagepng function. + // Note that the imagejpg function is optionally also used, but not required. + if (!function_exists('imagepng')) { + $status = $status | ImageCaptchaConstants::IMAGE_CAPTCHA_ERROR_NO_GDLIB; + } + + if (!function_exists('imagettftext')) { + $status = $status | ImageCaptchaConstants::IMAGE_CAPTCHA_ERROR_NO_TTF_SUPPORT; + } + + if ($check_fonts) { + // Check availability of enabled fonts. + $fonts = _image_captcha_get_enabled_fonts(); + if (empty($fonts)) { + // Config value might be wrong, try to reinstall the field. + require_once __DIR__ . '/image_captcha.install'; + image_captcha_install(); + + // Try again now. + $fonts = _image_captcha_get_enabled_fonts(); + if (empty($fonts)) { + $status = $status | ImageCaptchaConstants::IMAGE_CAPTCHA_ERROR_TTF_FILE_READ_PROBLEM; + } + } + if (!empty($fonts)) { + $problem_fonts = _image_captcha_check_fonts($fonts); + if (count($problem_fonts) != 0) { + $status = $status | ImageCaptchaConstants::IMAGE_CAPTCHA_ERROR_TTF_FILE_READ_PROBLEM; + } + } + } + + return $status; +} + +/** + * Helper function for calculating image height and width. + * + * They are based on given code and current font/spacing settings. + * + * @param string $code + * The utf8 string which will be used to split in characters. + * + * @return array + * [$width, $heigh]. + */ +function _image_captcha_image_size($code) { + $config = \Drupal::config('image_captcha.settings'); + $font_size = (int) $config->get('image_captcha_font_size'); + $character_spacing = (float) $config->get('image_captcha_character_spacing'); + $characters = _image_captcha_utf8_split($code); + $character_quantity = count($characters); + + // Calculate height and width. + $width = $character_spacing * $font_size * $character_quantity; + $height = 2 * $font_size; + + // Return the values as full pixel values (no floats): + return [(int) $width, (int) $height]; +} + +/** + * Implements hook_captcha(). + */ +function image_captcha_captcha($op, $captcha_type = '', $captcha_sid = NULL) { + $config = \Drupal::config('image_captcha.settings'); + + switch ($op) { + case 'list': + // Only offer the image CAPTCHA if it is possible to generate an image + // on this setup. + if (!(_image_captcha_check_setup() & ImageCaptchaConstants::IMAGE_CAPTCHA_ERROR_NO_GDLIB)) { + return ['Image']; + } + else { + return []; + } + break; + + case 'generate': + if ($captcha_type == 'Image') { + // In maintenance mode, the image CAPTCHA does not work because + // the request for the image itself won't succeed (only ?q=user + // is permitted for unauthenticated users). We fall back to the + // Math CAPTCHA in that case. + if (\Drupal::state()->get('system.maintenance_mode') + && \Drupal::currentUser()->isAnonymous() + ) { + return captcha_captcha('generate', 'Math'); + } + // Generate a CAPTCHA code. + $allowed_chars = _image_captcha_utf8_split($config->get('image_captcha_image_allowed_chars')); + $code_length = (int) $config->get('image_captcha_code_length'); + $code = ''; + + for ($i = 0; $i < $code_length; $i++) { + $code .= $allowed_chars[array_rand($allowed_chars)]; + } + + // Build the result to return. + $result = []; + + $result['solution'] = $code; + // Add CAPTCHA image wrapper (holds the refresh button + the image + // itself) + $result['form']['captcha_image_wrapper'] = [ + '#type' => 'container', + '#attributes' => ['class' => ['captcha__image-wrapper']], + ]; + // Generate image source URL (add timestamp to avoid problems with + // client side caching: subsequent images of the same CAPTCHA session + // have the same URL, but should display a different code). + [$width, $height] = _image_captcha_image_size($code); + $result['form']['captcha_image_wrapper']['captcha_image'] = [ + '#theme' => 'image', + '#uri' => Url::fromRoute('image_captcha.generator', [ + 'session_id' => $captcha_sid, + 'timestamp' => \Drupal::time()->getRequestTime(), + ])->toString(), + '#width' => $width, + '#height' => $height, + '#alt' => t('Image CAPTCHA'), + '#title' => t('Image CAPTCHA'), + '#weight' => -2, + ]; + + $result['form']['captcha_response'] = [ + '#type' => 'textfield', + '#title' => t('What code is in the image?'), + '#description' => t('Enter the characters shown in the image.'), + '#weight' => 0, + '#required' => TRUE, + '#size' => 15, + '#attributes' => ['autocomplete' => 'off'], + '#cache' => ['max-age' => 0], + ]; + + // Handle the case insensitive validation option combined with + // ignoring spaces. + switch (\Drupal::config('captcha.settings') + ->get('default_validation')) { + case CaptchaConstants::CAPTCHA_DEFAULT_VALIDATION_CASE_SENSITIVE: + $result['captcha_validate'] = 'captcha_validate_ignore_spaces'; + break; + + case CaptchaConstants::CAPTCHA_DEFAULT_VALIDATION_CASE_INSENSITIVE: + $result['captcha_validate'] = 'captcha_validate_case_insensitive_ignore_spaces'; + break; + } + \Drupal::service('page_cache_kill_switch')->trigger(); + + return $result; + } + break; + } +} + +/** + * Implements hook_theme(). + */ +function image_captcha_theme() { + return [ + 'image_captcha_refresh' => [ + 'variables' => ['captcha_refresh_link' => NULL], + ], + ]; +} + +/** + * Implements hook_element_info_alter(). + */ +function image_captcha_element_info_alter(array &$info) { + if (!empty($info['captcha'])) { + // Register the process callback. Sadly we can't determine here safely yet, + // if the processed captcha type is an image_captcha. That has to be done + // inside the #process callback. + $info['captcha']['#process'][] = [ + ImageCaptchaRenderService::class, + 'imageCaptchaAfterBuildProcess', + ]; + } +} diff --git a/web/modules/contrib/captcha/modules/image_captcha/image_captcha.routing.yml b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.routing.yml new file mode 100644 index 000000000..9a03e9532 --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.routing.yml @@ -0,0 +1,29 @@ +image_captcha.settings: + path: '/admin/config/people/captcha/image_captcha' + defaults: + _form: '\Drupal\image_captcha\Form\ImageCaptchaSettingsForm' + requirements: + _permission: 'administer CAPTCHA settings' + +image_captcha.font_preview: + path: '/admin/config/people/captcha/image_captcha/font_preview/{token}' + defaults: + _controller: '\Drupal\image_captcha\Controller\CaptchaFontPreviewController::getFont' + requirements: + _permission: 'administer CAPTCHA settings' + +image_captcha.generator: + path: '/image-captcha-generate/{session_id}/{timestamp}' + defaults: + _controller: '\Drupal\image_captcha\Controller\CaptchaImageGeneratorController::image' + requirements: + # Allow access, because anybody is allowed to generate the image captcha: + _access: 'TRUE' + +image_captcha.refresh: + path: '/image-captcha-refresh/{form_id}' + defaults: + _controller: '\Drupal\image_captcha\Controller\CaptchaImageRefreshController::refreshCaptcha' + requirements: + # Allow access, because anybody is allowed to refresh the image captcha: + _access: 'TRUE' diff --git a/web/modules/contrib/captcha/modules/image_captcha/image_captcha.services.yml b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.services.yml new file mode 100644 index 000000000..7581fa082 --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/image_captcha.services.yml @@ -0,0 +1,4 @@ +services: + image_captcha.render_service: + class: Drupal\image_captcha\Service\ImageCaptchaRenderService + arguments: [ '@config.factory', '@database', '@file_system'] diff --git a/web/modules/contrib/captcha/modules/image_captcha/js/admin.image_captcha.js b/web/modules/contrib/captcha/modules/image_captcha/js/admin.image_captcha.js new file mode 100644 index 000000000..0bdaa9c8a --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/js/admin.image_captcha.js @@ -0,0 +1,52 @@ +/** + * @file + * Contains helper js for Captcha admin pages. + * + * @TODO remove and use native states. + */ + +(function ($) { + 'use strict'; + + Drupal.behaviors.captchaAdmin = { + attach: function (context) { + + // Helper function to show/hide noise level widget. + var noise_level_shower = function (speed) { + speed = (typeof speed == 'undefined') ? 'slow' : speed; + if ($('#edit-image-captcha-dot-noise').is(':checked') + || $('#edit-image-captcha-line-noise').is(':checked')) { + $('.form-item-image-captcha-noise-level').show(speed); + } + else { + $('.form-item-image-captcha-noise-level').hide(speed); + } + }; + + // Add onclick handler to the dot and line noise check boxes. + $('#edit-image-captcha-dot-noise').click(noise_level_shower); + $('#edit-image-captcha-line-noise').click(noise_level_shower); + // Show or hide appropriately on page load. + noise_level_shower(0); + + // Helper function to show/hide smooth distortion widget. + var smooth_distortion_shower = function (speed) { + speed = (typeof speed == 'undefined') ? 'slow' : speed; + if ($('#edit-image-captcha-distortion-amplitude').val() > 0) { + $('.form-item-image-captcha-bilinear-interpolation').show(speed); + } + else { + $('.form-item-image-captcha-bilinear-interpolation').hide(speed); + } + }; + + // Add onchange handler to the distortion level select widget. + $('#edit-image-captcha-distortion-amplitude').change( + smooth_distortion_shower); + // Show or hide appropriately on page load. + smooth_distortion_shower(0); + + } + }; + +})(jQuery); diff --git a/web/modules/contrib/captcha/modules/image_captcha/js/image_captcha_refresh.js b/web/modules/contrib/captcha/modules/image_captcha/js/image_captcha_refresh.js new file mode 100644 index 000000000..63f3a142b --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/js/image_captcha_refresh.js @@ -0,0 +1,48 @@ +/** + * @file + * Attaches behaviors for the zipang captcha refresh module. + */ + +(function ($) { + "use strict"; + + /** + * Attaches jQuery validate behavior to forms. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the outline behavior to the right context. + */ + Drupal.behaviors.CaptchaRefresh = { + attach: function (context) { + $('.reload-captcha', context).not('.processed').bind('click', function () { + $(this).addClass('processed'); + const $form = $(this).parents('form'); + // Send post query for getting new captcha data. + const date = new Date(); + const baseUrl = document.location.origin; + const url = baseUrl.replace(/\/$/g, '') + '/' + $(this).attr('href').replace(/^\//g, '') + '?' + date.getTime(); + // Adding loader. + $('.captcha').addClass('captcha--loading'); + $.get( + url, + {}, + function (response) { + if (response.status === 1) { + $('.captcha', $form).find('img').attr('src', response.data.url); + $('input[name=captcha_sid]', $form).val(response.data.sid); + $('input[name=captcha_token]', $form).val(response.data.token); + $('.captcha').removeClass('captcha--loading'); + } + else { + alert(response.message); + } + }, + 'json' + ); + return false; + }); + } + }; +})(jQuery); diff --git a/web/modules/contrib/captcha/modules/image_captcha/src/Constants/ImageCaptchaConstants.php b/web/modules/contrib/captcha/modules/image_captcha/src/Constants/ImageCaptchaConstants.php new file mode 100644 index 000000000..2ba4562f6 --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/src/Constants/ImageCaptchaConstants.php @@ -0,0 +1,28 @@ +config = $config; + $this->killSwitch = $kill_switch; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('config.factory')->get('image_captcha.settings'), + $container->get('page_cache_kill_switch') + ); + } + + /** + * Main method that throw ImageResponse object to generate image. + * + * @return \Symfony\Component\HttpFoundation\StreamedResponse + * Make a StreamedResponse with the correct configuration and return it. + */ + public function getFont($token) { + $this->killSwitch->trigger(); + return new StreamedResponse(function () use ($token) { + // Get the font from the given font token. + if ($token == 'BUILTIN') { + $font = 'BUILTIN'; + } + else { + // Get the mapping of font tokens to font file objects. + $fonts = $this->config->get('image_captcha_fonts_preview_map_cache'); + if (!isset($fonts[$token])) { + throw new \LogicException('Given font token does not exist.'); + } + // Get the font path. + $font = $fonts[$token]['uri']; + // Some sanity checks if the given font is valid. + if (!is_file($font) || !is_readable($font)) { + throw new \LogicException('Font could not be loaded.'); + } + } + // Settings of the font preview. + $width = 120; + $text = 'AaBbCc123'; + $font_size = 14; + $height = 2 * $font_size; + + // Allocate image resource. + $image = imagecreatetruecolor($width, $height); + if (!$image) { + return NULL; + } + // White background and black foreground. + $background_color = imagecolorallocate($image, 255, 255, 255); + $color = imagecolorallocate($image, 0, 0, 0); + imagefilledrectangle($image, 0, 0, $width, $height, $background_color); + + // Draw preview text. + if ($font == 'BUILTIN') { + imagestring($image, 5, 1, .5 * $height - 10, $text, $color); + } + else { + imagettftext($image, $font_size, 0, 1, 1.5 * $font_size, $color, realpath($font), $text); + } + // Dump image data to client. + imagepng($image); + // Release image memory. + imagedestroy($image); + unset($image); + }, 200, ['Content-Type' => 'image/png']); + } + +} diff --git a/web/modules/contrib/captcha/modules/image_captcha/src/Controller/CaptchaImageGeneratorController.php b/web/modules/contrib/captcha/modules/image_captcha/src/Controller/CaptchaImageGeneratorController.php new file mode 100644 index 000000000..26b962aff --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/src/Controller/CaptchaImageGeneratorController.php @@ -0,0 +1,151 @@ +config = $config; + $this->logger = $logger; + $this->killSwitch = $kill_switch; + $this->connection = $connection; + $this->fileSystem = $file_system; + $this->imageRender = $image_capcha_render; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('config.factory')->get('image_captcha.settings'), + $container->get('logger.factory')->get('captcha'), + $container->get('page_cache_kill_switch'), + $container->get('database'), + $container->get('file_system'), + $container->get('image_captcha.render_service') + ); + } + + /** + * Main method that throw ImageResponse object to generate image. + * + * @return \Symfony\Component\HttpFoundation\StreamedResponse + * Make a StreamedResponse with the correct configuration and return it. + */ + public function image(Request $request) { + $this->killSwitch->trigger(); + + // Process the response headers for the image. + if ($this->config->get('image_captcha_file_format') == ImageCaptchaConstants::IMAGE_CAPTCHA_FILE_FORMAT_JPG) { + $response_headers = ['content-type' => 'image/jpeg']; + } + else { + $response_headers = ['content-type' => 'image/png']; + } + $response_headers['cache-control'] = 'no-store, must-revalidate'; + + // Check for existing session IDs. + $session_id = $request->get('session_id'); + $code = $this->connection + ->select('captcha_sessions', 'cs') + ->fields('cs', ['solution']) + ->condition('csid', $session_id) + ->execute() + ->fetchField(); + + // If there is an existing session, process the image. + $image = NULL; + if ($code !== FALSE) { + $image = $this->imageRender->generateImage($code); + + if (!$image) { + $this->logger->log('error', 'Generation of image CAPTCHA failed. Check your image CAPTCHA configuration and especially the used font.', []); + } + } + return new StreamedResponse(function () use ($image) { + if (!$image) { + return $this; + } + + // Begin capturing the byte stream. + ob_start(); + + // Get the file format of the image. + $file_format = $this->config->get('image_captcha_file_format'); + if ($file_format == ImageCaptchaConstants::IMAGE_CAPTCHA_FILE_FORMAT_JPG) { + imagejpeg($image); + } + else { + imagepng($image); + + } + // Release image memory. + imagedestroy($image); + unset($image); + return $this; + + }, 200, $response_headers); + } + +} diff --git a/web/modules/contrib/captcha/modules/image_captcha/src/Controller/CaptchaImageRefreshController.php b/web/modules/contrib/captcha/modules/image_captcha/src/Controller/CaptchaImageRefreshController.php new file mode 100644 index 000000000..e41592c03 --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/src/Controller/CaptchaImageRefreshController.php @@ -0,0 +1,96 @@ +time = $time; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('datetime.time') + ); + } + + /** + * Put your code here. + */ + public function refreshCaptcha($form_id = NULL) { + $result = [ + 'status' => 0, + 'message' => '', + ]; + try { + $this->moduleHandler()->loadInclude('captcha', 'inc', 'captcha'); + $config = $this->config('image_captcha.settings'); + $captcha_sid = _captcha_generate_captcha_session($form_id); + $captcha_token = Crypt::randomBytesBase64(); + $allowed_char = $config->get('image_captcha_image_allowed_chars') ? $config->get('image_captcha_image_allowed_chars') : ImageCaptchaConstants::IMAGE_CAPTCHA_ALLOWED_CHARACTERS; + $allowed_chars = _image_captcha_utf8_split($allowed_char); + $code_length = (int) $config->get('image_captcha_code_length'); + $code = ''; + for ($i = 0; $i < $code_length; $i++) { + $code .= $allowed_chars[array_rand($allowed_chars)]; + } + $connection = Database::getConnection(); + $connection->update('captcha_sessions') + ->fields([ + 'token' => $captcha_token, + 'solution' => $code, + ]) + ->condition('csid', $captcha_sid, '=') + ->execute(); + $result['data'] = [ + //phpcs:ignore + 'url' => Url::fromRoute('image_captcha.generator', ['session_id' => $captcha_sid, 'timestamp' => $this->time->getRequestTime()])->toString(), + 'token' => $captcha_token, + 'sid' => $captcha_sid, + ]; + $result['status'] = 1; + } + catch (\Exception $e) { + if ($message = $e->getMessage()) { + $result['message'] = $message; + } + else { + $result['message'] = $this->t('Error has occurred. Please contact to site administrator.'); + } + } + return new JsonResponse($result); + } + +} diff --git a/web/modules/contrib/captcha/modules/image_captcha/src/Form/ImageCaptchaSettingsForm.php b/web/modules/contrib/captcha/modules/image_captcha/src/Form/ImageCaptchaSettingsForm.php new file mode 100644 index 000000000..d13bb9612 --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/src/Form/ImageCaptchaSettingsForm.php @@ -0,0 +1,433 @@ +languageManager = $container->get('language_manager'); + $instance->fileSystem = $container->get('file_system'); + + return $instance; + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'image_captcha_settings'; + } + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames() { + return ['image_captcha.settings']; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $config = $this->config('image_captcha.settings'); + // Add CSS and JS for theming and added usability on admin form. + $form['#attached']['library'][] = 'image_captcha/admin'; + + // First some error checking. + $setup_status = _image_captcha_check_setup(FALSE); + if ($setup_status & ImageCaptchaConstants::IMAGE_CAPTCHA_ERROR_NO_GDLIB) { + $this->messenger()->addError($this->t( + 'The Image CAPTCHA module can not generate images because your PHP setup does not support it (no GD library with JPEG support).', + ['!gdlib' => 'http://php.net/manual/en/book.image.php'] + )); + // It is no use to continue building the rest of the settings form. + return $form; + } + + $form['image_captcha_example'] = [ + '#type' => 'details', + '#title' => $this->t('Example'), + '#description' => $this->t('Presolved image CAPTCHA example, generated with the current settings.'), + ]; + + $form['image_captcha_example']['image'] = [ + '#type' => 'captcha', + '#captcha_type' => ImageCaptchaConstants::IMAGE_CAPTCHA_CAPTCHA_TYPE, + '#captcha_admin_mode' => TRUE, + ]; + + // General code settings. + $form['image_captcha_code_settings'] = [ + '#type' => 'details', + '#title' => $this->t('Code settings'), + ]; + + $form['image_captcha_code_settings']['image_captcha_image_allowed_chars'] = [ + '#type' => 'textfield', + '#title' => $this->t('Characters to use in the code'), + '#default_value' => $config->get('image_captcha_image_allowed_chars') ? $config->get('image_captcha_image_allowed_chars') : ImageCaptchaConstants::IMAGE_CAPTCHA_ALLOWED_CHARACTERS, + ]; + $form['image_captcha_code_settings']['image_captcha_code_length'] = [ + '#type' => 'select', + '#title' => $this->t('Code length'), + '#options' => [2 => 2, 3, 4, 5, 6, 7, 8, 9, 10], + '#default_value' => $config->get('image_captcha_code_length'), + '#description' => $this->t('The code length influences the size of the image. Note that larger values make the image generation more CPU intensive.'), + ]; + // RTL support option (only show this option when there are RTL languages). + $language = $this->languageManager->getCurrentLanguage(); + if ($language->getDirection() == Language::DIRECTION_RTL) { + $form['image_captcha_code_settings']['image_captcha_rtl_support'] = [ + '#title' => $this->t('RTL support'), + '#type' => 'checkbox', + '#default_value' => $config->get('image_captcha_rtl_support'), + '#description' => $this->t('Enable this option to render the code from right to left for right to left languages.'), + ]; + } + + // Font related stuff. + $form['image_captcha_font_settings'] = $this->settingsDotSection(); + + // Color and file format settings. + $form['image_captcha_color_settings'] = [ + '#type' => 'details', + '#title' => $this->t('Color and image settings'), + '#description' => $this->t('Configuration of the background, text colors and file format of the image CAPTCHA.'), + ]; + + $form['image_captcha_color_settings']['image_captcha_background_color'] = [ + '#type' => 'textfield', + '#title' => $this->t('Background color'), + '#description' => $this->t('Enter the hexadecimal code for the background color (e.g. #FFF or #FFCE90). When using the PNG file format with transparent background, it is recommended to set this close to the underlying background color.'), + '#default_value' => $config->get('image_captcha_background_color'), + '#maxlength' => 7, + '#size' => 8, + ]; + $form['image_captcha_color_settings']['image_captcha_foreground_color'] = [ + '#type' => 'textfield', + '#title' => $this->t('Text color'), + '#description' => $this->t('Enter the hexadecimal code for the text color (e.g. #000 or #004283).'), + '#default_value' => $config->get('image_captcha_foreground_color'), + '#maxlength' => 7, + '#size' => 8, + ]; + $form['image_captcha_color_settings']['image_captcha_foreground_color_randomness'] = [ + '#type' => 'select', + '#title' => $this->t('Additional variation of text color'), + '#options' => [ + 0 => $this->t('No variation'), + 50 => $this->t('Little variation'), + 100 => $this->t('Medium variation'), + 150 => $this->t('High variation'), + 200 => $this->t('Very high variation'), + ], + '#default_value' => $config->get('image_captcha_foreground_color_randomness'), + '#description' => $this->t('The different characters will have randomized colors in the specified range around the text color.'), + ]; + $form['image_captcha_color_settings']['image_captcha_file_format'] = [ + '#type' => 'select', + '#title' => $this->t('File format'), + '#description' => $this->t('Select the file format for the image. JPEG usually results in smaller files, PNG allows transparency.'), + '#default_value' => $config->get('image_captcha_file_format'), + '#options' => [ + ImageCaptchaConstants::IMAGE_CAPTCHA_FILE_FORMAT_JPG => $this->t('JPEG'), + ImageCaptchaConstants::IMAGE_CAPTCHA_FILE_FORMAT_PNG => $this->t('PNG'), + ImageCaptchaConstants::IMAGE_CAPTCHA_FILE_FORMAT_TRANSPARENT_PNG => $this->t('PNG with transparent background'), + ], + ]; + + // Distortion and noise settings. + $form['image_captcha_distortion_and_noise'] = [ + '#type' => 'details', + '#title' => $this->t('Distortion and noise'), + '#description' => $this->t('With these settings you can control the degree of obfuscation by distortion and added noise. Do not exaggerate the obfuscation and assure that the code in the image is reasonably readable. For example, do not combine high levels of distortion and noise.'), + ]; + + $form['image_captcha_distortion_and_noise']['image_captcha_distortion_amplitude'] = [ + '#type' => 'select', + '#title' => $this->t('Distortion level'), + '#options' => [ + 0 => $this->t('@level - no distortion', ['@level' => '0']), + 1 => $this->t('@level - low', ['@level' => '1']), + 2 => '2', + 3 => '3', + 4 => '4', + 5 => $this->t('@level - medium', ['@level' => '5']), + 6 => '6', + 7 => '7', + 8 => '8', + 9 => '9', + 10 => $this->t('@level - high', ['@level' => '10']), + ], + '#default_value' => $config->get('image_captcha_distortion_amplitude'), + '#description' => $this->t('Set the degree of wave distortion in the image.'), + ]; + $form['image_captcha_distortion_and_noise']['image_captcha_bilinear_interpolation'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Smooth distortion'), + '#default_value' => $config->get('image_captcha_bilinear_interpolation'), + '#description' => $this->t('This option enables bilinear interpolation of the distortion which makes the image look smoother, but it is more CPU intensive.'), + ]; + + $form['image_captcha_distortion_and_noise']['image_captcha_dot_noise'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Add salt and pepper noise'), + '#default_value' => $config->get('image_captcha_dot_noise'), + '#description' => $this->t('This option adds randomly colored point noise.'), + ]; + + $form['image_captcha_distortion_and_noise']['image_captcha_line_noise'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Add line noise'), + '#default_value' => $config->get('image_captcha_line_noise', 0), + '#description' => $this->t('This option enables lines randomly drawn on top of the text code.'), + ]; + + $form['image_captcha_distortion_and_noise']['image_captcha_noise_level'] = [ + '#type' => 'select', + '#title' => $this->t('Noise level'), + '#options' => [ + 1 => '1 - ' . $this->t('low'), + 2 => '2', + 3 => '3 - ' . $this->t('medium'), + 4 => '4', + 5 => '5 - ' . $this->t('high'), + 7 => '7', + 10 => '10 - ' . $this->t('severe'), + ], + '#default_value' => (int) $config->get('image_captcha_noise_level'), + ]; + + $form['image_captcha_text_settings'] = [ + '#type' => 'details', + '#title' => $this->t('Text Content'), + '#description' => $this->t('Customize image CAPTCHA text content.'), + ]; + $form['image_captcha_text_settings']['image_captcha_text_refresh'] = [ + '#type' => 'textfield', + '#title' => $this->t('Refresh button text.'), + '#description' => $this->t('Customize the refresh button text.'), + '#default_value' => $config->get('image_captcha_text_refresh'), + '#size' => 15, + '#required' => TRUE, + ]; + // Enter the characters shown in the image. + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + + // Check image_captcha_image_allowed_chars for spaces. + if (preg_match('/\s/', $form_state->getValue('image_captcha_image_allowed_chars'))) { + $form_state->setErrorByName('image_captcha_image_allowed_chars', $this->t('The list of characters to use should not contain spaces.')); + } + + if (!isset($form['image_captcha_font_settings']['no_ttf_support'])) { + // Check the selected fonts. + // Filter the image_captcha fonts array to pick out the selected ones. + $fonts = array_filter($form_state->getValue('image_captcha_fonts')); + if (count($fonts) < 1) { + $form_state->setErrorByName('image_captcha_fonts', $this->t('You need to select at least one font.')); + } + if ($form_state->getValue('image_captcha_fonts')['BUILTIN']) { + // With the built in font, only latin2 characters should be used. + if (preg_match('/[^a-zA-Z0-9]/', $form_state->getValue('image_captcha_image_allowed_chars'))) { + $form_state->setErrorByName('image_captcha_image_allowed_chars', $this->t('The built-in font only supports Latin2 characters. Only use "a" to "z" and numbers.')); + } + } + $available_fonts = _image_captcha_get_font_uri(); + foreach ($fonts as $token) { + $fonts[$token] = $available_fonts[$token]; + } + $problem_fonts = _image_captcha_check_fonts($fonts); + if (count($problem_fonts) > 0) { + $form_state->setErrorByName('image_captcha_fonts', $this->t('The following fonts are not readable: %fonts.', ['%fonts' => implode(', ', $problem_fonts)])); + } + } + + // Check color settings. + if (!preg_match('/^#([0-9a-fA-F]{3}){1,2}$/', $form_state->getValue('image_captcha_background_color'))) { + $form_state->setErrorByName('image_captcha_background_color', $this->t('Background color is not a valid hexadecimal color value.')); + } + if (!preg_match('/^#([0-9a-fA-F]{3}){1,2}$/', $form_state->getValue('image_captcha_foreground_color'))) { + $form_state->setErrorByName('image_captcha_foreground_color', $this->t('Text color is not a valid hexadecimal color value.')); + } + + parent::validateForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $imageSettings = $form_state->cleanValues()->getValues(); + if (!isset($form['image_captcha_font_settings']['no_ttf_support'])) { + // Filter the image_captcha fonts array to pick out the selected ones. + $imageSettings['image_captcha_fonts'] = array_filter($imageSettings['image_captcha_fonts']); + } + $config = $this->config('image_captcha.settings'); + // Exclude few fields from config. + $exclude = ['image', 'captcha_sid', 'captcha_token', 'captcha_response']; + foreach ($imageSettings as $configName => $configValue) { + if (!in_array($configName, $exclude)) { + $config->set($configName, $configValue); + } + } + $config->save(); + + parent::SubmitForm($form, $form_state); + } + + /** + * Form elements for the font specific setting. + * + * This is refactored to a separate function to avoid polluting the + * general form function image_captcha_settings_form with some + * specific logic. + * + * @return array + * The font settings specific form elements. + */ + protected function settingsDotSection() { + $config = $this->config('image_captcha.settings'); + // Put it all in a details element. + $form = [ + '#type' => 'details', + '#title' => $this->t('Font settings'), + ]; + + // First check if there is TrueType support. + $setup_status = _image_captcha_check_setup(FALSE); + if ($setup_status & ImageCaptchaConstants::IMAGE_CAPTCHA_ERROR_NO_TTF_SUPPORT) { + // Show a warning that there is no TrueType support. + $form['no_ttf_support'] = [ + '#type' => 'item', + '#title' => $this->t('No TrueType support'), + '#markup' => $this->t('The Image CAPTCHA module can not use TrueType fonts because your PHP setup does not support it. You can only use a PHP built-in bitmap font of fixed size.'), + ]; + } + else { + // Build a list of all available fonts. + $available_fonts = []; + + // List of folders to search through for TrueType fonts. + $fonts = _image_captcha_get_available_fonts_from_directories(); + // Cache the list of previewable fonts. All the previews are done + // in separate requests, and we don't want to rescan the filesystem + // every time, so we cache the result. + $config->set('image_captcha_fonts_preview_map_cache', $fonts); + $config->save(); + // Put these fonts with preview image in the list. + foreach ($fonts as $token => $font) { + + $title = $this->t('Font preview of @font (@file)', [ + '@font' => $font['name'], + '@file' => $font['uri'], + ]); + $attributes = [ + 'src' => Url::fromRoute('image_captcha.font_preview', ['token' => $token]) + ->toString(), + 'title' => $title, + 'alt' => $title, + ]; + $available_fonts[$token] = ''; + } + + // Append the PHP built-in font at the end. + $title = $this->t('Preview of built-in font'); + $available_fonts['BUILTIN'] = $this->t('PHP built-in font: @title', [ + '@font_preview_url' => Url::fromRoute('image_captcha.font_preview', ['token' => 'BUILTIN']) + ->toString(), + '@title' => $title, + ])->__toString(); + + $default_fonts = _image_captcha_get_enabled_fonts(); + $conf_path = DrupalKernel::findSitePath($this->getRequest()); + + $form['image_captcha_fonts'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Fonts'), + '#default_value' => $default_fonts, + '#description' => $this->t('Select the fonts to use for the text in the image CAPTCHA. Apart from the provided defaults, you can also use your own TrueType fonts (filename extension .ttf) by putting them in %fonts_library_general or %fonts_library_specific.', + [ + '%fonts_library_general' => 'sites/all/libraries/fonts', + '%fonts_library_specific' => $conf_path . '/libraries/fonts', + ] + ), + '#options' => $available_fonts, + '#attributes' => ['class' => ['image-captcha-admin-fonts-selection']], + ]; + + $form['image_captcha_font_size'] = [ + '#type' => 'select', + '#title' => $this->t('Font size'), + // @codingStandardsIgnoreStart + // We should not translate "pt", so we ignore the coding standards here: + '#options' => [ + 9 => '9 pt - ' . $this->t('tiny'), + 12 => '12 pt - ' . $this->t('small'), + 18 => '18 pt', + 24 => '24 pt - ' . $this->t('normal'), + 30 => '30 pt', + 36 => '36 pt - ' . $this->t('large'), + 48 => '48 pt', + 64 => '64 pt - ' . $this->t('extra large'), + ], + // @codingStandardsIgnoreEnd + '#default_value' => (int) $config->get('image_captcha_font_size'), + '#description' => $this->t('The font size influences the size of the image. Note that larger values make the image generation more CPU intensive.'), + ]; + } + + // Character spacing (available for both the TrueType + // fonts and the builtin font. + $form['image_captcha_font_settings']['image_captcha_character_spacing'] = [ + '#type' => 'select', + '#title' => $this->t('Character spacing'), + '#description' => $this->t('Define the average spacing between characters. Note that larger values make the image generation more CPU intensive.'), + '#default_value' => $config->get('image_captcha_character_spacing'), + '#options' => [ + '0.75' => $this->t('tight'), + '1' => $this->t('normal'), + '1.2' => $this->t('wide'), + '1.5' => $this->t('extra wide'), + ], + ]; + + return $form; + } + +} diff --git a/web/modules/contrib/captcha/modules/image_captcha/src/Service/ImageCaptchaRenderService.php b/web/modules/contrib/captcha/modules/image_captcha/src/Service/ImageCaptchaRenderService.php new file mode 100644 index 000000000..d082d1911 --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/src/Service/ImageCaptchaRenderService.php @@ -0,0 +1,433 @@ +config = $config_factory->get('image_captcha.settings'); + $this->connection = $connection; + $this->fileSystem = $fileSystem; + } + + /** + * Small helper function for parsing a hexadecimal color to a RGB tuple. + * + * @param string $hex + * String representation of HEX color value. + * + * @return array + * Array representation of RGB color value. + */ + protected function hexToRgb($hex) { + if (mb_strlen($hex) == 4) { + $hex = $hex[1] . $hex[1] . $hex[2] . $hex[2] . $hex[3] . $hex[3]; + } + $hex = trim($hex, " #&Hh"); + $c = hexdec($hex); + $rgb = []; + for ($i = 16; $i >= 0; $i -= 8) { + $rgb[] = ($c >> $i) & 0xFF; + } + return $rgb; + } + + /** + * Base function for generating a image CAPTCHA. + * + * @param string $code + * String code to be presented on image. + * + * @return resource + * Image to be outputted contained $code string. + */ + public function generateImage($code) { + $fonts = _image_captcha_get_enabled_fonts(); + + $font_size = $this->config->get('image_captcha_font_size'); + [$width, $height] = _image_captcha_image_size($code); + + $image = imagecreatetruecolor($width, $height); + if (!$image) { + return FALSE; + } + + // Get the background color and paint the background. + $background_rgb = $this->hexToRGB($this->config->get('image_captcha_background_color')); + $background_color = imagecolorallocate($image, $background_rgb[0], $background_rgb[1], $background_rgb[2]); + // Set transparency if needed. + $file_format = $this->config->get('image_captcha_file_format'); + if ($file_format == ImageCaptchaConstants::IMAGE_CAPTCHA_FILE_FORMAT_TRANSPARENT_PNG) { + imagecolortransparent($image, $background_color); + } + imagefilledrectangle($image, 0, 0, $width, $height, $background_color); + + $result = $this->printString($image, $width, $height, $fonts, $font_size, $code); + if (!$result) { + return FALSE; + } + + $noise_colors = []; + for ($i = 0; $i < 20; $i++) { + $noise_colors[] = imagecolorallocate($image, mt_rand(0, 255), mt_rand(0, 255), mt_rand(0, 255)); + } + + // Add additional noise. + if ($this->config->get('image_captcha_dot_noise')) { + $this->addDots($image, $width, $height, $noise_colors); + } + + if ($this->config->get('image_captcha_line_noise')) { + $this->addLines($image, $width, $height, $noise_colors); + } + + $distortion_amplitude = .25 * $font_size * $this->config->get('image_captcha_distortion_amplitude') / 10.0; + + if ($distortion_amplitude > 1) { + $wavelength_xr = (2 + 3 * lcg_value()) * $font_size; + $wavelength_yr = (2 + 3 * lcg_value()) * $font_size; + $freq_xr = 2 * 3.141592 / $wavelength_xr; + $freq_yr = 2 * 3.141592 / $wavelength_yr; + $wavelength_xt = (2 + 3 * lcg_value()) * $font_size; + $wavelength_yt = (2 + 3 * lcg_value()) * $font_size; + $freq_xt = 2 * 3.141592 / $wavelength_xt; + $freq_yt = 2 * 3.141592 / $wavelength_yt; + + $distorted_image = imagecreatetruecolor($width, $height); + + if ($file_format == ImageCaptchaConstants::IMAGE_CAPTCHA_FILE_FORMAT_TRANSPARENT_PNG) { + imagecolortransparent($distorted_image, $background_color); + } + + if (!$distorted_image) { + return FALSE; + } + + if ($this->config->get('image_captcha_bilinear_interpolation')) { + // Distortion with bilinear interpolation. + for ($x = 0; $x < $width; $x++) { + for ($y = 0; $y < $height; $y++) { + // Get distorted sample point in source image. + $r = $distortion_amplitude * sin($x * $freq_xr + $y * $freq_yr); + $theta = $x * $freq_xt + $y * $freq_yt; + $sx = $x + $r * cos($theta); + $sy = $y + $r * sin($theta); + $sxf = (int) floor($sx); + $syf = (int) floor($sy); + if ($sxf < 0 || $syf < 0 || $sxf >= $width - 1 || $syf >= $height - 1) { + $color = $background_color; + } + else { + // Bilinear interpolation: sample at four corners. + $color_00 = imagecolorat($image, $sxf, $syf); + $color_00_r = ($color_00 >> 16) & 0xFF; + $color_00_g = ($color_00 >> 8) & 0xFF; + $color_00_b = $color_00 & 0xFF; + $color_10 = imagecolorat($image, $sxf + 1, $syf); + $color_10_r = ($color_10 >> 16) & 0xFF; + $color_10_g = ($color_10 >> 8) & 0xFF; + $color_10_b = $color_10 & 0xFF; + $color_01 = imagecolorat($image, $sxf, $syf + 1); + $color_01_r = ($color_01 >> 16) & 0xFF; + $color_01_g = ($color_01 >> 8) & 0xFF; + $color_01_b = $color_01 & 0xFF; + $color_11 = imagecolorat($image, $sxf + 1, $syf + 1); + $color_11_r = ($color_11 >> 16) & 0xFF; + $color_11_g = ($color_11 >> 8) & 0xFF; + $color_11_b = $color_11 & 0xFF; + // Interpolation factors. + $u = $sx - $sxf; + $v = $sy - $syf; + $r = (int) ((1 - $v) * ((1 - $u) * $color_00_r + $u * $color_10_r) + $v * ((1 - $u) * $color_01_r + $u * $color_11_r)); + $g = (int) ((1 - $v) * ((1 - $u) * $color_00_g + $u * $color_10_g) + $v * ((1 - $u) * $color_01_g + $u * $color_11_g)); + $b = (int) ((1 - $v) * ((1 - $u) * $color_00_b + $u * $color_10_b) + $v * ((1 - $u) * $color_01_b + $u * $color_11_b)); + $color = ($r << 16) + ($g << 8) + $b; + } + + imagesetpixel($distorted_image, $x, $y, $color); + } + } + } + else { + // Distortion with nearest neighbor interpolation. + for ($x = 0; $x < $width; $x++) { + for ($y = 0; $y < $height; $y++) { + // Get distorted sample point in source image. + $r = $distortion_amplitude * sin($x * $freq_xr + $y * $freq_yr); + $theta = $x * $freq_xt + $y * $freq_yt; + $sx = $x + $r * cos($theta); + $sy = $y + $r * sin($theta); + $sxf = (int) floor($sx); + $syf = (int) floor($sy); + if ($sxf < 0 || $syf < 0 || $sxf >= $width - 1 || $syf >= $height - 1) { + $color = $background_color; + } + else { + $color = imagecolorat($image, $sxf, $syf); + } + imagesetpixel($distorted_image, $x, $y, $color); + } + } + } + return $distorted_image; + } + else { + return $image; + } + } + + /** + * Add random noise lines to image with given color. + * + * @param resource $image + * Link to image stream resource. + * @param int $width + * Suggested output image width. + * @param int $height + * Suggested input image width. + * @param array $colors + * Font color. + */ + protected function addLines(&$image, $width, $height, array $colors) { + $line_quantity = $width * $height / 200.0 * ((int) $this->config->get('image_captcha_noise_level')) / 10.0; + + for ($i = 0; $i < $line_quantity; $i++) { + imageline($image, mt_rand(0, $width), mt_rand(0, $height), mt_rand(0, $width), mt_rand(0, $height), $colors[array_rand($colors)]); + } + } + + /** + * Add random noise dots to image with given color. + * + * @param resource $image + * Link to image stream resource. + * @param int $width + * Suggested output image width. + * @param int $height + * Suggested input image width. + * @param array $colors + * Font color. + */ + protected function addDots(&$image, $width, $height, array $colors) { + $noise_quantity = $width * $height * ((int) $this->config->get('image_captcha_noise_level')) / 10.0; + + for ($i = 0; $i < $noise_quantity; $i++) { + imagesetpixel($image, mt_rand(0, $width), mt_rand(0, $height), $colors[array_rand($colors)]); + } + } + + /** + * Helper function for drawing text on the image. + * + * @param resource $image + * Link to image stream resource. + * @param int $width + * Suggested output image width. + * @param int $height + * Suggested input image width. + * @param array $fonts + * Array of fonts names and paths. + * @param int $font_size + * Suggested font size. + * @param string $text + * Text to be written on the image. + * @param bool $rtl + * RTL. + * + * @return bool + * TRUE if image generation was successful, FALSE otherwise. + */ + protected function printString(&$image, $width, $height, array $fonts, $font_size, $text, $rtl = FALSE) { + $characters = _image_captcha_utf8_split($text); + $character_quantity = count($characters); + + $foreground_rgb = $this->hexToRgb($this->config->get('image_captcha_foreground_color')); + $foreground_color = imagecolorallocate($image, $foreground_rgb[0], $foreground_rgb[1], $foreground_rgb[2]); + // Precalculate the value ranges for color randomness. + $foreground_randomness = $this->config->get('image_captcha_foreground_color_randomness'); + $foreground_color_range = []; + + if ($foreground_randomness) { + for ($i = 0; $i < 3; $i++) { + $foreground_color_range[$i] = [ + max(0, $foreground_rgb[$i] - $foreground_randomness), + min(255, $foreground_rgb[$i] + $foreground_randomness), + ]; + } + } + + // Set default text color. + $color = $foreground_color; + + // The image is separated in different character cages, one for + // each character that will be somewhere inside that cage. + $ccage_width = $width / $character_quantity; + $ccage_height = $height; + + foreach ($characters as $c => $character) { + // Initial position of character: in the center of its cage. + $center_x = ($c + 0.5) * $ccage_width; + if ($rtl) { + $center_x = $width - $center_x; + } + $center_y = 0.5 * $height; + + // Pick a random font from the list. + $font = $fonts[array_rand($fonts)]; + $font = _image_captcha_get_font_uri($font); + + // Get character dimensions for TrueType fonts. + if ($font != 'BUILTIN') { + putenv('GDFONTPATH=' . realpath('.')); + $bbox = imagettfbbox($font_size, 0, $this->fileSystem->realpath($font), $character); + // In very rare cases with some versions of the GD library, the x-value + // of the left side of the bounding box as returned by the first call of + // imagettfbbox is corrupt (value -2147483648 = 0x80000000). + // The weird thing is that calling the function a second time + // can be used as workaround. + // This issue is discussed at http://drupal.org/node/349218. + if ($bbox[2] < 0) { + $bbox = imagettfbbox($font_size, 0, $this->fileSystem->realpath($font), $character); + } + } + else { + $character_width = imagefontwidth(5); + $character_height = imagefontheight(5); + $bbox = [ + 0, + $character_height, + $character_width, + $character_height, + $character_width, + 0, + 0, + 0, + ]; + } + + // Random (but small) rotation of the character. + // @todo add a setting for this? + $angle = mt_rand(-10, 10); + + // Determine print position: at what coordinate should the character be + // printed so that the bounding box would be nicely centered in the cage? + $bb_center_x = .5 * ($bbox[0] + $bbox[2]); + $bb_center_y = .5 * ($bbox[1] + $bbox[7]); + $angle_cos = cos($angle * 3.1415 / 180); + $angle_sin = sin($angle * 3.1415 / 180); + $pos_x = $center_x - ($angle_cos * $bb_center_x + $angle_sin * $bb_center_y); + $pos_y = $center_y - (-$angle_sin * $bb_center_x + $angle_cos * $bb_center_y); + + // Calculate available room to jitter: how much + // can the character be moved. So that it stays inside its cage? + $bb_width = $bbox[2] - $bbox[0]; + $bb_height = $bbox[1] - $bbox[7]; + $dev_x = .5 * max(0, $ccage_width - abs($angle_cos) * $bb_width - abs($angle_sin) * $bb_height); + $dev_y = .5 * max(0, $ccage_height - abs($angle_cos) * $bb_height - abs($angle_sin) * $bb_width); + + // Add jitter to position. + $pos_x = $pos_x + mt_rand(-(int) $dev_x, (int) $dev_x); + $pos_y = $pos_y + mt_rand(-(int) $dev_y, (int) $dev_y); + + // Calculate text color in case of randomness. + if ($foreground_randomness) { + $color = imagecolorallocate($image, + mt_rand($foreground_color_range[0][0], $foreground_color_range[0][1]), + mt_rand($foreground_color_range[1][0], $foreground_color_range[1][1]), + mt_rand($foreground_color_range[2][0], $foreground_color_range[2][1]) + ); + } + + // Draw character. + if ($font == 'BUILTIN') { + imagestring($image, 5, (int) $pos_x, (int) $pos_y, $character, $color); + } + else { + imagettftext($image, $font_size, $angle, (int) $pos_x, (int) $pos_y, $color, $this->fileSystem->realpath($font), $character); + } + } + + return TRUE; + } + + /** + * Add image refresh button to captcha form element. + * + * @return array + * The processed element. + * + * @see image_captcha_element_info_alter() + */ + public static function imageCaptchaAfterBuildProcess(array $element) { + $isCaptchaType = !empty($element['#captcha_type']) ? + $element['#captcha_type'] == ImageCaptchaConstants::IMAGE_CAPTCHA_CAPTCHA_TYPE : NULL; + // Only proceed, if we can determine the form_id and the captcha type: + if (!empty($element['#captcha_type']) && !empty($isCaptchaType)) { + if (!empty($element['#captcha_info']['form_id']) && !empty($element['#captcha_type'])) { + // We need the form_id for regenerating the image captcha: + $form_id = $element['#captcha_info']['form_id']; + // Check if this is an image_captcha: + if (isset($element['captcha_widgets']['captcha_image_wrapper']['captcha_image'])) { + $uri = Link::fromTextAndUrl(t('Get new captcha!'), + new Url('image_captcha.refresh', + ['form_id' => $form_id], + ['attributes' => ['class' => ['reload-captcha']]] + ) + ); + $element['captcha_widgets']['captcha_image_wrapper']['captcha_refresh'] = [ + '#theme' => 'image_captcha_refresh', + '#captcha_refresh_link' => $uri, + ]; + } + } + else { + \Drupal::service('logger.factory')->get('image_captcha')->error('Missing required form ID on route @route', [ + '@route' => \Drupal::routeMatch()->getRouteName() ?? 'Unknown', + ]); + } + } + return $element; + } + +} diff --git a/web/modules/contrib/captcha/modules/image_captcha/templates/image-captcha-refresh.html.twig b/web/modules/contrib/captcha/modules/image_captcha/templates/image-captcha-refresh.html.twig new file mode 100644 index 000000000..41bfc9c60 --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/templates/image-captcha-refresh.html.twig @@ -0,0 +1,19 @@ +{# +/** + * @file + * Custom theme implementation for custom field type defined. + * + * Available variables: + * - uri: An optional URL the image can be linked to. + * - title: An optional Title value which will be shown as text. + * - link_value: Value used to open the link in new or same tab. + * + * @see template_preprocess_custom_zipang_link_formatter() + * + * @ingroup themeable + */ +#} +{{ attach_library('image_captcha/image-captcha-refresh') }} +
+ {{captcha_refresh_link}} +
diff --git a/web/modules/contrib/captcha/modules/image_captcha/tests/modules/image_captcha_test/image_captcha_test.info.yml b/web/modules/contrib/captcha/modules/image_captcha/tests/modules/image_captcha_test/image_captcha_test.info.yml new file mode 100644 index 000000000..8bff14f35 --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/tests/modules/image_captcha_test/image_captcha_test.info.yml @@ -0,0 +1,13 @@ +name: 'Image Captcha Test' +type: module +description: 'Provides captcha types for tests.' +package: Testing +hidden: true +dependencies: + - captcha:captcha + - captcha:image_captcha + +# Information added by Drupal.org packaging script on 2025-07-28 +version: '2.0.9' +project: 'captcha' +datestamp: 1753701291 diff --git a/web/modules/contrib/captcha/modules/image_captcha/tests/modules/image_captcha_test/image_captcha_test.routing.yml b/web/modules/contrib/captcha/modules/image_captcha/tests/modules/image_captcha_test/image_captcha_test.routing.yml new file mode 100644 index 000000000..2d8c3896a --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/tests/modules/image_captcha_test/image_captcha_test.routing.yml @@ -0,0 +1,7 @@ +image_captcha_test.test: + path: '/captcha-test/image-test' + defaults: + _title: 'Test' + _form: 'Drupal\image_captcha_test\Form\ImageCaptchaTestForm' + requirements: + _permission: 'access content' diff --git a/web/modules/contrib/captcha/modules/image_captcha/tests/modules/image_captcha_test/src/Form/ImageCaptchaTestForm.php b/web/modules/contrib/captcha/modules/image_captcha/tests/modules/image_captcha_test/src/Form/ImageCaptchaTestForm.php new file mode 100644 index 000000000..2ca854518 --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/tests/modules/image_captcha_test/src/Form/ImageCaptchaTestForm.php @@ -0,0 +1,43 @@ + 'captcha', + '#captcha_type' => ImageCaptchaConstants::IMAGE_CAPTCHA_CAPTCHA_TYPE, + '#captcha_admin_mode' => TRUE, + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->messenger()->addStatus($this->t('Form submitted!')); + $form_state->setRedirect(''); + } + +} diff --git a/web/modules/contrib/captcha/modules/image_captcha/tests/src/Functional/ImageCaptchaBasicFunctionalTest.php b/web/modules/contrib/captcha/modules/image_captcha/tests/src/Functional/ImageCaptchaBasicFunctionalTest.php new file mode 100644 index 000000000..381d9581c --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/tests/src/Functional/ImageCaptchaBasicFunctionalTest.php @@ -0,0 +1,105 @@ +config('system.site')->set('page.front', '/test-page')->save(); + $this->user = $this->drupalCreateUser([]); + $this->adminUser = $this->drupalCreateUser([]); + $this->adminUser->addRole($this->createAdminRole('admin', 'admin')); + $this->adminUser->save(); + $this->drupalLogin($this->adminUser); + } + + /** + * Tests if the image captcha settings page is accessible. + */ + public function testImageCaptchaSettingsPage() { + $session = $this->assertSession(); + $this->drupalGet('admin/config/people/captcha/image_captcha'); + $session->statusCodeEquals(200); + $session->pageTextContains('Example'); + $session->pageTextContains('Color and image settings'); + } + + /** + * Tests the image form element and it's structure. + */ + public function testImageFormElement() { + $session = $this->assertSession(); + + $this->drupalLogin($this->adminUser); + + $this->drupalGet('/captcha-test/image-test'); + $session->statusCodeEquals(200); + + $session->elementExists('css', '#image-captcha-test-test'); + + $session->elementExists('css', '#image-captcha-test-test > fieldset[data-drupal-selector="edit-image-captcha"]'); + + // Check the first captcha form element and see if it is complete: + // Check captcha description: + $session->elementExists('css', '#image-captcha-test-test > fieldset[data-drupal-selector="edit-image-captcha"] > div.captcha__description'); + $session->elementTextContains('css', '#image-captcha-test-test > fieldset[data-drupal-selector="edit-image-captcha"] > div.captcha__description', 'This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.'); + $session->elementExists('css', '#image-captcha-test-test > fieldset[data-drupal-selector="edit-image-captcha"] > div.captcha__element > div.form-item-captcha-response'); + // Check Question label: + $session->elementExists('css', '#image-captcha-test-test > fieldset[data-drupal-selector="edit-image-captcha"] > div.captcha__element > div.form-item-captcha-response > label.form-required'); + $session->elementTextContains('css', '#image-captcha-test-test > fieldset[data-drupal-selector="edit-image-captcha"] > div.captcha__element > div.form-item-captcha-response > label.form-required', 'What code is in the image?'); + // Check other text elements: + $session->elementExists('css', '#image-captcha-test-test > fieldset[data-drupal-selector="edit-image-captcha"] > div.captcha__element > div.form-item-captcha-response > input.form-text'); + $session->elementExists('css', '#image-captcha-test-test > fieldset[data-drupal-selector="edit-image-captcha"] > div.captcha__element > div.form-item-captcha-response > div#edit-captcha-response--description'); + $session->elementTextContains('css', '#image-captcha-test-test > fieldset[data-drupal-selector="edit-image-captcha"] > div.captcha__element > div.form-item-captcha-response > div#edit-captcha-response--description', 'Enter the characters shown in the image.'); + // Check image exists: + $session->elementExists('css', '#image-captcha-test-test > fieldset[data-drupal-selector="edit-image-captcha"] > div.captcha__element > div#edit-captcha-image-wrapper'); + $session->elementExists('css', '#edit-captcha-image-wrapper > img[data-drupal-selector="edit-captcha-image"]'); + $session->elementExists('css', '#edit-captcha-image-wrapper > img[src*="/image-captcha-generate"]'); + // Check refresh button: + $session->elementExists('css', '#edit-captcha-image-wrapper > div.reload-captcha-wrapper'); + $session->elementExists('css', '#edit-captcha-image-wrapper > div.reload-captcha-wrapper > a.reload-captcha'); + } + +} diff --git a/web/modules/contrib/captcha/modules/image_captcha/tests/src/Functional/ImageCaptchaInstallationTest.php b/web/modules/contrib/captcha/modules/image_captcha/tests/src/Functional/ImageCaptchaInstallationTest.php new file mode 100644 index 000000000..0910ec34e --- /dev/null +++ b/web/modules/contrib/captcha/modules/image_captcha/tests/src/Functional/ImageCaptchaInstallationTest.php @@ -0,0 +1,127 @@ +config('system.site')->set('page.front', '/test-page')->save(); + $this->user = $this->drupalCreateUser([]); + $this->adminUser = $this->drupalCreateUser([]); + $this->adminUser->addRole($this->createAdminRole('admin', 'admin')); + $this->adminUser->save(); + $this->drupalLogin($this->adminUser); + } + + /** + * Tests if installing the module, won't break the site. + */ + public function testInstallation() { + // Remove when dropping support for Drupal 10.3 and below. + $installed = version_compare(\Drupal::VERSION, '10.3', '>=') ? 'installed' : 'enabled'; + $install = version_compare(\Drupal::VERSION, '10.3', '>=') ? 'install' : 'enable'; + + $session = $this->assertSession(); + $page = $this->getSession()->getPage(); + // As simply adding the module to the $modules array only installs required + // modules one by one, we also need to test installing both captcha and + // image captcha at once: + $this->drupalGet('/admin/modules'); + $page->checkField('edit-modules-image-captcha-enable'); + $page->pressButton('edit-submit'); + // Also install required modules: + $session->statusCodeEquals(200); + $session->pageTextContains('Some required modules must be ' . $installed); + $session->pageTextContains('You must ' . $install . ' the CAPTCHA module to install Image CAPTCHA.'); + // Continue: + $page->pressButton('edit-submit'); + $session->statusCodeEquals(200); + $session->pageTextContains('2 modules have been ' . $installed . ': Image CAPTCHA, CAPTCHA'); + // Go to front page and see if the site isn't broken: + $this->drupalGet(''); + // Ensure the status code is success: + $session->statusCodeEquals(200); + // Ensure the correct test page is loaded as front page: + $session->pageTextContains('Test page text.'); + } + + /** + * Tests if uninstalling the module, won't break the site. + */ + public function testUninstallation() { + // Remove when dropping support for Drupal 10.3 and below. + $installed = version_compare(\Drupal::VERSION, '10.3', '>=') ? 'installed' : 'enabled'; + $install = version_compare(\Drupal::VERSION, '10.3', '>=') ? 'install' : 'enable'; + + $session = $this->assertSession(); + $page = $this->getSession()->getPage(); + // Installation process: + $this->drupalGet('/admin/modules'); + $page->checkField('edit-modules-image-captcha-enable'); + $page->pressButton('edit-submit'); + // Also install required modules: + $session->statusCodeEquals(200); + $session->pageTextContains('Some required modules must be ' . $installed); + $session->pageTextContains('You must ' . $install . ' the CAPTCHA module to install Image CAPTCHA.'); + // Continue: + $page->pressButton('edit-submit'); + $session->statusCodeEquals(200); + $session->pageTextContains('2 modules have been ' . $installed . ': Image CAPTCHA, CAPTCHA'); + // Go to uninstallation page an uninstall image_captcha: + $this->drupalGet('/admin/modules/uninstall'); + $session->statusCodeEquals(200); + $page->checkField('edit-uninstall-image-captcha'); + $page->pressButton('edit-submit'); + $session->statusCodeEquals(200); + // Confirm uninstall: + $page->pressButton('edit-submit'); + $session->statusCodeEquals(200); + $session->pageTextContains('The selected modules have been uninstalled.'); + // Retest the frontpage: + $this->drupalGet(''); + // Ensure the status code is success: + $session->statusCodeEquals(200); + // Ensure the correct test page is loaded as front page: + $session->pageTextContains('Test page text.'); + } + +} diff --git a/web/modules/contrib/captcha/src/CaptchaPointInterface.php b/web/modules/contrib/captcha/src/CaptchaPointInterface.php new file mode 100755 index 000000000..0e322e0bb --- /dev/null +++ b/web/modules/contrib/captcha/src/CaptchaPointInterface.php @@ -0,0 +1,64 @@ +configFactory = $configFactory; + parent::__construct($configuration, $plugin_id, $plugin_definition); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('config.factory') + ); + } + + /** + * {@inheritdoc} + */ + public function getInfo() { + $captcha_element = [ + '#input' => TRUE, + '#process' => [[static::class, 'processCaptchaElement']], + // The type of challenge: e.g. 'default', 'captcha/Math', etc. + '#captcha_type' => CaptchaConstants::CAPTCHA_TYPE_DEFAULT, + '#default_value' => '', + // CAPTCHA in admin mode: presolve the CAPTCHA and always show + // it (despite previous successful responses). + '#captcha_admin_mode' => FALSE, + // The default CAPTCHA validation function. + // @todo should this be a single string or an array of strings? + '#captcha_validate' => 'captcha_validate_strict_equality', + ]; + // Override the default CAPTCHA validation function for case + // insensitive validation. + // @todo shouldn't this be done somewhere else, e.g. in form_alter? + if (CaptchaConstants::CAPTCHA_DEFAULT_VALIDATION_CASE_INSENSITIVE == $this->configFactory->get('captcha.settings') + ->get('default_validation') + ) { + $captcha_element['#captcha_validate'] = 'captcha_validate_case_insensitive_equality'; + } + return $captcha_element; + } + + /** + * Process callback for CAPTCHA form element. + */ + public static function processCaptchaElement(&$element, FormStateInterface $form_state, &$complete_form) { + // Add captcha.inc file. + \Drupal::moduleHandler()->loadInclude('captcha', 'inc'); + + // Add JavaScript for general CAPTCHA functionality. + $element['#attached']['library'][] = 'captcha/base'; + + if ($form_state->getTriggeringElement() && isset($form_state->getTriggeringElement()['#limit_validation_errors']) && is_array($form_state->getTriggeringElement()['#limit_validation_errors'])) { + // This is a partial (ajax) submission with limited validation. Do not + // change anything about the captcha element, assume that it will not + // update the captcha element, do not generate anything, which keeps the + // current token intact for the real submission. + return $element; + } + + // Get the form ID of the form we are currently processing (which is not + // necessary the same form that is submitted (if any). + $this_form_id = isset($complete_form['form_id']['#value']) ? + preg_replace("/[^a-z0-9_-]/", "", (string) $complete_form['form_id']['#value']) + : NULL; + + // Get the CAPTCHA session ID. + // If there is a submitted form: try to retrieve and reuse the + // CAPTCHA session ID from the posted data. + [$posted_form_id, $posted_captcha_sid] = _captcha_get_posted_captcha_info($element, $form_state, $this_form_id); + if ($this_form_id == $posted_form_id && isset($posted_captcha_sid)) { + $captcha_sid = $posted_captcha_sid; + } + else { + // Generate a new CAPTCHA session if we could + // not reuse one from a posted form. + $captcha_sid = _captcha_generate_captcha_session($this_form_id, CaptchaConstants::CAPTCHA_STATUS_UNSOLVED); + $captcha_token = Crypt::randomBytesBase64(); + \Drupal::database()->update('captcha_sessions') + ->fields(['token' => $captcha_token]) + ->condition('csid', $captcha_sid) + ->execute(); + } + + // Store CAPTCHA session ID as hidden field. + // Strictly speaking, it is not necessary to send the CAPTCHA session id + // with the form, as the one time CAPTCHA token (see lower) is enough. + // However, we still send it along, because it can help debugging + // problems on live sites with only access to the markup. + $element['captcha_sid'] = [ + '#type' => 'hidden', + '#value' => $captcha_sid, + ]; + + // Store CAPTCHA token as hidden field. + $captcha_token = \Drupal::database() + ->select('captcha_sessions', 'cs') + ->fields('cs', ['token']) + ->condition('csid', $captcha_sid) + ->execute() + ->fetchField(); + + $element['captcha_token'] = [ + '#type' => 'hidden', + '#value' => $captcha_token, + ]; + + // Get implementing module and challenge for CAPTCHA. + [$captcha_type_module, $captcha_type_challenge] = _captcha_parse_captcha_type($element['#captcha_type']); + + // Store CAPTCHA information for further processing in + // - $form_state->get('captcha_info'), which survives + // a form rebuild (e.g. node preview), + // useful in _captcha_get_posted_captcha_info(). + // - $element['#captcha_info'], for post processing functions that do not + // receive a $form_state argument (e.g. the pre_render callback). + // Added a new access attribute, + // by default it will be true if access attribute + // not defined in a custom form. + $info = [ + 'this_form_id' => $this_form_id, + 'posted_form_id' => $posted_form_id, + 'captcha_sid' => $captcha_sid, + 'module' => $captcha_type_module, + 'captcha_type' => $captcha_type_challenge, + 'access' => $element['#access'] ?? CaptchaConstants::CAPTCHA_FIELD_DEFAULT_ACCESS, + ]; + $form_state->set('captcha_info', $info); + $element['#captcha_info'] = [ + 'form_id' => $this_form_id, + 'captcha_sid' => $captcha_sid, + ]; + + if (_captcha_required_for_user($captcha_sid, $this_form_id) || $element['#captcha_admin_mode']) { + // Generate a CAPTCHA and its solution + // (note that the CAPTCHA session ID is given as third argument). + $captcha = \Drupal::moduleHandler() + ->invoke($captcha_type_module, 'captcha', [ + 'generate', + $captcha_type_challenge, + $captcha_sid, + ]); + + // Allow other modules to alter the generated captcha: + \Drupal::moduleHandler()->alter('captcha', $captcha, $info); + + // @todo Isn't this moment a bit late to figure out + // that we don't need CAPTCHA? + if (!isset($captcha)) { + return $element; + } + + if (!isset($captcha['form']) || !isset($captcha['solution'])) { + // The selected module did not return what we expected: + // log about it and quit. + \Drupal::logger('CAPTCHA')->error( + 'CAPTCHA problem: unexpected result from hook_captcha() of module %module when trying to retrieve challenge type %type for form %form_id.', + [ + '%type' => $captcha_type_challenge, + '%module' => $captcha_type_module, + '%form_id' => $this_form_id, + ] + ); + + return $element; + } + // Add form elements from challenge as children to CAPTCHA form element. + $element['captcha_widgets'] = $captcha['form']; + + // Add a validation callback for the CAPTCHA form element + // (when not in admin mode). + if (!$element['#captcha_admin_mode']) { + $element['#element_validate'] = ['captcha_validate']; + } + + // Set a custom CAPTCHA validate function if requested. + if (isset($captcha['captcha_validate'])) { + $element['#captcha_validate'] = $captcha['captcha_validate']; + } + + // Set the theme function. + $element['#theme'] = 'captcha'; + + // Provide the captcha type: + $element['#captcha_type_challenge'] = $captcha_type_challenge; + + // Add pre_render callback for additional CAPTCHA processing. + if (!isset($element['#pre_render'])) { + $element['#pre_render'] = []; + } + $element['#pre_render'][] = [Captcha::class, 'preRenderProcess']; + + // Store the solution in the #captcha_info array. + $element['#captcha_info']['solution'] = $captcha['solution']; + + // Store if this captcha type is cacheable: + // A cacheable captcha must not depend on the sid or solution, but be + // independent - like for example recaptcha. + $element['#captcha_info']['cacheable'] = !empty($captcha['cacheable']); + + if (!empty($element['#captcha_info']['cacheable'])) { + // This is only used to avoid the re-use message. + $element['captcha_cacheable'] = [ + '#type' => 'hidden', + '#value' => 1, + ]; + } + + // Make sure we can use a top level form value + // $form_state->getValue('captcha_response'), + // even if the form has #tree=true. + $element['#tree'] = FALSE; + } + + return $element; + } + + /** + * Pre-render callback for additional processing of a CAPTCHA form element. + * + * This encompasses tasks that should happen after the general FAPI processing + * (building, submission and validation) but before rendering + * (e.g. storing the solution). + * + * @param array $element + * The CAPTCHA form element. + * + * @return array + * The manipulated element. + */ + public static function preRenderProcess(array $element) { + \Drupal::moduleHandler()->loadInclude('captcha', 'inc'); + + // Get form and CAPTCHA information. + $captcha_info = $element['#captcha_info']; + $form_id = $captcha_info['form_id']; + $captcha_sid = (int) ($captcha_info['captcha_sid']); + // Check if CAPTCHA is still required. + // This check is done in a first phase during the element processing + // (@see captcha_process), but it is also done here for better support + // of multi-page forms. Take previewing a node submission for example: + // when the challenge is solved correctly on preview, the form is still + // not completely submitted, but the CAPTCHA can be skipped. + if (_captcha_required_for_user($captcha_sid, $form_id) || $element['#captcha_admin_mode']) { + // Update captcha_sessions table: store the solution + // of the generated CAPTCHA. + _captcha_update_captcha_session($captcha_sid, $captcha_info['solution']); + + // Handle the response field if it is available and if it is a textfield. + if (isset($element['captcha_widgets']['captcha_response']['#type']) && $element['captcha_widgets']['captcha_response']['#type'] == 'textfield') { + // Before rendering: presolve an admin mode challenge or + // empty the value of the captcha_response form item. + $value = $element['#captcha_admin_mode'] ? $captcha_info['solution'] : ''; + $element['captcha_widgets']['captcha_response']['#value'] = $value; + } + } + else { + // Remove CAPTCHA widgets from form. + unset($element['captcha_widgets']); + } + + return $element; + } + +} diff --git a/web/modules/contrib/captcha/src/Entity/CaptchaPoint.php b/web/modules/contrib/captcha/src/Entity/CaptchaPoint.php new file mode 100755 index 000000000..5b3e3e76b --- /dev/null +++ b/web/modules/contrib/captcha/src/Entity/CaptchaPoint.php @@ -0,0 +1,133 @@ +formId; + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return $this->formId; + } + + /** + * {@inheritdoc} + */ + public function setFormId($form_id) { + $this->formId = $form_id; + } + + /** + * {@inheritdoc} + */ + public function getLabel() { + return $this->label; + } + + /** + * {@inheritdoc} + */ + public function setLabel($label) { + $this->label = $label; + } + + /** + * {@inheritdoc} + */ + public function getCaptchaType() { + if (isset($this->captchaType)) { + return $this->captchaType; + } + else { + return static::getConfigManager() + ->getConfigFactory() + ->get('captcha.settings') + ->get('default_challenge'); + } + } + + /** + * {@inheritdoc} + */ + public function setCaptchaType($captcha_type) { + $this->captchaType = $captcha_type != CaptchaConstants::CAPTCHA_TYPE_DEFAULT ? $captcha_type : NULL; + } + +} diff --git a/web/modules/contrib/captcha/src/Entity/Controller/CaptchaPointListBuilder.php b/web/modules/contrib/captcha/src/Entity/Controller/CaptchaPointListBuilder.php new file mode 100755 index 000000000..f7888a462 --- /dev/null +++ b/web/modules/contrib/captcha/src/Entity/Controller/CaptchaPointListBuilder.php @@ -0,0 +1,35 @@ +t('Captcha Point form ID'); + $header['captcha_type'] = $this->t('Captcha Point challenge type'); + $header['captcha_status'] = $this->t('Status'); + + return $header + parent::buildHeader(); + } + + /** + * {@inheritdoc} + */ + public function buildRow(EntityInterface $entity) { + $row['form_id'] = $entity->id(); + $row['captcha_type'] = $entity->getCaptchaType(); + $row['captcha_status'] = $entity->status() ? $this->t('Enabled') : $this->t('Disabled'); + + return $row + parent::buildRow($entity); + } + +} diff --git a/web/modules/contrib/captcha/src/EventSubscriber/CaptchaCachedSettingsSubscriber.php b/web/modules/contrib/captcha/src/EventSubscriber/CaptchaCachedSettingsSubscriber.php new file mode 100644 index 000000000..d518696c8 --- /dev/null +++ b/web/modules/contrib/captcha/src/EventSubscriber/CaptchaCachedSettingsSubscriber.php @@ -0,0 +1,54 @@ +elementInfo = $elementInfo; + } + + /** + * Clearing the cached definitions whenever the settings are modified. + * + * @param \Drupal\Core\Config\ConfigCrudEvent $event + * The Event to process. + */ + public function onSave(ConfigCrudEvent $event) { + // Changing the Captcha settings means that any page might result in other + // settings for captcha so the cached definitions need to be cleared. + if ($event->getConfig()->getName() === 'captcha.settings') { + $this->elementInfo->clearCachedDefinitions(); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + $events[ConfigEvents::SAVE][] = ['onSave']; + return $events; + } + +} diff --git a/web/modules/contrib/captcha/src/Form/CaptchaExamplesForm.php b/web/modules/contrib/captcha/src/Form/CaptchaExamplesForm.php new file mode 100755 index 000000000..73971d636 --- /dev/null +++ b/web/modules/contrib/captcha/src/Form/CaptchaExamplesForm.php @@ -0,0 +1,114 @@ +moduleHandler = $moduleHandler; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('module_handler') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'captcha_examples'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, $module = NULL, $challenge = NULL) { + $form = []; + if ($module && $challenge) { + // Generate 10 example challenges. + for ($i = 0; $i < 10; $i++) { + $form["challenge_{$i}"] = $this->buildChallenge($module, $challenge); + } + } + else { + // Generate a list with examples of the available CAPTCHA types. + $form['info'] = [ + '#markup' => $this->t('This page gives an overview of all available challenge types, generated with their current settings.'), + ]; + $challenges = []; + $this->moduleHandler->invokeAllWith('captcha', function (callable $hook, string $module) use (&$challenges) { + if ($challenge = $hook('list')) { + $challenges[$module] = $challenge; + } + }); + + if ($challenges) { + foreach ($challenges as $module => $challenge_list) { + foreach ($challenge_list as $ckey => $challenge) { + $form["captcha_{$module}_{$ckey}"] = [ + '#type' => 'details', + '#title' => $this->t('Challenge %challenge by module %module', [ + '%challenge' => $challenge, + '%module' => $module, + ]), + 'challenge' => $this->buildChallenge($module, $challenge), + 'more_examples' => [ + '#markup' => Link::fromTextAndUrl($this->t('10 more examples of this challenge.'), Url::fromRoute('captcha_examples', [ + 'module' => $module, + 'challenge' => $challenge, + ]))->toString(), + ], + ]; + } + } + } + } + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) {} + + /** + * Returns a renderable array for a given CAPTCHA challenge. + */ + protected function buildChallenge($module, $challenge) { + return [ + '#type' => 'captcha', + '#captcha_type' => $module . '/' . $challenge, + '#captcha_admin_mode' => TRUE, + ]; + } + +} diff --git a/web/modules/contrib/captcha/src/Form/CaptchaPointDeleteForm.php b/web/modules/contrib/captcha/src/Form/CaptchaPointDeleteForm.php new file mode 100755 index 000000000..45ab12115 --- /dev/null +++ b/web/modules/contrib/captcha/src/Form/CaptchaPointDeleteForm.php @@ -0,0 +1,44 @@ +t('Are you sure you want to delete %name?', ['%name' => $this->entity->label()]); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return new Url('captcha_point.list'); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return $this->t('Delete'); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->entity->delete(); + $this->messenger()->addMessage($this->t('Captcha point %label has been deleted.', ['%label' => $this->entity->label() ?? $this->entity->id()])); + $form_state->setRedirectUrl($this->getCancelUrl()); + } + +} diff --git a/web/modules/contrib/captcha/src/Form/CaptchaPointDisableForm.php b/web/modules/contrib/captcha/src/Form/CaptchaPointDisableForm.php new file mode 100644 index 000000000..02f7c66c4 --- /dev/null +++ b/web/modules/contrib/captcha/src/Form/CaptchaPointDisableForm.php @@ -0,0 +1,52 @@ +t('Are you sure you want to disable the Captcha?'); + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + return $this->t('This will disable the captcha.'); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return new Url('captcha_point.list'); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return $this->t('Disable'); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->entity->disable(); + $this->entity->save(); + $this->messenger()->addMessage($this->t('Captcha point %label has been disabled.', ['%label' => $this->entity->label() ?? $this->entity->id()])); + $form_state->setRedirect('captcha_point.list'); + } + +} diff --git a/web/modules/contrib/captcha/src/Form/CaptchaPointEnableForm.php b/web/modules/contrib/captcha/src/Form/CaptchaPointEnableForm.php new file mode 100644 index 000000000..c54aa27ec --- /dev/null +++ b/web/modules/contrib/captcha/src/Form/CaptchaPointEnableForm.php @@ -0,0 +1,52 @@ +t('Are you sure you want to enable the Captcha?'); + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + return $this->t('This will enable the captcha.'); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return new Url('captcha_point.list'); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return $this->t('Enable'); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->entity->enable(); + $this->entity->save(); + $this->messenger()->addMessage($this->t('Captcha point %label has been enabled.', ['%label' => $this->entity->label() ?? $this->entity->id()])); + $form_state->setRedirect('captcha_point.list'); + } + +} diff --git a/web/modules/contrib/captcha/src/Form/CaptchaPointForm.php b/web/modules/contrib/captcha/src/Form/CaptchaPointForm.php new file mode 100755 index 000000000..d2f37c2d0 --- /dev/null +++ b/web/modules/contrib/captcha/src/Form/CaptchaPointForm.php @@ -0,0 +1,126 @@ +requestStack = $request_stack; + $this->captchaService = $captcha_service; + } + + /** + * Create Captcha Points. + * + * @param \Symfony\Component\DependencyInjection\ContainerInterface $container + * Event to create Captcha points. + * + * @return static + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('request_stack'), + $container->get('captcha.helper') + ); + } + + /** + * {@inheritdoc} + */ + public function form(array $form, FormStateInterface $form_state) { + $form = parent::form($form, $form_state); + + /** @var \Drupal\captcha\CaptchaPointInterface $captchaPoint */ + $captcha_point = $this->entity; + + // Support to set a default form_id through a query argument. + $request = $this->requestStack->getCurrentRequest(); + if ($captcha_point->isNew() && !$captcha_point->id() && $request->query->has('form_id')) { + $captcha_point->set('formId', $request->query->get('form_id')); + $captcha_point->set('label', $request->query->get('form_id')); + } + + $form['label'] = [ + '#type' => 'textfield', + '#title' => $this->t('Form ID'), + '#description' => $this->t('Also works with the base form ID.'), + '#default_value' => $captcha_point->label(), + '#required' => TRUE, + ]; + + $form['formId'] = [ + '#type' => 'machine_name', + '#default_value' => $captcha_point->id(), + '#machine_name' => [ + 'exists' => 'captcha_point_load', + ], + '#disable' => !$captcha_point->isNew(), + '#required' => TRUE, + ]; + + // Select widget for CAPTCHA type. + $form['captchaType'] = [ + '#type' => 'select', + '#title' => $this->t('Challenge type'), + '#description' => $this->t('The CAPTCHA type to use for this form.'), + '#default_value' => $captcha_point->getCaptchaType() ?: $this->config('captcha.settings')->get('default_challenge'), + '#options' => $this->captchaService->getAvailableChallengeTypes(), + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + /** @var CaptchaPoint $captcha_point */ + $captcha_point = $this->entity; + $status = $captcha_point->save(); + + if ($status == SAVED_NEW) { + $this->messenger()->addMessage($this->t('Captcha Point for %form_id form was created.', [ + '%form_id' => $captcha_point->getFormId(), + ])); + } + else { + $this->messenger()->addMessage($this->t('Captcha Point for %form_id form was updated.', [ + '%form_id' => $captcha_point->getFormId(), + ])); + } + $form_state->setRedirect('captcha_point.list'); + return $status; + } + +} diff --git a/web/modules/contrib/captcha/src/Form/CaptchaSettingsForm.php b/web/modules/contrib/captcha/src/Form/CaptchaSettingsForm.php new file mode 100755 index 000000000..65bdd588a --- /dev/null +++ b/web/modules/contrib/captcha/src/Form/CaptchaSettingsForm.php @@ -0,0 +1,332 @@ +cacheBackend = $container->get('cache.default'); + $instance->moduleHandler = $container->get('module_handler'); + $instance->captchaService = $container->get('captcha.helper'); + $instance->requestStack = $container->get('request_stack'); + + return $instance; + } + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames() { + return ['captcha.settings']; + } + + /** + * Implements \Drupal\Core\Form\FormInterface::getFormID(). + */ + public function getFormId() { + return 'captcha_settings'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $config = $this->config('captcha.settings'); + $this->moduleHandler->loadInclude('captcha', 'inc'); + + $form['default_challenge'] = [ + '#type' => 'select', + '#title' => $this->t('Default challenge type'), + '#description' => $this->t('Select the default CAPTCHA Point challenge type. This can be overridden for each CAPTCHA Point individually.'), + '#options' => $this->captchaService->getAvailableChallengeTypes(FALSE), + '#default_value' => $config->get('default_challenge'), + ]; + + // Option for enabling CAPTCHA for all forms. + $form['enable_globally'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Add CAPTCHA challenges on all forms'), + '#description' => $this->t('Adds CAPTCHA to all Drupal forms, regardless of the Captcha Points list. Note, that the captcha point default challenge will be used as the challenge type for the created CAPTCHA challenges.'), + '#default_value' => $config->get('enable_globally'), + ]; + $form['enable_globally_on_admin_routes'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Additionally add CAPTCHA challenges on admin forms'), + '#default_value' => $config->get('enable_globally_on_admin_routes'), + '#states' => [ + 'invisible' => [ + ':input[name="enable_globally"]' => ['checked' => FALSE], + ], + ], + ]; + // Field for the CAPTCHA administration mode. + $form['administration_mode'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Add CAPTCHA administration information to forms'), + '#default_value' => $config->get('administration_mode'), + '#description' => $this->t('This option makes it easy to manage CAPTCHA settings on forms. When enabled, users with the administer CAPTCHA settings permission will see a fieldset with CAPTCHA administration links and informations on all forms, except on administrative pages.'), + ]; + // Field for the CAPTCHAs on admin pages. + $form['administration_mode_on_admin_routes'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Additionally add administration informations on admin pages'), + '#description' => $this->t("Typically this isn't needed. In some situations, e.g. in the case of demo sites, it can be useful to allow CAPTCHAs on administrative pages."), + '#default_value' => $config->get('administration_mode_on_admin_routes'), + '#states' => [ + 'invisible' => [ + ':input[name="administration_mode"]' => ['checked' => FALSE], + ], + ], + ]; + + // Adding configuration for ip protection. + $form['whitelist_ips_settings'] = [ + '#type' => 'details', + '#title' => $this->t('Whitelisted IP Addresses'), + '#description' => $this->t('Enter the IP addresses or IP address ranges you wish to whitelist. All CAPTCHA challenges will be skipped for these IP addresses.'), + '#open' => !empty($config->get('whitelist_ips')), + ]; + + $ip_address = $this->requestStack->getCurrentRequest()->getClientIp(); + $form['whitelist_ips_settings']['whitelist_ips'] = [ + '#title' => $this->t('IP addresses list'), + '#type' => 'textarea', + '#required' => FALSE, + '#default_value' => $config->get('whitelist_ips'), + '#description' => $this->t('Enter one IP-address per row in the format XXX.XXX.XXX.XXX. Alternatively you can also define IP-address ranges per row in the format XXX.XXX.XXX.YYY-XXX.XXX.XXX.ZZZ. No spaces allowed. Your current IP address is %ip_address.', ['%ip_address' => $ip_address]), + ]; + + // Button for clearing the CAPTCHA placement cache. + // Based on Drupal core's "Clear all caches" (performance settings page). + $form['placement_caching'] = [ + '#type' => 'item', + '#title' => $this->t('CAPTCHA placement caching'), + '#description' => $this->t('For efficiency, the positions of the CAPTCHA elements in each of the configured forms are cached. Most of the time, the structure of a form does not change and it would be a waste to recalculate the positions every time. Occasionally however, the form structure can change (e.g. during site building) and clearing the CAPTCHA placement cache can be required to fix the CAPTCHA placement.'), + ]; + $form['placement_caching']['placement_cache_clear'] = [ + '#type' => 'submit', + '#value' => $this->t('Clear the CAPTCHA placement cache'), + '#submit' => ['::clearCaptchaPlacementCacheSubmit'], + ]; + + $form['title'] = [ + '#type' => 'textfield', + '#title' => $this->t('Challenge title'), + '#description' => $this->t('Configure the title for the CAPTCHA form. Leave empty to show no title. Default: "@title_default"', ['@title_default' => $this->t('CAPTCHA')]), + '#default_value' => _captcha_get_title(), + '#maxlength' => 256, + ]; + + $form['description'] = [ + '#type' => 'textfield', + '#title' => $this->t('Challenge description'), + '#description' => $this->t('Configurable description of the CAPTCHA. Leave empty to show no description. Default: "@description_default"', ['@description_default' => $this->t('This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.')]), + '#default_value' => _captcha_get_description(), + '#maxlength' => 256, + ]; + + // Field for the wrong captcha response error message. + $form['wrong_captcha_response_message'] = [ + '#type' => 'textfield', + '#title' => $this->t('Wrong CAPTCHA response error message'), + '#description' => $this->t('Configurable error message that the user gets when it enters an incorrect CAPTCHA answer.'), + '#default_value' => _captcha_get_error_message(), + '#maxlength' => 256, + '#required' => TRUE, + ]; + + // Option for case sensitive/insensitive validation of the responses. + $form['default_validation'] = [ + '#type' => 'radios', + '#title' => $this->t('Default CAPTCHA validation'), + '#description' => $this->t('Define how the response should be processed by default. Note that the modules that provide the actual challenges can override or ignore this.'), + '#options' => [ + CaptchaConstants::CAPTCHA_DEFAULT_VALIDATION_CASE_SENSITIVE => $this->t('Case sensitive validation: the response has to exactly match the solution.'), + CaptchaConstants::CAPTCHA_DEFAULT_VALIDATION_CASE_INSENSITIVE => $this->t('Case insensitive validation: lowercase/uppercase errors are ignored.'), + ], + '#default_value' => $config->get('default_validation'), + ]; + + // Field for CAPTCHA persistence. + // @todo for D7: Rethink/simplify the explanation and UI strings. + $form['persistence'] = [ + '#type' => 'radios', + '#title' => $this->t('Persistence'), + '#default_value' => $config->get('persistence'), + '#options' => [ + CaptchaConstants::CAPTCHA_PERSISTENCE_SHOW_ALWAYS => $this->t('Always add a challenge.'), + CaptchaConstants::CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL_PER_FORM_INSTANCE => $this->t('Omit challenges in a multi-step/preview workflow once the user successfully responds to a challenge.'), + CaptchaConstants::CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL_PER_FORM_TYPE => $this->t('Omit challenges on a form type once the user successfully responds to a challenge on a form of that type.'), + CaptchaConstants::CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL => $this->t('Omit challenges on all forms once the user successfully responds to any challenge on the site.'), + ], + '#description' => $this->t('Define if challenges should be omitted during the rest of a session once the user successfully responds to a challenge.'), + ]; + + // Enable wrong response counter. + $form['enable_stats'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable statistics'), + '#description' => $this->t('Keep CAPTCHA related counters in the status report. Note that this comes with a performance penalty as updating the counters results in clearing the variable cache.', [ + ':statusreport' => Url::fromRoute('system.status')->toString(), + ]), + '#default_value' => $config->get('enable_stats'), + ]; + + // Option for logging wrong responses. + $form['log_wrong_responses'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Log wrong responses'), + '#description' => $this->t('Report information about wrong responses to the log.'), + '#default_value' => $config->get('log_wrong_responses'), + ]; + + // Replace the description with a link if dblog.module is enabled. + if ($this->moduleHandler->moduleExists('dblog')) { + $form['log_wrong_responses']['#description'] = $this->t('Report information about wrong responses to the log.', [ + ':dblog' => Url::fromRoute('dblog.overview')->toString(), + ]); + } + + // Submit button. + $form['actions'] = ['#type' => 'actions']; + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Save configuration'), + ]; + + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + // Validating whitelisted ip addresses. + $whitelist_ips_value = trim($form_state->getValue('whitelist_ips', '')); + if (!empty($whitelist_ips_value)) { + $whitelist_ips = captcha_whitelist_ips_parse_values($whitelist_ips_value); + + // Checking single ip addresses. + foreach ($whitelist_ips[CaptchaConstants::CAPTCHA_WHITELIST_IP_ADDRESS] as $ip_address) { + if (filter_var($ip_address, FILTER_VALIDATE_IP) == FALSE) { + $form_state->setErrorByName('whitelist_ips', $this->t('IP address %ip_address is not valid.', ['%ip_address' => $ip_address])); + } + } + + // Checking ip ranges. + foreach ($whitelist_ips[CaptchaConstants::CAPTCHA_WHITELIST_IP_RANGE] as $ip_range) { + [$ip_lower, $ip_upper] = explode('-', $ip_range, 2); + + if (filter_var($ip_lower, FILTER_VALIDATE_IP) == FALSE) { + $form_state->setErrorByName('whitelist_ips', $this->t('Lower IP address %ip_address in range %ip_range is not valid.', [ + '%ip_address' => $ip_lower, + '%ip_range' => $ip_range, + ])); + } + + if (filter_var($ip_upper, FILTER_VALIDATE_IP) == FALSE) { + $form_state->setErrorByName('whitelist_ips', $this->t('Upper IP address %ip_address in range %ip_range is not valid.', [ + '%ip_address' => $ip_upper, + '%ip_range' => $ip_range, + ])); + } + + $ip_lower_dec = (float) sprintf("%u", ip2long($ip_lower)); + $ip_upper_dec = (float) sprintf("%u", ip2long($ip_upper)); + + if ($ip_lower_dec == $ip_upper_dec) { + $form_state->setErrorByName('whitelist_ips', $this->t('Lower and upper IP addresses should be different. Please correct range %ip_range.', ['%ip_range' => $ip_range])); + } + elseif ($ip_lower_dec > $ip_upper_dec) { + $form_state->setErrorByName('whitelist_ips', $this->t("Lower IP can't be greater than upper IP addresses in range. Please correct range %ip_range.", ['%ip_range' => $ip_range])); + } + } + } + + parent::validateForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $config = $this->config('captcha.settings'); + $config->set('administration_mode', $form_state->getValue('administration_mode')); + $config->set('administration_mode_on_admin_routes', ($form_state->getValue('administration_mode') && $form_state->getValue('administration_mode_on_admin_routes'))); + $config->set('enable_globally', $form_state->getValue('enable_globally')); + $config->set('enable_globally_on_admin_routes', ($form_state->getValue('enable_globally') && $form_state->getValue('enable_globally_on_admin_routes'))); + $config->set('default_challenge', $form_state->getValue('default_challenge')); + + // Whitelisted ip addresses and ranges. + $config->set('whitelist_ips', $form_state->getValue('whitelist_ips')); + + // Save the CAPTCHA title: + $config->set('title', $form_state->getValue('title')); + // Save the CAPTCHA description: + $config->set('description', $form_state->getValue('description')); + + $config->set('wrong_captcha_response_message', $form_state->getValue('wrong_captcha_response_message')); + $config->set('default_validation', $form_state->getValue('default_validation')); + $config->set('persistence', $form_state->getValue('persistence')); + $config->set('enable_stats', $form_state->getValue('enable_stats')); + $config->set('log_wrong_responses', $form_state->getValue('log_wrong_responses')); + $config->save(); + + parent::submitForm($form, $form_state); + } + + /** + * Submit callback; clear CAPTCHA placement cache. + * + * @param array $form + * Form structured array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * Form state structured array. + */ + public function clearCaptchaPlacementCacheSubmit(array $form, FormStateInterface $form_state) { + $this->cacheBackend->delete('captcha_placement_map_cache'); + $this->messenger()->addMessage($this->t('Cleared the CAPTCHA placement cache.')); + } + +} diff --git a/web/modules/contrib/captcha/src/Plugin/migrate/process/CaptchaTypeFormatter.php b/web/modules/contrib/captcha/src/Plugin/migrate/process/CaptchaTypeFormatter.php new file mode 100644 index 000000000..d83eb5c81 --- /dev/null +++ b/web/modules/contrib/captcha/src/Plugin/migrate/process/CaptchaTypeFormatter.php @@ -0,0 +1,35 @@ +getSourceProperty('module') ?? 'captcha'; + $type = $row->getSourceProperty('captcha_type'); + return $module . '/' . $type; + } + +} diff --git a/web/modules/contrib/captcha/src/Plugin/migrate/source/CaptchaPoints.php b/web/modules/contrib/captcha/src/Plugin/migrate/source/CaptchaPoints.php new file mode 100644 index 000000000..3f3932f2d --- /dev/null +++ b/web/modules/contrib/captcha/src/Plugin/migrate/source/CaptchaPoints.php @@ -0,0 +1,59 @@ +get('state'), + $container->get('entity_type.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function query() { + return $this->select('captcha_points', 'c')->fields('c'); + } + + /** + * {@inheritdoc} + */ + public function fields() { + return [ + 'form_id' => $this->t('The name of the form'), + 'module' => $this->t('The captcha point providing module.'), + 'captcha_type' => $this->t('The captcha type.'), + ]; + } + + /** + * {@inheritdoc} + */ + public function getIds() { + $ids['form_id']['type'] = 'string'; + return $ids; + } + +} diff --git a/web/modules/contrib/captcha/src/Service/CaptchaService.php b/web/modules/contrib/captcha/src/Service/CaptchaService.php new file mode 100644 index 000000000..770d96ad9 --- /dev/null +++ b/web/modules/contrib/captcha/src/Service/CaptchaService.php @@ -0,0 +1,154 @@ +moduleHandler = $module_handler; + } + + /** + * Return an array with the available CAPTCHA types. + * + * For use as options array for a select form elements. + * + * @param bool $add_special_options + * If true: also add the 'default' option. + * + * @return array + * An associative array mapping "$module/$type" to + * "$type (from module $module)" with $module the module name + * implementing the CAPTCHA and $type the name of the CAPTCHA type. + */ + public function getAvailableChallengeTypes(bool $add_special_options = TRUE) { + $challenges = []; + + if ($add_special_options) { + $challenges[CaptchaConstants::CAPTCHA_TYPE_DEFAULT] = $this->t('Default challenge type'); + } + + // We do our own version of Drupal's module_invoke_all() here because + // we want to build an array with custom keys and values. + $types = []; + $this->moduleHandler->invokeAllWith('captcha', function (callable $hook, string $module) use (&$types) { + if ($type = $hook('list')) { + if (!is_array($type)) { + $types[$module] = [$type]; + } + else { + $types[$module] = $type; + } + } + }); + if (!empty($types)) { + foreach ($types as $module => $values) { + foreach ($values as $value) { + $challenges["$module/$value"] = $this->t('@type (from module @module)', [ + '@type' => $value, + '@module' => $module, + ]); + } + } + } + + return $challenges; + } + + /** + * Helper function to insert a CAPTCHA element before a given form element. + * + * @param array $form + * the form to add the CAPTCHA element to. + * @param null|array $placement + * information where the CAPTCHA element should be inserted. + * $placement should be an associative array with fields: + * - 'path': path (array of path items) of the container in + * the form where the CAPTCHA element should be inserted. + * - 'key': the key of the element before which the CAPTCHA element + * should be inserted. If the field 'key' is undefined or NULL, + * the CAPTCHA will just be appended in the container. + * - 'weight': if 'key' is not NULL: should be the weight of the + * element defined by 'key'. If 'key' is NULL and weight is not NULL: + * set the weight property of the CAPTCHA element to this value. + * @param array $captcha_element + * the CAPTCHA element to insert. + */ + public function insertCaptchaElement(array &$form, ?array $placement = NULL, ?array $captcha_element = NULL) { + // Get path, target and target weight or use defaults if not available. + $target_key = $placement['key'] ?? NULL; + $target_weight = $placement['weight'] ?? NULL; + $path = $placement['path'] ?? []; + + // Walk through the form along the path. + $form_stepper = &$form; + foreach ($path as $step) { + if (isset($form_stepper[$step])) { + $form_stepper = &$form_stepper[$step]; + } + else { + // Given path is invalid: stop stepping and + // continue in best effort (append instead of insert). + $target_key = NULL; + break; + } + } + + // If no target is available: just append the CAPTCHA element + // to the container. + if ($target_key == NULL || !array_key_exists($target_key, $form_stepper)) { + // Optionally, set weight of CAPTCHA element. + if ($target_weight != NULL) { + $captcha_element['#weight'] = $target_weight; + } + $form_stepper['captcha'] = $captcha_element; + } + // If there is a target available: make sure the CAPTCHA element + // comes right before it. + else { + // If target has a weight: set weight of CAPTCHA element a bit smaller + // and just append the CAPTCHA: sorting will fix the ordering anyway. + if ($target_weight != NULL) { + $captcha_element['#weight'] = $target_weight - .1; + $form_stepper['captcha'] = $captcha_element; + } + else { + // If we can't play with weights: insert the CAPTCHA element + // at the right position. Because PHP lacks a function for + // this (array_splice() comes close, but it does not preserve + // the key of the inserted element), we do it by hand: chop of + // the end, append the CAPTCHA element and put the end back. + $offset = array_search($target_key, array_keys($form_stepper)); + $end = array_splice($form_stepper, $offset); + $form_stepper['captcha'] = $captcha_element; + foreach ($end as $k => $v) { + $form_stepper[$k] = $v; + } + } + + } + } + +} diff --git a/web/modules/contrib/captcha/templates/captcha.html.twig b/web/modules/contrib/captcha/templates/captcha.html.twig new file mode 100755 index 000000000..ce347c129 --- /dev/null +++ b/web/modules/contrib/captcha/templates/captcha.html.twig @@ -0,0 +1,57 @@ +{# +/** + * @file + * Default theme implementation for a captcha. + * + * Available variables: + * - is_visible: Boolean to indicate if the CAPTCHA is visible or was + * already solved (but element still needed for hidden inputs). + * - attributes: HTML attributes for the containing element. + * - title: The captcha title + * - description: The captcha description + * - element: The captcha element itself (image, recaptcha, ...) + * + * @see template_preprocess_captcha() + * + * @ingroup themeable + */ +#} + +{% + set classes = [ + 'captcha', + ('captcha-type-challenge--' ~ element['#captcha_type_challenge'])|clean_class, + ] +%} +{# Tell search engines not to index the captcha element: #} +{% set attributes = attributes.addClass(classes).setAttribute('data-nosnippet', true) %} + +{% block captcha %} + {% if is_visible %} + {% block captcha_display %} + {% if title is not empty %} +
+ + {{ title }} + + {% else %} +
+ {% endif %} +
+ {{ element }} +
+ {% if description is not empty %} +
{{ description }}
+ {% endif %} + {% if title is not empty %} +
+ {% else %} + + {% endif %} + {% endblock %} + {% else %} + {# Required for _captcha_required_for_user() to preserve the hidden fields + in the form even though the CAPTCHA is not visible. #} + {{ element }} + {% endif %} +{% endblock %} diff --git a/web/modules/contrib/captcha/tests/fixtures/drupal7.php b/web/modules/contrib/captcha/tests/fixtures/drupal7.php new file mode 100644 index 000000000..d1f839289 --- /dev/null +++ b/web/modules/contrib/captcha/tests/fixtures/drupal7.php @@ -0,0 +1,252 @@ +insert('variable') + ->fields([ + 'name', + 'value', + ]) + ->values([ + 'name' => 'captcha_administration_mode', + 'value' => 'i:1;', + ]) + ->values([ + 'name' => 'captcha_allow_on_admin_pages', + 'value' => 'i:0;', + ]) + ->values([ + 'name' => 'captcha_default_challenge', + 'value' => 's:12:"captcha/Math";', + ]) + ->values([ + 'name' => 'captcha_default_challenge_on_nonlisted_forms', + 'value' => 'i:1;', + ]) + ->values([ + 'name' => 'captcha_default_validation', + 'value' => 's:1:"1";', + ]) + ->values([ + 'name' => 'captcha_description', + 'value' => 's:110:"This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.";', + ]) + ->values([ + 'name' => 'captcha_enable_stats', + 'value' => 'i:1;', + ]) + ->values([ + 'name' => 'captcha_error_message', + 'value' => 's:55:"The answer you entered for the CAPTCHA was not correct.";', + ]) + ->values([ + 'name' => 'captcha_log_wrong_responses', + 'value' => 'i:1;', + ]) + ->values([ + 'name' => 'captcha_persistence', + 'value' => 's:1:"1";', + ]) + ->execute(); + +$connection->insert('system') + ->fields([ + 'filename', + 'name', + 'type', + 'owner', + 'status', + 'bootstrap', + 'schema_version', + 'weight', + 'info', + ]) + ->values([ + 'filename' => 'sites/all/modules/captcha/captcha.module', + 'name' => 'captcha', + 'type' => 'module', + 'owner' => '', + 'status' => '1', + 'bootstrap' => '0', + 'schema_version' => '7001', + 'weight' => '0', + 'info' => 'a:13:{s:4:\"name\";s:7:\"CAPTCHA\";s:11:\"description\";s:61:\"Base CAPTCHA module for adding challenges to arbitrary forms.\";s:7:\"package\";s:12:\"Spam control\";s:4:\"core\";s:3:\"7.x\";s:9:\"configure\";s:27:\"admin/config/people/captcha\";s:5:\"files\";a:5:{i:0;s:14:\"captcha.module\";i:1;s:11:\"captcha.inc\";i:2;s:17:\"captcha.admin.inc\";i:3;s:15:\"captcha.install\";i:4;s:12:\"captcha.test\";}s:7:\"version\";s:7:\"7.x-1.7\";s:7:\"project\";s:7:\"captcha\";s:9:\"datestamp\";s:10:\"1582293280\";s:5:\"mtime\";i:1582293280;s:12:\"dependencies\";a:0:{}s:3:\"php\";s:5:\"5.2.4\";s:9:\"bootstrap\";i:0;}', + ]) + ->execute(); + +// Create the Captcha Points D7 Table. +$connection->schema()->createTable('captcha_points', [ + 'fields' => [ + 'form_id' => [ + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => 128, + 'default' => '', + ], + 'module' => [ + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => 64, + 'default' => '', + ], + 'captcha_type' => [ + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => 64, + 'default' => '', + ], + ], + 'primary key' => [ + 'form_id', + ], + 'mysql_character_set' => 'utf8', +]); + +$connection->insert('captcha_points') + ->fields([ + 'form_id', + 'module', + 'captcha_type', + ]) + ->values([ + 'form_id' => 'comment_node_article_form', + 'module' => 'captcha', + 'captcha_type' => 'Math', + ]) + ->values([ + 'form_id' => 'user_pass', + 'module' => 'captcha', + 'captcha_type' => 'Math', + ]) + ->execute(); + +// Create the Captcha Points D7 Table. +$connection->schema()->createTable('captcha_sessions', [ + 'fields' => [ + 'csid' => [ + 'type' => 'serial', + 'not null' => TRUE, + 'size' => 'normal', + ], + 'token' => [ + 'type' => 'varchar', + 'not null' => FALSE, + 'length' => 64, + ], + 'uid' => [ + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'normal', + 'default' => 0, + ], + 'sid' => [ + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => 128, + 'default' => '', + ], + 'ip_address' => [ + 'type' => 'varchar', + 'not null' => FALSE, + 'length' => 128, + ], + 'timestamp' => [ + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'normal', + 'default' => 0, + ], + 'form_id' => [ + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => 128, + 'default' => '', + ], + 'solution' => [ + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => 128, + 'default' => '', + ], + 'status' => [ + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'normal', + 'default' => 0, + ], + 'attempts' => [ + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'normal', + 'default' => 0, + ], + ], + 'primary key' => [ + 'csid', + ], + 'indexes' => [ + 'csid_ip' => [ + 'csid', + 'ip_address', + ], + ], + 'mysql_character_set' => 'utf8', +]); + +$connection->insert('captcha_sessions') + ->fields([ + 'csid', + 'token', + 'uid', + 'sid', + 'ip_address', + 'timestamp', + 'form_id', + 'solution', + 'status', + 'attempts', + ]) + ->values([ + 'csid' => 1, + 'token' => '69e2767a2c651a887764bb60ea04cd0a', + 'uid' => 0, + 'sid' => 'svBxnT_AK4YFTbiUdCN3g9lCEqhC66NEbxasNNvGRug', + 'ip_address' => '172.18.0.1', + 'timestamp' => 1617948210, + 'form_id' => 'user_login_block', + 'solution' => '11', + 'status' => 0, + 'attempts' => 0, + ]) + ->values([ + 'csid' => 2, + 'token' => '69e2767a2c651a887764bb60ea04cd0b', + 'uid' => 0, + 'sid' => 'avBxnT_AK4YFTbiUdCN3g9lCEqhC66NEbxasNNvGRug', + 'ip_address' => '172.18.0.1', + 'timestamp' => 1617948230, + 'form_id' => 'user_login_block', + 'solution' => '20', + 'status' => 0, + 'attempts' => 0, + ]) + ->values([ + 'csid' => 3, + 'token' => '69e2767a2c651a887764bb60ea04cd0c', + 'uid' => 0, + 'sid' => 'bvBxnT_AK4YFTbiUdCN3g9lCEqhC66NEbxasNNvGRug', + 'ip_address' => '172.18.0.1', + 'timestamp' => 1617948240, + 'form_id' => 'user_login_block', + 'solution' => '25', + 'status' => 0, + 'attempts' => 0, + ]) + ->execute(); diff --git a/web/modules/contrib/captcha/tests/modules/captcha_long_form_id_test/captcha_long_form_id_test.info.yml b/web/modules/contrib/captcha/tests/modules/captcha_long_form_id_test/captcha_long_form_id_test.info.yml new file mode 100644 index 000000000..74db32100 --- /dev/null +++ b/web/modules/contrib/captcha/tests/modules/captcha_long_form_id_test/captcha_long_form_id_test.info.yml @@ -0,0 +1,10 @@ +name: captcha long form id test module +type: module +description: 'Test module for testing captchas added to forms with ids longer than 64 characters' +package: Testing +hidden: true + +# Information added by Drupal.org packaging script on 2025-07-28 +version: '2.0.9' +project: 'captcha' +datestamp: 1753701291 diff --git a/web/modules/contrib/captcha/tests/modules/captcha_long_form_id_test/captcha_long_form_id_test.routing.yml b/web/modules/contrib/captcha/tests/modules/captcha_long_form_id_test/captcha_long_form_id_test.routing.yml new file mode 100644 index 000000000..0fc46ae98 --- /dev/null +++ b/web/modules/contrib/captcha/tests/modules/captcha_long_form_id_test/captcha_long_form_id_test.routing.yml @@ -0,0 +1,8 @@ +captcha_long_form_id_test.this_formid_is_intentionally_longer_than_64_characters_to_test_captcha: + path: 'captcha/test_form/long_id' + defaults: + _form: '\Drupal\captcha_long_form_id_test\Form\LongIdForm' + _title: 'LongIdForm' + requirements: + # for testing only, can be open + _access: 'TRUE' diff --git a/web/modules/contrib/captcha/tests/modules/captcha_long_form_id_test/src/Form/LongIdForm.php b/web/modules/contrib/captcha/tests/modules/captcha_long_form_id_test/src/Form/LongIdForm.php new file mode 100644 index 000000000..97b95d0be --- /dev/null +++ b/web/modules/contrib/captcha/tests/modules/captcha_long_form_id_test/src/Form/LongIdForm.php @@ -0,0 +1,49 @@ + 'textfield', + '#title' => $this->t('Text Field'), + '#maxlength' => 64, + '#size' => 64, + ]; + $form['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Submit'), + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + // Display result. + foreach ($form_state->getValues() as $key => $value) { + $this->messenger()->addMessage($key . ': ' . $value); + } + + } + +} diff --git a/web/modules/contrib/captcha/tests/modules/captcha_test/captcha_test.info.yml b/web/modules/contrib/captcha/tests/modules/captcha_test/captcha_test.info.yml new file mode 100644 index 000000000..d646cf0d3 --- /dev/null +++ b/web/modules/contrib/captcha/tests/modules/captcha_test/captcha_test.info.yml @@ -0,0 +1,12 @@ +name: 'Captcha Test' +type: module +description: 'Provides captcha types for tests.' +package: Testing +hidden: true +dependencies: + - captcha:captcha + +# Information added by Drupal.org packaging script on 2025-07-28 +version: '2.0.9' +project: 'captcha' +datestamp: 1753701291 diff --git a/web/modules/contrib/captcha/tests/modules/captcha_test/captcha_test.module b/web/modules/contrib/captcha/tests/modules/captcha_test/captcha_test.module new file mode 100644 index 000000000..97d976847 --- /dev/null +++ b/web/modules/contrib/captcha/tests/modules/captcha_test/captcha_test.module @@ -0,0 +1,45 @@ + TRUE, + // Cacheable captcha types need to provide a custom validation + // callback that doesn't care about the solution, because a form can + // be shown containing a cached CSID that has since been deleted + // from the {captcha_sessions} table. + 'captcha_validate' => 'captcha_test_captcha_captcha_validation', + 'solution' => 'Test 123', + 'form' => [], + ]; + $result['form']['captcha_response'] = [ + '#type' => 'textfield', + '#title' => t('Test one two three'), + '#required' => TRUE, + ]; + + return $result; + } + } +} + +/** + * Validation callback. + */ +function captcha_test_captcha_captcha_validation() { + return TRUE; +} diff --git a/web/modules/contrib/captcha/tests/modules/captcha_test/captcha_test.routing.yml b/web/modules/contrib/captcha/tests/modules/captcha_test/captcha_test.routing.yml new file mode 100644 index 000000000..002391579 --- /dev/null +++ b/web/modules/contrib/captcha/tests/modules/captcha_test/captcha_test.routing.yml @@ -0,0 +1,7 @@ +captcha_test.test: + path: '/captcha-test/test' + defaults: + _title: 'Test' + _form: 'Drupal\captcha_test\Form\TestForm' + requirements: + _permission: 'access content' diff --git a/web/modules/contrib/captcha/tests/modules/captcha_test/src/Form/TestForm.php b/web/modules/contrib/captcha/tests/modules/captcha_test/src/Form/TestForm.php new file mode 100644 index 000000000..78783bce6 --- /dev/null +++ b/web/modules/contrib/captcha/tests/modules/captcha_test/src/Form/TestForm.php @@ -0,0 +1,53 @@ + 'captcha', + '#captcha_type' => CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE, + '#captcha_admin_mode' => TRUE, + ]; + + $form['math_captcha_admin_false'] = [ + '#type' => 'captcha', + '#captcha_type' => CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE, + '#captcha_admin_mode' => FALSE, + ]; + + $form['math_captcha_admin_not_set'] = [ + '#type' => 'captcha', + '#captcha_type' => CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE, + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->messenger()->addStatus($this->t('Form submitted!')); + } + +} diff --git a/web/modules/contrib/captcha/tests/src/Functional/CaptchaAdminTest.php b/web/modules/contrib/captcha/tests/src/Functional/CaptchaAdminTest.php new file mode 100755 index 000000000..07556bc1e --- /dev/null +++ b/web/modules/contrib/captcha/tests/src/Functional/CaptchaAdminTest.php @@ -0,0 +1,688 @@ +userWithoutSkipCaptcha = $this->drupalCreateUser([ + 'access content', + 'administer site configuration', + 'administer CAPTCHA settings', + ]); + } + + /** + * Modules to enable. + * + * @var array + */ + protected static $modules = [ + 'test_page_test', + ]; + + /** + * Test access to the admin pages. + */ + public function testAdminAccess() { + $this->drupalLogin($this->normalUser); + $this->drupalGet(self::CAPTCHA_ADMIN_PATH); + $this->assertSession()->pageTextContains($this->t('Access denied')); + + $this->drupalLogin($this->adminUser); + $this->drupalGet(self::CAPTCHA_ADMIN_PATH); + $this->assertSession()->pageTextNotContains($this->t('Access denied')); + } + + /** + * Test the CAPTCHA point setting getter/setter. + */ + public function testCaptchaPointSettingGetterAndSetter() { + $comment_form_id = self::COMMENT_FORM_ID; + captcha_set_form_id_setting($comment_form_id, 'test'); + /** @var \Drupal\captcha\Entity\CaptchaPoint $result */ + $result = captcha_get_form_id_setting($comment_form_id); + $this->assertNotNull($result, 'CAPTCHA exists'); + $this->assertEquals($result->getCaptchaType(), 'test', 'CAPTCHA type: default'); + $result = captcha_get_form_id_setting($comment_form_id, TRUE); + $this->assertNotNull($result, 'CAPTCHA exists'); + $this->assertEquals($result, 'test', 'Setting and symbolic getting CAPTCHA point: "test"'); + + // Set to 'default'. + captcha_set_form_id_setting($comment_form_id, CaptchaConstants::CAPTCHA_TYPE_DEFAULT); + $this->config('captcha.settings') + ->set('default_challenge', 'foo/bar') + ->save(); + $result = captcha_get_form_id_setting($comment_form_id); + $this->assertNotNull($result, 'CAPTCHA exists'); + $this->assertEquals($result->getCaptchaType(), 'foo/bar', 'Setting and getting CAPTCHA point: default'); + $result = captcha_get_form_id_setting($comment_form_id, TRUE); + $this->assertNotNull($result, 'Setting and symbolic getting CAPTCHA point: "default"'); + $this->assertEquals($result, 'foo/bar', 'Setting and symbolic getting CAPTCHA point: default'); + + // Set to 'baz/boo'. + captcha_set_form_id_setting($comment_form_id, 'baz/boo'); + $result = captcha_get_form_id_setting($comment_form_id); + $this->assertNotNull($result, 'CAPTCHA exists'); + $this->assertEquals($result->getCaptchaType(), 'baz/boo', 'Setting and getting CAPTCHA point: baz/boo'); + $result = captcha_get_form_id_setting($comment_form_id, TRUE); + $this->assertEquals($result, 'baz/boo', 'Setting and symbolic getting CAPTCHA point: "baz/boo"'); + + // Set to NULL (which should delete the CAPTCHA point setting entry). + captcha_set_form_id_setting($comment_form_id, NULL); + $result = captcha_get_form_id_setting($comment_form_id); + $this->assertNotNull($result, 'CAPTCHA exists'); + $this->assertEquals($result->getCaptchaType(), 'foo/bar', 'Setting and getting CAPTCHA point: NULL'); + $result = captcha_get_form_id_setting($comment_form_id, TRUE); + $this->assertNotNull($result, 'CAPTCHA exists'); + + // Set with object. + $captcha_type = 'baba/fofo'; + captcha_set_form_id_setting($comment_form_id, $captcha_type); + + $result = captcha_get_form_id_setting($comment_form_id); + $this->assertNotNull($result, 'Setting and getting CAPTCHA point: baba/fofo'); + // $this->assertEqual($result->module, 'baba', 'Setting and getting + // CAPTCHA point: baba/fofo', 'CAPTCHA');. + $this->assertEquals($result->getCaptchaType(), 'baba/fofo', 'Setting and getting CAPTCHA point: baba/fofo'); + $result = captcha_get_form_id_setting($comment_form_id, TRUE); + $this->assertEquals($result, 'baba/fofo', 'Setting and symbolic getting CAPTCHA point: "baba/fofo"'); + } + + /** + * Helper function for checking CAPTCHA setting of a form. + * + * @param string $form_id + * The form_id of the form to investigate. + * @param string $challenge_type + * What the challenge type should be: + * NULL, 'default' or something like 'captcha/Math'. + */ + protected function assertCaptchaSetting($form_id, $challenge_type) { + $result = captcha_get_form_id_setting(self::COMMENT_FORM_ID, TRUE); + $this->assertEquals($result, $challenge_type, + $this->t('Check CAPTCHA setting for form: expected: @expected, received: @received.', + [ + '@expected' => var_export($challenge_type, TRUE), + '@received' => var_export($result, TRUE), + ])); + } + + /** + * Testing of the CAPTCHA administration links. + */ + public function testCaptchaAdminLinks() { + $this->drupalLogin($this->adminUser); + + // Enable CAPTCHA administration links. + $edit = [ + 'administration_mode' => TRUE, + ]; + + $this->drupalGet(self::CAPTCHA_ADMIN_PATH); + $this->submitForm($edit, 'Save configuration'); + + // Create a node with comments enabled. + $node = $this->drupalCreateNode(); + + // Go to node page. + $this->drupalGet('node/' . $node->id()); + + // Click the add new comment link. + $this->clickLink($this->t('Add new comment')); + $add_comment_url = $this->getUrl(); + + // Remove fragment part from comment URL to avoid + // problems with later asserts. + $add_comment_url = strtok($add_comment_url, "#"); + + // Click the CAPTCHA admin link to enable a challenge. + $this->clickLink($this->t('Place a CAPTCHA here for untrusted users.')); + + // Enable Math CAPTCHA. + $edit = ['captchaType' => CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE]; + $this->drupalGet($this->getUrl()); + $this->submitForm($edit, 'Save'); + + // Check if returned to original comment form. + $this->assertSession()->addressEquals($add_comment_url); + + // Check if CAPTCHA was successfully enabled + // (on CAPTCHA admin links fieldset). + $this->assertSession()->pageTextContains($this->t('CAPTCHA: challenge "@type" enabled', ['@type' => $edit['captchaType']])); + + // Check if CAPTCHA was successfully enabled (through API). + $this->assertCaptchaSetting(self::COMMENT_FORM_ID, CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE); + + // Edit challenge type through CAPTCHA admin links. + $this->clickLink($this->t('change')); + + // Enable Math CAPTCHA. + $edit = ['captchaType' => CaptchaConstants::CAPTCHA_TYPE_DEFAULT]; + $this->drupalGet($this->getUrl()); + $this->submitForm($edit, 'Save'); + + // Check if returned to original comment form. + $this->assertEquals($add_comment_url, $this->getUrl(), + 'After editing challenge type CAPTCHA admin links: should return to original form.'); + + // Check if CAPTCHA was successfully changed + // (on CAPTCHA admin links fieldset). + // This is actually the same as the previous setting because + // the captcha/Math is the default for the default challenge. + // @todo Make sure the edit is a real change. + $this->assertSession()->pageTextContains($this->t('CAPTCHA: challenge "@type" enabled', ['@type' => $edit['captchaType']])); + // Check if CAPTCHA was successfully edited (through API). + $this->assertCaptchaSetting(self::COMMENT_FORM_ID, CaptchaConstants::CAPTCHA_TYPE_DEFAULT); + + // Disable challenge through CAPTCHA admin links. + $this->drupalGet(Url::fromRoute('entity.captcha_point.disable', ['captcha_point' => self::COMMENT_FORM_ID])); + $this->submitForm([], 'Disable'); + + // Check if returned to captcha point list. + global $base_url; + $this->assertEquals($base_url . '/admin/config/people/captcha/captcha-points', $this->getUrl(), + 'After disabling challenge in CAPTCHA admin: should return to captcha point list.'); + + // Check if CAPTCHA was successfully disabled + // (on CAPTCHA admin links fieldset). + $this->assertSession()->responseContains($this->t('Captcha point %form_id has been disabled.', ['%form_id' => self::COMMENT_FORM_ID])); + } + + /** + * Test untrusted user posting. + */ + public function testUntrustedUserPosting() { + // Set CAPTCHA on comment form. + captcha_set_form_id_setting(self::COMMENT_FORM_ID, CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE); + + // Create a node with comments enabled. + $node = $this->drupalCreateNode(); + + // Log in as normal (untrusted) user. + $this->drupalLogin($this->normalUser); + + // Go to node page and click the "add comment" link. + $this->drupalGet('node/' . $node->id()); + $this->clickLink($this->t('Add new comment')); + $add_comment_url = $this->getUrl(); + + // Check if CAPTCHA is visible on form. + $this->assertCaptchaPresence(TRUE); + // Try to post a comment with wrong answer. + $edit = $this->getCommentFormValues(); + $edit['captcha_response'] = 'xx'; + $this->drupalGet($add_comment_url); + $this->submitForm($edit, 'Preview'); + $this->assertSession()->pageTextContains(self::CAPTCHA_WRONG_RESPONSE_ERROR_MESSAGE); + } + + /** + * Test XSS vulnerability on CAPTCHA description. + */ + public function testXssOnCaptchaDescription() { + // Set CAPTCHA on user register form. + captcha_set_form_id_setting('user_register', CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE); + + // Put JavaScript snippet in CAPTCHA description. + $this->drupalLogin($this->adminUser); + $xss = ''; + $edit = ['description' => $xss]; + $this->drupalGet(self::CAPTCHA_ADMIN_PATH); + $this->submitForm($edit, 'Save configuration'); + + // Visit user register form and check if JavaScript snippet is there. + $this->drupalLogout(); + $this->drupalGet('user/register'); + $this->assertSession()->responseNotContains($xss); + } + + /** + * Test the CAPTCHA placement clearing. + */ + public function testCaptchaPlacementCacheClearing() { + // Set CAPTCHA on user register form. + captcha_set_form_id_setting('user_register_form', CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE); + // Visit user register form to fill the CAPTCHA placement cache. + $this->drupalGet('user/register'); + // Check if there is CAPTCHA placement cache. + $placement_map = $this->container->get('cache.default') + ->get('captcha_placement_map_cache'); + $this->assertNotNull($placement_map, 'CAPTCHA placement cache should be set.'); + // Clear the cache. + $this->drupalLogin($this->adminUser); + $this->drupalGet(self::CAPTCHA_ADMIN_PATH); + $this->submitForm([], 'Clear the CAPTCHA placement cache'); + + // Check that the placement cache is unset. + $placement_map = $this->container->get('cache.default') + ->get('captcha_placement_map_cache'); + $this->assertFalse($placement_map, 'CAPTCHA placement cache should be unset after cache clear.'); + } + + /** + * Helper function to get CAPTCHA point setting straight from the database. + * + * @param string $form_id + * Form machine ID. + * + * @return \Drupal\captcha\Entity\CaptchaPoint + * CaptchaPoint with mysql query result. + */ + protected function getCaptchaPointSettingFromDatabase($form_id) { + $ids = \Drupal::entityQuery('captcha_point') + ->condition('formId', $form_id) + ->execute(); + return $ids ? CaptchaPoint::load(reset($ids)) : NULL; + } + + /** + * Method for testing the CAPTCHA point administration. + */ + public function testCaptchaPointAdministration() { + // Generate CAPTCHA point data: + // Drupal form ID should consist of lowercase alphanumerics and underscore). + $captcha_point_form_id = 'form_' . strtolower($this->randomMachineName(32)); + // The Math CAPTCHA by the CAPTCHA module is always available, + // so let's use it. + $captcha_point_module = 'captcha'; + $captcha_point_type = 'Math'; + + // Log in as admin. + $this->drupalLogin($this->adminUser); + $label = 'TEST'; + + // Try and set CAPTCHA point without the #required label. Should fail. + $form_values = [ + 'formId' => $captcha_point_form_id, + 'captchaType' => $captcha_point_module . '/' . $captcha_point_type, + ]; + $this->drupalGet(self::CAPTCHA_ADMIN_PATH . '/captcha-points/add'); + $this->submitForm($form_values, 'Save'); + $this->assertSession()->pageTextContains($this->t('Form ID field is required.')); + + // Set CAPTCHA point through admin/user/captcha/captcha/captcha_point. + $form_values['label'] = $label; + $this->drupalGet(self::CAPTCHA_ADMIN_PATH . '/captcha-points/add'); + $this->submitForm($form_values, 'Save'); + $this->assertSession()->responseContains($this->t('Captcha Point for %label form was created.', ['%label' => $captcha_point_form_id])); + + // Check in database. + /** @var \Drupal\captcha\Entity\CaptchaPoint result */ + $result = $this->getCaptchaPointSettingFromDatabase($captcha_point_form_id); + $this->assertEquals($result->captchaType, $captcha_point_module . '/' . $captcha_point_type, + 'Enabled CAPTCHA point should have module and type set'); + + // Disable CAPTCHA point again. + $this->drupalGet(self::CAPTCHA_ADMIN_PATH . '/captcha-points/' . $captcha_point_form_id . '/disable'); + $this->submitForm([], 'Disable'); + $this->assertSession()->responseContains($this->t('Captcha point %label has been disabled.', ['%label' => $label])); + + // Check in database. + $result = $this->getCaptchaPointSettingFromDatabase($captcha_point_form_id); + $this->assertInstanceOf(CaptchaPoint::class, $result, 'Disabled CAPTCHA point should be in database'); + $this->assertFalse($result->status()); + + // Set CAPTCHA point via admin/user/captcha/captcha/captcha_point/$form_id. + $form_values = [ + 'captchaType' => $captcha_point_module . '/' . $captcha_point_type, + ]; + $this->drupalGet(self::CAPTCHA_ADMIN_PATH . '/captcha-points/' . $captcha_point_form_id); + $this->submitForm($form_values, 'Save'); + $this->assertSession()->responseContains($this->t('Captcha Point for %form_id form was updated.', ['%form_id' => $captcha_point_form_id])); + + // Check in database. + $result = $this->getCaptchaPointSettingFromDatabase($captcha_point_form_id); + $this->assertEquals($result->captchaType, $captcha_point_module . '/' . $captcha_point_type, + 'Enabled CAPTCHA point should have module and type set'); + + // Delete CAPTCHA point. + $this->drupalGet(self::CAPTCHA_ADMIN_PATH . '/captcha-points/' . $captcha_point_form_id . '/delete'); + $this->submitForm([], 'Delete'); + $this->assertSession()->responseContains($this->t('Captcha point %label has been deleted.', ['%label' => $label])); + + $result = $this->getCaptchaPointSettingFromDatabase($captcha_point_form_id); + $this->assertNull($result, 'Deleted CAPTCHA point should not be in database'); + } + + /** + * Method for testing the CAPTCHA point administration. + */ + public function testCaptchaPointAdministrationByNonAdmin() { + // First add a CAPTCHA point (as admin). + $captcha_point_form_id = 'form_' . strtolower($this->randomMachineName(32)); + $captcha_point_module = 'captcha'; + $captcha_point_type = 'Math'; + $label = 'TEST_2'; + + $this->drupalLogin($this->adminUser); + + $form_values = [ + 'label' => $label, + 'formId' => $captcha_point_form_id, + 'captchaType' => $captcha_point_module . '/' . $captcha_point_type, + ]; + $this->drupalGet(self::CAPTCHA_ADMIN_PATH . '/captcha-points/add'); + $this->submitForm($form_values, 'Save'); + $this->assertSession()->responseContains($this->t('Captcha Point for %form_id form was created.', ['%form_id' => $captcha_point_form_id])); + + // Switch from admin to non-admin. + $this->drupalLogin($this->normalUser); + + // Try to set CAPTCHA point + // through admin/user/captcha/captcha/captcha_point. + $this->drupalGet(self::CAPTCHA_ADMIN_PATH . '/captcha-points'); + $this->assertSession()->pageTextContains($this->t('You are not authorized to access this page.')); + + // Try to disable the CAPTCHA point. + $this->drupalGet(self::CAPTCHA_ADMIN_PATH . '/captcha-points/' . $captcha_point_form_id . '/disable'); + $this->assertSession()->pageTextContains($this->t('You are not authorized to access this page.')); + + // Try to delete the CAPTCHA point. + $this->drupalGet(self::CAPTCHA_ADMIN_PATH . '/captcha-points/' . $captcha_point_form_id . '/delete'); + $this->assertSession()->pageTextContains($this->t('You are not authorized to access this page.')); + + // Switch from nonadmin to admin again. + $this->drupalLogin($this->adminUser); + + // Check if original CAPTCHA point still exists in database. + $result = $this->getCaptchaPointSettingFromDatabase($captcha_point_form_id); + $this->assertEquals($result->captchaType, $captcha_point_module . '/' . $captcha_point_type, 'Enabled CAPTCHA point should have module and type set'); + + // Delete captcha point. + $this->drupalGet(self::CAPTCHA_ADMIN_PATH . '/captcha-points/' . $captcha_point_form_id . '/delete'); + $this->submitForm([], 'Delete'); + $this->assertSession()->responseContains($this->t('Captcha point %label has been deleted.', ['%label' => $label])); + } + + /** + * Tests the admin captcha examples form. + */ + public function testCaptchaAdminExamplesForm() { + $this->drupalLogin($this->adminUser); + $session = $this->assertSession(); + $this->drupalGet('/admin/config/people/captcha/examples'); + $session->statusCodeEquals(200); + $session->pageTextContains('CAPTCHA examples'); + // Check if math challenge details exists: + $session->elementExists('css', '#edit-captcha-captcha-0'); + $session->elementTextEquals('css', 'details#edit-captcha-captcha-0 > summary', 'Challenge Math by module captcha'); + // Check if math captcha exists: + $session->elementExists('css', 'fieldset.captcha.captcha.captcha-type-challenge--math'); + $session->elementExists('css', 'fieldset.captcha.captcha.captcha-type-challenge--math > div.captcha__element'); + } + + /** + * Tests the captcha administration mode (admin informations). + */ + public function testCaptchaAdministrationMode() { + $this->drupalLogin($this->adminUser); + $session = $this->assertSession(); + // Enable administration mode: + $this->config('captcha.settings')->set('administration_mode', TRUE)->save(); + // Create Captcha point on a non admin test page: + CaptchaPoint::create([ + 'formId' => 'test_page_form', + 'label' => 'CaptchaPointOnNonAdminPage', + 'captchaType' => CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE, + ])->save(); + // Create Captcha point on a admin test page: + CaptchaPoint::create([ + 'formId' => 'system_performance_settings', + 'label' => 'CaptchaPointOnAdminPage', + 'captchaType' => CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE, + ])->save(); + // Go to the test page and check if the admin information get displayed: + $this->drupalGet('/test-field-xpath'); + $session->pageTextContains('Users without the "skip CAPTCHA" permission will see a CAPTCHA here'); + $session->elementExists('css', 'details.captcha-admin-links.form-wrapper'); + // Check summary text: + $session->elementExists('css', 'details.captcha-admin-links.form-wrapper > summary'); + $session->elementTextContains('css', 'details.captcha-admin-links.form-wrapper > summary', 'CAPTCHA: challenge "captcha/Math" enabled'); + // Check if link to settings page exists: + $session->elementExists('css', 'details.captcha-admin-links.form-wrapper > a[href*="captcha"]'); + // Check if link to assoicated captcha point exists: + $session->elementExists('css', 'details.captcha-admin-links.form-wrapper > div#edit-challenge'); + $session->elementExists('css', 'details.captcha-admin-links.form-wrapper > div#edit-challenge > a[href*="/admin/config/people/captcha/captcha-points/test_page_form"]'); + + // Go to the admin form and see if there is no captcha at all, as it should + // be simply skipped: + $this->drupalGet('/admin/config/development/performance'); + $session->elementNotExists('css', 'fieldset.captcha'); + $session->elementNotExists('css', 'fieldset.captcha > div.captcha__element'); + $session->pageTextNotContains('This question is for testing whether or not you are a human visitor and to prevent automated spam submissions'); + + // Login as a user without the "skip CAPTCHA" permission and check + // everything once again: + $this->drupalLogout(); + $this->drupalLogin($this->userWithoutSkipCaptcha); + $this->drupalGet('/test-field-xpath'); + + // Go to the test page and check if the admin information won't get + // displayed: + $session->pageTextNotContains('Users without the "skip CAPTCHA" permission will see a CAPTCHA here'); + $session->elementNotExists('css', 'details.captcha-admin-links.form-wrapper'); + // See if instead the captcha appears: + $session->elementExists('css', 'fieldset.captcha.captcha-type-challenge--math'); + + // The same behaviour should happen on the admin page: + $session->pageTextNotContains('Users without the "skip CAPTCHA" permission will see a CAPTCHA here'); + $session->elementNotExists('css', 'details.captcha-admin-links.form-wrapper'); + // See if instead the captcha appears: + $session->elementExists('css', 'fieldset.captcha.captcha-type-challenge--math'); + + // Logout and check the behaviour on the non admin page: + $this->drupalLogout(); + $this->drupalGet('/test-field-xpath'); + + // Go to the test page and check if the admin information won't get + // displayed: + $session->pageTextNotContains('Users without the "skip CAPTCHA" permission will see a CAPTCHA here'); + $session->elementNotExists('css', 'details.captcha-admin-links.form-wrapper'); + // See if instead the captcha appears: + $session->elementExists('css', 'fieldset.captcha.captcha-type-challenge--math'); + } + + /** + * Tests the captcha administration mode (admin informations). + */ + public function testCaptchaAdministrationModeOnAdminRoutes() { + $this->drupalLogin($this->adminUser); + $session = $this->assertSession(); + // Enable administration mode: + $this->config('captcha.settings')->set('administration_mode', TRUE)->save(); + $this->config('captcha.settings')->set('administration_mode_on_admin_routes', TRUE)->save(); + // Create Captcha point on a non admin test page: + CaptchaPoint::create([ + 'formId' => 'test_page_form', + 'label' => 'CaptchaPointOnNonAdminPage', + 'captchaType' => CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE, + ])->save(); + // Create Captcha point on a admin test page: + CaptchaPoint::create([ + 'formId' => 'system_performance_settings', + 'label' => 'CaptchaPointOnAdminPage', + 'captchaType' => CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE, + ])->save(); + // Go to the test page and check if the admin information get displayed: + $this->drupalGet('/test-field-xpath'); + $session->pageTextContains('Users without the "skip CAPTCHA" permission will see a CAPTCHA here'); + $session->elementExists('css', 'details.captcha-admin-links.form-wrapper'); + // Check summary text: + $session->elementExists('css', 'details.captcha-admin-links.form-wrapper > summary'); + $session->elementTextContains('css', 'details.captcha-admin-links.form-wrapper > summary', 'CAPTCHA: challenge "captcha/Math" enabled'); + // Check if link to settings page exists: + $session->elementExists('css', 'details.captcha-admin-links.form-wrapper > a[href*="/admin/config/people/captcha"]'); + // Check if link to assoicated captcha point exists: + $session->elementExists('css', 'details.captcha-admin-links.form-wrapper > div#edit-challenge'); + $session->elementExists('css', 'details.captcha-admin-links.form-wrapper > div#edit-challenge > a[href*="/admin/config/people/captcha/captcha-points/test_page_form"]'); + + // Go to the admin form and see if also there the admin information get + // displayed: + $this->drupalGet('/admin/config/development/performance'); + $session->pageTextContains('Users without the "skip CAPTCHA" permission will see a CAPTCHA here'); + $session->elementExists('css', 'details.captcha-admin-links.form-wrapper'); + // Check summary text: + $session->elementExists('css', 'details.captcha-admin-links.form-wrapper > summary'); + $session->elementTextContains('css', '#system-performance-settings > details.captcha-admin-links.form-wrapper > summary', 'CAPTCHA: challenge "captcha/Math" enabled'); + // Check if link to settings page exists: + $session->elementExists('css', 'details.captcha-admin-links.form-wrapper > a[href*="/admin/config/people/captcha"]'); + // Check if link to assoicated captcha point exists: + $session->elementExists('css', 'details.captcha-admin-links.form-wrapper > div#edit-challenge'); + $session->elementExists('css', 'details.captcha-admin-links.form-wrapper > div#edit-challenge > a[href*="/admin/config/people/captcha/captcha-points/system_performance_settings"]'); + + // Login as a user without the "skip CAPTCHA" permission and check + // everything once again: + $this->drupalLogout(); + $this->drupalLogin($this->userWithoutSkipCaptcha); + $this->drupalGet('/test-field-xpath'); + + // Go to the test page and check if the admin information won't get + // displayed: + $session->pageTextNotContains('Users without the "skip CAPTCHA" permission will see a CAPTCHA here'); + $session->elementNotExists('css', 'details.captcha-admin-links.form-wrapper'); + // See if instead the captcha appears: + $session->elementExists('css', 'fieldset.captcha.captcha-type-challenge--math'); + + // The same behaviour should happen on the admin page: + $session->pageTextNotContains('Users without the "skip CAPTCHA" permission will see a CAPTCHA here'); + $session->elementNotExists('css', 'details.captcha-admin-links.form-wrapper'); + // See if instead the captcha appears: + $session->elementExists('css', 'fieldset.captcha.captcha-type-challenge--math'); + + // Logout and check the behaviour on the non admin page: + $this->drupalLogout(); + $this->drupalGet('/test-field-xpath'); + + // Go to the test page and check if the admin information won't get + // displayed: + $session->pageTextNotContains('Users without the "skip CAPTCHA" permission will see a CAPTCHA here'); + $session->elementNotExists('css', 'details.captcha-admin-links.form-wrapper'); + // See if instead the captcha appears: + $session->elementExists('css', 'fieldset.captcha.captcha-type-challenge--math'); + } + + /** + * Tests the captcha enable globally setting. + */ + public function testCaptchaEnableGlobally() { + // Disable login captcha to be able to log in: + $this->disableLoginCaptchaPoint(); + $this->drupalLogin($this->adminUser); + $session = $this->assertSession(); + // Set math challenge as default: + $this->setDefaultChallenge('captcha/Math'); + + // Enable globally: + $this->config('captcha.settings')->set('enable_globally', TRUE)->save(); + + // Go to the test page and check if there is no captcha displayed, as the + // admin has the "skip CAPTCHA" permission: + $this->drupalGet('/test-field-xpath'); + $session->elementNotExists('css', 'fieldset.captcha.captcha-type-challenge--math'); + $session->pageTextNotContains('This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.'); + + // Go to the admin form and see if there no captcha displayed: + $this->drupalGet('/admin/config/development/performance'); + $session->elementNotExists('css', 'fieldset.captcha.captcha-type-challenge--math'); + $session->pageTextNotContains('This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.'); + + // Login as a user without the "skip CAPTCHA" permission and check + // everything once again: + $this->drupalLogout(); + $this->drupalLogin($this->userWithoutSkipCaptcha); + $this->drupalGet('/test-field-xpath'); + + // Go to the test page and check if the captcha gets displayed: + $this->drupalGet('/test-field-xpath'); + $session->elementExists('css', 'fieldset.captcha.captcha-type-challenge--math'); + $session->pageTextContains('This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.'); + + // Go to the admin form and see if there no captcha is displayed: + $this->drupalGet('/admin/config/development/performance'); + $session->elementNotExists('css', 'fieldset.captcha.captcha-type-challenge--math'); + $session->pageTextNotContains('This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.'); + + // Logout and check the behaviour on the non admin page: + method_exists($this, 'drupalResetSession') ? $this->drupalResetSession() : $this->drupalLogout(); + $this->drupalGet('/test-field-xpath'); + + // Go to the test page and check if the captcha gets displayed: + $this->drupalGet('/test-field-xpath'); + $session->elementExists('css', 'fieldset.captcha.captcha-type-challenge--math'); + $session->pageTextContains('This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.'); + } + + /** + * Tests the captcha enable globally setting. + */ + public function testCaptchaEnableGloballyOnAdminRoutes() { + // Disable login captcha to be able to log in: + $this->disableLoginCaptchaPoint(); + $this->drupalLogin($this->adminUser); + $session = $this->assertSession(); + // Set math challenge as default: + $this->setDefaultChallenge('captcha/Math'); + + // Enable globally: + $this->config('captcha.settings')->set('enable_globally', TRUE)->save(); + $this->config('captcha.settings')->set('enable_globally_on_admin_routes', TRUE)->save(); + + // Go to the test page and check if there is no captcha displayed, as the + // admin has the "skip CAPTCHA" permission: + $this->drupalGet('/test-field-xpath'); + $session->elementNotExists('css', 'fieldset.captcha.captcha-type-challenge--math'); + $session->pageTextNotContains('This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.'); + + // Go to the admin form and see if there no captcha displayed: + $this->drupalGet('/admin/config/development/performance'); + $session->elementNotExists('css', 'fieldset.captcha.captcha-type-challenge--math'); + $session->pageTextNotContains('This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.'); + + // Login as a user without the "skip CAPTCHA" permission and check + // everything once again: + $this->drupalLogout(); + $this->drupalLogin($this->userWithoutSkipCaptcha); + $this->drupalGet('/test-field-xpath'); + + // Go to the test page and check if the captcha gets displayed: + $this->drupalGet('/test-field-xpath'); + $session->elementExists('css', 'fieldset.captcha.captcha-type-challenge--math'); + $session->pageTextContains('This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.'); + + // Go to the admin form and see if there is also a captcha displayed: + $this->drupalGet('/admin/config/development/performance'); + $session->elementExists('css', 'fieldset.captcha.captcha-type-challenge--math'); + $session->pageTextContains('This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.'); + } + +} diff --git a/web/modules/contrib/captcha/tests/src/Functional/CaptchaCacheTest.php b/web/modules/contrib/captcha/tests/src/Functional/CaptchaCacheTest.php new file mode 100644 index 000000000..06e02c597 --- /dev/null +++ b/web/modules/contrib/captcha/tests/src/Functional/CaptchaCacheTest.php @@ -0,0 +1,144 @@ +drupalPlaceBlock('user_login_block', ['id' => 'login']); + } + + /** + * Test the cache tags. + */ + public function testCacheTags() { + // Check caching without captcha as anonymous user. + $this->drupalGet(''); + $this->assertEquals($this->getSession()->getResponseHeader('x-drupal-cache'), 'MISS'); + $this->drupalGet(''); + $this->assertEquals($this->getSession()->getResponseHeader('x-drupal-cache'), 'HIT'); + + // Enable captcha on login block and test caching. + captcha_set_form_id_setting('user_login_form', CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE); + $this->drupalGet(''); + $sid = $this->getCaptchaSidFromForm(); + $header = $this->getSession()->getResponseHeader('x-drupal-cache'); + // @see https://www.drupal.org/node/2958442 + // This is to support "phpunit (previous minor)" + $pre_headers_changed = $header === NULL; + if ($pre_headers_changed) { + $this->assertNull($this->getSession()->getResponseHeader('x-drupal-cache'), 'Cache is disabled'); + } + else { + $this->assertEquals($this->getSession()->getResponseHeader('x-drupal-cache'), 'UNCACHEABLE (response policy)', 'Cache is disabled'); + } + $this->drupalGet(''); + $this->assertNotEquals($sid, $this->getCaptchaSidFromForm()); + + // Switch challenge to captcha/Test, check the captcha isn't cached. + captcha_set_form_id_setting('user_login_form', 'captcha/Test'); + $this->drupalGet(''); + $sid = $this->getCaptchaSidFromForm(); + if ($pre_headers_changed) { + $this->assertNull($this->getSession()->getResponseHeader('x-drupal-cache'), 'Cache is disabled'); + } + else { + $this->assertEquals($this->getSession()->getResponseHeader('x-drupal-cache'), 'UNCACHEABLE (response policy)', 'Cache is disabled'); + } + $this->drupalGet(''); + $this->assertNotEquals($sid, $this->getCaptchaSidFromForm()); + + // Switch challenge to image_captcha/Image, check the captcha isn't cached. + captcha_set_form_id_setting('user_login_form', ImageCaptchaConstants::IMAGE_CAPTCHA_CAPTCHA_TYPE); + $this->drupalGet(''); + $image_path = $this->getSession()->getPage()->find('css', '.captcha img')->getAttribute('src'); + if ($pre_headers_changed) { + $this->assertNull($this->getSession()->getResponseHeader('x-drupal-cache'), 'Cache is disabled'); + } + else { + $this->assertEquals($this->getSession()->getResponseHeader('x-drupal-cache'), 'UNCACHEABLE (response policy)', 'Cache is disabled'); + } + // Check that we get a new image when vising the page again. + $this->drupalGet(''); + $this->assertNotEquals($image_path, $this->getSession()->getPage()->find('css', '.captcha img')->getAttribute('src')); + // Check image caching, remove the base path since drupalGet() expects the + // internal path. + // @todo Fix with issue #3285734. It currently breaks D10 DrupalCi. + // $this->drupalGet(substr($image_path, strlen($base_path))); + // $this->assertSession()->statusCodeEquals(200); + // Request image twice to make sure no errors happen (due to page caching). + // $this->drupalGet(substr($image_path, strlen($base_path))); + // $this->assertSession()->statusCodeEquals(200); + } + + /** + * Tests a cacheable captcha type. + */ + public function testCacheableCaptcha() { + $web_assert = $this->assertSession(); + + // Enable captcha on login block with a cacheable captcha. + $type = 'captcha_test/TestCacheable'; + captcha_set_form_id_setting('user_login_form', $type); + + // Warm up the caches. + $this->drupalGet(''); + + // Let's check if the page is cached. + $this->drupalGet(''); + static::assertSame('HIT', $this->getSession()->getResponseHeader('X-Drupal-Cache'), 'Cache enabled'); + + $edit = [ + 'name' => $this->normalUser->getDisplayName(), + 'pass' => $this->normalUser->pass_raw, + 'captcha_response' => 'Test 123', + ]; + $this->submitForm($edit, 'Log in'); + $web_assert->addressEquals('user/' . $this->normalUser->id()); + + // Simulate a cron run that deletes the {captcha_session} data. + $connection = Database::getConnection(); + $connection->delete('captcha_sessions')->execute(); + + // Log out and reload the form. Because the captcha is cacheable, the form + // is retrieved from the render cache, and contains the same CSID as + // previously. + $this->drupalLogout(); + $this->drupalGet(''); + static::assertSame('HIT', $this->getSession()->getResponseHeader('X-Drupal-Cache'), 'Cache enabled'); + + $edit = [ + 'name' => $this->normalUser->getDisplayName(), + 'pass' => $this->normalUser->pass_raw, + 'captcha_response' => 'Test 123', + ]; + $this->submitForm($edit, 'Log in'); + $web_assert->addressEquals('user/' . $this->normalUser->id()); + } + +} diff --git a/web/modules/contrib/captcha/tests/src/Functional/CaptchaCronTest.php b/web/modules/contrib/captcha/tests/src/Functional/CaptchaCronTest.php new file mode 100644 index 000000000..0c43ca479 --- /dev/null +++ b/web/modules/contrib/captcha/tests/src/Functional/CaptchaCronTest.php @@ -0,0 +1,104 @@ +getRequestTime(); + + // Add removed session. + $time = $request_time - 1 - 60 * 60 * 24; + $this->captchaSessions['remove_sid'] = $this->addCaptchaSession('captcha_cron_test_remove', $time); + // Add remain session. + $this->captchaSessions['remain_sid'] = $this->addCaptchaSession('captcha_cron_test_remain', $request_time); + } + + /** + * Add test CAPTCHA session data. + * + * @param string $form_id + * Form id. + * @param int $request_time + * Timestamp. + * + * @return int + * CAPTCHA session id. + */ + public function addCaptchaSession($form_id, $request_time) { + // Initialize solution with random data. + $solution = hash('sha256', mt_rand()); + + // Insert an entry and thankfully receive the value + // of the autoincrement field 'csid'. + $connection = Database::getConnection(); + $captcha_sid = $connection->insert('captcha_sessions')->fields([ + 'uid' => 0, + 'sid' => session_id(), + 'ip_address' => \Drupal::request()->getClientIp(), + 'timestamp' => $request_time, + 'form_id' => $form_id, + 'solution' => $solution, + 'status' => 1, + 'attempts' => 0, + ])->execute(); + + return $captcha_sid; + } + + /** + * Test CAPTCHA cron. + */ + public function testCron() { + \Drupal::service('cron')->run(); + + $connection = Database::getConnection(); + $sids = $connection->select('captcha_sessions') + ->fields('captcha_sessions', ['csid']) + ->condition('csid', array_values($this->captchaSessions), 'IN') + ->execute() + ->fetchCol(); + + // Test if CAPTCHA cron appropriately removes sessions older than a day. + $this->assertNotContains($this->captchaSessions['remove_sid'], $sids, 'CAPTCHA cron removes captcha session data older than 1 day.'); + + // Test if CAPTCHA cron appropriately keeps sessions younger than a day. + $this->assertContains($this->captchaSessions['remain_sid'], $sids, 'CAPTCHA cron does not remove captcha session data younger than 1 day.'); + } + +} diff --git a/web/modules/contrib/captcha/tests/src/Functional/CaptchaPersistenceTest.php b/web/modules/contrib/captcha/tests/src/Functional/CaptchaPersistenceTest.php new file mode 100755 index 000000000..d483b414b --- /dev/null +++ b/web/modules/contrib/captcha/tests/src/Functional/CaptchaPersistenceTest.php @@ -0,0 +1,219 @@ +drupalLogin($this->adminUser); + // Set persistence. + $edit = ['persistence' => (string) $persistence]; + $this->drupalGet(self::CAPTCHA_ADMIN_PATH); + $this->submitForm($edit, 'Save configuration'); + // Log admin out. + $this->drupalLogout(); + + // Set the Test123 CAPTCHA on user register and comment form. + // We have to do this with the function captcha_set_form_id_setting() + // (because the CATCHA admin form does not show the Test123 option). + // We also have to do this after all usage of the CAPTCHA admin form + // (because posting the CAPTCHA admin form would set the CAPTCHA to 'none'). + captcha_set_form_id_setting('user_login_form', 'captcha/Test'); + $this->drupalGet('user'); + $this->assertCaptchaPresence(TRUE); + captcha_set_form_id_setting('user_register_form', 'captcha/Test'); + $this->drupalGet('user/register'); + $this->assertCaptchaPresence(TRUE); + } + + /** + * Check if Captcha sid present in form. + * + * @param string $captcha_sid_initial + * Captcha SID token. + */ + protected function assertPreservedCsid($captcha_sid_initial) { + $captcha_sid = $this->getCaptchaSidFromForm(); + $this->assertEquals($captcha_sid_initial, $captcha_sid, + "CAPTCHA session ID should be preserved (expected: $captcha_sid_initial, found: $captcha_sid)."); + } + + /** + * Check if message about SID present. + * + * @param string $captcha_sid_initial + * Captcha SID token. + */ + protected function assertDifferentCsid($captcha_sid_initial) { + $captcha_sid = $this->getCaptchaSidFromForm(); + $this->assertNotEquals($captcha_sid_initial, $captcha_sid, "CAPTCHA session ID should be different."); + } + + /** + * Test persistence always. + */ + public function testPersistenceAlways() { + // Set up of persistence and CAPTCHAs. + $this->setUpPersistence(CaptchaConstants::CAPTCHA_PERSISTENCE_SHOW_ALWAYS); + + // Go to login form and check if there is a CAPTCHA + // on the login form (look for the title). + $this->drupalGet(''); + $this->assertCaptchaPresence(TRUE); + $captcha_sid_initial = $this->getCaptchaSidFromForm(); + + // Try to with wrong user name and password, but correct CAPTCHA. + $edit = [ + 'name' => 'foobar', + 'pass' => 'bazlaz', + 'captcha_response' => 'Test 123', + ]; + $this->submitForm($edit, 'Log in', self::LOGIN_HTML_FORM_ID); + // Check that there was no error message for the CAPTCHA. + $this->assertCaptchaResponseAccepted(); + + // Name and password were wrong, we should get an updated + // form with a fresh CAPTCHA. + $this->assertCaptchaPresence(TRUE); + $this->assertPreservedCsid($captcha_sid_initial); + + // Post from again. + $this->submitForm($edit, 'Log in', self::LOGIN_HTML_FORM_ID); + // Check that there was no error message for the CAPTCHA. + $this->assertCaptchaResponseAccepted(); + $this->assertPreservedCsid($captcha_sid_initial); + } + + /** + * Test persistence per form instance. + */ + public function testPersistencePerFormInstance() { + // Set up of persistence and CAPTCHAs. + $this->setUpPersistence(CaptchaConstants::CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL_PER_FORM_INSTANCE); + + // Go to login form and check if there is a CAPTCHA on the login form. + $this->drupalGet(''); + $this->assertCaptchaPresence(TRUE); + $captcha_sid_initial = $this->getCaptchaSidFromForm(); + + // Try to with wrong user name and password, but correct CAPTCHA. + $edit = [ + 'name' => 'foobar', + 'pass' => 'bazlaz', + 'captcha_response' => 'Test 123', + ]; + $this->submitForm($edit, 'Log in', self::LOGIN_HTML_FORM_ID); + // Check that there was no error message for the CAPTCHA. + $this->assertCaptchaResponseAccepted(); + // There shouldn't be a CAPTCHA on the new form. + $this->assertCaptchaPresence(FALSE); + $this->assertPreservedCsid($captcha_sid_initial); + + // Start a new form instance/session. + $this->drupalGet('node'); + $this->drupalGet('user'); + $this->assertCaptchaPresence(TRUE); + $this->assertDifferentCsid($captcha_sid_initial); + + // Check another form. + $this->drupalGet('user/register'); + $this->assertCaptchaPresence(TRUE); + $this->assertDifferentCsid($captcha_sid_initial); + } + + /** + * Test Persistence per form type. + */ + public function testPersistencePerFormType() { + // Set up of persistence and CAPTCHAs. + $this->setUpPersistence(CaptchaConstants::CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL_PER_FORM_TYPE); + + // Go to login form and check if there is a CAPTCHA on the login form. + $this->drupalGet(''); + $this->assertCaptchaPresence(TRUE); + $captcha_sid_initial = $this->getCaptchaSidFromForm(); + + // Try to with wrong user name and password, but correct CAPTCHA. + $edit = [ + 'name' => 'foobar', + 'pass' => 'bazlaz', + 'captcha_response' => 'Test 123', + ]; + $this->submitForm($edit, 'Log in', self::LOGIN_HTML_FORM_ID); + // Check that there was no error message for the CAPTCHA. + $this->assertCaptchaResponseAccepted(); + // There shouldn't be a CAPTCHA on the new form. + $this->assertCaptchaPresence(FALSE); + $this->assertPreservedCsid($captcha_sid_initial); + + // Start a new form instance/session. + $this->drupalGet('node'); + $this->drupalGet('user'); + $this->assertCaptchaPresence(FALSE); + $this->assertDifferentCsid($captcha_sid_initial); + + // Check another form. + /** @var \Drupal\captcha\Entity\CaptchaPoint $captcha_point */ + $captcha_point = \Drupal::entityTypeManager() + ->getStorage('captcha_point') + ->load('user_register_form'); + $captcha_point->enable()->save(); + $this->drupalGet('user/register'); + $this->assertCaptchaPresence(TRUE); + $this->assertDifferentCsid($captcha_sid_initial); + } + + /** + * Test Persistence "Only once". + */ + public function testPersistenceOnlyOnce() { + // Set up of persistence and CAPTCHAs. + $this->setUpPersistence(CaptchaConstants::CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL); + + // Go to login form and check if there is a CAPTCHA on the login form. + $this->drupalGet(''); + $this->assertCaptchaPresence(TRUE); + $captcha_sid_initial = $this->getCaptchaSidFromForm(); + + // Try to with wrong user name and password, but correct CAPTCHA. + $edit = [ + 'name' => 'foobar', + 'pass' => 'bazlaz', + 'captcha_response' => 'Test 123', + ]; + $this->submitForm($edit, 'Log in', self::LOGIN_HTML_FORM_ID); + // Check that there was no error message for the CAPTCHA. + $this->assertCaptchaResponseAccepted(); + // There shouldn't be a CAPTCHA on the new form. + $this->assertCaptchaPresence(FALSE); + $this->assertPreservedCsid($captcha_sid_initial); + + // Start a new form instance/session. + $this->drupalGet('node'); + $this->drupalGet('user'); + $this->assertCaptchaPresence(FALSE); + $this->assertDifferentCsid($captcha_sid_initial); + + // Check another form. + $this->drupalGet('user/register'); + $this->assertCaptchaPresence(FALSE); + $this->assertDifferentCsid($captcha_sid_initial); + } + +} diff --git a/web/modules/contrib/captcha/tests/src/Functional/CaptchaSessionReuseAttackTestCase.php b/web/modules/contrib/captcha/tests/src/Functional/CaptchaSessionReuseAttackTestCase.php new file mode 100755 index 000000000..ae2832ddb --- /dev/null +++ b/web/modules/contrib/captcha/tests/src/Functional/CaptchaSessionReuseAttackTestCase.php @@ -0,0 +1,192 @@ +assertSession()->pageTextContains(self::CAPTCHA_WRONG_RESPONSE_ERROR_MESSAGE); + } + + /** + * Test captcha attack detection on comment form. + */ + public function testCaptchaSessionReuseAttackDetectionOnCommentPreview() { + // Create commentable node. + $node = $this->drupalCreateNode(); + // Set Test CAPTCHA on comment form. + captcha_set_form_id_setting(self::COMMENT_FORM_ID, 'captcha/Test'); + $this->config('captcha.settings') + ->set('persistence', CaptchaConstants::CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL_PER_FORM_INSTANCE) + ->save(); + + // Log in as normal user. + $this->drupalLogin($this->normalUser); + + // Go to comment form of commentable node. + $this->drupalGet('comment/reply/node/' . $node->id() . '/comment'); + $this->assertCaptchaPresence(TRUE); + + // Get CAPTCHA session ID and solution of the challenge. + $captcha_sid = $this->getCaptchaSidFromForm(); + $captcha_token = $this->getCaptchaTokenFromForm(); + $solution = "Test 123"; + + // Post the form with the solution. + $edit = $this->getCommentFormValues(); + $edit['captcha_response'] = $solution; + $this->submitForm($edit, 'Preview'); + // Answer should be accepted and further CAPTCHA omitted. + $this->assertCaptchaResponseAccepted(); + $this->assertCaptchaPresence(FALSE); + + // Go to comment form of commentable node again. + $this->drupalGet('comment/reply/node/' . $node->id() . '/comment'); + + // Post a new comment, reusing the previous CAPTCHA session. + $edit = $this->getCommentFormValues(); + $this->assertSession()->hiddenFieldExists("captcha_sid")->setValue((string) $captcha_sid); + $this->assertSession()->hiddenFieldExists("captcha_token")->setValue((string) $captcha_token); + $edit['captcha_response'] = $solution; + $this->submitForm($edit, 'Preview'); + // CAPTCHA session reuse attack should be detected. + $this->assertCaptchaSessionIdReuseAttackDetection(); + // There should be a CAPTCHA. + $this->assertCaptchaPresence(TRUE); + } + + /** + * Test captcha attach detection on node form. + */ + public function testCaptchaSessionReuseAttackDetectionOnNodeForm() { + // Set CAPTCHA on page form. + captcha_set_form_id_setting('node_page_form', 'captcha/Test'); + $this->config('captcha.settings') + ->set('persistence', CaptchaConstants::CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL_PER_FORM_INSTANCE) + ->save(); + + // Log in as normal user. + $this->drupalLogin($this->normalUser); + + // Go to node add form. + $this->drupalGet('node/add/page'); + $this->assertCaptchaPresence(TRUE); + + // Get CAPTCHA session ID and solution of the challenge. + $captcha_sid = $this->getCaptchaSidFromForm(); + $captcha_token = $this->getCaptchaTokenFromForm(); + $solution = "Test 123"; + + // Page settings to post, with correct CAPTCHA answer. + $edit = $this->getNodeFormValues(); + $edit['captcha_response'] = $solution; + // Preview the node. + $this->submitForm($edit, 'Preview'); + // Answer should be accepted. + $this->assertCaptchaResponseAccepted(); + // Check that there is no CAPTCHA after preview. + $this->assertCaptchaPresence(FALSE); + + // Go to node add form again. + $this->drupalGet('node/add/page'); + $this->assertCaptchaPresence(TRUE); + + // Post a new node, reusing the previous CAPTCHA session. + $edit = $this->getNodeFormValues(); + $this->assertSession()->hiddenFieldExists("captcha_sid")->setValue((string) $captcha_sid); + $this->assertSession()->hiddenFieldExists("captcha_token")->setValue((string) $captcha_token); + $edit['captcha_response'] = $solution; + $this->submitForm($edit, 'Preview'); + // CAPTCHA session reuse attack should be detected. + $this->assertCaptchaSessionIdReuseAttackDetection(); + // There should be a CAPTCHA. + $this->assertCaptchaPresence(TRUE); + } + + /** + * Test Captcha attack detection on login form. + */ + public function testCaptchaSessionReuseAttackDetectionOnLoginForm() { + // Set CAPTCHA on login form. + captcha_set_form_id_setting('user_login_form', 'captcha/Test'); + $this->config('captcha.settings') + ->set('persistence', CaptchaConstants::CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL_PER_FORM_INSTANCE) + ->save(); + + // Go to log in form. + $this->drupalGet(''); + $this->assertCaptchaPresence(TRUE); + + // Get CAPTCHA session ID and solution of the challenge. + $captcha_sid = $this->getCaptchaSidFromForm(); + $captcha_token = $this->getCaptchaTokenFromForm(); + $solution = "Test 123"; + + // Log in through form. + $edit = [ + 'name' => $this->normalUser->getDisplayName(), + 'pass' => $this->normalUser->pass_raw, + 'captcha_response' => $solution, + ]; + $this->submitForm($edit, 'Log in', self::LOGIN_HTML_FORM_ID); + $this->assertCaptchaResponseAccepted(); + $this->assertCaptchaPresence(FALSE); + // If a "log out" link appears on the page, it is almost certainly because + // the login was successful. + $this->assertSession()->pageTextContains($this->normalUser->getDisplayName()); + + // Log out again. + $this->drupalLogout(); + + // Go to log in form again. + $this->drupalGet(''); + + // Try to log in again, reusing the previous CAPTCHA session. + $this->assertSession()->hiddenFieldExists("captcha_sid")->setValue((string) $captcha_sid); + $this->assertSession()->hiddenFieldExists("captcha_token")->setValue((string) $captcha_token); + $this->assertNotEmpty(json_encode($edit)); + $this->submitForm($edit, 'Log in'); + // CAPTCHA session reuse attack should be detected. + $this->assertCaptchaSessionIdReuseAttackDetection(); + // There should be a CAPTCHA. + $this->assertCaptchaPresence(TRUE); + } + + /** + * Test multiple captcha widgets on single page. + */ + public function testMultipleCaptchaProtectedFormsOnOnePage() { + \Drupal::service('module_installer')->install(['block']); + $this->drupalPlaceBlock('user_login_block'); + // Set Test CAPTCHA on comment form and login block. + captcha_set_form_id_setting(self::COMMENT_FORM_ID, 'captcha/Test'); + captcha_set_form_id_setting('user_login_form', 'captcha/Test'); + $this->allowCommentPostingForAnonymousVisitors(); + + // Create a node with comments enabled. + $node = $this->drupalCreateNode(); + + // Preview comment with correct CAPTCHA answer. + $edit = $this->getCommentFormValues(); + $comment_subject = $edit['subject[0][value]']; + $edit['captcha_response'] = 'Test 123'; + $this->drupalGet('comment/reply/node/' . $node->id() . '/comment'); + $this->submitForm($edit, 'Preview'); + // Post should be accepted: no warnings, + // no CAPTCHA reuse detection (which could be used by user log in block). + $this->assertCaptchaResponseAccepted(); + $this->assertSession()->pageTextContains($comment_subject); + } + +} diff --git a/web/modules/contrib/captcha/tests/src/Functional/CaptchaTest.php b/web/modules/contrib/captcha/tests/src/Functional/CaptchaTest.php new file mode 100755 index 000000000..c4ece49c4 --- /dev/null +++ b/web/modules/contrib/captcha/tests/src/Functional/CaptchaTest.php @@ -0,0 +1,496 @@ +drupalCreateUser(); + $this->drupalLogin($user); + // Log out again. + $this->drupalLogout(); + + // Set a CAPTCHA on login form. + /** @var \Drupal\captcha\Entity\CaptchaPoint $captcha_point */ + $captcha_point = \Drupal::entityTypeManager() + ->getStorage('captcha_point') + ->load('user_login_form'); + $captcha_point->setCaptchaType(CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE); + $captcha_point->enable()->save(); + + // Check if there is a CAPTCHA on the login form (look for the title). + $this->drupalGet('user'); + $this->assertCaptchaPresence(TRUE); + + // Try to log in, which should fail. + $edit = [ + 'name' => $user->getDisplayName(), + 'pass' => $user->pass_raw, + 'captcha_response' => '?', + ]; + $this->submitForm($edit, 'Log in', self::LOGIN_HTML_FORM_ID); + // Check for error message. + $this->assertSession()->pageTextContains(self::CAPTCHA_WRONG_RESPONSE_ERROR_MESSAGE); + + // And make sure that user is not logged in: + // check for name and password fields on ?q=user. + $this->drupalGet('user'); + $this->assertSession()->fieldExists('name'); + $this->assertSession()->fieldExists('pass'); + } + + /** + * Testing the response error menssage. + */ + public function testCaptchaResponseErrorMessage() { + // Customize the response error message. + $this->drupalLogin($this->adminUser); + $customized_menssage = 'The answer you entered is wrong.'; + $edit = [ + 'wrong_captcha_response_message' => $customized_menssage, + ]; + $this->drupalGet("admin/config/people/captcha"); + $this->submitForm($edit, 'Save configuration'); + + // Set a CAPTCHA on login form. + /** @var \Drupal\captcha\Entity\CaptchaPoint $captcha_point */ + $captcha_point = \Drupal::entityTypeManager() + ->getStorage('captcha_point') + ->load('user_login_form'); + $captcha_point->setCaptchaType(CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE); + $captcha_point->enable()->save(); + + // Check if the menssage is default. + $this->drupalLogout(); + $this->drupalGet('user'); + // Try to log in, which should fail. + $edit = [ + 'name' => $this->adminUser->getDisplayName(), + 'pass' => $this->adminUser->pass_raw, + 'captcha_response' => '?', + ]; + $this->submitForm($edit, 'Log in', self::LOGIN_HTML_FORM_ID); + $this->assertSession()->pageTextContains($customized_menssage); + + } + + /** + * Assert function for testing if comment posting works as it should. + * + * Creates node with comment writing enabled, tries to post comment + * with given CAPTCHA response (caller should enable the desired + * challenge on page node comment forms) and checks if + * the result is as expected. + * + * @param string $captcha_response + * The response on the CAPTCHA. + * @param bool $should_pass + * Describing if the posting should pass or should be blocked. + * @param string $message + * To prefix to nested asserts. + */ + protected function assertCommentPosting($captcha_response, $should_pass, $message) { + // Make sure comments on pages can be saved directly without preview. + $this->container->get('state') + ->set('comment_preview_page', DRUPAL_OPTIONAL); + + // Create a node with comments enabled. + $node = $this->drupalCreateNode(); + + // Post comment on node. + $edit = $this->getCommentFormValues(); + $comment_subject = $edit['subject[0][value]']; + $comment_body = $edit['comment_body[0][value]']; + $edit['captcha_response'] = $captcha_response; + $this->drupalGet('comment/reply/node/' . $node->id() . '/comment'); + $this->submitForm($edit, 'Save', 'comment-form'); + + if ($should_pass) { + // There should be no error message. + $this->assertCaptchaResponseAccepted(); + // Get node page and check that comment shows up. + $this->drupalGet('node/' . $node->id()); + $this->assertSession()->pageTextContains($comment_subject); + $this->assertSession()->pageTextContains($comment_body); + } + else { + // Check for error message. + $this->assertSession()->pageTextContains(self::CAPTCHA_WRONG_RESPONSE_ERROR_MESSAGE); + // Get node page and check that comment is not present. + $this->drupalGet('node/' . $node->id()); + $this->assertSession()->pageTextNotContains($comment_subject); + $this->assertSession()->pageTextNotContains($comment_body); + } + } + + /** + * Testing the case sensitive/insensitive validation. + */ + public function testCaseInsensitiveValidation() { + $config = $this->config('captcha.settings'); + // Set Test CAPTCHA on comment form. + captcha_set_form_id_setting(self::COMMENT_FORM_ID, 'captcha/Test'); + + // Log in as normal user. + $this->drupalLogin($this->normalUser); + + // Test case sensitive posting. + $config->set('default_validation', CaptchaConstants::CAPTCHA_DEFAULT_VALIDATION_CASE_SENSITIVE); + $config->save(); + + $this->assertCommentPosting('Test 123', TRUE, 'Case sensitive validation of right casing.'); + $this->assertCommentPosting('test 123', FALSE, 'Case sensitive validation of wrong casing.'); + $this->assertCommentPosting('TEST 123', FALSE, 'Case sensitive validation of wrong casing.'); + + // Test case insensitive posting (the default). + $config->set('default_validation', CaptchaConstants::CAPTCHA_DEFAULT_VALIDATION_CASE_INSENSITIVE); + $config->save(); + + $this->assertCommentPosting('Test 123', TRUE, 'Case insensitive validation of right casing.'); + $this->assertCommentPosting('test 123', TRUE, 'Case insensitive validation of wrong casing.'); + $this->assertCommentPosting('TEST 123', TRUE, 'Case insensitive validation of wrong casing.'); + } + + /** + * Test if the CAPTCHA description is only shown with challenge widgets. + * + * For example, when a comment is previewed with correct CAPTCHA answer, + * a challenge is generated and added to the form but removed in the + * pre_render phase. The CAPTCHA description should not show up either. + * + * @see testCaptchaSessionReuseOnNodeForms() + */ + public function testCaptchaDescriptionAfterCommentPreview() { + // Set Test CAPTCHA on comment form. + captcha_set_form_id_setting(self::COMMENT_FORM_ID, 'captcha/Test'); + + // Log in as normal user. + $this->drupalLogin($this->normalUser); + + // Create a node with comments enabled. + $node = $this->drupalCreateNode(); + + // Preview comment with correct CAPTCHA answer. + $edit = $this->getCommentFormValues(); + $edit['captcha_response'] = 'Test 123'; + $this->drupalGet('comment/reply/node/' . $node->id() . '/comment'); + $this->submitForm($edit, 'Preview'); + + // Check that there is no CAPTCHA after preview. + $this->assertCaptchaPresence(FALSE); + } + + /** + * Test if the CAPTCHA session ID is reused when previewing nodes. + * + * Node preview after correct response should not show CAPTCHA anymore. + * The preview functionality of comments and nodes works + * slightly different under the hood. + * CAPTCHA module should be able to handle both. + * + * @see testCaptchaDescriptionAfterCommentPreview() + */ + public function testCaptchaSessionReuseOnNodeForms() { + // Set Test CAPTCHA on page form. + captcha_set_form_id_setting('node_page_form', 'captcha/Test'); + + // Log in as normal user. + $this->drupalLogin($this->normalUser); + + // Page settings to post, with correct CAPTCHA answer. + $edit = $this->getNodeFormValues(); + $edit['captcha_response'] = 'Test 123'; + $this->drupalGet('node/add/page'); + $this->submitForm($edit, 'Preview'); + + $this->assertCaptchaPresence(FALSE); + } + + /** + * CAPTCHA should be put on admin pages even if visitor has no access. + */ + public function testCaptchaOnLoginBlockOnAdminPagesIssue893810() { + // Set a CAPTCHA on login block form. + /** @var \Drupal\captcha\Entity\CaptchaPoint $captcha_point */ + $captcha_point = \Drupal::entityTypeManager() + ->getStorage('captcha_point') + ->load('user_login_form'); + $captcha_point->setCaptchaType(CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE); + $captcha_point->enable()->save(); + + // Enable the user login block. + $this->drupalPlaceBlock('user_login_block', ['id' => 'login']); + + // Check if there is a CAPTCHA on home page. + $this->drupalGet(''); + $this->assertCaptchaPresence(TRUE); + + // Check there is a CAPTCHA on "forbidden" admin pages. + $this->drupalGet('admin'); + $this->assertCaptchaPresence(TRUE); + } + + /** + * Test that forms with IDs exceeding 64 characters can be assigned captchas. + */ + public function testLongFormId() { + // We add the form manually so we can mimic the character + // truncation of the label field as formId. + $this->drupalLogin($this->adminUser); + $this->drupalGet(self::CAPTCHA_ADMIN_PATH); + + $label = 'this_formid_is_intentionally_longer_than_64_characters_to_test_captcha'; + // Truncated to 64 chars so it can be a machine name. + $formId = substr($label, 0, 64); + + $form_values = [ + 'label' => $label, + 'formId' => $formId, + 'captchaType' => CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE, + ]; + + // Create intentionally long id Captcha Point. + $this->drupalGet(self::CAPTCHA_ADMIN_PATH . '/captcha-points/add'); + $this->submitForm($form_values, 'Save'); + $this->assertSession()->responseContains($this->t('Captcha Point for %label form was created.', ['%label' => $formId])); + + // We need to log out to test the captcha. + $this->drupalLogout(); + + // Navigate to the form with a >64 char id and confirm there is Captcha. + $this->drupalGet('captcha/test_form/long_id'); + $this->assertCaptchaPresence(TRUE); + } + + /** + * Test if the correct classes from our twig template are set. + */ + public function testFormCorrectClassesSet() { + $session = $this->assertSession(); + + // Set default challenge math: + $this->config('captcha.settings') + ->set('default_challenge', CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE) + ->save(); + + // Check if there is a CAPTCHA on the login form (look for the title). + $this->drupalGet('user'); + $this->assertCaptchaPresence(TRUE); + + // Check if the correct classes are set from our template with default + // challenge type set: + // Check if fieldset exists with correct classes set: + $session->elementExists('css', '#user-login-form > fieldset'); + $session->elementAttributeContains('css', '#user-login-form > fieldset', 'class', 'captcha'); + $session->elementAttributeContains('css', '#user-login-form > fieldset', 'class', 'captcha-type-challenge--math'); + // The challenge type should NEVER be 'default'. + $session->elementAttributeNotContains('css', '#user-login-form > fieldset', 'class', 'captcha-type-challenge--default'); + + // Check if title exists with the correct class and standard title value: + $session->elementExists('css', '#user-login-form > fieldset > legend.captcha__title'); + $session->elementTextContains('css', '#user-login-form > fieldset > legend', 'CAPTCHA'); + + // Check if description exists with the correct class and standard title + // value: + $session->elementExists('css', '#user-login-form > fieldset > div.captcha__description'); + $session->elementTextContains('css', '#user-login-form > fieldset > div.captcha__description', 'This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.'); + + // Check if the element exists with the correct class: + $session->elementExists('css', '#user-login-form > fieldset > div.captcha__element'); + + // Set challenge type "captcha/Math" explicitly and do the tests again. + /** @var \Drupal\captcha\Entity\CaptchaPoint $captcha_point */ + $captcha_point = \Drupal::entityTypeManager() + ->getStorage('captcha_point') + ->load('user_login_form'); + $captcha_point->setCaptchaType(CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE); + $captcha_point->enable()->save(); + + $this->drupalGet('user'); + + // Check if fieldset exists with correct classes set: + $session->elementExists('css', '#user-login-form > fieldset'); + $session->elementAttributeContains('css', '#user-login-form > fieldset', 'class', 'captcha'); + $session->elementAttributeContains('css', '#user-login-form > fieldset', 'class', 'captcha-type-challenge--math'); + // The challenge type should NEVER be 'default'. + $session->elementAttributeNotContains('css', '#user-login-form > fieldset', 'class', 'captcha-type-challenge--default'); + + // Check if title exists with the correct class and standard title value: + $session->elementExists('css', '#user-login-form > fieldset > legend.captcha__title'); + $session->elementTextContains('css', '#user-login-form > fieldset > legend', 'CAPTCHA'); + + // Check if description exists with the correct class and standard title + // value: + $session->elementExists('css', '#user-login-form > fieldset > div.captcha__description'); + $session->elementTextContains('css', '#user-login-form > fieldset > div.captcha__description', 'This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.'); + + // Check if the element exists with the correct class: + $session->elementExists('css', '#user-login-form > fieldset > div.captcha__element'); + } + + /** + * Test if the title element is not present, when title is an empty string. + */ + public function testTitleNotPresent() { + $session = $this->assertSession(); + + // Set default challenge math: + $this->config('captcha.settings') + ->set('title', '') + ->set('default_challenge', CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE) + ->save(); + + $this->drupalGet('user'); + $this->assertCaptchaPresence(TRUE); + + // Check if the title element does not exist: + $session->elementNotExists('css', '#user-login-form > fieldset > legend.captcha__title'); + // Even the fieldset should not exist: + $session->elementNotExists('css', '#user-login-form > fieldset.captcha'); + // But instead a div should be used: + $session->elementExists('css', '#user-login-form > div.captcha'); + // Containing the captcha element: + $session->elementExists('css', '#user-login-form > div.captcha > div.captcha__element'); + } + + /** + * Test if the description element is not present, when title is empty. + */ + public function testDescriptionNotPresent() { + $session = $this->assertSession(); + + // Set default challenge math: + $this->config('captcha.settings') + ->set('description', '') + ->set('default_challenge', CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE) + ->save(); + + $this->drupalGet('user'); + $this->assertCaptchaPresence(TRUE); + + // Check if the description element does not exist: + $session->elementNotExists('css', '#user-login-form > fieldset.captcha > div.captcha__description'); + // But the captcha element exists: + $session->elementExists('css', '#user-login-form > fieldset.captcha > div.captcha__element'); + } + + /** + * Test if the description and title element is not present, when title empty. + */ + public function testDescriptionAndTitleNotPresent() { + $session = $this->assertSession(); + + // Set default challenge math: + $this->config('captcha.settings') + ->set('title', '') + ->set('description', '') + ->set('default_challenge', CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE) + ->save(); + + $this->drupalGet('user'); + $this->assertCaptchaPresence(TRUE); + + // Check if the title element does not exist: + $session->elementNotExists('css', '#user-login-form > fieldset > label.captcha__title'); + $session->elementNotExists('css', '#user-login-form > div > label.captcha__title'); + // Check if the title element does not exist: + $session->elementNotExists('css', '#user-login-form > fieldset > div.captcha__description'); + $session->elementNotExists('css', '#user-login-form > div > div.captcha__description'); + // Even the fieldset should not exist: + $session->elementNotExists('css', '#user-login-form > fieldset'); + // But instead a div should be used, just containing the captcha element: + $session->elementExists('css', '#user-login-form > div.captcha'); + $session->elementExists('css', '#user-login-form > div.captcha > div.captcha__element'); + } + + /** + * Tests the math form element and its structure. + */ + public function testMathFormElement() { + $session = $this->assertSession(); + + $this->drupalLogin($this->adminUser); + + $this->drupalGet('/captcha-test/test'); + $session->statusCodeEquals(200); + + $session->elementExists('css', '#captcha-test-test'); + + $session->elementExists('css', '#captcha-test-test > fieldset[data-drupal-selector="edit-math-captcha-admin-true"]'); + + // Check the first captcha form element and see if it is complete: + // Check captcha description: + $session->elementExists('css', '#captcha-test-test > fieldset[data-drupal-selector="edit-math-captcha-admin-true"] > div.captcha__description'); + $session->elementTextContains('css', '#captcha-test-test > fieldset[data-drupal-selector="edit-math-captcha-admin-true"] > div.captcha__description', 'This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.'); + $session->elementExists('css', '#captcha-test-test > fieldset[data-drupal-selector="edit-math-captcha-admin-true"] > div.captcha__element > div.form-item-captcha-response'); + // Check Question label: + $session->elementExists('css', '#captcha-test-test > fieldset[data-drupal-selector="edit-math-captcha-admin-true"] > div.captcha__element > div.form-item-captcha-response > label.form-required'); + $session->elementTextContains('css', '#captcha-test-test > fieldset[data-drupal-selector="edit-math-captcha-admin-true"] > div.captcha__element > div.form-item-captcha-response > label.form-required', 'Math question'); + // Check other elements: + $session->elementExists('css', '#captcha-test-test > fieldset[data-drupal-selector="edit-math-captcha-admin-true"] > div.captcha__element > div.form-item-captcha-response > input.form-text'); + $session->elementExists('css', '#captcha-test-test > fieldset[data-drupal-selector="edit-math-captcha-admin-true"] > div.captcha__element > div.form-item-captcha-response > div#edit-captcha-response--description'); + $session->elementTextContains('css', '#captcha-test-test > fieldset[data-drupal-selector="edit-math-captcha-admin-true"] > div.captcha__element > div.form-item-captcha-response > div#edit-captcha-response--description', 'Solve this simple math problem and enter the result. E.g. for 1+3, enter 4.'); + } + + /** + * Tests the math form element behaviour. + * + * @todo This test will fail, because the "skip CAPTCHA" permission doesn't + * work for Captcha form elements, but only in conjunction with captcha + * points. The problem is the captcha rendering on two seperate levels. For + * more informations, see + * https://www.drupal.org/project/captcha/issues/2941496 + */ + public function todoTestMathFormElementBehaviour() { + $session = $this->assertSession(); + + $this->drupalLogin($this->adminUser); + + $this->drupalGet('/captcha-test/test'); + $session->statusCodeEquals(200); + + $session->elementExists('css', '#captcha-test-test'); + + // As our admin user has the "skip CAPTCHA" permission, they should only see + // the first captcha element on the page: + $session->elementExists('css', '#captcha-test-test > fieldset[data-drupal-selector="edit-math-captcha-admin-true"]'); + $session->elementNotExists('css', '#captcha-test-test > fieldset[data-drupal-selector="edit-math-captcha-admin-false"]'); + $session->elementNotExists('css', '#captcha-test-test > fieldset[data-drupal-selector="edit-math-captcha-admin-not-set"]'); + + $this->drupalLogout(); + $this->drupalLogin($this->normalUser); + $this->drupalGet('/captcha-test/test'); + + // As our normal user does not have the "skip CAPTCHA" permission, they + // should be able to see all three captchas: + $session->elementExists('css', '#captcha-test-test > fieldset[data-drupal-selector="edit-math-captcha-admin-true"]'); + $session->elementExists('css', '#captcha-test-test > fieldset[data-drupal-selector="edit-math-captcha-admin-false"]'); + $session->elementExists('css', '#captcha-test-test > fieldset[data-drupal-selector="edit-math-captcha-admin-not-set"]'); + } + +} diff --git a/web/modules/contrib/captcha/tests/src/Functional/CaptchaWebTestBase.php b/web/modules/contrib/captcha/tests/src/Functional/CaptchaWebTestBase.php new file mode 100755 index 000000000..fc90dbfe9 --- /dev/null +++ b/web/modules/contrib/captcha/tests/src/Functional/CaptchaWebTestBase.php @@ -0,0 +1,295 @@ +loadInclude('captcha', 'inc'); + + $this->drupalCreateContentType(['type' => 'page']); + + // Create a normal user. + $permissions = [ + 'access comments', + 'post comments', + 'skip comment approval', + 'access content', + 'create page content', + 'edit own page content', + ]; + $this->normalUser = $this->drupalCreateUser($permissions); + + // Create an admin user. + $this->adminUser = $this->drupalCreateUser([]); + $this->adminUser->addRole($this->createAdminRole('admin', 'admin')); + $this->adminUser->save(); + + // Set default captcha type 'captcha/test': + $this->setDefaultChallenge('captcha/test'); + + // @todo This should not happen in the base test class. Do this where it's + // needed instead: + $this->enableComments(); + + // @todo do not enable this globally in this base class, only where it's + // needed instead. It polutes tests and prevents us from being able to + // switch users in tests: + $this->enableLoginCaptchaPoint(); + } + + /** + * Helper function to enable comments on nodes for testing captcha. + */ + protected function enableComments($entity_type = 'node', $entity_bundle = 'page') { + // Open comment for page content type. + $this->addDefaultCommentField($entity_type, $entity_bundle); + + // Put comments on page nodes on a separate page. + $comment_field = FieldConfig::loadByName($entity_type, $entity_bundle, 'comment'); + $comment_field->setSetting('form_location', CommentItemInterface::FORM_SEPARATE_PAGE); + $comment_field->save(); + } + + /** + * Helper method to enable the captcha point for the Drupal login form. + */ + protected function enableLoginCaptchaPoint() { + /** @var \Drupal\captcha\Entity\CaptchaPoint $captcha_point */ + $captcha_point = \Drupal::entityTypeManager() + ->getStorage('captcha_point') + ->load('user_login_form'); + $captcha_point->enable()->save(); + } + + /** + * Helper method to disable the captcha point for the Drupal login form. + */ + protected function disableLoginCaptchaPoint() { + /** @var \Drupal\captcha\Entity\CaptchaPoint $captcha_point */ + $captcha_point = \Drupal::entityTypeManager() + ->getStorage('captcha_point') + ->load('user_login_form'); + $captcha_point->disable()->save(); + } + + /** + * Helper method to set the default captcha challenge. + * + * @param string $captchaType + * The captcha type, e.g. "captcha/Math" or "captcha/test". + */ + protected function setDefaultChallenge($captchaType) { + $this->config('captcha.settings') + ->set('default_challenge', $captchaType) + ->save(); + } + + /** + * Assert that the response is accepted. + * + * No "unknown CSID" message, no "CSID reuse attack detection" message, + * No "wrong answer" message. + */ + protected function assertCaptchaResponseAccepted() { + // There should be no error message about unknown CAPTCHA session ID. + $this->assertSession()->pageTextNotContains(self::CAPTCHA_UNKNOWN_CSID_ERROR_MESSAGE); + // There should be no error message about wrong response. + $this->assertSession()->pageTextNotContains(self::CAPTCHA_WRONG_RESPONSE_ERROR_MESSAGE); + } + + /** + * Assert that there is a CAPTCHA on the form or not. + * + * @param bool $presence + * Whether there should be a CAPTCHA or not. + */ + protected function assertCaptchaPresence($presence) { + if ($presence) { + $this->assertSession()->pageTextContains(_captcha_get_description()); + } + else { + $this->assertSession()->pageTextNotContains(_captcha_get_description()); + } + } + + /** + * Helper function to generate a form values array for comment forms. + */ + protected function getCommentFormValues() { + $edit = [ + 'subject[0][value]' => 'comment_subject ' . $this->randomMachineName(32), + 'comment_body[0][value]' => 'comment_body ' . $this->randomMachineName(256), + ]; + + return $edit; + } + + /** + * Helper function to generate a form values array for node forms. + */ + protected function getNodeFormValues() { + $edit = [ + 'title[0][value]' => 'node_title ' . $this->randomMachineName(32), + 'body[0][value]' => 'node_body ' . $this->randomMachineName(256), + ]; + + return $edit; + } + + /** + * Get the CAPTCHA session id from the current form in the browser. + * + * @param null|string $form_html_id + * HTML form id attribute. + * + * @return int + * Captcha SID integer. + */ + protected function getCaptchaSidFromForm($form_html_id = NULL) { + if (!$form_html_id) { + $elements = $this->xpath('//input[@name="captcha_sid"]'); + } + else { + $elements = $this->xpath('//form[@id="' . $form_html_id . '"]//input[@name="captcha_sid"]'); + } + + $element = current($elements); + $captcha_sid = (int) $element->getValue(); + + return $captcha_sid; + } + + /** + * Get the CAPTCHA token from the current form in the browser. + * + * @param null|string $form_html_id + * HTML form id attribute. + * + * @return int + * Captcha token integer. + */ + protected function getCaptchaTokenFromForm($form_html_id = NULL) { + if (!$form_html_id) { + $elements = $this->xpath('//input[@name="captcha_token"]'); + } + else { + $elements = $this->xpath('//form[@id="' . $form_html_id . '"]//input[@name="captcha_token"]'); + } + $element = current($elements); + $captcha_token = (int) $element->getValue(); + + return $captcha_token; + } + + /** + * Get the solution of the math CAPTCHA from the current form in the browser. + * + * @param null|string $form_html_id + * HTML form id attribute. + * + * @return int + * Calculated Math solution. + */ + protected function getMathCaptchaSolutionFromForm($form_html_id = NULL) { + // Get the math challenge. + if (!$form_html_id) { + $elements = $this->xpath('//div[contains(@class, "form-item-captcha-response")]/span[@class="field-prefix"]'); + } + else { + $elements = $this->xpath('//form[@id="' . $form_html_id . '"]//div[contains(@class, "form-item-captcha-response")]/span[@class="field-prefix"]'); + } + $this->assertTrue('pass', json_encode($elements)); + $challenge = (string) $elements[0]; + $this->assertTrue('pass', $challenge); + // Extract terms and operator from challenge. + $matches = []; + preg_match('/\\s*(\\d+)\\s*(-|\\+)\\s*(\\d+)\\s*=\\s*/', $challenge, $matches); + // Solve the challenge. + $a = (int) $matches[1]; + $b = (int) $matches[3]; + $solution = $matches[2] == '-' ? $a - $b : $a + $b; + + return $solution; + } + + /** + * Helper function to allow comment posting for anonymous users. + */ + protected function allowCommentPostingForAnonymousVisitors() { + // Enable anonymous comments. + user_role_grant_permissions(AccountInterface::ANONYMOUS_ROLE, [ + 'access comments', + 'post comments', + 'skip comment approval', + ]); + } + +} diff --git a/web/modules/contrib/captcha/tests/src/Kernel/Migrate/d7/MigrateCaptchaPointsTest.php b/web/modules/contrib/captcha/tests/src/Kernel/Migrate/d7/MigrateCaptchaPointsTest.php new file mode 100644 index 000000000..08aa72a44 --- /dev/null +++ b/web/modules/contrib/captcha/tests/src/Kernel/Migrate/d7/MigrateCaptchaPointsTest.php @@ -0,0 +1,97 @@ + 'comment_node_article_form', + 'captcha_type' => CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE, + 'status' => TRUE, + ], + [ + 'form_id' => 'user_pass', + 'captcha_type' => CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE, + 'status' => TRUE, + ], + ]; + + /** + * {@inheritdoc} + */ + protected $captchaStorage; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->loadFixture(implode(DIRECTORY_SEPARATOR, [ + DRUPAL_ROOT, + \Drupal::service('extension.list.module')->getPath('captcha'), + 'tests', + 'fixtures', + 'drupal7.php', + ])); + + $this->installEntitySchema('captcha_point'); + $this->installSchema('captcha', ['captcha_sessions']); + $this->installConfig('captcha'); + + $migrations = [ + 'd7_captcha_points', + ]; + $this->executeMigrations($migrations); + } + + /** + * Tests a single captcha point type. + * + * @param string $form_id + * The captcha point form id. + * @param string $captcha_type + * The expected captcha type for the config. + * @param bool $status + * The expected status for a captcha type. + */ + protected function assertEntity(string $form_id, string $captcha_type, $status) { + /** @var \Drupal\captcha\CaptchaPointInterface $entity */ + $entity = CaptchaPoint::load($form_id); + $this->assertInstanceOf(CaptchaPointInterface::class, $entity); + $this->assertSame($form_id, $entity->getFormId()); + $this->assertSame($captcha_type, $entity->getCaptchaType()); + $this->assertSame($status, $entity->status()); + } + + /** + * Tests that all expected configuration gets migrated. + */ + public function testCaptchaPointsMigration() { + // Test captcha points. + foreach ($this->captchaPoints as $captcha_point) { + $this->assertEntity($captcha_point['form_id'], $captcha_point['captcha_type'], $captcha_point['status']); + } + } + +} diff --git a/web/modules/contrib/captcha/tests/src/Kernel/Migrate/d7/MigrateCaptchaSimpleConfigurationTest.php b/web/modules/contrib/captcha/tests/src/Kernel/Migrate/d7/MigrateCaptchaSimpleConfigurationTest.php new file mode 100644 index 000000000..8a9efe0b3 --- /dev/null +++ b/web/modules/contrib/captcha/tests/src/Kernel/Migrate/d7/MigrateCaptchaSimpleConfigurationTest.php @@ -0,0 +1,71 @@ + [ + 'enable_globally' => 1, + 'enable_globally_on_admin_routes' => FALSE, + 'default_challenge' => CaptchaConstants::CAPTCHA_MATH_CAPTCHA_TYPE, + 'description' => 'This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.', + 'administration_mode' => TRUE, + 'administration_mode_on_admin_routes' => FALSE, + 'default_validation' => 1, + 'persistence' => 1, + 'enable_stats' => TRUE, + 'log_wrong_responses' => TRUE, + ], + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->loadFixture(implode(DIRECTORY_SEPARATOR, [ + DRUPAL_ROOT, + \Drupal::service('extension.list.module')->getPath('captcha'), + 'tests', + 'fixtures', + 'drupal7.php', + ])); + + $migrations = [ + 'd7_captcha_settings', + ]; + $this->executeMigrations($migrations); + } + + /** + * Tests that all expected configuration gets migrated. + */ + public function testConfigurationMigration() { + // Test Config. + $this->expectedConfig['captcha.settings'] = version_compare(\Drupal::VERSION, '10.3.0', '>=') ? ['langcode' => 'en'] + $this->expectedConfig['captcha.settings'] : $this->expectedConfig['captcha.settings']; + foreach ($this->expectedConfig as $config_id => $values) { + $actual = \Drupal::config($config_id)->get(); + $this->assertSame($values, $actual); + } + } + +} diff --git a/web/modules/contrib/captcha/tests/src/Unit/Controller/CaptchaPointListBuilderTest.php b/web/modules/contrib/captcha/tests/src/Unit/Controller/CaptchaPointListBuilderTest.php new file mode 100644 index 000000000..4ea04ed05 --- /dev/null +++ b/web/modules/contrib/captcha/tests/src/Unit/Controller/CaptchaPointListBuilderTest.php @@ -0,0 +1,114 @@ +mockModuleHandler = $this->prophesize(ModuleHandlerInterface::class); + $this->mockModuleHandler->invokeAll(Argument::any(), Argument::any())->willReturn([]); + $this->mockModuleHandler->alter(Argument::any(), Argument::any(), Argument::any())->willReturn([]); + + $this->mockContainer = $this->prophesize(ContainerInterface::class); + $this->mockContainer->get('string_translation')->willReturn($this->getStringTranslationStub()); + $this->mockContainer->get('module_handler')->willReturn($this->mockModuleHandler->reveal()); + + $this->mockEntityType = $this->prophesize(EntityTypeInterface::class); + $this->mockEntityStorage = $this->prophesize(EntityStorageInterface::class); + $this->listBuilder = new CaptchaPointListBuilder($this->mockEntityType->reveal(), $this->mockEntityStorage->reveal()); + + \Drupal::setContainer($this->mockContainer->reveal()); + } + + /** + * Test for buildHeader. + */ + public function testBuildHeader() { + $header = $this->listBuilder->buildHeader(); + $this->assertArrayHasKey('form_id', $header); + $this->assertArrayHasKey('captcha_type', $header); + $this->assertArrayHasKey('captcha_status', $header); + $this->assertArrayHasKey('operations', $header); + } + + /** + * Test for buildRow. + */ + public function testBuildRow() { + $mockEntity = $this->prophesize(CaptchaPoint::class); + $mockEntity->access(Argument::any())->willReturn(FALSE); + $mockEntity->id()->willReturn('target_form_id'); + $mockEntity->getCaptchaType()->willReturn('captcha_type'); + $mockEntity->status()->willReturn('captcha_status'); + $mockEntity->hasLinkTemplate('edit-form')->willReturn(FALSE); + $mockEntity->hasLinkTemplate('delete-form')->willReturn(FALSE); + + $row = $this->listBuilder->buildRow($mockEntity->reveal()); + + $this->assertArrayHasKey('form_id', $row); + $this->assertEquals('target_form_id', $row['form_id']); + + $this->assertArrayHasKey('captcha_type', $row); + $this->assertEquals('captcha_type', $row['captcha_type']); + + $this->assertArrayHasKey('captcha_status', $row); + $this->assertEquals('Enabled', $row['captcha_status']); + } + +} diff --git a/web/modules/contrib/recaptcha/.gitignore b/web/modules/contrib/recaptcha/.gitignore new file mode 100644 index 000000000..b5485c3f0 --- /dev/null +++ b/web/modules/contrib/recaptcha/.gitignore @@ -0,0 +1,11 @@ +/recaptcha-php/examples +/recaptcha-php/tests +/recaptcha-php/.travis.yml +/recaptcha-php/app.yaml +/recaptcha-php/ARCHITECTURE.md +/recaptcha-php/composer.json +/recaptcha-php/CONTRIBUTING.md +/recaptcha-php/LICENSE +/recaptcha-php/phpunit.xml.dist +/recaptcha-php/README.mdg +recaptcha-php/.github \ No newline at end of file diff --git a/web/modules/contrib/recaptcha/.gitlab-ci.yml b/web/modules/contrib/recaptcha/.gitlab-ci.yml new file mode 100644 index 000000000..146d47f4c --- /dev/null +++ b/web/modules/contrib/recaptcha/.gitlab-ci.yml @@ -0,0 +1,15 @@ +include: + - project: $_GITLAB_TEMPLATES_REPO + ref: $_GITLAB_TEMPLATES_REF + file: + - '/includes/include.drupalci.main.yml' + - '/includes/include.drupalci.variables.yml' + - '/includes/include.drupalci.workflows.yml' + +variables: + # Broaden test coverage. + OPT_IN_TEST_PREVIOUS_MINOR: 1 + OPT_IN_TEST_NEXT_MINOR: 1 + OPT_IN_TEST_NEXT_MAJOR: 1 + OPT_IN_TEST_MAX_PHP: 1 + _CSPELL_WORDS: 'grecaptcha, sitekey' diff --git a/web/modules/contrib/recaptcha/LICENSE.txt b/web/modules/contrib/recaptcha/LICENSE.txt new file mode 100644 index 000000000..d159169d1 --- /dev/null +++ b/web/modules/contrib/recaptcha/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/web/modules/contrib/recaptcha/README.md b/web/modules/contrib/recaptcha/README.md new file mode 100644 index 000000000..748ce7da2 --- /dev/null +++ b/web/modules/contrib/recaptcha/README.md @@ -0,0 +1,60 @@ +# reCAPTCHA for Drupal + +The reCAPTCHA module uses the reCAPTCHA web service to improve the CAPTCHA +system and protect email addresses. +This version of the module uses the new Google No CAPTCHA reCAPTCHA API. + +For a full description of the module, visit the +[project page](https://www.drupal.org/project/recaptcha). + +Submit bug reports and feature suggestions, or track changes in the +[issue queue](https://www.drupal.org/project/issues/recaptcha). + + +## Table of contents + +- Requirements +- Installation +- Configuration +- Known Issues +- Thank You + + +## Installation + +Install as you would normally install a contributed Drupal module. For further +information, see +[Installing Drupal Modules](https://www.drupal.org/docs/extending-drupal/installing-drupal-modules). + + +## Requirements + +This module requires the following modules: +- [CAPTCHA module](https://drupal.org/project/captcha) + + +## Configuration + +1. Enable reCAPTCHA and CAPTCHA modules in: *admin/modules* +2. You'll now find a reCAPTCHA tab in the CAPTCHA administration page + available at: *admin/config/people/captcha/recaptcha* +3. Register your web site [in the reCAPTCHA Administration]( + https://www.google.com/recaptcha/admin/create) +4. Input the site and private keys into the reCAPTCHA settings +5. Visit the Captcha administration page and set where you want the + reCAPTCHA form to be presented: *admin/config/people/captcha* + + +## Known Issues + +- cURL requests fail because of outdated root certificate. The reCAPTCHA module + may not able to connect to Google servers and fails to verify the answer. + + [See Issue #2481341](https://www.drupal.org/node/2481341) for more detail. + + +## Thank You + +- Thank you goes to the reCAPTCHA team for all their + help, support and their amazing Captcha solution + [recaptcha](https://www.google.com/recaptcha) diff --git a/web/modules/contrib/recaptcha/composer.json b/web/modules/contrib/recaptcha/composer.json new file mode 100644 index 000000000..fc1007d8d --- /dev/null +++ b/web/modules/contrib/recaptcha/composer.json @@ -0,0 +1,25 @@ +{ + "name": "drupal/recaptcha", + "description": "Protect your website from spam and abuse while letting real people pass through with ease.", + "type": "drupal-module", + "homepage": "https://www.drupal.org/project/recaptcha", + "authors": [ + { + "name": "hass", + "homepage": "https://www.drupal.org/u/hass" + }, + { + "name": "See other contributors", + "homepage":"https://www.drupal.org/node/147903/committers" + } + ], + "support": { + "issues": "https://www.drupal.org/project/issues/recaptcha", + "source": "https://git.drupalcode.org/project/recaptcha.git" + }, + "license": "GPL-2.0-or-later", + "require": { + "drupal/captcha": "^1.15 || ^2.0", + "google/recaptcha": "^1.3" + } +} diff --git a/web/modules/contrib/recaptcha/config/install/recaptcha.settings.yml b/web/modules/contrib/recaptcha/config/install/recaptcha.settings.yml new file mode 100644 index 000000000..2e56c721d --- /dev/null +++ b/web/modules/contrib/recaptcha/config/install/recaptcha.settings.yml @@ -0,0 +1,9 @@ +site_key: '' +secret_key: '' +verify_hostname: false +use_globally: false +widget: + theme: 'light' + type: 'image' + size: '' + noscript: false diff --git a/web/modules/contrib/recaptcha/config/schema/recaptcha.schema.yml b/web/modules/contrib/recaptcha/config/schema/recaptcha.schema.yml new file mode 100644 index 000000000..da7bcfb1c --- /dev/null +++ b/web/modules/contrib/recaptcha/config/schema/recaptcha.schema.yml @@ -0,0 +1,34 @@ +# Schema for the configuration files of the recaptcha module. + +recaptcha.settings: + type: config_object + label: 'reCAPTCHA settings' + mapping: + site_key: + type: string + label: 'Site key' + secret_key: + type: string + label: 'Secret key' + verify_hostname: + type: boolean + label: 'Local domain name validation' + use_globally: + type: boolean + label: 'Use reCAPTCHA globally' + widget: + type: mapping + label: 'Widget settings' + mapping: + theme: + type: string + label: 'Theme' + type: + type: string + label: 'Type' + size: + type: string + label: 'Size' + noscript: + type: boolean + label: 'Enable fallback for browsers with JavaScript disabled' diff --git a/web/modules/contrib/recaptcha/js/recaptcha.js b/web/modules/contrib/recaptcha/js/recaptcha.js new file mode 100644 index 000000000..aa290926b --- /dev/null +++ b/web/modules/contrib/recaptcha/js/recaptcha.js @@ -0,0 +1,36 @@ +/** + * @file + * Contains the definition of the behaviour recaptcha. + */ + +(function ($, Drupal) { + Drupal.behaviors.recaptcha = { + attach(context) { + $('.g-recaptcha', context).each(function () { + if ( + typeof grecaptcha === 'undefined' || + typeof grecaptcha.render !== 'function' + ) { + return; + } + if ($(this).closest('body').length > 0) { + if ($(this).hasClass('recaptcha-processed')) { + grecaptcha.reset(); + } else { + grecaptcha.render(this, $(this).data()); + $(this).addClass('recaptcha-processed'); + } + } + }); + }, + }; + + window.drupalRecaptchaOnload = function () { + $('.g-recaptcha').each(function () { + if (!$(this).hasClass('recaptcha-processed')) { + grecaptcha.render(this, $(this).data()); + $(this).addClass('recaptcha-processed'); + } + }); + }; +})(jQuery, Drupal); diff --git a/web/modules/contrib/recaptcha/ludwig.json b/web/modules/contrib/recaptcha/ludwig.json new file mode 100644 index 000000000..30b602b8c --- /dev/null +++ b/web/modules/contrib/recaptcha/ludwig.json @@ -0,0 +1,8 @@ +{ + "require": { + "google/recaptcha": { + "version": "v1.2.4", + "url": "https://github.com/google/recaptcha/archive/1.2.4.zip" + } + } +} diff --git a/web/modules/contrib/recaptcha/migrations/d6_recaptcha_settings.yml b/web/modules/contrib/recaptcha/migrations/d6_recaptcha_settings.yml new file mode 100644 index 000000000..c3247c440 --- /dev/null +++ b/web/modules/contrib/recaptcha/migrations/d6_recaptcha_settings.yml @@ -0,0 +1,25 @@ +id: d6_recaptcha_settings +label: reCAPTCHA 6 configuration +migration_tags: + - Drupal 6 + - Configuration +source: + plugin: variable + variables: + - recaptcha_noscript + - recaptcha_site_key + - recaptcha_size + - recaptcha_secret_key + - recaptcha_theme + - recaptcha_type + source_module: recaptcha +process: + site_key: recaptcha_site_key + secret_key: recaptcha_secret_key + 'widget/theme': recaptcha_theme + 'widget/type': recaptcha_type + 'widget/size': recaptcha_size + 'widget/noscript': recaptcha_noscript +destination: + plugin: config + config_name: recaptcha.settings diff --git a/web/modules/contrib/recaptcha/migrations/d7_recaptcha_settings.yml b/web/modules/contrib/recaptcha/migrations/d7_recaptcha_settings.yml new file mode 100644 index 000000000..795d272be --- /dev/null +++ b/web/modules/contrib/recaptcha/migrations/d7_recaptcha_settings.yml @@ -0,0 +1,29 @@ +id: d7_recaptcha_settings +label: reCAPTCHA 7 configuration +migration_tags: + - Drupal 7 + - Configuration +source: + plugin: variable + variables: + - recaptcha_noscript + - recaptcha_site_key + - recaptcha_size + - recaptcha_secret_key + - recaptcha_theme + - recaptcha_type + - recaptcha_use_globally + - recaptcha_verify_hostname + source_module: recaptcha +process: + site_key: recaptcha_site_key + secret_key: recaptcha_secret_key + verify_hostname: recaptcha_verify_hostname + use_globally: recaptcha_use_globally + 'widget/theme': recaptcha_theme + 'widget/type': recaptcha_type + 'widget/size': recaptcha_size + 'widget/noscript': recaptcha_noscript +destination: + plugin: config + config_name: recaptcha.settings diff --git a/web/modules/contrib/recaptcha/migrations/state/recaptcha.migrate_drupal.yml b/web/modules/contrib/recaptcha/migrations/state/recaptcha.migrate_drupal.yml new file mode 100644 index 000000000..4b87f5a87 --- /dev/null +++ b/web/modules/contrib/recaptcha/migrations/state/recaptcha.migrate_drupal.yml @@ -0,0 +1,5 @@ +finished: + 6: + recaptcha: recaptcha + 7: + recaptcha: recaptcha diff --git a/web/modules/contrib/recaptcha/phpcs.xml.dist b/web/modules/contrib/recaptcha/phpcs.xml.dist new file mode 100644 index 000000000..914c4a86f --- /dev/null +++ b/web/modules/contrib/recaptcha/phpcs.xml.dist @@ -0,0 +1,10 @@ + + + reCAPTCHA for Drupal PHP_CodeSniffer standards overrides. + . + + + + + + \ No newline at end of file diff --git a/web/modules/contrib/recaptcha/recaptcha.info.yml b/web/modules/contrib/recaptcha/recaptcha.info.yml new file mode 100644 index 000000000..52e4dc2a3 --- /dev/null +++ b/web/modules/contrib/recaptcha/recaptcha.info.yml @@ -0,0 +1,13 @@ +name: 'reCAPTCHA' +type: module +description: 'Protect your website from spam and abuse while letting real people pass through with ease.' +package: Spam control +core_version_requirement: ^10 || ^11 +configure: recaptcha.admin_settings_form +dependencies: + - captcha:captcha + +# Information added by Drupal.org packaging script on 2024-08-13 +version: '8.x-3.4' +project: 'recaptcha' +datestamp: 1723563037 diff --git a/web/modules/contrib/recaptcha/recaptcha.libraries.yml b/web/modules/contrib/recaptcha/recaptcha.libraries.yml new file mode 100644 index 000000000..7471b37cc --- /dev/null +++ b/web/modules/contrib/recaptcha/recaptcha.libraries.yml @@ -0,0 +1,6 @@ +recaptcha: + js: + js/recaptcha.js: {} + dependencies: + - core/drupal + - core/jquery diff --git a/web/modules/contrib/recaptcha/recaptcha.links.menu.yml b/web/modules/contrib/recaptcha/recaptcha.links.menu.yml new file mode 100644 index 000000000..877bb52ff --- /dev/null +++ b/web/modules/contrib/recaptcha/recaptcha.links.menu.yml @@ -0,0 +1,6 @@ +recaptcha.admin_settings_form: + title: 'reCAPTCHA' + parent: captcha.settings + description: 'Administer the Google No CAPTCHA reCAPTCHA web service.' + route_name: recaptcha.admin_settings_form + weight: 1 diff --git a/web/modules/contrib/recaptcha/recaptcha.links.task.yml b/web/modules/contrib/recaptcha/recaptcha.links.task.yml new file mode 100644 index 000000000..892fac987 --- /dev/null +++ b/web/modules/contrib/recaptcha/recaptcha.links.task.yml @@ -0,0 +1,4 @@ +recaptcha.admin_settings_form_tab: + route_name: recaptcha.admin_settings_form + title: reCAPTCHA + base_route: captcha_settings diff --git a/web/modules/contrib/recaptcha/recaptcha.module b/web/modules/contrib/recaptcha/recaptcha.module new file mode 100644 index 000000000..b922edbe0 --- /dev/null +++ b/web/modules/contrib/recaptcha/recaptcha.module @@ -0,0 +1,263 @@ +' . t('About') . ''; + $output .= '

' . t('Google reCAPTCHA is a free service to protect your website from spam and abuse. reCAPTCHA uses an advanced risk analysis engine and adaptive CAPTCHAs to keep automated software from engaging in abusive activities on your site. It does this while letting your valid users pass through with ease.', [':url' => 'https://www.google.com/recaptcha']) . '

'; + $output .= '

' . t('Uses') . '

'; + $output .= '
'; + $output .= '
' . t('Protects and defends') . '
'; + $output .= '
' . t('reCAPTCHA is built for security. Armed with state of the art technology, it always stays at the forefront of spam and abuse fighting trends. reCAPTCHA is on guard for you, so you can rest easy.') . '
'; + $output .= '
'; + $output .= '

' . t('Configuration') . '

'; + $output .= '
    '; + $output .= '
  1. ' . t('Enable reCAPTCHA and CAPTCHA modules in Administration > Extend') . '
  2. '; + $output .= '
  3. ' . t('You will now find a reCAPTCHA tab in the CAPTCHA administration page available at: Administration > Configuration > People > CAPTCHA module settings > reCAPTCHA') . '
  4. '; + $output .= '
  5. ' . t('Register your web site at https://www.google.com/recaptcha/admin/create', [':url' => 'https://www.google.com/recaptcha/admin/create']) . '
  6. '; + $output .= '
  7. ' . t('Input the site and private keys into the reCAPTCHA settings.') . '
  8. '; + $output .= '
  9. ' . t('Visit the Captcha administration page and set where you want the reCAPTCHA form to be presented: Administration > Configuration > People > CAPTCHA module settings') . '
  10. '; + $output .= '
'; + return $output; + } +} + +/** + * Implements hook_theme(). + */ +function recaptcha_theme() { + return [ + 'recaptcha_widget_noscript' => [ + 'variables' => [ + 'widget' => NULL, + ], + 'template' => 'recaptcha-widget-noscript', + ], + ]; +} + +/** + * Implements hook_captcha(). + */ +function recaptcha_captcha($op, $captcha_type = '') { + + switch ($op) { + case 'list': + return ['reCAPTCHA']; + + case 'generate': + $captcha = []; + if ($captcha_type == 'reCAPTCHA') { + $config = \Drupal::config('recaptcha.settings'); + $renderer = \Drupal::service('renderer'); + $recaptcha_site_key = $config->get('site_key'); + $recaptcha_secret_key = $config->get('secret_key'); + $recaptcha_use_globally = $config->get('use_globally'); + + if (!empty($recaptcha_site_key) && !empty($recaptcha_secret_key)) { + // Build the reCAPTCHA captcha form if site_key and secret_key are + // configured. Captcha requires TRUE to be returned in solution. + $captcha['solution'] = TRUE; + $captcha['captcha_validate'] = 'recaptcha_captcha_validation'; + $captcha['form']['captcha_response'] = [ + '#type' => 'hidden', + ]; + + // As the validate callback does not depend on sid or solution, this + // captcha type can be displayed on cached pages. + $captcha['cacheable'] = TRUE; + + $noscript = ''; + if ($config->get('widget.noscript')) { + // Check if reCAPTCHA use globally is enabled. + $recaptcha_src_fallback = 'https://www.google.com/recaptcha/api/fallback'; + if ($recaptcha_use_globally) { + $recaptcha_src_fallback = 'https://www.recaptcha.net/recaptcha/api/fallback'; + } + + $recaptcha_widget_noscript = [ + '#theme' => 'recaptcha_widget_noscript', + '#widget' => [ + 'sitekey' => $recaptcha_site_key, + 'recaptcha_src_fallback' => $recaptcha_src_fallback, + 'language' => \Drupal::service('language_manager')->getCurrentLanguage()->getId(), + ], + ]; + $noscript = $renderer->render($recaptcha_widget_noscript); + } + + $attributes = [ + 'class' => 'g-recaptcha', + 'data-sitekey' => $recaptcha_site_key, + 'data-theme' => $config->get('widget.theme'), + 'data-type' => $config->get('widget.type'), + 'data-size' => $config->get('widget.size'), + ]; + // Filter out any empty elements from the attributes. + $attributes = array_filter($attributes); + + $captcha['form']['recaptcha_widget'] = [ + '#markup' => '', + '#suffix' => $noscript, + '#attached' => [ + 'library' => [ + 'recaptcha/recaptcha', + 'recaptcha/google.recaptcha_' . \Drupal::service('language_manager')->getCurrentLanguage()->getId(), + ], + ], + '#cache' => [ + 'tags' => ['library_info'], + 'contexts' => ['languages'], + ], + ]; + } + else { + // Fallback to Math captcha as reCAPTCHA is not configured. + $captcha = captcha_captcha('generate', 'Math'); + } + + // If module configuration changes the form cache need to be refreshed. + $renderer->addCacheableDependency($captcha['form'], $config); + } + return $captcha; + } +} + +/** + * Implements hook_library_info_build(). + */ +function recaptcha_library_info_build() { + $libraries = []; + $languages = \Drupal::service('language_manager')->getLanguages(); + $config = \Drupal::config('recaptcha.settings'); + $use_globally = $config->get('use_globally'); + $recaptcha_src = 'https://www.google.com/recaptcha/api.js'; + if ($use_globally) { + $recaptcha_src = 'https://www.recaptcha.net/recaptcha/api.js'; + } + + foreach ($languages as $lang => $language) { + $url = Url::fromUri($recaptcha_src, [ + 'query' => [ + 'hl' => $lang, + 'render' => 'explicit', + 'onload' => 'drupalRecaptchaOnload', + ], + 'absolute' => TRUE, + ])->toString(); + $libraries['google.recaptcha_' . $lang] = [ + 'version' => '1.x', + 'header' => TRUE, + 'js' => [ + $url => [ + 'type' => 'external', + 'minified' => TRUE, + 'attributes' => [ + 'async' => TRUE, + 'defer' => TRUE, + ], + ], + ], + ]; + } + return $libraries; +} + +/** + * CAPTCHA Callback; Validates the reCAPTCHA code. + */ +function recaptcha_captcha_validation($solution, $response, $element, $form_state) { + $config = \Drupal::config('recaptcha.settings'); + $request_stack = \Drupal::service('request_stack'); + + $recaptcha_response = $request_stack->getCurrentRequest()->request->get('g-recaptcha-response'); + $recaptcha_secret_key = $config->get('secret_key'); + if (empty($recaptcha_response) || empty($recaptcha_secret_key)) { + return FALSE; + } + + // Use Drupal::httpClient() to circumvent all issues with the Google library. + $drupal8post = Drupal::service('recaptcha.drupal8post'); + $recaptcha = new ReCaptcha($recaptcha_secret_key, $drupal8post); + + // Ensures the hostname matches. Required if "Domain Name Validation" is + // disabled for credentials. + if ($config->get('verify_hostname')) { + $recaptcha->setExpectedHostname(\Drupal::request()->getHost()); + } + + $resp = $recaptcha->verify( + $recaptcha_response, + \Drupal::request()->getClientIp() + ); + + if ($resp->isSuccess()) { + // Verified! + return TRUE; + } + else { + // Error code reference, https://developers.google.com/recaptcha/docs/verify + $error_codes = [ + 'action-mismatch' => t('Expected action did not match.'), + 'apk_package_name-mismatch' => t('Expected APK package name did not match.'), + 'bad-response' => t('Did not receive a 200 from the service.'), + 'bad-request' => t('The request is invalid or malformed.'), + 'connection-failed' => t('Could not connect to service.'), + 'invalid-input-response' => t('The response parameter is invalid or malformed.'), + 'invalid-input-secret' => t('The secret parameter is invalid or malformed.'), + 'invalid-json' => t('The json response is invalid or malformed.'), + 'missing-input-response' => t('The response parameter is missing.'), + 'missing-input-secret' => t('The secret parameter is missing.'), + 'hostname-mismatch' => t('Expected hostname did not match.'), + ]; + $info_codes = [ + 'challenge-timeout' => t('Challenge timeout.'), + 'score-threshold-not-met' => t('Score threshold not met.'), + 'timeout-or-duplicate' => t('The challenge response timed out or was already verified.'), + 'unknown-error' => t('Not a success, but no error codes received!'), + ]; + foreach ($resp->getErrorCodes() as $code) { + if (isset($info_codes[$code])) { + \Drupal::logger('reCAPTCHA web service')->info('@info', ['@info' => $info_codes[$code]]); + } + else { + if (!isset($error_codes[$code])) { + $code = 'unknown-error'; + } + \Drupal::logger('reCAPTCHA web service')->error('@error', ['@error' => $error_codes[$code]]); + } + } + } + return FALSE; +} + +/** + * Process variables for recaptcha-widget-noscript.tpl.php. + * + * @see recaptcha-widget-noscript.tpl.php + */ +function template_preprocess_recaptcha_widget_noscript(&$variables) { + $variables['sitekey'] = $variables['widget']['sitekey']; + $variables['language'] = $variables['widget']['language']; + $variables['url'] = Url::fromUri($variables['widget']['recaptcha_src_fallback'], [ + 'query' => [ + 'k' => $variables['widget']['sitekey'], + 'hl' => $variables['widget']['language'], + ], + 'absolute' => TRUE, + ])->toString(); +} diff --git a/web/modules/contrib/recaptcha/recaptcha.permissions.yml b/web/modules/contrib/recaptcha/recaptcha.permissions.yml new file mode 100644 index 000000000..96b5c4c37 --- /dev/null +++ b/web/modules/contrib/recaptcha/recaptcha.permissions.yml @@ -0,0 +1,3 @@ +administer recaptcha: + title: 'Administer reCAPTCHA' + description: 'Administer reCAPTCHA settings.' diff --git a/web/modules/contrib/recaptcha/recaptcha.routing.yml b/web/modules/contrib/recaptcha/recaptcha.routing.yml new file mode 100644 index 000000000..fe5b60ef6 --- /dev/null +++ b/web/modules/contrib/recaptcha/recaptcha.routing.yml @@ -0,0 +1,7 @@ +recaptcha.admin_settings_form: + path: '/admin/config/people/captcha/recaptcha' + defaults: + _form: '\Drupal\recaptcha\Form\ReCaptchaAdminSettingsForm' + _title: 'reCAPTCHA' + requirements: + _permission: 'administer recaptcha' diff --git a/web/modules/contrib/recaptcha/recaptcha.services.yml b/web/modules/contrib/recaptcha/recaptcha.services.yml new file mode 100644 index 000000000..b001088fc --- /dev/null +++ b/web/modules/contrib/recaptcha/recaptcha.services.yml @@ -0,0 +1,9 @@ +services: + recaptcha.config_subscriber: + class: Drupal\recaptcha\EventSubscriber\RecaptchaSettingsConfigSubscriber + arguments: ['@cache_tags.invalidator'] + tags: + - { name: event_subscriber } + recaptcha.drupal8post: + class: Drupal\recaptcha\ReCaptcha\RequestMethod\Drupal8Post + arguments: ['@http_client'] diff --git a/web/modules/contrib/recaptcha/src/EventSubscriber/RecaptchaSettingsConfigSubscriber.php b/web/modules/contrib/recaptcha/src/EventSubscriber/RecaptchaSettingsConfigSubscriber.php new file mode 100644 index 000000000..09c5ade38 --- /dev/null +++ b/web/modules/contrib/recaptcha/src/EventSubscriber/RecaptchaSettingsConfigSubscriber.php @@ -0,0 +1,60 @@ +cacheTagsInvalidator = $cache_tags_invalidator; + } + + /** + * Invalidates the library_info tag. + * + * Invalidates the library_info tag when the value of recaptcha.settings + * use_globally is changed. + * + * @param \Drupal\Core\Config\ConfigCrudEvent $event + * The Event to process. + */ + public function onSave(ConfigCrudEvent $event) { + if ($event->getConfig()->getName() === 'recaptcha.settings') { + if ($event->isChanged('use_globally')) { + $this->cacheTagsInvalidator->invalidateTags(['library_info']); + } + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $events[ConfigEvents::SAVE][] = ['onSave']; + return $events; + } + +} diff --git a/web/modules/contrib/recaptcha/src/Form/ReCaptchaAdminSettingsForm.php b/web/modules/contrib/recaptcha/src/Form/ReCaptchaAdminSettingsForm.php new file mode 100644 index 000000000..7035c1397 --- /dev/null +++ b/web/modules/contrib/recaptcha/src/Form/ReCaptchaAdminSettingsForm.php @@ -0,0 +1,136 @@ +config('recaptcha.settings'); + + $form['general'] = [ + '#type' => 'details', + '#title' => $this->t('General settings'), + '#open' => TRUE, + ]; + + $form['general']['recaptcha_site_key'] = [ + '#default_value' => $config->get('site_key'), + '#description' => $this->t('The site key given to you when you register for reCAPTCHA.', [':url' => 'https://www.google.com/recaptcha/admin']), + '#maxlength' => 40, + '#required' => TRUE, + '#title' => $this->t('Site key'), + '#type' => 'textfield', + ]; + + $form['general']['recaptcha_secret_key'] = [ + '#default_value' => $config->get('secret_key'), + '#description' => $this->t('The secret key given to you when you register for reCAPTCHA.', [':url' => 'https://www.google.com/recaptcha/admin']), + '#maxlength' => 40, + '#required' => TRUE, + '#title' => $this->t('Secret key'), + '#type' => 'textfield', + ]; + + $form['general']['recaptcha_verify_hostname'] = [ + '#default_value' => $config->get('verify_hostname'), + '#description' => $this->t('Checks the hostname on your server when verifying a solution. Enable this validation only, if Verify the origin of reCAPTCHA solutions is unchecked for your key pair. Provides crucial security by verifying requests come from one of your listed domains.'), + '#title' => $this->t('Local domain name validation'), + '#type' => 'checkbox', + ]; + + $form['general']['recaptcha_use_globally'] = [ + '#default_value' => $config->get('use_globally'), + '#description' => $this->t('Enable this in circumstances when "www.google.com" is not accessible, e.g. China.'), + '#title' => $this->t('Use reCAPTCHA globally'), + '#type' => 'checkbox', + ]; + + // Widget configurations. + $form['widget'] = [ + '#type' => 'details', + '#title' => $this->t('Widget settings'), + '#open' => TRUE, + ]; + $form['widget']['recaptcha_theme'] = [ + '#default_value' => $config->get('widget.theme'), + '#description' => $this->t('Defines which theme to use for reCAPTCHA.'), + '#options' => [ + 'light' => $this->t('Light (default)'), + 'dark' => $this->t('Dark'), + ], + '#title' => $this->t('Theme'), + '#type' => 'select', + ]; + $form['widget']['recaptcha_type'] = [ + '#default_value' => $config->get('widget.type'), + '#description' => $this->t('The type of CAPTCHA to serve.'), + '#options' => [ + 'image' => $this->t('Image (default)'), + 'audio' => $this->t('Audio'), + ], + '#title' => $this->t('Type'), + '#type' => 'select', + ]; + $form['widget']['recaptcha_size'] = [ + '#default_value' => $config->get('widget.size'), + '#description' => $this->t('The size of CAPTCHA to serve.'), + '#options' => [ + '' => $this->t('Normal (default)'), + 'compact' => $this->t('Compact'), + ], + '#title' => $this->t('Size'), + '#type' => 'select', + ]; + $form['widget']['recaptcha_noscript'] = [ + '#default_value' => $config->get('widget.noscript'), + '#description' => $this->t('If JavaScript is a requirement for your site, you should not enable this feature. With this enabled, a compatibility layer will be added to the captcha to support non-js users.'), + '#title' => $this->t('Enable fallback for browsers with JavaScript disabled'), + '#type' => 'checkbox', + ]; + + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $config = $this->config('recaptcha.settings'); + $config + ->set('site_key', $form_state->getValue('recaptcha_site_key')) + ->set('secret_key', $form_state->getValue('recaptcha_secret_key')) + ->set('verify_hostname', $form_state->getValue('recaptcha_verify_hostname')) + ->set('use_globally', $form_state->getValue('recaptcha_use_globally')) + ->set('widget.theme', $form_state->getValue('recaptcha_theme')) + ->set('widget.type', $form_state->getValue('recaptcha_type')) + ->set('widget.size', $form_state->getValue('recaptcha_size')) + ->set('widget.noscript', $form_state->getValue('recaptcha_noscript')) + ->save(); + + parent::submitForm($form, $form_state); + } + +} diff --git a/web/modules/contrib/recaptcha/src/ReCaptcha/RequestMethod/Drupal8Post.php b/web/modules/contrib/recaptcha/src/ReCaptcha/RequestMethod/Drupal8Post.php new file mode 100644 index 000000000..3f5404a9c --- /dev/null +++ b/web/modules/contrib/recaptcha/src/ReCaptcha/RequestMethod/Drupal8Post.php @@ -0,0 +1,67 @@ +httpClient = $http_client; + } + + /** + * Submit the POST request with the specified parameters. + * + * @param \ReCaptcha\ReCaptcha\RequestParameters $params + * Request parameters. + * + * @return string + * Body of the reCAPTCHA response. + */ + public function submit(RequestParameters $params) { + + $options = [ + 'headers' => [ + 'Content-type' => 'application/x-www-form-urlencoded', + ], + 'body' => $params->toQueryString(), + // Stop firing exception on response status code >= 300. + // See http://docs.guzzlephp.org/en/stable/handlers-and-middleware.html + 'http_errors' => FALSE, + ]; + + $response = $this->httpClient->post(ReCaptcha::SITE_VERIFY_URL, $options); + + if ($response->getStatusCode() == 200) { + // The service request was successful. + return (string) $response->getBody(); + } + elseif ($response->getStatusCode() < 0) { + // Negative status codes typically point to network or socket issues. + return '{"success": false, "error-codes": ["' . ReCaptcha::E_CONNECTION_FAILED . '"]}'; + } + else { + // Positive none 200 status code typically means the request has failed. + return '{"success": false, "error-codes": ["' . ReCaptcha::E_BAD_RESPONSE . '"]}'; + } + } + +} diff --git a/web/modules/contrib/recaptcha/templates/recaptcha-widget-noscript.html.twig b/web/modules/contrib/recaptcha/templates/recaptcha-widget-noscript.html.twig new file mode 100644 index 000000000..617b4e3f4 --- /dev/null +++ b/web/modules/contrib/recaptcha/templates/recaptcha-widget-noscript.html.twig @@ -0,0 +1,29 @@ +{# +/** + * @file recaptcha-widget-noscript.tpl.php + * Default theme implementation to present the reCAPTCHA noscript code. + * + * Available variables: + * - sitekey: Google web service site key. + * - language: Current site language code. + * - url: Google web service API url. + * + * @see template_preprocess() + * @see template_preprocess_recaptcha_widget_noscript() + * + * @ingroup themeable + */ +#} + + diff --git a/web/modules/contrib/recaptcha/tests/modules/recaptcha_test/config/install/captcha.captcha_point.recaptcha_test_ajax_form.yml b/web/modules/contrib/recaptcha/tests/modules/recaptcha_test/config/install/captcha.captcha_point.recaptcha_test_ajax_form.yml new file mode 100644 index 000000000..b2a4951b9 --- /dev/null +++ b/web/modules/contrib/recaptcha/tests/modules/recaptcha_test/config/install/captcha.captcha_point.recaptcha_test_ajax_form.yml @@ -0,0 +1,9 @@ +langcode: en +status: true +formId: recaptcha_test_ajax_form +captchaType: recaptcha/reCAPTCHA +label: recaptcha_test_ajax_form +dependencies: + enforced: + module: + - recaptcha_test diff --git a/web/modules/contrib/recaptcha/tests/modules/recaptcha_test/recaptcha_test.info.yml b/web/modules/contrib/recaptcha/tests/modules/recaptcha_test/recaptcha_test.info.yml new file mode 100644 index 000000000..141a870a1 --- /dev/null +++ b/web/modules/contrib/recaptcha/tests/modules/recaptcha_test/recaptcha_test.info.yml @@ -0,0 +1,12 @@ +name: 'reCAPTCHA Test' +type: module +description: 'Test module for the recaptcha module.' +package: Testing +core_version_requirement: ^8.9 || ^9 || ^10 +dependencies: + - fences:recaptcha + +# Information added by Drupal.org packaging script on 2024-08-13 +version: '8.x-3.4' +project: 'recaptcha' +datestamp: 1723563037 diff --git a/web/modules/contrib/recaptcha/tests/modules/recaptcha_test/recaptcha_test.routing.yml b/web/modules/contrib/recaptcha/tests/modules/recaptcha_test/recaptcha_test.routing.yml new file mode 100644 index 000000000..04c6ddc66 --- /dev/null +++ b/web/modules/contrib/recaptcha/tests/modules/recaptcha_test/recaptcha_test.routing.yml @@ -0,0 +1,26 @@ +recaptcha_test.page: + path: '/recaptcha-test' + defaults: + _controller: '\Drupal\recaptcha_test\Controller\RecaptchaTestAjaxFormController::button' + _title: 'reCAPTCHA Test Ajax Page' + requirements: + # This route is only meant for testing purposes, no permission check needed: + _access: 'TRUE' + +recaptcha_test.ajax: + path: '/recaptcha-test/ajax' + defaults: + _controller: '\Drupal\recaptcha_test\Controller\RecaptchaTestAjaxFormController::ajaxForm' + _title: 'reCAPTCHA Test Ajax Controller' + requirements: + # This route is only meant for testing purposes, no permission check needed: + _access: 'TRUE' + +recaptcha_test.form: + path: '/recaptcha-test/form' + defaults: + _form: '\Drupal\recaptcha_test\Form\RecaptchaTestAjaxForm' + _title: 'reCAPTCHA Test Ajax Form' + requirements: + # This route is only meant for testing purposes, no permission check needed: + _access: 'TRUE' diff --git a/web/modules/contrib/recaptcha/tests/modules/recaptcha_test/src/Controller/RecaptchaTestAjaxFormController.php b/web/modules/contrib/recaptcha/tests/modules/recaptcha_test/src/Controller/RecaptchaTestAjaxFormController.php new file mode 100644 index 000000000..ac7f63b00 --- /dev/null +++ b/web/modules/contrib/recaptcha/tests/modules/recaptcha_test/src/Controller/RecaptchaTestAjaxFormController.php @@ -0,0 +1,79 @@ +formBuilder = $form_builder; + } + + /** + * Container creation method. + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('form_builder') + ); + } + + /** + * Button rendering method. + */ + public function button() { + $output = []; + + $output['container'] = [ + '#type' => 'container', + '#attributes' => [ + 'id' => 'recaptcha-test-container', + ], + ]; + + $url = Url::fromRoute('recaptcha_test.ajax', []); + + $output['container']['ajax_link'] = [ + '#id' => 'load-ajax-form', + '#type' => 'link', + '#title' => $this->t('Load Ajax Form'), + '#url' => $url, + '#attributes' => [ + 'class' => ['use-ajax', 'button', 'secondary', 'btn', 'btn-secondary'], + ], + ]; + + $output['#attached']['library'][] = 'core/drupal.ajax'; + + // @see https://api.drupal.org/api/drupal/core%21core.api.php/group/ajax/8.2.x + return $output; + } + + /** + * Ajax callback returning a form. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The AJAX response. + */ + public function ajaxForm() { + $form = $this->formBuilder->getForm('Drupal\recaptcha_test\Form\RecaptchaTestAjaxForm'); + + $ajax = new AjaxResponse(); + $ajax->addCommand(new ReplaceCommand('#recaptcha-test-container', $form)); + return $ajax; + } + +} diff --git a/web/modules/contrib/recaptcha/tests/modules/recaptcha_test/src/Form/RecaptchaTestAjaxForm.php b/web/modules/contrib/recaptcha/tests/modules/recaptcha_test/src/Form/RecaptchaTestAjaxForm.php new file mode 100644 index 000000000..bd0ecf2c1 --- /dev/null +++ b/web/modules/contrib/recaptcha/tests/modules/recaptcha_test/src/Form/RecaptchaTestAjaxForm.php @@ -0,0 +1,79 @@ + 'status_messages', + ]; + + $form['email'] = [ + '#type' => 'email', + '#title' => $this->t('Email'), + '#required' => TRUE, + ]; + + $form['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Submit'), + '#validate' => ['::validateForm'], + '#ajax' => [ + 'callback' => '::ajaxCallback', + 'wrapper' => 'recaptcha-test-ajax-form-wrapper', + ], + ]; + + $form['#prefix'] = '
'; + $form['#suffix'] = '
'; + return $form; + } + + /** + * Ajax callback method for form. + */ + public function ajaxCallback(array &$form, FormStateInterface $form_state) { + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + $email = $form_state->getValue('email'); + if ($email == 'invalid@example.com') { + $form_state->setError($form['email'], 'Invalid email'); + } + + } + + /** + * Form submission method. + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->messenger()->addStatus('Form submit successful.'); + } + +} diff --git a/web/modules/contrib/recaptcha/tests/src/Functional/ReCaptchaBasicTest.php b/web/modules/contrib/recaptcha/tests/src/Functional/ReCaptchaBasicTest.php new file mode 100644 index 000000000..6311bd7a7 --- /dev/null +++ b/web/modules/contrib/recaptcha/tests/src/Functional/ReCaptchaBasicTest.php @@ -0,0 +1,237 @@ +loadInclude('captcha', 'inc'); + + // Create a normal user. + $permissions = [ + 'access content', + ]; + $this->normalUser = $this->drupalCreateUser($permissions); + + // Create an admin user. + $permissions += [ + 'administer CAPTCHA settings', + 'skip CAPTCHA', + 'administer permissions', + 'administer content types', + 'administer recaptcha', + ]; + $this->adminUser = $this->drupalCreateUser($permissions); + } + + /** + * Test access to the administration page. + */ + public function testReCaptchaAdminAccess() { + $this->drupalLogin($this->adminUser); + $this->drupalGet('admin/config/people/captcha/recaptcha'); + $this->assertSession()->pageTextNotContains($this->t('Access denied')); + $this->drupalLogout(); + } + + /** + * Test the reCAPTCHA settings form. + */ + public function testReCaptchaAdminSettingsForm() { + $this->drupalLogin($this->adminUser); + + $site_key = $this->randomMachineName(40); + $secret_key = $this->randomMachineName(40); + + // Check form validation. + $edit['recaptcha_site_key'] = ''; + $edit['recaptcha_secret_key'] = ''; + $this->drupalGet('admin/config/people/captcha/recaptcha'); + $this->submitForm($edit, $this->t('Save configuration')); + + $this->assertSession()->responseContains($this->t('Site key field is required.')); + $this->assertSession()->responseContains($this->t('Secret key field is required.')); + + // Save form with valid values. + $edit['recaptcha_site_key'] = $site_key; + $edit['recaptcha_secret_key'] = $secret_key; + $this->drupalGet('admin/config/people/captcha/recaptcha'); + $this->submitForm($edit, $this->t('Save configuration')); + $this->assertSession()->responseContains($this->t('The configuration options have been saved.')); + + $this->assertSession()->responseNotContains($this->t('Site key field is required.')); + $this->assertSession()->responseNotContains($this->t('Secret key field is required.')); + + $this->drupalLogout(); + } + + /** + * Testing the protection of the user login form. + */ + public function testReCaptchaOnLoginForm() { + $site_key = $this->randomMachineName(40); + $secret_key = $this->randomMachineName(40); + $grecaptchaSelector = "div.g-recaptcha[data-sitekey=$site_key][data-theme=light][data-type=image]"; + + // Test if login works. + $this->drupalLogin($this->normalUser); + $this->drupalLogout(); + + $this->drupalGet('user/login'); + // reCAPTCHA is not shown on form. + $this->assertSession()->elementNotExists('css', $grecaptchaSelector); + + // Enable 'captcha/Math' CAPTCHA on login form. + captcha_set_form_id_setting('user_login_form', 'captcha/Math'); + + $this->drupalGet('user/login'); + // reCAPTCHA is not shown on form. + $this->assertSession()->elementNotExists('css', $grecaptchaSelector); + + // Enable 'recaptcha/reCAPTCHA' on login form. + captcha_set_form_id_setting('user_login_form', 'recaptcha/reCAPTCHA'); + $result = captcha_get_form_id_setting('user_login_form'); + $this->assertNotNull($result, 'A configuration has been found for CAPTCHA point: user_login_form'); + $this->assertEquals($result->getCaptchaType(), 'recaptcha/reCAPTCHA', 'reCAPTCHA type has been configured for CAPTCHA point: user_login_form'); + + // Check if a Math CAPTCHA is still shown on the login form. The site key + // and security key have not yet configured for reCAPTCHA. The module need + // to fall back to math captcha. + $this->drupalGet('user/login'); + $this->assertSession()->responseContains($this->t('Math question')); + + // Configure site key and security key to show reCAPTCHA and no fall back. + $this->config('recaptcha.settings')->set('site_key', $site_key)->save(); + $this->config('recaptcha.settings')->set('secret_key', $secret_key)->save(); + + // Check if there is a reCAPTCHA on the login form. + $this->drupalGet('user/login'); + // reCAPTCHA is shown on form. + $this->assertSession()->elementExists('css', $grecaptchaSelector); + $options = [ + 'query' => [ + 'hl' => \Drupal::service('language_manager')->getCurrentLanguage()->getId(), + 'render' => 'explicit', + 'onload' => 'drupalRecaptchaOnload', + ], + 'absolute' => TRUE, + ]; + $this->assertSession()->responseContains(Html::escape(Url::fromUri('https://www.google.com/recaptcha/api.js', $options)->toString())); + // NoScript code is not enabled for the reCAPTCHA. + $this->assertSession()->elementNotExists('css', "$grecaptchaSelector + noscript"); + + // Test if the fall back url is properly build and noscript code added. + $this->config('recaptcha.settings')->set('widget.noscript', 1)->save(); + + $this->drupalGet('user/login'); + // NoScript for reCAPTCHA is shown on form. + $this->assertSession()->elementExists('css', "$grecaptchaSelector + noscript"); + $options = [ + 'query' => [ + 'k' => $site_key, + 'hl' => \Drupal::service('language_manager')->getCurrentLanguage()->getId(), + ], + 'absolute' => TRUE, + ]; + $this->assertSession()->responseContains(Html::escape(Url::fromUri('https://www.google.com/recaptcha/api/fallback', $options)->toString())); + + // Check if there is a reCAPTCHA with global url on the login form. + $this->config('recaptcha.settings')->set('use_globally', TRUE)->save(); + $this->drupalGet('user/login'); + $options = [ + 'query' => [ + 'hl' => \Drupal::service('language_manager')->getCurrentLanguage()->getId(), + 'render' => 'explicit', + 'onload' => 'drupalRecaptchaOnload', + ], + 'absolute' => TRUE, + ]; + $this->assertSession()->responseContains(Html::escape(Url::fromUri('https://www.recaptcha.net/recaptcha/api.js', $options)->toString()), '[testReCaptchaOnLoginForm]: Global reCAPTCHA is shown on form.'); + $options = [ + 'query' => [ + 'k' => $site_key, + 'hl' => \Drupal::service('language_manager')->getCurrentLanguage()->getId(), + ], + 'absolute' => TRUE, + ]; + $this->assertSession()->responseContains(Html::escape(Url::fromUri('https://www.recaptcha.net/recaptcha/api/fallback', $options)->toString())); + + // Check that data-size attribute does not exists. + $this->config('recaptcha.settings')->set('widget.size', '')->save(); + $this->drupalGet('user/login'); + $args = [':class' => 'g-recaptcha', ':size' => 'small']; + $element = $this->xpath('//div[@class=:class and @data-size=:size]', $args); + $this->assertEmpty($element, 'Tag contains no data-size attribute.'); + + // Check that data-size attribute exists. + $this->config('recaptcha.settings')->set('widget.size', 'small')->save(); + $this->drupalGet('user/login'); + $args = [':class' => 'g-recaptcha', ':size' => 'small']; + $element = $this->xpath('//div[@class=:class and @data-size=:size]', $args); + $this->assertNotEmpty($element, 'Tag contains data-size attribute and value.'); + + // Try to log in, which should fail. + $edit['name'] = $this->normalUser->getAccountName(); + $edit['pass'] = $this->normalUser->getPassword(); + $this->assertSession()->responseContains('captcha_response'); + $this->assertSession() + ->hiddenFieldExists('captcha_response') + ->setValue('?'); + + $this->drupalGet('user/login'); + $this->submitForm($edit, $this->t('Log in')); + // Check for error message. + $this->assertSession()->pageTextContains($this->t('The answer you entered for the CAPTCHA was not correct.')); + + // And make sure that user is not logged in: check for name and password + // fields on "?q=user". + $this->drupalGet('user/login'); + $this->assertSession()->fieldExists('name'); + $this->assertSession()->fieldExists('pass'); + } + +} diff --git a/web/modules/contrib/recaptcha/tests/src/Functional/RecaptchaJavascriptTest.php b/web/modules/contrib/recaptcha/tests/src/Functional/RecaptchaJavascriptTest.php new file mode 100644 index 000000000..24e2660e9 --- /dev/null +++ b/web/modules/contrib/recaptcha/tests/src/Functional/RecaptchaJavascriptTest.php @@ -0,0 +1,158 @@ +config('recaptcha.settings') + ->set('site_key', self::SITE_KEY) + ->set('secret_key', self::SECRET_KEY) + ->save(); + } + + /** + * Test the recaptcha on a form loaded via ajax that also submits via ajax. + */ + public function testRecaptchaOnAjaxForm() { + // Load the /recaptcha-test page with the AJAX button. + $path = Url::fromRoute('recaptcha_test.page')->toString(); + $this->drupalGet($path); + + // No recaptcha JS on the page. + $this->assertSession()->responseNotContains('https://www.google.com/recaptcha/api.js', 'reCAPTCHA js is not present before the form is loaded via AJAX.'); + + // Click the button. + $this->click('a#load-ajax-form'); + + // Once the form is loaded. + $this->getSession()->wait(2000, '(jQuery("form[data-drupal-selector^=recaptcha-test-ajax-form]").length > 0)'); + $this->assertJsCondition('Drupal.behaviors.recaptcha', 100, 'recaptcha Drupal behaviors found.'); + + // The recaptcha should be on the page. + $this->assertSession()->responseContains('https://www.google.com/recaptcha/api.js', 'reCAPTCHA js has been added.'); + $grecaptcha = $this->getSession()->getPage()->find('css', 'form .g-recaptcha'); + $this->assertJsCondition('window.grecaptcha !== undefined', 1000, 'The Google recaptcha library is loaded.'); + $this->assertNotEmpty($grecaptcha, 'g-recaptcha element is found.'); + + // Test form submission. + // First, try a submission that will trigger the validation error handler. + $this->submitForm([ + 'email' => 'invalid@example.com', + ], 'Submit'); + $messages = $this->getMessages(); + $this->assertNotEmpty($messages); + $this->assertStringContainsString('Invalid email', $messages); + $this->assertStringContainsString('The answer you entered for the CAPTCHA was not correct.', $messages); + $this->assertStringNotContainsString('Form submit successful.', $messages); + + // Now submit again with a valid email. + $this->submitForm([ + 'email' => 'valid@example.com', + ], 'Submit'); + $messages = $this->getMessages(); + $this->assertStringContainsString('The answer you entered for the CAPTCHA was not correct.', $messages); + $this->assertStringNotContainsString('Form submit successful.', $messages); + + // We need to re-validate the captcha; + // So click it,. + $this->clickRecaptcha(); + // And submit for the last time. + $this->submitForm([ + 'email' => 'valid@email.com', + ], 'Submit'); + $messages = $this->getMessages(); + $this->assertStringNotContainsString('The answer you entered for the CAPTCHA was not correct.', $messages); + $this->assertStringContainsString('Form submit successful.', $messages); + } + + /** + * {@inheritdoc} + */ + protected function submitForm(array $edit, $submit, $form_html_id = NULL) { + parent::submitForm($edit, $submit, $form_html_id); + + // Because we're submitting the form via AJAX give it 500ms before we test + // anything else with the response. + $this->getSession()->wait(500); + } + + /** + * Click the captcha checkbox element and wait for it to be validated. + * + * @param int $timeout + * The time to wait for the recaptcha to get validated (in miliseconds). + * + * @throws \Behat\Mink\Exception\DriverException + * @throws \Behat\Mink\Exception\UnsupportedDriverActionException + */ + protected function clickRecaptcha($timeout = 2000) { + $driver = $this->getSession()->getDriver(); + $recaptchaIFrame = $this->getSession()->getPage()->find('css', 'form .g-recaptcha iframe'); + $driver->switchToIFrame($recaptchaIFrame->getAttribute('name')); + $recaptchaCheckbox = $driver->find('//span[@id="recaptcha-anchor"]'); + if (!empty($recaptchaCheckbox)) { + $recaptchaCheckbox[0]->click(); + $this->getSession()->wait($timeout, 'document.getElementById("recaptcha-anchor").attributes["aria-checked"].value === true;'); + } + else { + $this->fail('Unable to find recaptcha checkbox.'); + } + $driver->switchToWindow(); + $this->assertJsCondition('grecaptcha.getResponse() !== ""', $timeout, 'grecaptcha has response.'); + } + + /** + * Search for messages in the last html page response. + * + * @return string + * The message. + */ + public function getMessages($timeout = 2000) { + $this->getSession()->wait($timeout, '(jQuery("div[data-drupal-messages]").length > 0)'); + $page = $this->getSession()->getPage(); + $messages = $page->findAll('css', 'div[data-drupal-messages]'); + $text = ''; + if (isset($messages)) { + foreach ($messages as $message) { + $text .= $message->getText(); + } + } + + return $text; + } + +} diff --git a/web/modules/contrib/recaptcha/tests/src/Kernel/ValidateD7MigrationStateTest.php b/web/modules/contrib/recaptcha/tests/src/Kernel/ValidateD7MigrationStateTest.php new file mode 100644 index 000000000..5376914e0 --- /dev/null +++ b/web/modules/contrib/recaptcha/tests/src/Kernel/ValidateD7MigrationStateTest.php @@ -0,0 +1,26 @@ +