diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..b94a8ce
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,66 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - master
+
+jobs:
+ run:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php:
+ - '8.1'
+ - '8.2'
+ - '8.3'
+ os: [ ubuntu-latest ]
+ experimental: [ false ]
+ symfony-versions: [false]
+ include:
+ - description: 'PHP nightly'
+ php: '8.4'
+ experimental: true
+ - description: 'unstable dependencies'
+ php: '8.1'
+ use-beta: true
+ experimental: true
+ - description: 'lowest dependencies'
+ php: '8.1'
+ lowest: true
+
+ name: PHP ${{ matrix.php }} ${{ matrix.description }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - uses: actions/cache@v3
+ with:
+ path: ~/.composer/cache/files
+ key: ${{ matrix.php }}-${{ matrix.symfony-versions }}
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+
+ - name: Allow beta dependencies
+ run: composer config minimum-stability beta
+ if: matrix.use-beta
+
+ - name: Install dependencies
+ run: composer update ${{ matrix.lowest && '--prefer-lowest --prefer-stable }}
+
+ - name: Run PHPStan static analysis
+ run: vendor/bin/phpstan
+
+ - name: Check for dangerous and broken dependencies
+ run: composer audit
+
+ - name: Run automated tests
+ run: vendor/bin/phpunit --coverage-text
+
+ - name: Run infection tests
+ run: vendor/bin/infection --threads=max
diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml
new file mode 100644
index 0000000..fb14a48
--- /dev/null
+++ b/.github/workflows/docs.yaml
@@ -0,0 +1,30 @@
+name: ci
+
+on:
+ push:
+ branches:
+ - master
+
+permissions:
+ contents: write
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v4
+ with:
+ python-version: 3.x
+ - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
+
+ - uses: actions/cache@v3
+ with:
+ key: mkdocs-material-${{ env.cache_id }}
+ path: .cache
+ restore-keys: |
+ mkdocs-material-
+
+ - run: pip install mkdocs-material mkdocs-material-extensions
+ - run: mkdocs gh-deploy --force
+ working-directory: ./docs
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6cd0115
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+/.*.cache
+/.idea
+/composer.lock
+/build
+/infection
+/log
+/tmp
+/vendor
+*~
diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php
new file mode 100644
index 0000000..d5d4db5
--- /dev/null
+++ b/.php-cs-fixer.php
@@ -0,0 +1,50 @@
+files()
+ ->name('*.php')
+ ->in(__DIR__ . '/src')
+ ->in(__DIR__ . '/tests')->exclude('Fixture')
+ ->in(__DIR__ . '/tests/Fixture/src')
+;
+
+$config = new PhpCsFixer\Config();
+
+return $config
+ ->setRiskyAllowed(true)
+ ->setRules([
+ '@Symfony' => true,
+
+ 'declare_strict_types' => true,
+ 'strict_param' => true,
+ 'strict_comparison' => true,
+ 'array_syntax' => ['syntax' => 'short'],
+ 'concat_space' => ['spacing' => 'one'],
+ 'header_comment' => ['header' => $header, 'location' => 'after_open'],
+
+ 'mb_str_functions' => true,
+ 'ordered_imports' => true,
+ 'phpdoc_align' => false,
+ 'phpdoc_separation' => false,
+ 'phpdoc_var_without_name' => false,
+ ])
+ ->setFinder($finder)
+;
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..f0a6ba8
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,6 @@
+# Changelog
+All notable changes to `omines\antispam-bundle` will be documented in this file.
+This project adheres to [Semantic Versioning](http://semver.org/).
+
+## [Unreleased]
+Nothing yet.
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..a132ade
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,46 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at opensource@omines.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
+
+[homepage]: http://contributor-covenant.org
+[version]: http://contributor-covenant.org/version/1/4/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..2355fa5
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,28 @@
+# Contributing
+
+Contributions are **welcome** and will be credited.
+
+We accept contributions via Pull Requests on [Github](https://github.com/omines/antispam-bundle).
+Follow [good standards](http://www.phptherightway.com/), keep the PHPstan level maxed, include tests with proper
+coverage, and run `bin/prepare-commit` during development and before committing.
+
+Infection testing is not done automatically, but it is *recommended* to run `bin/infection`
+before finishing a PR.
+
+## Running a test environment
+
+There is a full Symfony test project in `tests/Fixture` for functional testing. It can be run
+standalone as well if you have the Symfony CLI installed for easy development:
+
+```sh
+bin/testsite
+```
+
+## Update documentation
+
+To generate the mkdocs site in `/docs` run:
+
+```sh
+pip install mkdocs mkdocs-material mkdocs-material-extensions
+bin/serve-docs
+```
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5d83e64
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 and beyond, Omines Internetbureau B.V.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+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/README.md b/README.md
new file mode 100644
index 0000000..ec1ad2c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,39 @@
+# Symfony Anti-Spam Bundle
+[![Latest Stable Version](https://poser.pugx.org/omines/antispam-bundle/version)](https://packagist.org/packages/omines/antispam-bundle)
+[![Total Downloads](https://poser.pugx.org/omines/antispam-bundle/downloads)](https://packagist.org/packages/omines/antispam-bundle)
+[![Latest Unstable Version](https://poser.pugx.org/omines/antispam-bundle/v/unstable)](//packagist.org/packages/omines/antispam-bundle)
+[![License](https://poser.pugx.org/omines/antispam-bundle/license)](https://packagist.org/packages/omines/antispam-bundle)
+
+You have found the Swiss Army Knife of battling form spam in your Symfony application!
+
+This bundle provides a ton of different mechanisms for detecting and stopping spammers,
+scammers and abusers using your forms for their nefarious purposes, and brings them
+all together in an easy to configure profile system.
+
+This bundle is compatible with PHP 8.1+ and Symfony 6.3 or later.
+
+## What does it do
+
+This bundle provides you with a ton of methods to easily combat spam through tested and
+proven methods:
+
+- *Honeypot*:
+
+## Quickstart
+
+Install the bundle:
+```sh
+composer require omines/antispam-bundle
+```
+Symfony Flex will enable the bundle and provide a basic configuration file with samples
+at `config/packages/antispam.yaml`.
+
+## Contributing
+
+Please see [CONTRIBUTING.md](https://github.com/omines/antispam-bundle/blob/master/CONTRIBUTING.md) for details.
+
+## Legal
+
+This software was developed for internal use at [Omines Full Service Internetbureau](https://www.omines.nl/)
+in Eindhoven, the Netherlands. It is shared with the general public under the permissive MIT license, without
+any guarantee of fitness for any particular purpose. Refer to the included `LICENSE` file for more details.
diff --git a/bin/infection b/bin/infection
new file mode 100755
index 0000000..5754e13
--- /dev/null
+++ b/bin/infection
@@ -0,0 +1,11 @@
+#!/bin/sh
+set -e
+cd $(dirname $0)/..
+
+export APP_ENV=test
+export APP_DEBUG=1
+export XDEBUG_MODE=coverage
+
+# Next line not needed for now as the composer level integration works fine
+#[ ! -f "infection/infection.phar" ] && bin/install-infection
+vendor/bin/infection --threads=max $@
diff --git a/bin/install-infection b/bin/install-infection
new file mode 100755
index 0000000..d4a8035
--- /dev/null
+++ b/bin/install-infection
@@ -0,0 +1,11 @@
+#!/bin/sh
+cd $(dirname $0)/..
+
+wget https://github.com/infection/infection/releases/download/0.27.0/infection.phar
+wget https://github.com/infection/infection/releases/download/0.27.0/infection.phar.asc
+gpg --recv-keys C6D76C329EBADE2FB9C458CFC5095986493B4AA0
+gpg --with-fingerprint --verify infection.phar.asc infection.phar
+rm infection.phar.asc*
+chmod +x infection.phar
+mkdir -p infection
+mv infection.phar build
diff --git a/bin/prepare-commit b/bin/prepare-commit
new file mode 100755
index 0000000..d9766a0
--- /dev/null
+++ b/bin/prepare-commit
@@ -0,0 +1,21 @@
+#!/bin/sh
+set -e
+cd $(dirname $0)/..
+
+export APP_ENV=test
+export APP_DEBUG=1
+export XDEBUG_MODE=coverage
+
+vendor/bin/php-cs-fixer fix
+vendor/bin/phpstan
+
+# Clear cache manually to avoid locking up with corrupted container
+rm -rf tests/fixture/var/cache/test
+php -d "zend.assertions=1" vendor/bin/phpunit --coverage-text --display-warnings \
+ --coverage-xml=build/coverage/coverage-xml --log-junit=build/coverage/junit.xml
+
+# Run with lower MSI in diff-filter mode as it doesn't include coverage from other tests
+vendor/bin/infection --threads=max --git-diff-filter=AM --min-msi=80 --min-covered-msi=80 \
+ --coverage=build/coverage
+
+echo "All good, ready for commit!"
diff --git a/bin/serve-docs b/bin/serve-docs
new file mode 100755
index 0000000..0414278
--- /dev/null
+++ b/bin/serve-docs
@@ -0,0 +1,8 @@
+#!/bin/sh
+set -e
+cd $(dirname $0)/../docs
+
+echo "If need be install mkdocs with 'apt install mkdocs-material mkdocs-material-extensions'"
+echo "You can choose a different port with 'bin/serve-docs -a 127.0.0.1:4000'"
+echo
+mkdocs serve $@
diff --git a/bin/testsite b/bin/testsite
new file mode 100755
index 0000000..8337666
--- /dev/null
+++ b/bin/testsite
@@ -0,0 +1,5 @@
+#!/bin/sh
+set -e
+cd $(dirname $0)/../tests/Fixture
+
+symfony serve --no-tls $@
diff --git a/bin/update-iso15924.php b/bin/update-iso15924.php
new file mode 100755
index 0000000..ef5c838
--- /dev/null
+++ b/bin/update-iso15924.php
@@ -0,0 +1,39 @@
+#!/bin/env php
+ !(empty($line) || $line[0] === '#'));
+
+$output = <<<'EOT'
+=8.1",
+ "psr/log": "^3.0",
+ "symfony/clock": "^6.3|^7.0",
+ "symfony/config": "^6.3|^7.0",
+ "symfony/event-dispatcher": "^6.3|^7.0",
+ "symfony/form": "^6.3|^7.0",
+ "symfony/framework-bundle": "^6.3|^7.0",
+ "symfony/options-resolver": "^6.3|^7.0",
+ "symfony/validator": "^6.3|^7.0",
+ "symfony/yaml": "^6.3|^7.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.37.0",
+ "infection/infection": "^0.27.6",
+ "phpstan/extension-installer": "^1.3.1",
+ "phpstan/phpstan": "^1.10.39",
+ "phpstan/phpstan-phpunit": "^1.3.15",
+ "phpstan/phpstan-symfony": "^1.3.4",
+ "phpunit/phpunit": "^10.4",
+ "symfony/browser-kit": "^6.3|^7.0",
+ "symfony/css-selector": "^6.3|^7.0",
+ "symfony/debug-pack": "^1.0",
+ "symfony/dotenv": "^6.3|^7.0",
+ "symfony/routing": "^6.3|^7.0",
+ "symfony/runtime": "^6.3|^7.0",
+ "symfony/translation": "^6.3|^7.0",
+ "symfony/twig-bundle": "^6.3|^7.0"
+ },
+ "suggest": {
+ "omines/akismet": "To integrate with the Akismet online antispam service"
+ },
+ "autoload": {
+ "psr-4": { "Omines\\AntiSpamBundle\\": "src/"}
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Tests\\": "tests/",
+ "Tests\\Fixture\\": "tests/Fixture/src/"
+ }
+ },
+ "config": {
+ "preferred-install": "dist",
+ "sort-packages": true,
+ "allow-plugins": {
+ "infection/extension-installer": true,
+ "phpstan/extension-installer": true,
+ "symfony/flex": true,
+ "symfony/runtime": true
+ }
+ },
+ "minimum-stability": "stable",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "0.1-dev"
+ }
+ }
+}
diff --git a/config/services.yaml b/config/services.yaml
new file mode 100644
index 0000000..ae71be3
--- /dev/null
+++ b/config/services.yaml
@@ -0,0 +1,9 @@
+services:
+ _defaults:
+ autowire: true # Automatically injects dependencies in your services.
+ autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
+
+ Omines\AntiSpamBundle\:
+ resource: '../src/'
+ exclude:
+ - '../src/DependencyInjection/'
diff --git a/docs/includes/validator-groups-option.md b/docs/includes/validator-groups-option.md
new file mode 100644
index 0000000..36e7cff
--- /dev/null
+++ b/docs/includes/validator-groups-option.md
@@ -0,0 +1,6 @@
+### `groups`
+
+**type**: `array|string` **default**: `null`
+
+It defines the validation group or groups of this constraint. Read more about
+[validation groups](https://symfony.com/doc/current/validation/groups.html).
diff --git a/docs/includes/validator-null-warning.md b/docs/includes/validator-null-warning.md
new file mode 100644
index 0000000..aff8a4a
--- /dev/null
+++ b/docs/includes/validator-null-warning.md
@@ -0,0 +1,3 @@
+!!! warning "Null values are always valid"
+ As with most of Symfony's own constraints, `null` is considered a valid value. This is to allow the use of optional
+ values. If the value is mandatory, a common solution is to combine this constraint with [NotBlank](https://symfony.com/doc/current/reference/constraints/NotBlank.html).
diff --git a/docs/includes/validator-passive-option.md b/docs/includes/validator-passive-option.md
new file mode 100644
index 0000000..630d328
--- /dev/null
+++ b/docs/includes/validator-passive-option.md
@@ -0,0 +1,9 @@
+### `passive`
+
+**type**: `bool` **default**: `null`, defaulting to [bundle configuration](/configuration)
+
+When in passive mode the constraint will not generate a validation error, but instead
+dispatch an event.
+
+With [default bundle configuration](/configuration/#default-config) passive mode is **disabled**.
+
diff --git a/docs/includes/validator-payload-option.md b/docs/includes/validator-payload-option.md
new file mode 100644
index 0000000..22db759
--- /dev/null
+++ b/docs/includes/validator-payload-option.md
@@ -0,0 +1,9 @@
+### `payload`
+
+**type**: `mixed` **default**: `null`
+
+This option can be used to attach arbitrary domain-specific data to a constraint. The configured payload is not used by
+the Validator component, but its processing is completely up to you.
+
+For example, you may want to use [several error levels](https://symfony.com/doc/current/validation/severity.html) to
+present failed constraints differently in the front-end depending on the severity of the error.
diff --git a/docs/includes/validator-stealth-option.md b/docs/includes/validator-stealth-option.md
new file mode 100644
index 0000000..9385670
--- /dev/null
+++ b/docs/includes/validator-stealth-option.md
@@ -0,0 +1,12 @@
+### `stealth`
+
+**type**: `bool` **default**: `null`, defaulting to [bundle configuration](/configuration)
+
+With stealth mode disabled the validator will generate a verbose error, similar to Symfony built-in constraints,
+explaining for precisely which reasons what rule was validated.
+
+If stealth mode is enabled instead the validator only shows a generic error message, stating that form submission
+failed and the user should contact the website administrator for further assistance.
+
+With [default bundle configuration](/configuration/#default-config) stealth mode is **disabled** by default when used standalone,
+and **enabled** by default when applied as part of an anti-spam profile.
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
new file mode 100644
index 0000000..be64d87
--- /dev/null
+++ b/docs/mkdocs.yml
@@ -0,0 +1,71 @@
+# yaml-language-server: $schema=https://squidfunk.github.io/mkdocs-material/schema.json
+
+site_name: Symfony Anti-Spam Bundle
+site_description: The Swiss Army Knife for protecting your Symfony forms from all kinds of spam
+site_author: Niels Keurentjes
+docs_dir: mkdocs
+copyright: 'Docs and code are © Omines Full Service Internetbureau'
+repo_url: https://github.com/omines/antispam-bundle
+
+theme:
+ name: material
+ language: en
+ palette:
+ scheme: slate
+ features:
+ - content.code.annotate
+ - content.code.copy
+ - content.code.select
+ - content.tabs.link
+
+extra_css:
+ - https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/solid.min.css
+ - stylesheets/extra.css
+
+watch:
+ - includes
+
+nav:
+ - About: index.md
+ - Quickstart: quickstart.md
+ - Configuration: configuration.md
+ - Extending: extending.md
+ - Form Types:
+ - Honeypot: form/honeypot.md
+ - Submit Timer: form/submit_timer.md
+ - Validators:
+ - Banned Markup: validator/banned_markup.md
+ - Banned Phrases: validator/banned_phrases.md
+ - Banned Scripts: validator/banned_script.md
+ - Profile: validator/profile.md
+ - URL Count: validator/url_count.md
+
+markdown_extensions:
+ - admonition
+ - attr_list
+ - def_list
+ - md_in_html
+ - pymdownx.details
+ - pymdownx.emoji:
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
+ - pymdownx.highlight:
+ anchor_linenums: true
+ line_spans: __span
+ pygments_lang_class: true
+ use_pygments: true
+ extend_pygments_lang:
+ - name: php
+ lang: php
+ options:
+ startinline: true
+ - pymdownx.inlinehilite
+ - pymdownx.snippets
+ - pymdownx.superfences
+ - pymdownx.tabbed:
+ alternate_style: true
+ - sane_lists
+ - smarty
+ - tables
+ - toc:
+ permalink: true
\ No newline at end of file
diff --git a/docs/mkdocs/configuration.md b/docs/mkdocs/configuration.md
new file mode 100644
index 0000000..07fb795
--- /dev/null
+++ b/docs/mkdocs/configuration.md
@@ -0,0 +1,71 @@
+# Configuration
+
+The bundle configuration is for the most part self-documented, and the annotated default configuration can be viewed
+from the Symfony console with:
+```shell
+bin/console config:dump-reference antispam
+```
+
+Note that you can also view the resolved configuration during development:
+```shell
+bin/console debug:config antispam
+```
+
+## Default config
+
+```yaml
+# Default configuration for extension with alias: "antispam"
+antispam:
+
+ # Global default for whether included components issue verbose or stealthy error messages
+ stealth: false # (1)!
+
+ # A named list of different profiles used throughout your application
+ profiles:
+
+ # Prototype: Name the profile
+ name:
+
+ # Defines whether measures in this profile issue stealthy error messages
+ stealth: true # (1)!
+
+ # Passive mode will not make any of the included checks actually fail validation, they will still be logged
+ passive: false
+
+ # Defines whether to disallow content resembling markup languages like HTML and BBCode
+ banned_markup:
+ html: true
+ bbcode: true
+
+ # Simple array of phrases which are rejected when encountered in a submitted text field
+ banned_phrases: []
+
+ # Banned script types, like Cyrillic or Arabic (see docs for commonly used ISO 15924 names)
+ banned_scripts:
+ scripts: []
+ max_characters: null
+ max_percentage: 0
+
+ # Inject an invisible honeypot field in forms, baiting spambots to fill it in
+ honeypot:
+
+ # Base name of the injected field
+ field: ~ # Required
+
+ # Maximum number of URLs permitted in text fields
+ max_urls: ~
+
+ # Verify that time between retrieval and submission of a form is within human boundaries
+ timer:
+
+ # Base name of the injected field
+ field: __antispam_time
+ min: 3
+ max: 3600
+```
+
+1. The global and profile defaults for `stealth` are different on purpose. The global setting is applied to validators
+ and form types used separately, and will therefore default to acting like an actual validator, displaying the precise
+ error in the right place. Within a profile they become part of a larger antispam measure, and are therefore stealthed,
+ merging them together as a generic rejection message.
+
diff --git a/docs/mkdocs/extending.md b/docs/mkdocs/extending.md
new file mode 100644
index 0000000..e69de29
diff --git a/docs/mkdocs/form/honeypot.md b/docs/mkdocs/form/honeypot.md
new file mode 100644
index 0000000..66c30a6
--- /dev/null
+++ b/docs/mkdocs/form/honeypot.md
@@ -0,0 +1,3 @@
+# HoneypotType Field
+
+
diff --git a/docs/mkdocs/form/submit_timer.md b/docs/mkdocs/form/submit_timer.md
new file mode 100644
index 0000000..85aed8f
--- /dev/null
+++ b/docs/mkdocs/form/submit_timer.md
@@ -0,0 +1 @@
+# SubmitTimerType FIeld
diff --git a/docs/mkdocs/index.md b/docs/mkdocs/index.md
new file mode 100644
index 0000000..fdb5bb9
--- /dev/null
+++ b/docs/mkdocs/index.md
@@ -0,0 +1,77 @@
+# About
+[![Latest Stable Version](https://poser.pugx.org/omines/antispam-bundle/version)](https://packagist.org/packages/omines/antispam-bundle)
+[![Total Downloads](https://poser.pugx.org/omines/antispam-bundle/downloads)](https://packagist.org/packages/omines/antispam-bundle)
+[![Latest Unstable Version](https://poser.pugx.org/omines/antispam-bundle/v/unstable)](//packagist.org/packages/omines/antispam-bundle)
+[![License](https://poser.pugx.org/omines/antispam-bundle/license)](https://packagist.org/packages/omines/antispam-bundle)
+
+You have found the Swiss Army Knife of battling form spam in your Symfony application!
+
+This bundle provides a ton of different mechanisms for detecting and stopping spammers,
+scammers and abusers using your forms for their nefarious purposes, and brings them
+all together in an easy to configure profile system.
+
+This bundle is compatible with PHP 8.1+ and Symfony 6.3 or later.
+
+## Features
+
+This bundle provides you with a ton of methods to easily combat spam through tested and
+proven methods:
+
+- **[Honeypot](form/honeypot.md)**: Insert a hidden field in your forms that lures spambots into filling it in.
+- **[Timer](form/submit_timer.md)**: Reject forms that have been submitted unfeasibly fast or unrealistically slow.
+- **Max URLs**: Reject text fields that contain more hyperlinks than plausible.
+- **Banned markup**: Reject text fields containing HTML or UBB tags.
+- **Banned phrases**: Reject text fields containing signature phrases targeting your site.
+- **Banned scripts**: Reject text fields that contain too many characters in scripts not
+ expected by the site owners, like Cyrillic (Russian), Chinese or Arabic.
+
+It wraps all these methods in an easy to use and easy to apply profile system, allowing
+you to pick and match per form what methods are required.
+
+### Global features
+
+All components support *stealth mode*, which hides verbose errors showing the rejection
+reasons, and instead replaces them with a generic catch-all error at the form level.
+
+All components can run in *passive mode*, in which they do not actually block submission
+but otherwise do all logging and escalation as if they are. This enables you to evaluate
+impact before releasing invasive actions.
+
+All validators are implemented as regular Symfony constraints with attributes, meaning
+you can also apply them to your Doctrine entities, API classes and everything.
+
+## Installation
+
+Install the bundle:
+```shell
+composer require omines/antispam-bundle
+```
+
+Symfony Flex will enable the bundle and provide a basic configuration file with samples
+at `config/packages/antispam.yaml`. With the default config no invasive actions are enabled.
+
+!!! tip
+ Head over to the [Quickstart](quickstart.md) to have your spam protection up and running
+ within 5 minutes!
+
+## Frequently Asked Questions
+
+### Why is there no way to enable a profile globally for all forms?
+
+Because it's very dangerous and you're not likely to *really* want this.
+
+Plugging spam protection into all forms of your application is very well possible,
+but we cannot distinguish between forms in your contact form, CMS, customer portals
+and login forms. Accidentally enabling antispam methods that block HTML, foreign
+characters or slow form entry could be destructive to the user experience of your
+CMS, in which all those things are likely normal. So no, we do not provide an option
+that allows you to shoot your own foot in a way that will at some point in the future
+cause tons of unforeseen drama.
+
+### Why not a stable version number?
+
+As a matter of principle we [eat our own dog food](https://en.wikipedia.org/wiki/Eating_your_own_dog_food),
+so we use this bundle internally on multiple projects. When putting it out there however it
+comes with the territory that feedback points out unforeseen issues. So we keep the major
+version at 0 until we feel sufficiently confident that the core API, DX and mechanisms
+are stable.
diff --git a/docs/mkdocs/quickstart.md b/docs/mkdocs/quickstart.md
new file mode 100644
index 0000000..2933804
--- /dev/null
+++ b/docs/mkdocs/quickstart.md
@@ -0,0 +1,129 @@
+# Quickstart
+
+## Basic setup
+
+Install the bundle:
+```shell
+composer require omines/antispam-bundle
+```
+Symfony Flex will automatically enable the bundle, and run a recipe creating a basic configuration file in the right
+place for your project.
+
+Open it and it will look something like this between the comments:
+
+```yaml title="config/packages/antispam.yaml"
+antispam:
+ profiles:
+ default:
+ honeypot: email_address
+ timer:
+ min: 3
+ max: 3600
+```
+This defines a *profile* called `default`, which defines that any forms having the profile should insert a
+[honeypot](https://en.wikipedia.org/wiki/Honeypot_(computing)) field called `email_adress`, and have timer protection
+rejecting forms submitted either within 3 seconds, or after more than 1 hour.
+
+??? tip "But my form already has an email_address field!"
+ Don't worry about automatically generated field causing name conflicts, the bundle will detect this and create
+ a unique name instead by appending a number. In this case the honeypot would become `email_address1` instead
+ automatically!
+
+For the form that you want to protect, apply the profile in its options either when creating it:
+```php
+$form = $this->createForm(MyApplicationForm::class, options: [
+ 'antispam_profile' => 'default',
+]);
+```
+Or in its type definition's defaults:
+```php
+public function configureOptions(OptionsResolver $resolver): void
+{
+ $resolver->setDefault('antispam_profile', 'default');
+}
+```
+**THAT'S IT!** You should now already start noticing a severe decrease in spam submissions!
+
+??? warning "Not seeing an error when you manually try to submit a form too fast or too slow?"
+ Symfony Forms by default only shows form level errors if you are using `{{ form(form) }}` to render it as a whole.
+ If you use `{{ form_start(form) }}` instead you will need to ensure form level errors are also shown, either by
+ adding `{{ form_errors(form) }}` or manually rendering them with something like this:
+
+ ```twig
+ {% for error in form.vars.errors %}
+
{{ error.message }}
+ {% endfor %}
+ ```
+
+ Note that this is not a shortcoming of this bundle, and is in general a good idea to do. This bundle just exposes
+ the issue because it cannot show errors on fields that are by definition hidden.
+
+## Tailoring the weapons
+
+While the default setup will already stop many bots and scripts, there is also a lot of manual spam going on, and the
+spammers and scammers do write bots that can bypass these common methods. After running with the defaults for a bit
+you will have some idea of the kind of spam still getting through.
+
+Let's investigate some of the more invasive options available. For the following example, we're protecting the contact
+form of a local toy store operating in southern France.
+
+First of all, spammers are usually trying to sell you stuff, meaning they probably want to get you to head over to
+a forged or real website. So a contact form submission with several links in there is usually quite suspicious. For our
+toy store, we do expect someone to sometimes email a link, asking if we also stock the item. More than 1 is unlikely,
+more than 2 pretty much implausible.
+
+Spammers also have issues with "lingual targeting". It's really unlikely that a real customer would ever fill in our
+contact form in Russian or Hebrew. Spambots regularly do, so let's forbid that.
+
+Lastly, we're a toy store, not a software company or a digital agency. People will not send us snippets of HTML.
+Spambots regularly do, in lame attempts at [XSS](https://en.wikipedia.org/wiki/Cross-site_scripting) or phishing.
+We'll ensure it's not accepted.
+
+So let's create a new profile `contact` specifically for the website's contact form:
+```yaml title="config/packages/antispam.yaml"
+antispam:
+ profiles:
+ contact:
+ # We'll keep the honeypot and timer, they're non-invasive and
+ # highly effective
+ honeypot: email_address # (1)!
+ timer:
+ min: 3
+ max: 14400 # (2)!
+
+ # Reject form fields containing more than 2 URLs
+ max_urls: 2
+
+ # Reject content containing HTML or BBCode markup
+ banned_markup: true
+
+ # Reject forms that consist for more than half of Cyrillic (Russian)
+ # or Hebrew text
+ banned_scripts:
+ scripts: ['cyrillic', 'hebrew']
+ max_percentage: 50
+```
+
+1. Remember that the field name will automatically become `email_address1` or a higher number in case of a naming conflict.
+2. Toy stores attract kids :child:, and they may get distracted or go away. Allowing 4 hours for a response might be fair!
+
+What happens now when you apply the `contact` profile to your contact form type:
+
+- Your form will get automatically get 2 hidden fields injected:
+ * A [Honeypot](form/honeypot.md) called `email_address`, that will fail the form if it contains any value on submit.
+ * A [Submit Timer](form/submit_timer.md) cryptographically verifying the number of seconds between retrieving and
+ posting the form. Many spambots either submit a form within a single second, as they are in a rush to attack
+ millions of other sites, or they store the form so they can submit it hours, days or weeks later. The timer ensures
+ that the form is posted with a delay that is reasonable for a human filling it in, without spending days to do so.
+
+!!! warning "Always consider carefully what is normal on your specific site!"
+ All anti-spam measures can backfire at some point, and trigger false positives that may hurt your or your client's
+ interests. Blocking all Cyrillic (Russian) text in the contact form of a translation agency is a really bad idea.
+ Blocking **all** links also means your new sales opportunity can't say *"I want to have a similar site to
+ https://example.org"*. Even banning the phrase *"WE SELL VIAGRA"* could theoretically block someone requesting
+ commercial help with a spammer sending that message every hour.
+
+ Be prudent about the measures you implement, and try to err on the side of caution. Remember that it's better to
+ receive a single uncaught spam email per week than to lose a valuable customer every month.
+
+
diff --git a/docs/mkdocs/stylesheets/extra.css b/docs/mkdocs/stylesheets/extra.css
new file mode 100644
index 0000000..e7b82b8
--- /dev/null
+++ b/docs/mkdocs/stylesheets/extra.css
@@ -0,0 +1,8 @@
+article a[href^="http"]::after,
+article a[href^="https://"]::after
+{
+ content: "\f35d";
+ font: var(--fa-font-solid);
+ font-size: 60%;
+ vertical-align: top;
+}
diff --git a/docs/mkdocs/validator/banned_markup.md b/docs/mkdocs/validator/banned_markup.md
new file mode 100644
index 0000000..a92e7b4
--- /dev/null
+++ b/docs/mkdocs/validator/banned_markup.md
@@ -0,0 +1,82 @@
+# Banned Markup
+
+Validates that a given string does not contain a configured type of markup.
+
+It can recognize [HTML](https://en.wikipedia.org/wiki/HTML) and [BBCode](https://en.wikipedia.org/wiki/BBCode).
+
+Can be applied to [properties or methods](https://symfony.com/doc/current/validation.html#constraint-targets).
+
+??? tip "How strict is the detection of the markup types?"
+ Markup detection is loose on purpose, and will also flag "lame attempts" that are not valid, while at the same time
+ trying to keep the chance of false positives as low as possible. Spambots are not known to strictly adhere to
+ internet standards so being really strict would only reduce effectiveness.
+
+## Basic Usage
+
+=== "Attributes"
+
+ ```php
+ namespace App\Entity;
+
+ use Omines\AntiSpamBundle\Validator\Constraints as Antispam;
+
+ class Message
+ {
+ #[Antispam\BannedMarkup]
+ protected string $content;
+ }
+ ```
+
+=== "YAML"
+
+ ```yaml
+ # config/validator/validation.yaml
+ App\Entity\Message:
+ properties:
+ content:
+ - BannedMarkup:
+ ```
+
+=== "PHP"
+
+ ```php
+ // src/Entity/Participant.php
+ namespace App\Entity;
+
+ use Omines\AntiSpamBundle\Validator\Constraints as Antispam;
+ use Symfony\Component\Validator\Mapping\ClassMetadata;
+
+ class Message
+ {
+ // ...
+
+ public static function loadValidatorMetadata(ClassMetadata $metadata): void
+ {
+ $metadata->addPropertyConstraint('content', new Antispam\BannedMarkup());
+ }
+ }
+ ```
+
+--8<-- "includes/validator-null-warning.md"
+
+## Options
+
+### `bbcode`
+
+**type**: `boolean` **default**: `true`
+
+If set to `true` validation will fail if the given value contains tags resembling BBCode.
+
+--8<-- "includes/validator-groups-option.md"
+
+### `html`
+
+**type**: `boolean` **default**: `true`
+
+If set to `true` validation will fail if the given value contains tags resembling HTML.
+
+--8<-- "includes/validator-passive-option.md"
+
+--8<-- "includes/validator-payload-option.md"
+
+--8<-- "includes/validator-stealth-option.md"
diff --git a/docs/mkdocs/validator/banned_phrases.md b/docs/mkdocs/validator/banned_phrases.md
new file mode 100644
index 0000000..e69de29
diff --git a/docs/mkdocs/validator/banned_script.md b/docs/mkdocs/validator/banned_script.md
new file mode 100644
index 0000000..e69de29
diff --git a/docs/mkdocs/validator/profile.md b/docs/mkdocs/validator/profile.md
new file mode 100644
index 0000000..e69de29
diff --git a/docs/mkdocs/validator/url_count.md b/docs/mkdocs/validator/url_count.md
new file mode 100644
index 0000000..e69de29
diff --git a/infection.json5 b/infection.json5
new file mode 100644
index 0000000..9cba6fc
--- /dev/null
+++ b/infection.json5
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://raw.githubusercontent.com/infection/infection/0.27.0/resources/schema.json",
+ "timeout": 10,
+ "source": {
+ "directories": [
+ "src"
+ ]
+ },
+ "minMsi": 90,
+ "minCoveredMsi": 90,
+ "mutators": {
+ "@default": true,
+ "@conditional_boundary": false,
+ "@number": false,
+ "MBString": false,
+ "ProtectedVisibility": false
+ },
+ "logs": {
+ "text": "build/infection/infection.log",
+ "perMutator": "build/infection/mutators.md"
+ }
+}
\ No newline at end of file
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..833df4a
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,7 @@
+parameters:
+ level: max
+ paths:
+ - src
+ - tests/Fixture/src
+ - tests/Functional
+ - tests/Unit
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..95585e3
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ./tests/Unit
+
+
+ ./tests/Functional
+
+
+
+
diff --git a/src/AntiSpam.php b/src/AntiSpam.php
new file mode 100644
index 0000000..76b98a8
--- /dev/null
+++ b/src/AntiSpam.php
@@ -0,0 +1,54 @@
+ $profiles
+ */
+ public function __construct(
+ #[TaggedLocator('antispam.profile')]
+ private readonly ServiceLocator $profiles,
+ private readonly array $options,
+ ) {
+ }
+
+ public function getProfile(string $name): Profile
+ {
+ $id = "antispam.profile.$name";
+ if (!$this->profiles->has($id)) {
+ throw new InvalidProfileException(sprintf('There is no antispam profile "%s" defined, did you use the correct profile name from your antispam.yaml configuration file?', $name));
+ }
+
+ return $this->profiles->get($id);
+ }
+
+ public function isPassive(): bool
+ {
+ return $this->options['passive'];
+ }
+
+ public function isStealth(): bool
+ {
+ return $this->options['stealth'];
+ }
+}
diff --git a/src/AntiSpamBundle.php b/src/AntiSpamBundle.php
new file mode 100644
index 0000000..9c80aaf
--- /dev/null
+++ b/src/AntiSpamBundle.php
@@ -0,0 +1,29 @@
+extension) {
+ $this->extension = new AntiSpamExtension();
+ }
+
+ return $this->extension;
+ }
+}
diff --git a/src/DependencyInjection/AntiSpamExtension.php b/src/DependencyInjection/AntiSpamExtension.php
new file mode 100644
index 0000000..e68137a
--- /dev/null
+++ b/src/DependencyInjection/AntiSpamExtension.php
@@ -0,0 +1,62 @@
+load('services.yaml');
+
+ $mergedConfig = $this->processConfiguration(new Configuration(), $configs);
+ foreach ($mergedConfig['profiles'] as $name => $profile) {
+ $id = 'antispam.profile.' . $name;
+ $container
+ ->register($id, Profile::class)
+ ->addTag('antispam.profile')
+ ->addArgument($name)
+ ->addArgument($profile)
+ ;
+ }
+
+ unset($mergedConfig['profiles']);
+ $container
+ ->register(AntiSpam::class, AntiSpam::class)
+ ->setArgument(1, $mergedConfig)
+ ->setAutowired(true)
+ ;
+ }
+
+ public function prepend(ContainerBuilder $container)
+ {
+ if ($container->hasExtension('twig')) {
+ $container->prependExtensionConfig('twig', [
+ 'form_themes' => ['@AntiSpam/form/widgets.html.twig'],
+ ]);
+ }
+ }
+
+ public function getAlias(): string
+ {
+ return 'antispam'; // underscore is ugly in configs
+ }
+}
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
new file mode 100644
index 0000000..e0f117e
--- /dev/null
+++ b/src/DependencyInjection/Configuration.php
@@ -0,0 +1,128 @@
+getRootNode();
+
+ $rootNode
+ ->children()
+ ->booleanNode('passive')
+ ->info('Global default for whether included components should cause hard failures')
+ ->defaultFalse()
+ ->end()
+ ->booleanNode('stealth')
+ ->info('Global default for whether included components issue verbose or stealthy error messages')
+ ->defaultFalse()
+ ->end()
+ ->arrayNode('profiles')
+ ->info('A named list of different profiles used throughout your application')
+ ->useAttributeAsKey('name')
+ ->arrayPrototype()
+ ->info('Name the profile')
+ ->validate()->always(function (array $profile) {
+ if (empty($profile['banned_phrases'])) {
+ unset($profile['banned_phrases']);
+ }
+
+ return $profile;
+ })->end()
+ ->children()
+ ->booleanNode('stealth')
+ ->info('Defines whether measures in this profile issue stealthy error messages')
+ ->defaultTrue()
+ ->end()
+ ->booleanNode('passive')
+ ->info('Passive mode will not make any of the included checks actually fail validation, they will still be logged')
+ ->defaultFalse()
+ ->end()
+ ->arrayNode('banned_markup')
+ ->info('Defines whether to disallow content resembling markup languages like HTML and BBCode')
+ ->children()
+ ->booleanNode('html')->defaultTrue()->end()
+ ->booleanNode('bbcode')->defaultTrue()->end()
+ ->end()
+ ->end()
+ ->arrayNode('banned_phrases')
+ ->info('Simple array of phrases which are rejected when encountered in a submitted text field')
+ ->defaultValue([])
+ ->scalarPrototype()->end()
+ ->end()
+ ->arrayNode('banned_scripts')
+ ->info('Banned script types, like Cyrillic or Arabic (see docs for commonly used ISO 15924 names)')
+ ->beforeNormalization()
+ ->always(fn (array|string $v) => isset($v[0]) ? ['scripts' => is_string($v) ? [$v] : $v] : $v)
+ ->end()
+ ->children()
+ ->arrayNode('scripts')
+ ->requiresAtLeastOneElement()
+ ->scalarPrototype()
+ ->validate()->always(function (string $v) {
+ if (null === Script::tryFrom($v = mb_strtolower($v))) {
+ throw new InvalidConfigurationException(sprintf('"%s" is not a valid ISO-15924 script name, look in class %s for all valid options', $v, Script::class));
+ }
+
+ return $v;
+ })->end()
+ ->end()
+ ->end()
+ ->integerNode('max_characters')->min(0)->defaultNull()->end()
+ ->floatNode('max_percentage')->min(0)->max(100)->defaultValue(0)->end()
+ ->end()
+ ->end()
+ ->arrayNode('honeypot')
+ ->info('Inject an invisible honeypot field in forms, baiting spambots to fill it in')
+ ->beforeNormalization()
+ ->ifString()
+ ->then(fn (string $v) => ['field' => $v])
+ ->end()
+ ->children()
+ ->scalarNode('field')
+ ->info('Base name of the injected field')
+ ->isRequired()
+ ->end()
+ ->end()
+ ->end()
+ ->scalarNode('max_urls')
+ ->info('Maximum number of URLs permitted in text fields')
+ ->end()
+ ->arrayNode('timer')
+ ->info('Verify that time between retrieval and submission of a form is within human boundaries')
+ ->children()
+ ->scalarNode('field')
+ ->info('Base name of the injected field')
+ ->defaultValue('__antispam_time')
+ ->end()
+ ->integerNode('min')->defaultValue(SubmitTimerType::DEFAULT_MIN)->min(0)->end()
+ ->integerNode('max')->defaultValue(SubmitTimerType::DEFAULT_MAX)->min(60)->end()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ;
+
+ return $treeBuilder;
+ }
+}
diff --git a/src/Exception/InvalidProfileException.php b/src/Exception/InvalidProfileException.php
new file mode 100644
index 0000000..a3f9bf2
--- /dev/null
+++ b/src/Exception/InvalidProfileException.php
@@ -0,0 +1,17 @@
+define('antispam_profile')
+ ->default(null)
+ ->allowedTypes(Profile::class, 'string', 'null')
+ ->normalize(function (Options $options, mixed $profile) {
+ return is_string($profile) ? $this->antiSpam->getProfile($profile) : null;
+ })
+ ;
+ }
+
+ /**
+ * @param array{antispam_profile: ?Profile, compound: bool} $options
+ */
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ // Only act on compound form types with a profile set
+ if ($options['compound'] && null !== ($profile = $options['antispam_profile'])) {
+ $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($profile) {
+ /*
+ * Symfony Forms does not expose editing options after creation, so the only way
+ * to reliably modify options based on outside factors is to recreate the child
+ * from its parent with updated options. Hence why the code below is so complex
+ * instead of just creating a TextType extension. This is thankfully completely
+ * safe as the Forms recognize which elements were added in the event handler.
+ */
+ foreach ($event->getForm()->all() as $name => $child) {
+ $config = $child->getConfig();
+ $type = $config->getType();
+ while (null !== $type) {
+ if ($type->getInnerType() instanceof TextType) {
+ $options = $config->getOptions();
+ $this->applyTextTypeProfile($options, $profile);
+ $event->getForm()->add($name, $type->getInnerType()::class, $options);
+ /* @infection-ignore-all don't try to make this into an endless loop kthnxbye */
+ break;
+ }
+ $type = $type->getParent();
+ }
+ }
+ });
+
+ $builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event) use ($profile) {
+ $this->applyFormTypeProfile($event->getForm(), $profile);
+ });
+ }
+ }
+
+ /**
+ * @param array{constraints: array} $options
+ */
+ protected function applyTextTypeProfile(array &$options, Profile $profile): void
+ {
+ if (!empty($constraints = $profile->getTextTypeConstraints())) {
+ $options['constraints'][] = new Sequentially($constraints);
+ }
+ }
+
+ protected function applyFormTypeProfile(FormInterface $form, Profile $profile): void
+ {
+ // Add honeypot field as required
+ if ($honeypot = $profile->getHoneypotConfig()) {
+ $form->add(self::uniqueFieldName($form, $honeypot['field']), HoneypotType::class);
+ }
+
+ // Add hidden and signed timer field
+ if ($timer = $profile->getTimerConfig()) {
+ if ($form->isRoot()) {
+ $form->add(self::uniqueFieldName($form, $timer['field']), SubmitTimerType::class, [
+ 'min' => $timer['min'],
+ 'max' => $timer['max'],
+ ]);
+ } else {
+ $this->logger?->info(sprintf('Ignoring timer configuration from profile "%s" on embedded form', $profile->getName()));
+ }
+ }
+ }
+
+ /**
+ * @infection-ignore-all Infection creates an endless loop here
+ */
+ private static function uniqueFieldName(FormInterface $form, string $basename): string
+ {
+ $field = $basename;
+ $counter = 0;
+ while ($form->has($field)) {
+ $field = $basename . ++$counter;
+ }
+
+ return $field;
+ }
+}
diff --git a/src/Form/Type/AbstractAntiSpamType.php b/src/Form/Type/AbstractAntiSpamType.php
new file mode 100644
index 0000000..1ebab15
--- /dev/null
+++ b/src/Form/Type/AbstractAntiSpamType.php
@@ -0,0 +1,63 @@
+setDefaults([
+ 'mapped' => false,
+ ]);
+ }
+
+ #[Required]
+ public function setAntiSpam(AntiSpam $antiSpam): void
+ {
+ $this->antiSpam = $antiSpam;
+ }
+
+ #[Required]
+ public function setTranslator(TranslatorInterface $translator): void
+ {
+ $this->translator = $translator;
+ }
+
+ /**
+ * @param array $parameters
+ */
+ protected function createFormError(FormInterface $form, string $template, array $parameters = [], string $cause = null): void
+ {
+ if ($this->antiSpam->isPassive()) {
+ // No hard errors when in passive mode
+ return;
+ }
+ if ($this->antiSpam->isStealth()) {
+ $message = $this->translator->trans('form.stealthed', domain: 'antispam');
+ } else {
+ $message = $this->translator->trans($template, $parameters, domain: 'antispam');
+ }
+ $form->addError(new FormError($message, $template, $parameters, cause: $cause));
+ }
+}
diff --git a/src/Form/Type/HoneypotType.php b/src/Form/Type/HoneypotType.php
new file mode 100644
index 0000000..0644696
--- /dev/null
+++ b/src/Form/Type/HoneypotType.php
@@ -0,0 +1,42 @@
+addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
+ /** @var string $data */
+ $data = $event->getData();
+ if (!empty($data)) {
+ $this->createFormError($event->getForm(), 'form.honeypot.not_empty');
+ }
+ });
+ }
+
+ public function getParent(): ?string
+ {
+ return FormType::class;
+ }
+
+ public function getBlockPrefix(): string
+ {
+ return 'antispam_honeypot';
+ }
+}
diff --git a/src/Form/Type/NonInteractiveAntiSpamType.php b/src/Form/Type/NonInteractiveAntiSpamType.php
new file mode 100644
index 0000000..e4d5f99
--- /dev/null
+++ b/src/Form/Type/NonInteractiveAntiSpamType.php
@@ -0,0 +1,28 @@
+setDefaults([
+ 'compound' => false,
+ 'error_bubbling' => true,
+ ]);
+ }
+}
diff --git a/src/Form/Type/SubmitTimerType.php b/src/Form/Type/SubmitTimerType.php
new file mode 100644
index 0000000..8ef2152
--- /dev/null
+++ b/src/Form/Type/SubmitTimerType.php
@@ -0,0 +1,111 @@
+setDefaults([
+ 'min' => self::DEFAULT_MIN,
+ 'max' => self::DEFAULT_MAX,
+ 'secret' => $this->secret,
+ ]);
+
+ $resolver->setAllowedTypes('min', 'int');
+ $resolver->setAllowedTypes('max', 'int');
+ $resolver->setAllowedTypes('secret', 'string');
+ }
+
+ /**
+ * @param array $options
+ */
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($options) {
+ $form = $event->getForm();
+ $data = $event->getData();
+ assert(is_string($data));
+
+ if (empty($data) || false === ($decoded = \base64_decode($data, true))) {
+ $this->createFormError($form, 'form.timer.corrupted', cause: 'Data could not be decoded');
+ } elseif (3 !== count($parts = explode('|', $decoded))) {
+ $this->createFormError($form, 'form.timer.corrupted', cause: 'Data must contain 3 elements');
+ } else {
+ [$ip, $ts, $hash] = $parts;
+
+ $secret = $options['secret'];
+ if ($hash !== hash('sha256', "$ts|$ip|$secret")) {
+ $this->createFormError($form, 'form.timer.corrupted', cause: 'Hash verification failed');
+
+ return;
+ }
+
+ $currentIp = $this->requestStack->getMainRequest()?->getClientIp() ?? self::NO_IP;
+ if (null !== $currentIp && $ip !== $currentIp) {
+ $this->createFormError($form, 'form.timer.mismatch_ip', ['original' => $ip, 'current' => $currentIp],
+ cause: "The client IP address changed from $ip to $currentIp");
+ }
+
+ $age = $this->now()->getTimestamp() - intval($ts);
+ if ($age < $options['min']) {
+ $this->createFormError($form, 'form.timer.too_fast', cause: "Form was submitted after $age seconds");
+ } elseif ($age > $options['max']) {
+ $this->createFormError($form, 'form.timer.too_slow', cause: "Form was submitted after $age seconds");
+ }
+ }
+ });
+ }
+
+ /**
+ * @param array $options
+ */
+ public function buildView(FormView $view, FormInterface $form, array $options): void
+ {
+ $ip = $this->requestStack->getMainRequest()?->getClientIp() ?? self::NO_IP;
+ $ts = $this->now()->getTimestamp();
+ $secret = $options['secret'];
+ $view->vars['value'] = \base64_encode(implode('|', [$ip, $ts, hash('sha256', "$ts|$ip|$secret")]));
+ }
+
+ public function getBlockPrefix(): string
+ {
+ return 'antispam_submit_timer';
+ }
+}
diff --git a/src/Profile.php b/src/Profile.php
new file mode 100644
index 0000000..1d85601
--- /dev/null
+++ b/src/Profile.php
@@ -0,0 +1,140 @@
+name;
+ }
+
+ /**
+ * @return array
+ */
+ public function getConfig(): array
+ {
+ return $this->config;
+ }
+
+ /**
+ * @return ?HoneypotConfig
+ */
+ public function getHoneypotConfig(): ?array
+ {
+ return $this->config['honeypot'] ?? null;
+ }
+
+ /**
+ * @return ?TimerConfig
+ */
+ public function getTimerConfig(): ?array
+ {
+ return $this->config['timer'] ?? null;
+ }
+
+ /**
+ * @return Constraint[]
+ */
+ public function getTextTypeConstraints(): array
+ {
+ return $this->constraints ?? $this->buildTextTypeConstraints();
+ }
+
+ /**
+ * Note that constraints are added in order of increasing cost/effectiveness balance so the resulting array
+ * can be used Sequentially efficiently. Iow: add new costly ones at the end, cheap ones up front.
+ *
+ * @return Constraint[]
+ */
+ protected function buildTextTypeConstraints(): array
+ {
+ static $types;
+
+ if (!isset($types)) {
+ $types = [
+ 'banned_markup' => fn ($config) => $this->createBannedMarkupConstraint($config),
+ 'banned_phrases' => fn ($config) => $this->createBannedPhrasesConstraint($config),
+ 'banned_scripts' => fn ($config) => $this->createBannedScriptsConstraint($config),
+ 'max_urls' => fn ($config) => $this->createMaxUrlsConstraints($config),
+ ];
+ }
+
+ $this->constraints = [];
+ foreach ($types as $key => $closure) {
+ if ($config = $this->config[$key] ?? null) {
+ $this->constraints[] = $closure($config);
+ }
+ }
+
+ return $this->constraints;
+ }
+
+ /**
+ * @param BannedMarkupConfig $config
+ */
+ protected function createBannedMarkupConstraint(mixed $config): AntiSpamConstraint
+ {
+ return new BannedMarkup(...$config);
+ }
+
+ /**
+ * @param BannedPhrasesConfig $config
+ */
+ protected function createBannedPhrasesConstraint(mixed $config): AntiSpamConstraint
+ {
+ return new BannedPhrases($config);
+ }
+
+ /**
+ * @param BannedScriptsConfig $config
+ */
+ protected function createBannedScriptsConstraint(mixed $config): AntiSpamConstraint
+ {
+ return new BannedScripts($config['scripts'], $config['max_percentage'], $config['max_characters']);
+ }
+
+ protected function createMaxUrlsConstraints(int $count): AntiSpamConstraint
+ {
+ return new UrlCount(max: $count);
+ }
+}
diff --git a/src/Type/Script.php b/src/Type/Script.php
new file mode 100644
index 0000000..d443d76
--- /dev/null
+++ b/src/Type/Script.php
@@ -0,0 +1,179 @@
+antiSpam->isPassive();
+ }
+
+ protected function getGlobalStealth(): bool
+ {
+ return $this->antiSpam->isStealth();
+ }
+}
diff --git a/src/Validator/Constraints/BannedMarkup.php b/src/Validator/Constraints/BannedMarkup.php
new file mode 100644
index 0000000..5b53559
--- /dev/null
+++ b/src/Validator/Constraints/BannedMarkup.php
@@ -0,0 +1,28 @@
+#i', $value)) {
+ $this->context->buildViolation('html was found')->addViolation();
+ }
+ }
+}
diff --git a/src/Validator/Constraints/BannedPhrases.php b/src/Validator/Constraints/BannedPhrases.php
new file mode 100644
index 0000000..958cf24
--- /dev/null
+++ b/src/Validator/Constraints/BannedPhrases.php
@@ -0,0 +1,51 @@
+phrases = is_array($phrases) ? $phrases : [$phrases];
+
+ parent::__construct($passive, $stealth, $groups, $payload);
+ }
+
+ public function getRegularExpression(): string
+ {
+ if (!isset($this->regexp)) {
+ $this->regexp = sprintf('#(%s)#i', implode('|', array_map(fn (string $v) => preg_quote($v, '#'), $this->phrases)));
+ }
+
+ return $this->regexp;
+ }
+}
diff --git a/src/Validator/Constraints/BannedPhrasesValidator.php b/src/Validator/Constraints/BannedPhrasesValidator.php
new file mode 100644
index 0000000..111ed88
--- /dev/null
+++ b/src/Validator/Constraints/BannedPhrasesValidator.php
@@ -0,0 +1,43 @@
+getRegularExpression();
+
+ if (preg_match($regexp, $value, $matches)) {
+ $this->context
+ ->buildViolation('validator.banned_phrases.phrase_found')
+ ->setParameter('phrase', $matches[1])
+ ->setInvalidValue($value)
+ ->setCode(BannedPhrases::CONTAINS_BANNED_PHRASE_ERROR)
+ ->setTranslationDomain('antispam')
+ ->addViolation();
+ }
+ }
+}
diff --git a/src/Validator/Constraints/BannedScripts.php b/src/Validator/Constraints/BannedScripts.php
new file mode 100644
index 0000000..b6ff25d
--- /dev/null
+++ b/src/Validator/Constraints/BannedScripts.php
@@ -0,0 +1,62 @@
+scripts = is_array($scripts) ? $scripts : [$scripts];
+
+ parent::__construct($passive, $stealth, $groups, $payload);
+ }
+
+ public function getCharacterClass(): string
+ {
+ if (!isset($this->characterClass)) {
+ $this->scripts = array_map(fn (Script|string $v) => is_string($v) ? Script::from($v) : $v, $this->scripts);
+ $this->characterClass = sprintf('[%s]', implode('', array_map(fn (Script $script) => sprintf('\p{%s}', $script->value), $this->scripts)));
+ }
+
+ return $this->characterClass;
+ }
+
+ public function getReadableScripts(): string
+ {
+ return implode(', ', array_map(fn (Script $script) => $script->name, $this->scripts));
+ }
+}
diff --git a/src/Validator/Constraints/BannedScriptsValidator.php b/src/Validator/Constraints/BannedScriptsValidator.php
new file mode 100644
index 0000000..b19c639
--- /dev/null
+++ b/src/Validator/Constraints/BannedScriptsValidator.php
@@ -0,0 +1,71 @@
+getCharacterClass();
+
+ // Try the cheap test first for early fail
+ if (preg_match("/{$class}/u", $value)) {
+ if ($constraint->maxPercentage > 0 || null !== $constraint->maxCharacters) {
+ // Only do an expensive full count once we know we need the numbers
+ $count = preg_match_all("/{$class}/u", $value);
+ $percentage = 100 * $count / mb_strlen($value);
+ if ($constraint->maxPercentage <= 0 && $count > $constraint->maxCharacters) {
+ $this->context
+ ->buildViolation('validator.banned_script.characters_exceeded')
+ ->setParameter('count', (string) $count)
+ ->setParameter('max', (string) $constraint->maxCharacters)
+ ->setParameter('scripts', $constraint->getReadableScripts())
+ ->setInvalidValue($value)
+ ->setCode(BannedScripts::TOO_MANY_CHARACTERS_ERROR)
+ ->setTranslationDomain('antispam')
+ ->addViolation();
+ } elseif (null === $constraint->maxCharacters && $percentage > $constraint->maxPercentage) {
+ $this->context
+ ->buildViolation('validator.banned_script.percentage_exceeded')
+ ->setParameter('percentage', (string) ceil($percentage))
+ ->setParameter('max', (string) $constraint->maxPercentage)
+ ->setParameter('scripts', $constraint->getReadableScripts())
+ ->setInvalidValue($value)
+ ->setCode(BannedScripts::TOO_HIGH_PERCENTAGE_ERROR)
+ ->setTranslationDomain('antispam')
+ ->addViolation();
+ }
+ } else {
+ $this->context
+ ->buildViolation('validator.banned_script.not_allowed')
+ ->setParameter('scripts', $constraint->getReadableScripts())
+ ->setInvalidValue($value)
+ ->setCode(BannedScripts::NOT_ALLOWED_ERROR)
+ ->setTranslationDomain('antispam')
+ ->addViolation();
+ }
+ }
+ }
+}
diff --git a/src/Validator/Constraints/UrlCount.php b/src/Validator/Constraints/UrlCount.php
new file mode 100644
index 0000000..76801ad
--- /dev/null
+++ b/src/Validator/Constraints/UrlCount.php
@@ -0,0 +1,29 @@
+ $constraint->max) {
+ $this->context->buildViolation('validator.url_count.exceeded')
+ ->setParameter('count', (string) $urlCount)
+ ->setParameter('limit', (string) $constraint->max)
+ ->setInvalidValue($value)
+ ->setCode(UrlCount::TOO_MANY_URLS_ERROR)
+ ->setTranslationDomain('antispam')
+ ->addViolation();
+ }
+ }
+}
diff --git a/templates/form/widgets.html.twig b/templates/form/widgets.html.twig
new file mode 100644
index 0000000..6860259
--- /dev/null
+++ b/templates/form/widgets.html.twig
@@ -0,0 +1,11 @@
+{% block antispam_honeypot_row %}
+ {%- set type = 'text' -%}
+ {%- set attr = attr|merge({'style': 'display:none'}) -%}
+ {%- set required = false -%}
+ {{- block('form_widget_simple') -}}
+{% endblock %}
+
+{% block antispam_submit_timer_row %}
+ {%- set type = 'hidden' -%}
+ {{- block('form_widget_simple') -}}
+{% endblock %}
diff --git a/tests/Fixture/.env b/tests/Fixture/.env
new file mode 100644
index 0000000..3c15363
--- /dev/null
+++ b/tests/Fixture/.env
@@ -0,0 +1,30 @@
+# In all environments, the following files are loaded if they exist,
+# the latter taking precedence over the former:
+#
+# * .env contains default values for the environment variables needed by the app
+# * .env.local uncommitted file with local overrides
+# * .env.$APP_ENV committed environment-specific defaults
+# * .env.$APP_ENV.local uncommitted environment-specific overrides
+#
+# Real environment variables win over .env files.
+#
+# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
+# https://symfony.com/doc/current/configuration/secrets.html
+#
+# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
+# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
+
+###> symfony/framework-bundle ###
+APP_ENV=dev
+APP_SECRET=74e6b43aa44671aced54bae25f0dc1f6
+###< symfony/framework-bundle ###
+###> symfony/messenger ###
+# Choose one of the transports below
+# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
+# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
+MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
+###< symfony/messenger ###
+
+###> symfony/mailer ###
+# MAILER_DSN=null://null
+###< symfony/mailer ###
diff --git a/tests/Fixture/.gitignore b/tests/Fixture/.gitignore
new file mode 100644
index 0000000..1506387
--- /dev/null
+++ b/tests/Fixture/.gitignore
@@ -0,0 +1,9 @@
+###> symfony/framework-bundle ###
+/.env.local
+/.env.local.php
+/.env.*.local
+/config/secrets/prod/prod.decrypt.private.php
+/public/bundles/
+/var/
+/vendor/
+###< symfony/framework-bundle ###
diff --git a/tests/Fixture/bin/console b/tests/Fixture/bin/console
new file mode 100755
index 0000000..a3397f7
--- /dev/null
+++ b/tests/Fixture/bin/console
@@ -0,0 +1,16 @@
+#!/usr/bin/env php
+ dirname(__DIR__)];
+
+require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
+
+return function (array $context) {
+ $kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
+
+ return new Application($kernel);
+};
diff --git a/tests/Fixture/config/bundles.php b/tests/Fixture/config/bundles.php
new file mode 100644
index 0000000..d203161
--- /dev/null
+++ b/tests/Fixture/config/bundles.php
@@ -0,0 +1,9 @@
+ ['all' => true],
+ Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
+ Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true],
+ Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
+ Omines\AntiSpamBundle\AntiSpamBundle::class => ['all' => true],
+];
diff --git a/tests/Fixture/config/packages/antispam.yaml b/tests/Fixture/config/packages/antispam.yaml
new file mode 100644
index 0000000..fe0aa81
--- /dev/null
+++ b/tests/Fixture/config/packages/antispam.yaml
@@ -0,0 +1,37 @@
+antispam:
+ profiles:
+ test1:
+ max_urls: 2
+ banned_markup: true
+ banned_phrases:
+ - 'official partner of Wordpress'
+ - 'with hourly rate'
+ - 'expert developers'
+
+ banned_scripts:
+ - cyrillic
+ - arabic
+
+ timer:
+ min: 3
+ max: 900
+ field: '__custom_timer_field'
+
+ honeypot: email_address
+
+ test2:
+ stealth: false
+
+ max_urls: 1
+ banned_markup: true
+ banned_phrases: ['viagra']
+ banned_scripts: cyrillic
+ timer: ~
+ honeypot: message
+
+ test3:
+ timer: ~
+ honeypot: email
+
+ empty:
+
diff --git a/tests/Fixture/config/packages/cache.yaml b/tests/Fixture/config/packages/cache.yaml
new file mode 100644
index 0000000..6899b72
--- /dev/null
+++ b/tests/Fixture/config/packages/cache.yaml
@@ -0,0 +1,19 @@
+framework:
+ cache:
+ # Unique name of your app: used to compute stable namespaces for cache keys.
+ #prefix_seed: your_vendor_name/app_name
+
+ # The "app" cache stores to the filesystem by default.
+ # The data in this cache should persist between deploys.
+ # Other options include:
+
+ # Redis
+ #app: cache.adapter.redis
+ #default_redis_provider: redis://localhost
+
+ # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
+ #app: cache.adapter.apcu
+
+ # Namespaced pools use the above "app" backend by default
+ #pools:
+ #my.dedicated.cache: null
diff --git a/tests/Fixture/config/packages/framework.yaml b/tests/Fixture/config/packages/framework.yaml
new file mode 100644
index 0000000..6d85c29
--- /dev/null
+++ b/tests/Fixture/config/packages/framework.yaml
@@ -0,0 +1,25 @@
+# see https://symfony.com/doc/current/reference/configuration/framework.html
+framework:
+ secret: '%env(APP_SECRET)%'
+ #csrf_protection: true
+ http_method_override: false
+ handle_all_throwables: true
+
+ # Enables session support. Note that the session will ONLY be started if you read or write from it.
+ # Remove or comment this section to explicitly disable session support.
+ session:
+ handler_id: null
+ cookie_secure: auto
+ cookie_samesite: lax
+ storage_factory_id: session.storage.factory.native
+
+ #esi: true
+ #fragments: true
+ php_errors:
+ log: true
+
+when@test:
+ framework:
+ test: true
+ session:
+ storage_factory_id: session.storage.factory.mock_file
diff --git a/tests/Fixture/config/packages/routing.yaml b/tests/Fixture/config/packages/routing.yaml
new file mode 100644
index 0000000..4b766ce
--- /dev/null
+++ b/tests/Fixture/config/packages/routing.yaml
@@ -0,0 +1,12 @@
+framework:
+ router:
+ utf8: true
+
+ # Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
+ # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
+ #default_uri: http://localhost
+
+when@prod:
+ framework:
+ router:
+ strict_requirements: null
diff --git a/tests/Fixture/config/packages/translation.yaml b/tests/Fixture/config/packages/translation.yaml
new file mode 100644
index 0000000..3f8ea21
--- /dev/null
+++ b/tests/Fixture/config/packages/translation.yaml
@@ -0,0 +1,6 @@
+framework:
+ default_locale: '%default_locale%'
+ translator:
+ default_path: '%kernel.project_dir%/translations'
+ fallbacks:
+ - en
diff --git a/tests/Fixture/config/packages/twig.yaml b/tests/Fixture/config/packages/twig.yaml
new file mode 100644
index 0000000..3be7d52
--- /dev/null
+++ b/tests/Fixture/config/packages/twig.yaml
@@ -0,0 +1,7 @@
+twig:
+ default_path: '%kernel.project_dir%/templates'
+ form_themes: ['bootstrap_5_layout.html.twig']
+
+when@test:
+ twig:
+ strict_variables: true
diff --git a/tests/Fixture/config/packages/validator.yaml b/tests/Fixture/config/packages/validator.yaml
new file mode 100644
index 0000000..0201281
--- /dev/null
+++ b/tests/Fixture/config/packages/validator.yaml
@@ -0,0 +1,13 @@
+framework:
+ validation:
+ email_validation_mode: html5
+
+ # Enables validator auto-mapping support.
+ # For instance, basic validation constraints will be inferred from Doctrine's metadata.
+ #auto_mapping:
+ # App\Entity\: []
+
+when@test:
+ framework:
+ validation:
+ not_compromised_password: false
diff --git a/tests/Fixture/config/packages/web_profiler.yaml b/tests/Fixture/config/packages/web_profiler.yaml
new file mode 100644
index 0000000..b946111
--- /dev/null
+++ b/tests/Fixture/config/packages/web_profiler.yaml
@@ -0,0 +1,17 @@
+when@dev:
+ web_profiler:
+ toolbar: true
+ intercept_redirects: false
+
+ framework:
+ profiler:
+ only_exceptions: false
+ collect_serializer_data: true
+
+when@test:
+ web_profiler:
+ toolbar: false
+ intercept_redirects: false
+
+ framework:
+ profiler: { collect: false }
diff --git a/tests/Fixture/config/routes.yaml b/tests/Fixture/config/routes.yaml
new file mode 100644
index 0000000..a285d83
--- /dev/null
+++ b/tests/Fixture/config/routes.yaml
@@ -0,0 +1,16 @@
+controllers:
+ resource:
+ path: ../src/Controller/
+ namespace: Tests\Fixture\Controller
+ type: attribute
+ prefix: /{_locale}
+ requirements:
+ _locale: '%locales%'
+ defaults:
+ _locales: '%default_locale%'
+
+root:
+ path: /
+ controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController
+ defaults:
+ route: home
\ No newline at end of file
diff --git a/tests/Fixture/config/routes/framework.yaml b/tests/Fixture/config/routes/framework.yaml
new file mode 100644
index 0000000..0fc74bb
--- /dev/null
+++ b/tests/Fixture/config/routes/framework.yaml
@@ -0,0 +1,4 @@
+when@dev:
+ _errors:
+ resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
+ prefix: /_error
diff --git a/tests/Fixture/config/routes/web_profiler.yaml b/tests/Fixture/config/routes/web_profiler.yaml
new file mode 100644
index 0000000..8d85319
--- /dev/null
+++ b/tests/Fixture/config/routes/web_profiler.yaml
@@ -0,0 +1,8 @@
+when@dev:
+ web_profiler_wdt:
+ resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
+ prefix: /_wdt
+
+ web_profiler_profiler:
+ resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
+ prefix: /_profiler
diff --git a/tests/Fixture/config/services.yaml b/tests/Fixture/config/services.yaml
new file mode 100644
index 0000000..f97b0dd
--- /dev/null
+++ b/tests/Fixture/config/services.yaml
@@ -0,0 +1,26 @@
+# This file is the entry point to configure your own services.
+# Files in the packages/ subdirectory configure your dependencies.
+
+# Put parameters here that don't need to change on each machine where the app is deployed
+# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
+parameters:
+ default_locale: en
+ locales: en|nl
+
+services:
+ # default configuration for services in *this* file
+ _defaults:
+ autowire: true # Automatically injects dependencies in your services.
+ autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
+
+ # makes classes in src/ available to be used as services
+ # this creates a service per class whose id is the fully-qualified class name
+ Tests\Fixture\:
+ resource: '../src/'
+ exclude:
+ - '../src/DependencyInjection/'
+ - '../src/Entity/'
+ - '../src/Kernel.php'
+
+ # add more service definitions when explicit configuration is needed
+ # please note that last definitions always *replace* previous ones
diff --git a/tests/Fixture/public/index.php b/tests/Fixture/public/index.php
new file mode 100644
index 0000000..b1f46c7
--- /dev/null
+++ b/tests/Fixture/public/index.php
@@ -0,0 +1,12 @@
+ dirname(__DIR__)];
+
+require_once dirname(__DIR__) . '/vendor/autoload_runtime.php';
+
+return function () {
+ return new Kernel('dev', true);
+};
diff --git a/tests/Fixture/src/Controller/PageController.php b/tests/Fixture/src/Controller/PageController.php
new file mode 100644
index 0000000..f646c28
--- /dev/null
+++ b/tests/Fixture/src/Controller/PageController.php
@@ -0,0 +1,69 @@
+createForm(KitchenSinkForm::class);
+ $form->add('submit', SubmitType::class);
+
+ return $this->finishRequest($form, $request);
+ }
+
+ #[Route('/profile/{profile}')]
+ public function profile(string $profile, Request $request): Response
+ {
+ $form = $this->createForm(BasicForm::class, options: [
+ 'antispam_profile' => $profile,
+ ]);
+ $form->add('submit', SubmitType::class);
+
+ return $this->finishRequest($form, $request);
+ }
+
+ #[Route('/embedded')]
+ public function embedded(Request $request): Response
+ {
+ $form = $this->createForm(EmbeddingForm::class, options: [
+ 'antispam_profile' => 'test1',
+ ]);
+ $form->add('submit', SubmitType::class);
+
+ return $this->finishRequest($form, $request);
+ }
+
+ private function finishRequest(FormInterface $form, Request $request): Response
+ {
+ $form->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ $this->addFlash('message', 'Form passed');
+ }
+
+ return $this->render('form.html.twig', [
+ 'form' => $form->createView(),
+ ]);
+ }
+}
diff --git a/tests/Fixture/src/Entity/Comment.php b/tests/Fixture/src/Entity/Comment.php
new file mode 100644
index 0000000..77f13bf
--- /dev/null
+++ b/tests/Fixture/src/Entity/Comment.php
@@ -0,0 +1,37 @@
+message;
+ }
+
+ public function setMessage(string $message): void
+ {
+ $this->message = $message;
+ }
+}
diff --git a/tests/Fixture/src/Form/Type/BasicForm.php b/tests/Fixture/src/Form/Type/BasicForm.php
new file mode 100644
index 0000000..9808d20
--- /dev/null
+++ b/tests/Fixture/src/Form/Type/BasicForm.php
@@ -0,0 +1,46 @@
+add('name', TextType::class, [
+ 'constraints' => [
+ new Length(min: 3, max: 100),
+ ],
+ ])
+ ->add('email', EmailType::class)
+ ->add('phone', TelType::class)
+ ->add('message', TextareaType::class, [
+ 'attr' => [
+ 'cols' => 80,
+ 'rows' => 6,
+ ],
+ 'constraints' => [
+ new Length(min: 10, max: 1000),
+ ],
+ ])
+ ;
+ }
+}
diff --git a/tests/Fixture/src/Form/Type/EmbeddingForm.php b/tests/Fixture/src/Form/Type/EmbeddingForm.php
new file mode 100644
index 0000000..af60f29
--- /dev/null
+++ b/tests/Fixture/src/Form/Type/EmbeddingForm.php
@@ -0,0 +1,33 @@
+add('title', TextType::class, [
+ 'antispam_profile' => 'test3',
+ ])
+ ->add('description', TextType::class)
+ ->add('embedded', BasicForm::class, [
+ 'antispam_profile' => 'test2',
+ ])
+ ;
+ }
+}
diff --git a/tests/Fixture/src/Form/Type/KitchenSinkForm.php b/tests/Fixture/src/Form/Type/KitchenSinkForm.php
new file mode 100644
index 0000000..8c14675
--- /dev/null
+++ b/tests/Fixture/src/Form/Type/KitchenSinkForm.php
@@ -0,0 +1,55 @@
+add('name', TextType::class, [
+ 'constraints' => [
+ new Length(min: 3, max: 100),
+ ],
+ ])
+ ->add('email', EmailType::class)
+ ->add('message', TextareaType::class, [
+ 'attr' => [
+ 'cols' => 80,
+ 'rows' => 6,
+ ],
+ 'constraints' => [
+ new Length(min: 10, max: 1000),
+ new BannedScripts([Script::Cyrillic, Script::Hebrew], maxPercentage: 25),
+ new UrlCount(1),
+ ],
+ ])
+ ->add('honeypot', HoneypotType::class)
+ ->add('timer', SubmitTimerType::class, [
+ 'min' => 5,
+ ])
+ ;
+ }
+}
diff --git a/tests/Fixture/src/Kernel.php b/tests/Fixture/src/Kernel.php
new file mode 100644
index 0000000..5728f44
--- /dev/null
+++ b/tests/Fixture/src/Kernel.php
@@ -0,0 +1,26 @@
+
+
+
+
+
+ {{ title }}
+
+
+ {% block stylesheets %}
+ {% endblock %}
+
+ {% block javascripts %}
+ {% endblock %}
+
+
+
+
{{ title|default('No title set') }}
+ {% for message in app.session.flashBag.get('message') %}
+
+ {{ message }}
+
+
+ {% endfor %}
+ {% block body %}{% endblock %}
+
+
+
+
diff --git a/tests/Fixture/templates/contact.html.twig b/tests/Fixture/templates/contact.html.twig
new file mode 100644
index 0000000..a3ebeee
--- /dev/null
+++ b/tests/Fixture/templates/contact.html.twig
@@ -0,0 +1,6 @@
+{% extends 'base.html.twig' %}
+{% set title = 'Contact page' %}
+
+{% block body %}
+ Contact page
+{% endblock %}
diff --git a/tests/Fixture/templates/form.html.twig b/tests/Fixture/templates/form.html.twig
new file mode 100644
index 0000000..b74b456
--- /dev/null
+++ b/tests/Fixture/templates/form.html.twig
@@ -0,0 +1,10 @@
+{% extends 'base.html.twig' %}
+{% set title = 'Omines Anti-Spam Bundle test site' %}
+
+{% block body %}
+
+{% endblock %}
diff --git a/tests/Fixture/vendor b/tests/Fixture/vendor
new file mode 120000
index 0000000..668c889
--- /dev/null
+++ b/tests/Fixture/vendor
@@ -0,0 +1 @@
+../../vendor
\ No newline at end of file
diff --git a/tests/Functional/IntegrationTest.php b/tests/Functional/IntegrationTest.php
new file mode 100644
index 0000000..a983a8e
--- /dev/null
+++ b/tests/Functional/IntegrationTest.php
@@ -0,0 +1,257 @@
+catchExceptions(false);
+
+ return $client;
+ }
+
+ public function testHoneypotAndTimerAreHidden(): void
+ {
+ $client = static::createClient();
+ $crawler = $client->request('GET', '/en/');
+ $this->assertResponseIsSuccessful('The request failed');
+
+ $honeypot = $crawler->filter('input[name="kitchen_sink_form[honeypot]"]');
+ $this->assertCount(1, $honeypot);
+ $this->assertSame('input', $honeypot->nodeName());
+ $this->assertSame('text', $honeypot->attr('type'));
+ $this->assertSame('display:none', $honeypot->attr('style'));
+ $this->assertNull($honeypot->attr('required'));
+
+ $timer = $crawler->filter('input[name="kitchen_sink_form[timer]"]');
+ $this->assertCount(1, $timer);
+ $this->assertSame('input', $timer->nodeName());
+ $this->assertSame('hidden', $timer->attr('type'));
+ }
+
+ public function testFastAndSlowResponsesAreCaught(): void
+ {
+ static::mockTime('2023-10-31 09:00:00');
+
+ $client = static::createClient();
+ $crawler = $client->request('GET', '/en/');
+ $this->assertResponseIsSuccessful();
+
+ $formData = [
+ 'kitchen_sink_form[name]' => 'John Doe',
+ 'kitchen_sink_form[email]' => 'foo@example.org',
+ 'kitchen_sink_form[message]' => 'Just a normal text that should pass validation',
+ ];
+
+ static::mockTime('+4 seconds');
+ $crawler = $client->submit($crawler->filter('form[name=kitchen_sink_form]')->form(), $formData);
+ $this->assertSelectorExists('.alert-danger', 'The form was submitted too quickly to pass');
+ $this->assertSelectorTextContains('.alert-danger', 'unreasonably fast');
+
+ static::mockTime('+5 seconds');
+ $client->submit($crawler->filter('form[name=kitchen_sink_form]')->form(), $formData);
+ $this->assertSelectorNotExists('.alert-danger', 'The form was submitted after a reasonable delay');
+
+ static::mockTime('+3594 seconds');
+ $client->submit($crawler->filter('form[name=kitchen_sink_form]')->form(), $formData);
+ $this->assertSelectorNotExists('.alert-danger', 'The form was submitted after a reasonable delay');
+
+ static::mockTime('+1 second');
+ $client->submit($crawler->filter('form[name=kitchen_sink_form]')->form(), $formData);
+ $this->assertSelectorNotExists('.alert-danger', 'The form was submitted after a reasonable delay');
+
+ static::mockTime('+1 second');
+ $client->submit($crawler->filter('form[name=kitchen_sink_form]')->form(), $formData);
+ $this->assertSelectorExists('.alert-danger', 'The form was submitted too slow to pass');
+ $this->assertSelectorTextContains('.alert-danger', 'unreasonably slow');
+
+ static::mockTime('+3 days');
+ $client->submit($crawler->filter('form[name=kitchen_sink_form]')->form(), $formData);
+ $this->assertSelectorExists('.alert-danger', 'The form was submitted too slow to pass');
+ $this->assertSelectorTextContains('.alert-danger', 'unreasonably slow');
+ }
+
+ public function testFillingInTheHoneypotFailsTheForm(): void
+ {
+ $client = static::createClient();
+ $crawler = $client->request('GET', '/en/');
+ $this->assertResponseIsSuccessful();
+
+ $formData = [
+ 'kitchen_sink_form[name]' => 'John Doe',
+ 'kitchen_sink_form[email]' => 'foo@example.org',
+ 'kitchen_sink_form[message]' => 'Just a normal text that should pass validation',
+ 'kitchen_sink_form[honeypot]' => 'WE OFFER ADVERTISING SERVICES FOR FREE',
+ ];
+
+ $client->submit($crawler->filter('form[name=kitchen_sink_form]')->form(), $formData);
+ $this->assertResponseIsSuccessful();
+ $this->assertSelectorExists('.alert-danger', 'The form should not have passed with the honeypot filled');
+ $this->assertSelectorTextContains('.alert-danger', 'honeypot field was supposed');
+ }
+
+ public function testCorruptingTheHashFailsValidation(): void
+ {
+ $client = static::createClient();
+ $crawler = $client->request('GET', '/en/');
+ $this->assertResponseIsSuccessful();
+
+ $formData = [
+ 'kitchen_sink_form[name]' => 'John Doe',
+ 'kitchen_sink_form[email]' => 'foo@example.org',
+ 'kitchen_sink_form[message]' => 'Just a normal text that should pass validation',
+ ];
+
+ $crawler = $client->submit($crawler->filter('form[name=kitchen_sink_form]')->form(), $formData, [
+ 'REMOTE_ADDR' => '1.2.3.4',
+ ]);
+ $this->assertResponseIsSuccessful();
+ $this->expectFormErrors($crawler, ['Your IP address changed', 'unreasonably fast']);
+
+ $formData['kitchen_sink_form[timer]'] = '';
+ $crawler = $client->submit($crawler->filter('form[name=kitchen_sink_form]')->form(), $formData);
+ $this->assertResponseIsSuccessful();
+ $this->expectFormErrors($crawler, 'Technical reasons');
+
+ $formData['kitchen_sink_form[timer]'] = '%%%%%NOT_BASE_64%%%%%%';
+ $crawler = $client->submit($crawler->filter('form[name=kitchen_sink_form]')->form(), $formData);
+ $this->assertResponseIsSuccessful();
+ $this->expectFormErrors($crawler, 'Technical reasons');
+
+ $formData['kitchen_sink_form[timer]'] = \base64_encode('incorrect structure');
+ $crawler = $client->submit($crawler->filter('form[name=kitchen_sink_form]')->form(), $formData);
+ $this->assertResponseIsSuccessful();
+ $this->expectFormErrors($crawler, 'Technical reasons');
+
+ $formData['kitchen_sink_form[timer]'] = \base64_encode('123456|127.0.0.1|invalid_hash');
+ $crawler = $client->submit($crawler->filter('form[name=kitchen_sink_form]')->form(), $formData);
+ $this->assertResponseIsSuccessful();
+ $this->expectFormErrors($crawler, 'Technical reasons');
+ }
+
+ public function testProfileTest1(): void
+ {
+ $client = static::createClient();
+ $crawler = $client->request('GET', '/en/profile/test1');
+ $this->assertResponseIsSuccessful();
+ $this->assertSelectorExists('#basic_form___custom_timer_field', 'Configured custom name for timer field');
+
+ $formData = [
+ 'basic_form[name]' => 'Priya Kaila',
+ 'basic_form[email]' => 'foo@example.org',
+ 'basic_form[message]' => << 'I love spam',
+ ];
+
+ $crawler = $client->submit($crawler->filter('form[name=basic_form]')->form(), $formData);
+ $this->expectFormErrors($crawler,
+ ['unreasonably fast', 'honeypot field was supposed to be empty'],
+ ['disallowed phrase']);
+
+ static::mockTime('+10 seconds');
+
+ $formData['basic_form[name]'] = 'Арнолд Шварзэнэджр';
+ $formData['basic_form[phone]'] = '';
+ $formData['basic_form[message]'] = 'Please visit my website at https://example.org';
+ $formData['basic_form[email_address]'] = '';
+ $crawler = $client->submit($crawler->filter('form[name=basic_form]')->form(), $formData);
+ $this->expectFormErrors($crawler, fieldErrors: ['disallowed scripts', 'html was found']);
+ }
+
+ public function testProfileTest2(): void
+ {
+ $client = static::createClient();
+ $crawler = $client->request('GET', '/en/profile/test2');
+ $this->assertResponseIsSuccessful();
+ }
+
+ public function testProfileTest3(): void
+ {
+ $client = static::createClient();
+ $crawler = $client->request('GET', '/en/profile/test3');
+ $this->assertResponseIsSuccessful();
+ $this->assertSelectorExists('#basic_form_email1', 'Duplicate field name should add counter');
+ }
+
+ public function testEmbeddedFormsWithConflictingProfiles(): void
+ {
+ $client = static::createClient();
+ $crawler = $client->request('GET', '/en/embedded');
+ $this->assertResponseIsSuccessful();
+ }
+
+ public function testEmptyProfile(): void
+ {
+ $client = static::createClient();
+ $crawler = $client->request('GET', '/en/profile/empty');
+ $this->assertResponseIsSuccessful();
+ }
+
+ /**
+ * @param string[] $formErrors
+ * @param string[] $fieldErrors
+ */
+ private function expectFormErrors(Crawler $crawler, string|array $formErrors = [], string|array $fieldErrors = []): void
+ {
+ if (empty($formErrors) && empty($fieldErrors)) {
+ $this->assertSelectorExists('.alert-primary', 'The form was not accepted successfully.');
+ } else {
+ $this->expectErrors($crawler, '.alert-danger', 'form level', (array) $formErrors);
+ $this->expectErrors($crawler, '.invalid-feedback', 'field level', (array) $fieldErrors);
+ }
+ }
+
+ /**
+ * @param string[] $errors
+ */
+ private function expectErrors(Crawler $crawler, string $selector, string $type, array $errors): void
+ {
+ $actual = [];
+ foreach ($crawler->filter($selector) as $element) {
+ $actual[] = $element->textContent;
+ foreach ($errors as $idx => $error) {
+ if (str_contains($element->textContent, $error)) {
+ unset($errors[$idx]);
+ continue 2;
+ }
+ }
+ $this->fail(sprintf('Unexpected %s error: "%s"', $type, $element->textContent));
+ }
+ if (!empty($errors)) {
+ $this->fail(sprintf('Expected %s errors not found: "%s", actual errors: "%s"',
+ $type, implode('", "', $errors), implode('", "', $actual)));
+ }
+ }
+}
diff --git a/tests/Unit/AntiSpamTest.php b/tests/Unit/AntiSpamTest.php
new file mode 100644
index 0000000..526f88c
--- /dev/null
+++ b/tests/Unit/AntiSpamTest.php
@@ -0,0 +1,45 @@
+expectException(InvalidProfileException::class);
+
+ /** @var AntiSpam $antispam */
+ $antispam = static::getContainer()->get(AntiSpam::class);
+ $antispam->getProfile('non_existent_service_name');
+ }
+
+ public function testConfigAndNameArePassedToProfile(): void
+ {
+ /** @var AntiSpam $antispam */
+ $antispam = static::getContainer()->get(AntiSpam::class);
+ $this->assertFalse($antispam->isPassive());
+ $this->assertFalse($antispam->isStealth());
+
+ $profile = $antispam->getProfile('test1');
+ $this->assertSame('test1', $profile->getName());
+ $this->assertNotEmpty($profile->getConfig());
+ }
+}
diff --git a/tests/Unit/BundleTest.php b/tests/Unit/BundleTest.php
new file mode 100644
index 0000000..dedf53a
--- /dev/null
+++ b/tests/Unit/BundleTest.php
@@ -0,0 +1,211 @@
+getContainerExtension();
+
+ $this->assertSame('AntiSpamBundle', $bundle->getName());
+ $this->assertSame('antispam', $extension?->getAlias());
+ }
+
+ public function testBundleInjectsDependencies(): void
+ {
+ $builder = new ContainerBuilder();
+ $builder->registerExtension(new TwigExtension());
+ $builder->loadFromExtension('twig');
+
+ $extension = (new AntiSpamBundle())->getContainerExtension();
+ $this->assertInstanceOf(AntiSpamExtension::class, $extension);
+ $extension->load(['antispam' => ['profiles' => ['default' => []]]], $builder);
+ $extension->prepend($builder);
+
+ /** @var array{form_themes: string[]}[] $twigConfig */
+ $twigConfig = $builder->getExtensionConfig('twig');
+ $this->assertContains('@AntiSpam/form/widgets.html.twig', $twigConfig[0]['form_themes']);
+
+ $this->assertTrue($builder->has('antispam.profile.default'));
+ $this->assertTrue($builder->has(FormTypeAntiSpamExtension::class));
+ }
+
+ public function testConfigurationDefaultsAreEmpty(): void
+ {
+ $processor = new Processor();
+ $result = $processor->processConfiguration(new Configuration(), []);
+
+ $this->assertEmpty($result['profiles']);
+ }
+
+ /**
+ * @param array $input
+ * @param array $expected
+ */
+ #[DataProvider('provideProfiles')]
+ public function testProfileExpansionAndParsing(array $input, array $expected): void
+ {
+ $processor = new Processor();
+ $result = $processor->processConfiguration(new Configuration(), $input);
+
+ $this->assertEquals($expected, $result);
+ }
+
+ /**
+ * @return \Generator
+ */
+ public static function provideProfiles(): \Generator
+ {
+ $finder = (new Finder())
+ ->in(__DIR__ . '/Fixtures')
+ ->name('config-*-input.yaml');
+
+ foreach ($finder as $file) {
+ $input = $file->getRealPath();
+ $output = str_replace('-input.', '-output.', $input);
+ if (false === ($expected = @file_get_contents($output))) {
+ throw new \LogicException("Missing required file $output");
+ }
+ yield $file->getFilename() => [Yaml::parseFile($input), Yaml::parse($expected)];
+ }
+ }
+
+ /**
+ * @param array $input
+ */
+ #[DataProvider('provideValidationTests')]
+ public function testConfigurationValidation(array $input, string $expectedError = null): void
+ {
+ $wrapped = [
+ 'antispam' => [
+ 'profiles' => [
+ 'default' => $input,
+ ],
+ ],
+ ];
+ if (null !== $expectedError) {
+ $this->expectException(InvalidConfigurationException::class);
+ $this->expectExceptionMessage($expectedError);
+ }
+ $this->assertIsArray((new Processor())->processConfiguration(new Configuration(), $wrapped));
+ }
+
+ /**
+ * @return array, 1?: string}>
+ */
+ public static function provideValidationTests(): array
+ {
+ return [
+ 'min timer value' => [
+ ['timer' => ['min' => -1]],
+ 'is too small',
+ ],
+ 'min timer value is 0' => [
+ ['timer' => ['min' => 0]],
+ ],
+ 'max timer value' => [
+ ['timer' => ['max' => 59]],
+ 'is too small',
+ ],
+ 'max timer value is 60' => [
+ ['timer' => ['max' => 60]],
+ ],
+ 'min timer not an int' => [
+ ['timer' => ['min' => '0']],
+ 'Expected "int"',
+ ],
+ 'max timer not an int' => [
+ ['timer' => ['max' => '60']],
+ 'Expected "int"',
+ ],
+ 'min percentage value too small' => [
+ ['banned_scripts' => ['max_percentage' => -1]],
+ 'is too small',
+ ],
+ 'min percentage value at 0%' => [
+ ['banned_scripts' => ['max_percentage' => 0]],
+ ],
+ 'max percentage value too big' => [
+ ['banned_scripts' => ['max_percentage' => 101]],
+ 'is too big',
+ ],
+ 'max percentage value at 100%' => [
+ ['banned_scripts' => ['max_percentage' => 100]],
+ ],
+ 'max character count too small' => [
+ ['banned_scripts' => ['max_characters' => -1]],
+ 'is too small',
+ ],
+ 'max character count at 0' => [
+ ['banned_scripts' => ['max_characters' => 0]],
+ ],
+ ];
+ }
+
+ public function testBannedScriptConfigIsExpandedAndNormalized(): void
+ {
+ $processor = new Processor();
+ $resultString = $processor->processConfiguration(new Configuration(), [
+ 'antispam' => ['profiles' => ['default' => [
+ 'banned_scripts' => Script::Armenian->value,
+ ]]],
+ ]);
+ $resultArray = $processor->processConfiguration(new Configuration(), [
+ 'antispam' => ['profiles' => ['default' => [
+ 'banned_scripts' => [Script::Armenian->value],
+ ]]],
+ ]);
+ $resultObject = $processor->processConfiguration(new Configuration(), [
+ 'antispam' => ['profiles' => ['default' => [
+ 'banned_scripts' => [
+ 'scripts' => [Script::Armenian->value],
+ ],
+ ]]],
+ ]);
+ $this->assertSame($resultString, $resultArray);
+ $this->assertSame($resultString, $resultObject);
+ $this->assertContains(Script::Armenian->value, $resultObject['profiles']['default']['banned_scripts']['scripts']);
+ $this->assertSame(0, $resultObject['profiles']['default']['banned_scripts']['max_percentage']);
+ $this->assertNull($resultObject['profiles']['default']['banned_scripts']['max_characters']);
+ }
+
+ public function testBannedScriptNameMustBeValid(): void
+ {
+ $this->expectException(InvalidConfigurationException::class);
+ $this->expectExceptionMessage('valid ISO-15924 script name');
+
+ (new Processor())->processConfiguration(new Configuration(), [
+ 'antispam' => ['profiles' => ['default' => ['banned_scripts' => 'monkeys']]],
+ ]);
+ }
+}
diff --git a/tests/Unit/Fixtures/config-1-input.yaml b/tests/Unit/Fixtures/config-1-input.yaml
new file mode 100644
index 0000000..9b1d69f
--- /dev/null
+++ b/tests/Unit/Fixtures/config-1-input.yaml
@@ -0,0 +1,17 @@
+antispam:
+ profiles:
+ default:
+ banned_markup: true
+ banned_phrases: ['viagra', 'cialis']
+ banned_scripts: gREEK
+
+ max_urls: 3
+
+ honeypot: email_address
+
+ timer: ~
+
+ bare:
+ timer:
+ min: 2
+
diff --git a/tests/Unit/Fixtures/config-1-output.yaml b/tests/Unit/Fixtures/config-1-output.yaml
new file mode 100644
index 0000000..313275d
--- /dev/null
+++ b/tests/Unit/Fixtures/config-1-output.yaml
@@ -0,0 +1,37 @@
+passive: false
+stealth: false
+
+profiles:
+ default:
+ banned_markup:
+ bbcode: true
+ html: true
+
+ banned_phrases: ['viagra', 'cialis']
+
+ banned_scripts:
+ scripts:
+ - greek
+ max_characters: ~
+ max_percentage: 0
+
+ honeypot:
+ field: email_address
+
+ timer:
+ min: 3
+ max: 3600
+ field: __antispam_time
+
+ max_urls: 3
+ passive: false
+ stealth: true
+
+ bare:
+ timer:
+ min: 2
+ max: 3600
+ field: __antispam_time
+
+ stealth: true
+ passive: false
diff --git a/tests/Unit/Form/FormTypesTest.php b/tests/Unit/Form/FormTypesTest.php
new file mode 100644
index 0000000..625820a
--- /dev/null
+++ b/tests/Unit/Form/FormTypesTest.php
@@ -0,0 +1,52 @@
+get(FormFactoryInterface::class);
+ $this->assertInstanceOf(FormFactoryInterface::class, $factory);
+
+ $form = $factory->create(KitchenSinkForm::class);
+ $this->assertTrue($form->has('honeypot'));
+ $this->assertTrue($form->has('timer'));
+
+ $view = $form->createView();
+ self::mockTime('+10 seconds');
+
+ $request = Request::create('/', method: 'POST', parameters: [
+ 'kitchen_sink_form' => [
+ 'name' => 'John Doe',
+ 'email' => 'foo@example.org',
+ 'message' => 'Message for testing',
+ 'timer' => $view['timer']->vars['value'],
+ ],
+ ]);
+ $form->handleRequest($request);
+ $this->assertTrue($form->isValid() && $form->isSubmitted());
+
+ $this->assertIsArray($data = $form->getData());
+ $this->assertArrayNotHasKey('honeypot', $data);
+ $this->assertArrayNotHasKey('timer', $data);
+ }
+}
diff --git a/tests/Unit/Form/SubmitTimerTypeTest.php b/tests/Unit/Form/SubmitTimerTypeTest.php
new file mode 100644
index 0000000..66573ae
--- /dev/null
+++ b/tests/Unit/Form/SubmitTimerTypeTest.php
@@ -0,0 +1,53 @@
+ $options
+ */
+ #[DataProvider('provideSubmitTimerConfigurations')]
+ public function testSubmitTimerConfiguration(array $options, string $expectedError = null): void
+ {
+ $factory = static::getContainer()->get(FormFactoryInterface::class);
+ $this->assertInstanceOf(FormFactoryInterface::class, $factory);
+
+ if (null !== $expectedError) {
+ $this->expectException(InvalidOptionsException::class);
+ $this->expectExceptionMessage($expectedError);
+ }
+ $this->assertInstanceOf(FormInterface::class, $factory->create(SubmitTimerType::class, options: $options));
+ }
+
+ /**
+ * @return array{0: array, 1?: string}[]
+ */
+ public static function provideSubmitTimerConfigurations(): array
+ {
+ return [
+ 'min not an int' => [['min' => 'foo'], 'expected to be of type "int"'],
+ 'max not an int' => [['max' => 'bar'], 'expected to be of type "int"'],
+ 'secret not a string' => [['secret' => 684], 'expected to be of type "string"'],
+ ];
+ }
+}
diff --git a/tests/Unit/Validator/BannedMarkupTest.php b/tests/Unit/Validator/BannedMarkupTest.php
new file mode 100644
index 0000000..bffa489
--- /dev/null
+++ b/tests/Unit/Validator/BannedMarkupTest.php
@@ -0,0 +1,98 @@
+
+ */
+#[CoversClass(BannedMarkup::class)]
+#[CoversClass(BannedMarkupValidator::class)]
+class BannedMarkupTest extends ConstraintValidatorTestCase
+{
+ protected function getValidatorClass(): string
+ {
+ return BannedMarkupValidator::class;
+ }
+
+ public function testValidatorMismatchThrows(): void
+ {
+ $this->expectException(UnexpectedTypeException::class);
+ $this->constraintValidator->validate(684, new Length(min: 3));
+ }
+
+ public function testOnlyStringablesAndNullAreAccepted(): void
+ {
+ $constraint = new BannedMarkup();
+
+ $this->validate('aap', $constraint);
+ $this->assertNoViolation();
+
+ $this->validate(null, $constraint);
+ $this->assertNoViolation();
+
+ $this->validate(684, $constraint);
+ $this->assertNoViolation();
+
+ $this->validate(new class() implements \Stringable {
+ public function __toString(): string
+ {
+ return 'example-text';
+ }
+ }, $constraint);
+ $this->assertNoViolation();
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->constraintValidator->validate($this, $constraint);
+ }
+
+ #[DataProvider('provideBannedMarkupMessages')]
+ public function testBannedMarkupValidation(string $message, string $expectedError = null): void
+ {
+ static $constraint = new BannedMarkup();
+
+ $errors = $this->validate($message, $constraint);
+ if (null === $expectedError) {
+ $this->assertNoViolation();
+ } else {
+ $this->assertNotEmpty($errors, sprintf('Message "%s" was expected to contain %s', $message, $expectedError));
+ $this->assertStringContainsString($expectedError, (string) $errors->get(0)->getMessage());
+ }
+ }
+
+ /**
+ * @return array{0: string, 1?: string}[]
+ */
+ public static function provideBannedMarkupMessages(): array
+ {
+ return [
+ ['Please click our link to buy products.', 'html'],
+ ['Please click our link to buy products.', 'html'],
+ ['Please click our link to buy products.', 'html'],
+ ['Please click our link to buy products.', 'html'],
+ ['Please click our link to buy products.', 'html'],
+ ['ANCIENT HTML USED ALL CAPS', 'html'],
+ ['ANCIENT HTML USED ALL CAPS (but not consistently)', 'html'],
+ ['buggy html should not fire'],
+ ['Nor should a broken link element that will not work anyway'],
+ ['Or a text without any markup whatsoever'],
+ ];
+ }
+}
diff --git a/tests/Unit/Validator/BannedPhrasesTest.php b/tests/Unit/Validator/BannedPhrasesTest.php
new file mode 100644
index 0000000..e9295f2
--- /dev/null
+++ b/tests/Unit/Validator/BannedPhrasesTest.php
@@ -0,0 +1,87 @@
+
+ */
+#[CoversClass(BannedPhrases::class)]
+#[CoversClass(BannedPhrasesValidator::class)]
+class BannedPhrasesTest extends ConstraintValidatorTestCase
+{
+ protected function getValidatorClass(): string
+ {
+ return BannedPhrasesValidator::class;
+ }
+
+ public function testValidatorMismatchThrows(): void
+ {
+ $this->expectException(UnexpectedTypeException::class);
+ $this->constraintValidator->validate(684, new Length(min: 3));
+ }
+
+ public function testOnlyStringablesAndNullAreAccepted(): void
+ {
+ $constraint = new BannedPhrases(['foo', 'bar']);
+
+ $this->validate('aap', $constraint);
+ $this->assertNoViolation();
+
+ $this->validate(null, $constraint);
+ $this->assertNoViolation();
+
+ $this->validate(684, $constraint);
+ $this->assertNoViolation();
+
+ $this->validate(new class() implements \Stringable {
+ public function __toString(): string
+ {
+ return 'example-baz';
+ }
+ }, $constraint);
+ $this->assertNoViolation();
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->constraintValidator->validate($this, $constraint);
+ }
+
+ #[DataProvider('provideViolatingPhrases')]
+ public function testBannedPhraseDetection(string $text, BannedPhrases $constraint): void
+ {
+ $this->expectViolations($text, $constraint);
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideViolatingPhrases(): array
+ {
+ return [
+ ['The foo and bar are strong.', new BannedPhrases(['foo', 'bar'])],
+ ['The foo and bar are strong.', new BannedPhrases(['foo', 'baz'])],
+ ['The foo and bar are strong.', new BannedPhrases(['fool', 'bar'])],
+ ['The foo and bar are strong.', new BannedPhrases('foo')],
+ ['The foo and bar are strong.', new BannedPhrases('foo and bar')],
+ ['The #foo#and#bar#are#strong', new BannedPhrases('#and#')],
+ ['#does it (look)?.*like a regexp?#i', new BannedPhrases('(look)?.*')],
+ ];
+ }
+}
diff --git a/tests/Unit/Validator/BannedScriptTest.php b/tests/Unit/Validator/BannedScriptTest.php
new file mode 100644
index 0000000..e66ed92
--- /dev/null
+++ b/tests/Unit/Validator/BannedScriptTest.php
@@ -0,0 +1,168 @@
+
+ */
+#[CoversClass(BannedScripts::class)]
+#[CoversClass(BannedScriptsValidator::class)]
+class BannedScriptTest extends ConstraintValidatorTestCase
+{
+ private const SAMPLE_LATIN = 'An example sentence using only Latin characters';
+ private const SAMPLE_ARABIC = 'مثال على النص باستخدام الأحرف العربية فقط';
+ private const SAMPLE_CYRILLIC = 'Пример текста с использованием только русских символов';
+ private const SAMPLE_GREEK = 'Ένα παράδειγμα κειμένου που χρησιμοποιεί μόνο ελληνικούς χαρακτήρες';
+ private const SAMPLE_GURMUKHI = 'ਸਿਰਫ਼ ਪੰਜਾਬੀ ਅੱਖਰਾਂ ਦੀ ਵਰਤੋਂ ਕਰਦੇ ਹੋਏ ਇੱਕ ਉਦਾਹਰਨ ਟੈਕਸਟ';
+ private const SAMPLE_HEBREW = 'טקסט לדוגמה המשתמש בתווים בערבית בלבד';
+
+ private const LONG_LATIN = 'This is a long example using only Latin characters intended to be notably longer than all other samples for percentage testing';
+
+ protected function getValidatorClass(): string
+ {
+ return BannedScriptsValidator::class;
+ }
+
+ public function testValidatorMismatchThrows(): void
+ {
+ $this->expectException(UnexpectedTypeException::class);
+ $this->constraintValidator->validate(684, new Length(min: 3));
+ }
+
+ public function testOnlyStringablesAndNullAreAccepted(): void
+ {
+ $constraint = new BannedScripts(Script::Bengali);
+
+ $this->validate('aap', $constraint);
+ $this->assertNoViolation();
+
+ $this->validate(null, $constraint);
+ $this->assertNoViolation();
+
+ $this->validate(684, $constraint);
+ $this->assertNoViolation();
+
+ $this->validate(new class() implements \Stringable {
+ public function __toString(): string
+ {
+ return 'foo';
+ }
+ }, $constraint);
+ $this->assertNoViolation();
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->constraintValidator->validate($this, $constraint);
+ }
+
+ public function testCalculatingPercentages(): void
+ {
+ $constraint = new BannedScripts(Script::Hebrew, maxPercentage: 19);
+ $text = self::LONG_LATIN . self::SAMPLE_HEBREW;
+ $parameters = $this->expectViolations($text, $constraint)->get(0)->getParameters();
+ $this->assertSame('20', $parameters['percentage']);
+
+ $constraint->maxPercentage = 20;
+ $this->validate($text, $constraint);
+ $this->assertNoViolation();
+
+ $constraint->maxPercentage = 38;
+ $text = str_repeat(self::SAMPLE_LATIN . self::SAMPLE_HEBREW, 1000);
+ $parameters = $this->expectViolations($text, $constraint)->get(0)->getParameters();
+ $this->assertSame('39', $parameters['percentage']);
+
+ $constraint->maxPercentage = 39;
+ $this->validate($text, $constraint);
+ $this->assertNoViolation();
+ }
+
+ public function testCharacterThresholdsAreInclusive(): void
+ {
+ $constraint = new BannedScripts(Script::Hebrew, maxCharacters: 31);
+ $text = self::LONG_LATIN . self::SAMPLE_HEBREW;
+ $parameters = $this->expectViolations($text, $constraint)->get(0)->getParameters();
+ $this->assertSame('32', $parameters['count']);
+
+ $constraint->maxCharacters = 32;
+ $this->validate($text, $constraint);
+ $this->assertNoViolation();
+ }
+
+ #[DataProvider('provideBannedScripts')]
+ public function testBannedScriptValidation(BannedScripts $constraint, string $message, string $expectedCode = null): void
+ {
+ $errors = $this->validate($message, $constraint);
+ if (null === $expectedCode) {
+ $this->assertNoViolation();
+ } else {
+ $this->assertCount(1, $errors, 'Expected one single violation');
+ $this->assertEquals($errors->get(0)->getCode(), $expectedCode);
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideBannedScripts(): array
+ {
+ return [
+ 'full Latin text' => [
+ new BannedScripts(Script::Latin), self::SAMPLE_LATIN, BannedScripts::NOT_ALLOWED_ERROR,
+ ],
+ 'full Arabic text' => [
+ new BannedScripts(Script::Arabic), self::SAMPLE_ARABIC, BannedScripts::NOT_ALLOWED_ERROR,
+ ],
+ 'full Cyrillic text' => [
+ new BannedScripts(Script::Cyrillic), self::SAMPLE_CYRILLIC, BannedScripts::NOT_ALLOWED_ERROR,
+ ],
+ 'full Greek text' => [
+ new BannedScripts(Script::Greek), self::SAMPLE_GREEK, BannedScripts::NOT_ALLOWED_ERROR,
+ ],
+ 'full Gurmukhi text' => [
+ new BannedScripts(Script::Gurmukhi), self::SAMPLE_GURMUKHI, BannedScripts::NOT_ALLOWED_ERROR,
+ ],
+ 'full Hebrew text' => [
+ new BannedScripts(Script::Hebrew), self::SAMPLE_HEBREW, BannedScripts::NOT_ALLOWED_ERROR,
+ ],
+ 'partial Cyrillic text' => [
+ new BannedScripts(Script::Cyrillic), self::SAMPLE_LATIN . self::SAMPLE_CYRILLIC, BannedScripts::NOT_ALLOWED_ERROR,
+ ],
+ 'sufficiently high percentage' => [
+ new BannedScripts(Script::Hebrew, maxPercentage: 50),
+ self::LONG_LATIN . self::SAMPLE_HEBREW,
+ ],
+ 'sufficiently high character count' => [
+ new BannedScripts(Script::Hebrew, maxCharacters: 100),
+ self::LONG_LATIN . self::SAMPLE_HEBREW,
+ ],
+ 'low percentage' => [
+ new BannedScripts(Script::Hebrew, maxPercentage: 25),
+ self::SAMPLE_HEBREW . self::SAMPLE_CYRILLIC,
+ BannedScripts::TOO_HIGH_PERCENTAGE_ERROR,
+ ],
+ 'low max character count' => [
+ new BannedScripts(Script::Cyrillic, maxCharacters: 5),
+ self::SAMPLE_HEBREW . self::SAMPLE_CYRILLIC,
+ BannedScripts::TOO_MANY_CHARACTERS_ERROR,
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/Validator/ConstraintValidatorTestCase.php b/tests/Unit/Validator/ConstraintValidatorTestCase.php
new file mode 100644
index 0000000..d419fa0
--- /dev/null
+++ b/tests/Unit/Validator/ConstraintValidatorTestCase.php
@@ -0,0 +1,83 @@
+get(TranslatorInterface::class);
+ $validator = $container->get(ValidatorInterface::class);
+ $constraintValidator = $container->get($this->getValidatorClass());
+ assert($translator instanceof TranslatorInterface);
+ assert($validator instanceof ValidatorInterface);
+ assert($constraintValidator instanceof ConstraintValidatorInterface);
+
+ $this->validator = $validator;
+ $this->context = new ExecutionContext($validator, null, $translator);
+ $this->constraintValidator = $constraintValidator;
+ $this->constraintValidator->initialize($this->context);
+ }
+
+ protected function assertNoViolation(): void
+ {
+ if (!isset($this->lastViolations)) {
+ $this->fail('No validation has been run yet!');
+ }
+ $this->assertEmpty($this->lastViolations, 'Failed asserting that validation causes no violations.');
+ }
+
+ protected function expectNoViolations(string $value, Constraint $constraint): void
+ {
+ $errors = $this->validate($value, $constraint);
+ $this->assertCount(0, $errors, sprintf('Failed asserting that validating "%s" would cause no violations.', $value));
+ }
+
+ protected function expectViolations(string $value, Constraint $constraint, int $amount = 1): ConstraintViolationListInterface
+ {
+ $errors = $this->validate($value, $constraint);
+ $this->assertCount($amount, $errors, sprintf('Failed asserting that validating "%s" would cause %d violation(s)', $value, $amount));
+
+ return $errors;
+ }
+
+ /**
+ * @return class-string
+ */
+ abstract protected function getValidatorClass(): string;
+
+ /** @phpstan-ignore-next-line Forwarded function into Symfony validator */
+ public function validate(mixed $value, Constraint|array $constraints = null, string|GroupSequence|array $groups = null): ConstraintViolationListInterface
+ {
+ return $this->lastViolations = $this->validator->validate($value, $constraints, $groups);
+ }
+}
diff --git a/tests/Unit/Validator/UrlCountTest.php b/tests/Unit/Validator/UrlCountTest.php
new file mode 100644
index 0000000..07873f3
--- /dev/null
+++ b/tests/Unit/Validator/UrlCountTest.php
@@ -0,0 +1,98 @@
+
+ */
+#[CoversClass(UrlCount::class)]
+#[CoversClass(UrlCountValidator::class)]
+class UrlCountTest extends ConstraintValidatorTestCase
+{
+ protected function getValidatorClass(): string
+ {
+ return UrlCountValidator::class;
+ }
+
+ public function testValidatorMismatchThrows(): void
+ {
+ $this->expectException(UnexpectedTypeException::class);
+ $this->constraintValidator->validate(684, new Length(min: 3));
+ }
+
+ public function testOnlyStringablesAndNullAreAccepted(): void
+ {
+ $constraint = new UrlCount();
+
+ $this->validate('aap', $constraint);
+ $this->assertNoViolation();
+
+ $this->validate(null, $constraint);
+ $this->assertNoViolation();
+
+ $this->validate(684, $constraint);
+ $this->assertNoViolation();
+
+ $this->validate(new class() implements \Stringable {
+ public function __toString(): string
+ {
+ return 'foo';
+ }
+ }, $constraint);
+ $this->assertNoViolation();
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->constraintValidator->validate($this, $constraint);
+ }
+
+ #[DataProvider('provideUrlCounts')]
+ public function testUrlCountValidation(UrlCount $constraint, string $value, int $urlCount, int $expectedViolations = 0): void
+ {
+ $errors = $this->expectViolations($value, $constraint, $expectedViolations ? 1 : 0);
+ if ($expectedViolations) {
+ $this->assertEquals($expectedViolations, $errors->get(0)->getParameters()['count']);
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideUrlCounts(): array
+ {
+ return [
+ 'no URLs, default allowed' => [
+ new UrlCount(), 'Test without URL', 0,
+ ],
+ 'no URLs, 5 allowed' => [
+ new UrlCount(5), 'Test without URL', 0,
+ ],
+ '1 URL, default allowed' => [
+ new UrlCount(), 'Test with URL http://example.org in text', 1, 1,
+ ],
+ '1 URL, 5 allowed' => [
+ new UrlCount(5), 'Test with URL http://example.org in text', 1, 0,
+ ],
+ '2 URL, 1 allowed' => [
+ new UrlCount(1), 'Test with http://foo.org/bar and https://bar.org/foo in text', 1, 2,
+ ],
+ ];
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..45d495c
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,17 @@
+loadEnv(__DIR__ . '/Fixture/.env');
diff --git a/translations/antispam+intl-icu.en.yaml b/translations/antispam+intl-icu.en.yaml
new file mode 100644
index 0000000..da9845f
--- /dev/null
+++ b/translations/antispam+intl-icu.en.yaml
@@ -0,0 +1,27 @@
+form:
+ stealthed: 'The submitted value could not be processed. Please contact us if this problem persists.'
+
+ honeypot:
+ not_empty: 'The honeypot field was supposed to be empty but is not.'
+ timer:
+ corrupted: 'Technical reasons prevented processing the form.'
+ mismatch_ip: 'Your IP address changed while submitting the form. Please try again.'
+ too_fast: 'The form was submitted unreasonably fast. Please wait a short bit before trying again.'
+ too_slow: 'The form was submitted unreasonably slow. Please reload the page and try again.'
+
+validator:
+ banned_phrases:
+ phrase_found: 'The value contains the disallowed phrase "{phrase}".'
+
+ banned_script:
+ not_allowed: 'The value contains characters of disallowed scripts ({scripts}).'
+ percentage_exceeded: 'The value consists for {percentage}% of characters of disallowed scripts ({scripts}) while only {max}% is allowed.'
+ characters_exceeded: 'The value contains {count} characters from disallowed scripts ({scripts}) while only {max} are allowed.'
+
+ url_count:
+ exceeded: >-
+ {count, plural,
+ =1 {The value contains a URL. This is not allowed.}
+ other {The value contains # URLs. It should have at most {limit}.}
+ }
+
diff --git a/translations/antispam+intl-icu.nl.yaml b/translations/antispam+intl-icu.nl.yaml
new file mode 100644
index 0000000..29e2bfe
--- /dev/null
+++ b/translations/antispam+intl-icu.nl.yaml
@@ -0,0 +1,6 @@
+form:
+ honeypot:
+ not_empty: 'De honingpot had leeg moeten zijn, maar is gevuld.'
+ timer:
+ corrupted: 'Om technische redenen kon het formulier niet verwerkt worden.'
+ mismatch_ip: 'Het IP-adres is gewijzigd tijdens het indienen van het formulier. Probeer nogmaals.'