├── .editorconfig ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Docker.README.md ├── Dockerfile ├── LICENSE ├── README.md ├── bin └── service.php ├── composer.json ├── docs ├── CHANGELOG.md ├── TODO.md └── Testing.md ├── public ├── index.html ├── vue2-example.html └── vue3-example.html └── src ├── ActionInterface.php ├── ActionResolver.php ├── Actions ├── DiscardCode.php ├── Events.php ├── GetCurrentCaptchas.php ├── LocalResource.php ├── ObtainDecoded.php ├── SendImage.php └── SetCodeAnswer.php ├── Application.php ├── Captcha.php ├── Captchas.php ├── Exceptions ├── ExecuteException.php └── ResolverException.php ├── SubscriptorInterface.php └── Subscriptors.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | 9 | [**.yml] 10 | indent_size = 2 11 | 12 | [**.{php,md,xml,yml,json,dist}] 13 | charset = utf-8 14 | trim_trailing_whitespace = true 15 | insert_final_newline = true 16 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [eclipxe13@gmail.com](mailto:eclipxe@gmail.com). 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 126 | at [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome. We accept pull requests on [GitHub](https://github.com/eclipxe13/captcha-local-resolver). 4 | 5 | This project adheres to a 6 | [Contributor Code of Conduct](https://github.com/eclipxe13/captcha-local-resolver/blob/main/CODE_OF_CONDUCT.md). 7 | By participating in this project and its community, you are expected to uphold this code. 8 | 9 | ## Team members 10 | 11 | * [Carlos C Soto](https://github.com/eclipxe13) - original author and maintainer 12 | * [GitHub constributors](https://github.com/eclipxe13/captcha-local-resolver/graphs/contributors) 13 | 14 | ## Communication Channels 15 | 16 | You can find help and discussion in the following places: 17 | 18 | * GitHub Issues: 19 | 20 | ## Reporting Bugs 21 | 22 | Bugs are tracked in our project's [issue tracker](https://github.com/eclipxe13/captcha-local-resolver/issues). 23 | 24 | When submitting a bug report, please include enough information for us to reproduce the bug. 25 | A good bug report includes the following sections: 26 | 27 | * Expected outcome 28 | * Actual outcome 29 | * Steps to reproduce, including sample code 30 | * Any other information that will help us debug and reproduce the issue, including stack traces, system/environment information, and screenshots 31 | 32 | **Please do not include passwords or any personally identifiable information in your bug report and sample code.** 33 | 34 | ## Fixing Bugs 35 | 36 | We welcome pull requests to fix bugs! 37 | 38 | If you see a bug report that you'd like to fix, please feel free to do so. 39 | Following the directions and guidelines described in the "Adding New Features" 40 | section below, you may create bugfix branches and send us pull requests. 41 | 42 | ## Adding New Features 43 | 44 | If you have an idea for a new feature, it's a good idea to check out our 45 | [issues](https://github.com/eclipxe13/captcha-local-resolver/issues) or active 46 | [pull requests](https://github.com/eclipxe13/captcha-local-resolver/pulls) 47 | first to see if we are being working on the feature. 48 | If not, feel free to submit an issue first, asking whether the feature is beneficial to the project. 49 | This will save you from doing a lot of development work only to have your feature rejected. 50 | We don't enjoy rejecting your hard work, but some features just don't fit with the goals of the project. 51 | 52 | When you do begin working on your feature, here are some guidelines to consider: 53 | 54 | * Your pull request description should clearly detail the changes you have made. 55 | * Follow our code style using `squizlabs/php_codesniffer` and `friendsofphp/php-cs-fixer`. 56 | * Please **write tests** for any new features you add. 57 | * Please **ensure that tests pass** before submitting your pull request. Running the tests locally will help save time. 58 | * We have GitHub Actions automatically running tests for pull requests. 59 | * **Use topic/feature branches.** Please do not ask us to pull from your main branch. 60 | * **Submit one feature per pull request.** If you have multiple features you wish to submit, please break them up into separate pull requests. 61 | * **Send coherent history**. Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please squash them before submitting. 62 | 63 | ## Installing development tools 64 | 65 | This project uses different development tools to ensure code style, test and quality (using code analyzers). 66 | 67 | ```shell 68 | # install project direct dependences 69 | composer install 70 | 71 | # install development tools 72 | phive update 73 | ``` 74 | 75 | ## Check the code style 76 | 77 | If you are having issues with coding standars use `php-cs-fixer` and `phpcbf` 78 | 79 | ```shell 80 | # using composer 81 | composer dev:fix-style 82 | 83 | # or using tools individually 84 | tools/php-cs-fixer fix -v 85 | tools/phpcbf -sp 86 | ``` 87 | 88 | ## Running Tests 89 | 90 | The following tests must pass before we will accept a pull request. 91 | If any of these do not pass, it will result in a complete build failure. 92 | Before you can run these, be sure to `composer install` or `composer update`. 93 | 94 | ```shell 95 | # using composer 96 | composer dev:build 97 | 98 | # or using tools individually 99 | tools/phpcs -sp 100 | tools/php-cs-fixer fix -v --dry-run 101 | vendor/bin/phpunit --testdox 102 | tools/phpstan analyze 103 | ``` 104 | 105 | ## Running GitHub Actions locally 106 | 107 | You can use [`act`](https://github.com/nektos/act) to run your GitHub Actions locally. 108 | As documented in [`actions/setup-php-action`](https://github.com/marketplace/actions/setup-php-action#local-testing-setup) 109 | you will need to execute the command as: 110 | 111 | ```shell 112 | act -P ubuntu-latest=shivammathur/node:latest 113 | ``` 114 | -------------------------------------------------------------------------------- /Docker.README.md: -------------------------------------------------------------------------------- 1 | # phpcfdi/captcha-local-resolver dockerfile helper 2 | 3 | ```shell script 4 | # get the project repository on folder "captcha-local-resolver" 5 | git clone https://github.com/phpcfdi/captcha-local-resolver.git captcha-local-resolver 6 | 7 | # build the image "captcha-local-resolver" from folder "captcha-local-resolver/" 8 | docker build --tag captcha-local-resolver captcha-local-resolver/ 9 | 10 | # remove image captcha-local-resolver 11 | docker rmi captcha-local-resolver 12 | ``` 13 | 14 | ## Run command 15 | 16 | The project installed on `/opt/captcha-local-resolver/` and the entry point is the command 17 | `/opt/captcha-local-resolver/bin/service.php`. 18 | 19 | ```shell 20 | # show help 21 | docker run -it --rm --user="$(id -u):$(id -g)" \ 22 | captcha-local-resolver --help 23 | 24 | # run service on ip 127.0.0.1 port 8086 in the foreground 25 | docker run -it --rm --user="$(id -u):$(id -g)" --network host \ 26 | captcha-local-resolver 127.0.0.1:8086 27 | 28 | # run service on ip 127.0.0.1 port 8086 in the background 29 | docker run --rm --user="$(id -u):$(id -g)" --detach --network host --name captcha-local-resolver \ 30 | captcha-local-resolver 127.0.0.1:8086 31 | ``` 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.3-cli-alpine 2 | 3 | COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer 4 | 5 | COPY . /opt/captcha-local-resolver 6 | 7 | RUN set -e \ 8 | && composer update --working-dir=/opt/captcha-local-resolver --no-dev --prefer-dist --optimize-autoloader --no-interaction \ 9 | && rm -rf "$(composer config cache-dir --global)" "$(composer config data-dir --global)" "$(composer config home --global)" 10 | 11 | ENTRYPOINT ["php", "/opt/captcha-local-resolver/bin/service.php"] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 - 2024 Carlos C Soto https://eclipxe.com.mx/ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eclipxe/captcha-local-resolver 2 | 3 | [![Source Code][badge-source]][source] 4 | [![Software License][badge-license]][license] 5 | [![Build Status][badge-build]][build] 6 | [![Scrutinizer][badge-quality]][quality] 7 | [![Coverage Status][badge-coverage]][coverage] 8 | 9 | This is a project to create a human captcha resolver that emulates how online resolvers work. 10 | 11 | It is based on [ReactPHP](https://reactphp.org/) and exists to emulate an online captcha resolver service 12 | where you (human) are the one that provide the captcha solution using your amazing brains. 13 | 14 | ## Install the server 15 | 16 | Using git: 17 | 18 | ```shell 19 | git clone https://github.com/eclipxe13/captcha-local-resolver 20 | cd captcha-local-resolver 21 | composer install --no-dev 22 | ``` 23 | 24 | Using zip: 25 | 26 | ```shell 27 | curl -L https://github.com/eclipxe13/captcha-local-resolver/archive/refs/heads/main.zip 28 | unzip main.zip 29 | cd captcha-local-resolver-main 30 | composer install --no-dev 31 | ``` 32 | 33 | ## Run the server 34 | 35 | ```shell 36 | # php bin/service.php [[ip-address]:[port-number]] 37 | php bin/service.php :9595 38 | Server running at http://127.0.0.1:9595 39 | ``` 40 | 41 | Parameters are `ip-address` (default `127.0.0.1`) and `port` (default `80`) to listen. 42 | 43 | ## Run using Docker 44 | 45 | We distribute a `Dockerfile` file to create a local image and run the service without the need to install PHP 46 | or any of the dependencies. See [Docker README](Docker.README.md) for more information. 47 | 48 | ```shell 49 | # get the project source 50 | git clone https://github.com/eclipxe13/captcha-local-resolver 51 | 52 | # build the image "captcha-local-resolver" from folder "captcha-local-resolver/" 53 | docker build --tag captcha-local-resolver captcha-local-resolver/ 54 | 55 | # run service on ip 127.0.0.1 port 9595 in the background in a container named captcha-local-resolver 56 | docker run --user="$(id -u):$(id -g)" --detach --network host --name captcha-local-resolver \ 57 | captcha-local-resolver 127.0.0.1:9595 58 | 59 | # stop or start and container 60 | docker stop captcha-local-resolver 61 | docker start captcha-local-resolver 62 | ``` 63 | 64 | ## How it works (eagle view) 65 | 66 | You have to scrap, and the target website does not have an API, even worst, they are using a captcha, and you need 67 | to solve it to make your program work (*hello Mexico Goverment, how's your day?*). 68 | 69 | You need to resolve the captcha, and there are a lot of services that solve this problem, like *Decaptcher*, 70 | *AntiCaptcha*, etc... 71 | that services that have a cost (very cheap)... 72 | for any reason, you don't want to use that. 73 | Maybe you don't want to provide your credit card information, maybe you are running functional tests 74 | and cannot access the real captcha resolver account. 75 | 76 | Then, you can use this project. 77 | 78 | - Your scraper will find that require to solve a captcha and get the image. 79 | - Send the image to your instance of captcha-local-resolver 80 | - The captcha will appear on your browser, you solve it using your brain's amazing OCR 81 | - Check that the captcha has been resolved 82 | - Set the answer into the correct field and send the form. 83 | 84 | ## How it technically works 85 | 86 | You run `captcha-local-resolver`, and it will open a web server with specific actions (routes): 87 | 88 | ### Route `send-image` 89 | 90 | The route `POST /send-image` receives an image and create a code to track it. 91 | 92 | - Parameter: `(string) image` PNG image base64 encoded. 93 | - Response: `application/json {code: '...example'}` JSON with `code`. 94 | - Event: `{eventName: 'append', code: '...', image: '...'}`. 95 | 96 | ### Route `obtain-decoded` 97 | 98 | The route `POST /obtain-decoded` receives a code and return its current answer. 99 | If it already has an answer then the captcha is droped from the server. 100 | 101 | - Parameter: `(string) code` the code given when you post the captcha on `send-image`. 102 | - Response: `application/json {answer: '...example'}` JSON with `answer`. 103 | - Event: `{eventName: 'remove', code: '...'}`. 104 | 105 | If there is no answer yet then the answer will be an empty string. 106 | 107 | If the code does not exist you will receive a `404` HTTP Status Code. 108 | 109 | ### Route `/` 110 | 111 | The route `GET /` will return an HTML 5 will current captchas and *subscribes* to `/events`. 112 | This page gives you the inputs to solve the captcha and send the answer to `/set-code-answer`. 113 | 114 | - Output: `text/html`. 115 | 116 | ### Route `set-code-answer` 117 | 118 | The route `POST set-code-answer` receives a code and answer. 119 | 120 | - Output: `text/plain (empty)`. 121 | - Parameter: `(string) code` the code given when you post the captcha on `send-image`. 122 | - Parameter: `(string) answer` the captcha's solution. 123 | - Event: `{eventName: 'answer', code: '...', answer: '...'}`. 124 | 125 | ### Route `events` 126 | 127 | The route `GET /events` is used to subscribe to the server events, it is a vent stream that can be consumed 128 | using [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource). 129 | 130 | - Output: `text/event-stream`. 131 | 132 | There are only 3 events: 133 | 134 | - `append` when a new captcha has been inserted. 135 | - `answer` when a captcha solution has been posted. 136 | - `remove` when a captcha solution has been removed. 137 | 138 | This is what you can implement inside your own application to resolve captchas. 139 | 140 | ## Known limitations 141 | 142 | - Work with PNG image captchas. 143 | - Does not have a token, pass phrase, user/password or any other method to block unwanted access. 144 | 145 | ## Security 146 | 147 | This project is not intented to be outside your local network. 148 | Please, fill a new issue if you find any security issues. 149 | 150 | ## Compatibility 151 | 152 | This library is compatible with the *latest* supported PHP version. See . 153 | If you are going to contribute, try to use the full potential of the language. 154 | 155 | ## License 156 | 157 | The eclipxe/captcha-local-resolver project is copyright by [Carlos C Soto](https://eclipxe.com.mx/) and licensed for 158 | use under the MIT License (MIT). Please see [LICENSE] for more information. 159 | 160 | [contributing]: https://github.com/eclipxe13/captcha-local-resolver/blob/main/CONTRIBUTING.md 161 | [changelog]: https://github.com/eclipxe13/captcha-local-resolver/blob/main/docs/CHANGELOG.md 162 | [todo]: https://github.com/eclipxe13/captcha-local-resolver/blob/main/docs/TODO.md 163 | 164 | [source]: https://github.com/eclipxe13/captcha-local-resolver 165 | [license]: https://github.com/eclipxe13/captcha-local-resolver/blob/main/LICENSE 166 | [build]: https://travis-ci.com/eclipxe13/captcha-local-resolver?branch=main 167 | [quality]: https://scrutinizer-ci.com/g/eclipxe13/captcha-local-resolver/ 168 | [coverage]: https://scrutinizer-ci.com/g/eclipxe13/captcha-local-resolver/code-structure/main/code-coverage/src/ 169 | 170 | [badge-source]: https://img.shields.io/badge/source-eclipxe/captcha--local--resolver-blue?style=flat-square 171 | [badge-license]: https://img.shields.io/github/license/eclipxe13/captcha-local-resolver?style=flat-square 172 | [badge-build]: https://img.shields.io/github/actions/workflow/status/eclipxe13/captcha-local-resolver/build.yml?branch=main&style=flat-square 173 | [badge-quality]: https://img.shields.io/scrutinizer/g/eclipxe13/captcha-local-resolver/main?style=flat-square 174 | [badge-coverage]: https://img.shields.io/scrutinizer/coverage/g/eclipxe13/captcha-local-resolver/main?style=flat-square 175 | -------------------------------------------------------------------------------- /bin/service.php: -------------------------------------------------------------------------------- 1 | listen($socket); 43 | echo "Server running at http://$address:$port\n"; 44 | 45 | return 0; 46 | } catch (Throwable $exception) { 47 | file_put_contents('php://stdout', $exception->getMessage() . PHP_EOL, FILE_APPEND); 48 | return 1; 49 | } 50 | }, ...$argv)); 51 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eclipxe/captcha-local-resolver", 3 | "description": "Captcha local resolver (by humans)", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Carlos C Soto", 8 | "email": "eclipxe13@gmail.com" 9 | } 10 | ], 11 | "config": { 12 | "preferred-install": { 13 | "*": "dist" 14 | }, 15 | "optimize-autoloader": true 16 | }, 17 | "autoload": { 18 | "psr-4": { 19 | "CaptchaLocalResolver\\": "src/" 20 | } 21 | }, 22 | "autoload-dev": { 23 | "psr-4": { 24 | "CaptchaLocalResolver\\Tests\\": "tests/" 25 | } 26 | }, 27 | "require": { 28 | "php": ">=7.4", 29 | "ext-json": "*", 30 | "ext-fileinfo": "*", 31 | "psr/http-message": "^1.0|^2.0", 32 | "react/react": "^1.2", 33 | "react/http": "^1.4", 34 | "react/stream": "^1.2" 35 | }, 36 | "require-dev": { 37 | "phpunit/phpunit": "^9.5", 38 | "ringcentral/psr7": "^1.3" 39 | }, 40 | "scripts": { 41 | "dev:build": [ 42 | "@dev:fix-style", 43 | "@dev:tests" 44 | ], 45 | "dev:check-style": [ 46 | "@php tools/php-cs-fixer fix --dry-run --verbose", 47 | "@php tools/phpcs --colors -sp" 48 | ], 49 | "dev:fix-style": [ 50 | "@php tools/php-cs-fixer fix --verbose", 51 | "@php tools/phpcbf --colors -sp" 52 | ], 53 | "dev:tests": [ 54 | "@dev:check-style", 55 | "@php vendor/bin/phpunit --verbose", 56 | "@php tools/phpstan analyze --no-progress --verbose" 57 | ], 58 | "dev:coverage": [ 59 | "@php -dzend_extension=xdebug.so -dxdebug.mode=coverage vendor/bin/phpunit --verbose --coverage-html build/coverage/html/" 60 | ] 61 | }, 62 | "scripts-descriptions": { 63 | "dev:build": "DEV: run dev:fix-style and dev:tests, run before pull request", 64 | "dev:check-style": "DEV: search for code style errors using php-cs-fixer and phpcs", 65 | "dev:fix-style": "DEV: fix code style errors using php-cs-fixer and phpcbf", 66 | "dev:tests": "DEV: run executes phpunit tests", 67 | "dev:coverage": "DEV: run phpunit with xdebug and storage coverage in build/coverage/html/" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## About Semantic Versioning 4 | 5 | The Semantic Versioning rules apply *only* to: 6 | 7 | - The HTTP API. 8 | - The service startup (parameters to start the server). 9 | 10 | In summary, [SemVer](https://semver.org/) can be viewed as `[ Breaking ].[ Feature ].[ Fix ]`, where: 11 | 12 | - `Breaking version` includes incompatible changes to the API. 13 | - `Feature version` adds a new feature(s) in a backwards-compatible manner. 14 | - `Fix version` includes backwards-compatible bug fixes. 15 | 16 | **Version `0.x.x` doesn't have to apply any of the SemVer rules** 17 | 18 | ## Version 0.1.2 2024-06-13 19 | 20 | Fixes and improvements: 21 | 22 | - Fix continuous integration build by adding `ringcentral/psr7` ad development dependency. 23 | - Fix `server:port` argument on command line script. 24 | - Provide Docker instructions. 25 | 26 | Maintenance: 27 | 28 | - Update license year to 2024. 29 | - Remove release badge. 30 | - Update build badge. 31 | - Update code style standards configuration to recent versions. 32 | - Disallow language detection on `public/` directory. 33 | - Update GitHub workflow: 34 | - Allow running jobs manually. 35 | - Add PHP 8.2 and PHP 8.3 to test matrix. 36 | - Run jobs using PHP 8.3. 37 | - Change `::set-output` directive to `$GITHUB_OUTPUT`. 38 | - Update GitHub actions to version 4. 39 | - Update developent tools. 40 | 41 | ## Version 0.1.1 2022-05-25 42 | 43 | Maintenance review: 44 | 45 | - Update license year. 46 | - Fix PHPStan found issues. 47 | - Fix using deprecated React objects. 48 | - Update `.gitattibutes` excluded files. 49 | - Split CI *worflow steps* to *worflow jobs*. 50 | - Add PHP 8.1 to CI. 51 | - Migrate development tools from `develop/install-development-tools` to `phive`. 52 | - Update code style to PSR-12. 53 | - Scrutinizer create its own code coverage. 54 | - Move `development/docs/Testing.md` to `docs/Testing.md` 55 | - Remove `console.log` calls from `index.html` implementation. 56 | 57 | ## Version 0.1.0 2021-07-25 58 | 59 | Usage changes: 60 | 61 | - `bin/service.php` now receives 1 argument as `:`. 62 | - API now allows `application/json` content types in requests. 63 | 64 | Internal changes: 65 | 66 | - Update development environment. 67 | 68 | ## Version 0.0.2 2021-03-04 69 | 70 | - Add new route `/discard-code` and use it in default web application. 71 | - Fix web application double entry when the same image is inserted two times. 72 | - Upgrade to: `php: ^7.4`, `react/react: ^1.1`, `react/http: ^1.2` and `phpunit/phpunit: ^9.5`. 73 | 74 | ## Version 0.0.1 2020-02-06 75 | 76 | - Initial release 77 | -------------------------------------------------------------------------------- /docs/TODO.md: -------------------------------------------------------------------------------- 1 | # eclipxe/captcha-local-resolver To do list 2 | 3 | ## Pending 4 | 5 | ### Update dependencies 6 | 7 | Update PHP minimal version to PHP 8.2. 8 | Update PHP minimal version to PHP 8.3 after 2024-12-08 when PHP 8.2 will be not active. 9 | 10 | ### Logging 11 | 12 | Add logging to application. 13 | 14 | ### Always answer with json 15 | 16 | Exception, error and empty responses could be JSON. 17 | 18 | ### QA: testing 19 | 20 | Make unit tests and functional tests of the hole project, produce 100% code coverage. 21 | 22 | ### Add passphrase/token 23 | 24 | When starting the server, be able to set up a token, pass phrase or something that can ensure that the communication 25 | has a shared secret between the client and server and avoid unwanted access. 26 | 27 | ### Provide PHAR 28 | 29 | It would be very useful to use compile the project into a `PHAR` file. 30 | 31 | ### Provide Docker 32 | 33 | Set up a Docker image in Docker Hub. 34 | 35 | ## Solved 36 | 37 | *nothing yet* 38 | -------------------------------------------------------------------------------- /docs/Testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | ## Unit test 4 | 5 | This tests will check individually the components, tests must check only one class at the time. 6 | 7 | ## Functional tests 8 | 9 | These tests are more behavioral, they are expected to test from input to output but not using a 10 | running server. This can be made executing the `Application` object. 11 | 12 | ## Manual testing commands 13 | 14 | ```shell 15 | # run server 16 | php bin/service.php 127.0.0.1 9794 17 | 18 | # get homepage 19 | curl -v http://127.0.0.1:9794/ 20 | 21 | # get list of current captchas 22 | curl -s http://127.0.0.1:9794/captchas | jq . 23 | 24 | # send images 25 | # # image qwerty 26 | curl -s --data-urlencode "image=$(base64 -w0 tests/_files/captchas/qwerty.png)" http://127.0.0.1:9794/send-image | jq . 27 | { 28 | "code": "460ea98992564122f444fd6290108eda" 29 | } 30 | # # image 123456 31 | curl -s --data-urlencode "image=$(base64 -w0 tests/_files/captchas/123456.png)" http://127.0.0.1:9794/send-image | jq . 32 | { 33 | "code": "ff5f74f4d238eefafcc9dcbc52ee7a09" 34 | } 35 | 36 | # obtain decoded image qwerty (with no answer) 37 | curl -s -d code=460ea98992564122f444fd6290108eda http://127.0.0.1:9794/obtain-decoded | jq . 38 | { 39 | "code": "460ea98992564122f444fd6290108eda" 40 | } 41 | 42 | # set answer for image qwerty 43 | curl -vs -d code=460ea98992564122f444fd6290108eda -d answer=qwerty http://127.0.0.1:9794/set-code-answer | jq . 44 | 45 | # obtain decoded image qwerty (with answer) 46 | curl -s -d code=460ea98992564122f444fd6290108eda http://127.0.0.1:9794/obtain-decoded | jq . 47 | { 48 | "code": "460ea98992564122f444fd6290108eda" 49 | "answer": "qwerty" 50 | } 51 | 52 | # discard image 123456 53 | curl -sv -d "code=ff5f74f4d238eefafcc9dcbc52ee7a09" http://127.0.0.1:9794/discard-code 54 | ``` 55 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Captcha Local Resolver 5 | 24 | 25 | 26 |

Local Captcha Resolver

27 |
28 | 29 | 119 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /public/vue2-example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Captcha Local Resolver 5 | 24 | 25 | 26 | 27 |

Local Captcha Resolver

28 |
29 |
34 | 35 | 36 | 37 | 40 |
41 |
42 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /public/vue3-example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Captcha Local Resolver 5 | 24 | 25 | 26 | 27 |

Local Captcha Resolver

28 |
29 |
34 | 35 | 36 | 37 | 40 |
41 |
42 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /src/ActionInterface.php: -------------------------------------------------------------------------------- 1 | method = $method; 18 | $this->path = ('/' === $path) ? '/index.html' : $path; 19 | $this->webroot = $webroot; 20 | } 21 | 22 | /** 23 | * @param Application $app 24 | * @return ActionInterface 25 | * @throws Exceptions\ResolverException 26 | */ 27 | public function resolve(Application $app): ActionInterface 28 | { 29 | if ($this->checkPathMethod('GET', '/captchas')) { 30 | return new Actions\GetCurrentCaptchas($app->getRepository()); 31 | } 32 | if ($this->checkPathMethod('GET', '/events')) { 33 | return new Actions\Events($app->getRepository()); 34 | } 35 | if ($this->checkPathMethod('POST', '/set-code-answer')) { 36 | return new Actions\SetCodeAnswer($app->getRepository()); 37 | } 38 | if ($this->checkPathMethod('POST', '/discard-code')) { 39 | return new Actions\DiscardCode($app->getRepository()); 40 | } 41 | if ($this->checkPathMethod('POST', '/send-image')) { 42 | return new Actions\SendImage($app->getRepository()); 43 | } 44 | if ($this->checkPathMethod('POST', '/obtain-decoded')) { 45 | return new Actions\ObtainDecoded($app->getRepository()); 46 | } 47 | if ($this->checkPathMethod('GET', '/index.html') || 'GET' === $this->method) { 48 | return new Actions\LocalResource($this->webroot, $this->path); 49 | } 50 | 51 | throw Exceptions\ResolverException::unknownAction($this->method, $this->path); 52 | } 53 | 54 | /** 55 | * @param string $expectedMethod 56 | * @param string $expectedPath 57 | * @return bool 58 | * @throws Exceptions\ResolverException 59 | */ 60 | private function checkPathMethod(string $expectedMethod, string $expectedPath): bool 61 | { 62 | if ($expectedPath !== $this->path) { 63 | return false; 64 | } 65 | if ($expectedMethod !== $this->method) { 66 | throw Exceptions\ResolverException::unsupportedMethod($this->path, $this->method); 67 | } 68 | return true; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Actions/DiscardCode.php: -------------------------------------------------------------------------------- 1 | captchas = $captchas; 20 | } 21 | 22 | public function execute(array $arguments): ResponseInterface 23 | { 24 | // validate image 25 | $code = $arguments['code'] ?? ''; 26 | if ('' === $code) { 27 | throw ExecuteException::invalidArgument('code'); 28 | } 29 | 30 | if (null !== $this->captchas->findByCode($code)) { 31 | $this->captchas->remove($code); 32 | } 33 | 34 | return new Response(200, ['Content-Type' => 'text/plain; charset=utf-8'], ''); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Actions/Events.php: -------------------------------------------------------------------------------- 1 | captchas = $captchas; 24 | $this->stream = new ThroughStream(); 25 | $this->captchas->subscribe($this); 26 | } 27 | 28 | public function execute(array $arguments): ResponseInterface 29 | { 30 | return new Response(200, ['Content-Type' => 'text/event-stream; charset=utf-8', 'Cache-Control' => 'no-cache'], $this->stream); 31 | } 32 | 33 | public function onAppend(Captcha $captcha): void 34 | { 35 | $this->writeToStream(['eventName' => 'append', 'code' => $captcha->getCode(), 'image' => $captcha->getImage()]); 36 | } 37 | 38 | public function onAnswer(Captcha $captcha): void 39 | { 40 | $this->writeToStream(['eventName' => 'answer', 'code' => $captcha->getCode(), 'answer' => $captcha->getAnswer()]); 41 | } 42 | 43 | public function onRemove(Captcha $captcha): void 44 | { 45 | $this->writeToStream(['eventName' => 'remove', 'code' => $captcha->getCode()]); 46 | } 47 | 48 | /** 49 | * @param array $data 50 | */ 51 | public function writeToStream(array $data): void 52 | { 53 | $message = json_encode($data + ['on' => date('c')], JSON_UNESCAPED_SLASHES); 54 | // $message = implode(PHP_EOL . 'data: ', explode(PHP_EOL, $message)); 55 | // $message = str_replace(PHP_EOL, PHP_EOL . 'data: ', $message); 56 | $this->stream->write('data: ' . $message . PHP_EOL . PHP_EOL); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Actions/GetCurrentCaptchas.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 18 | } 19 | 20 | public function execute(array $arguments): JsonSerializable 21 | { 22 | return $this->repository; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Actions/LocalResource.php: -------------------------------------------------------------------------------- 1 | webroot = $webroot; 22 | $this->path = $path; 23 | } 24 | 25 | public function execute(array $arguments): ResponseInterface 26 | { 27 | $absolutePath = $this->webroot . '/' . $this->satinizePath($this->path); 28 | 29 | if (file_exists($absolutePath) && is_file($absolutePath) && is_readable($absolutePath)) { 30 | $contentType = $this->readFileContentType($absolutePath); // RFC 2045 31 | $headers = array_filter(['Content-Type' => $contentType]); // if not known content type do not send any value 32 | return new Response(200, $headers, file_get_contents($absolutePath) ?: ''); 33 | } 34 | 35 | throw ExecuteException::pathNotFound($this->path); 36 | } 37 | 38 | public static function satinizePath(string $path): string 39 | { 40 | // simplify the path 41 | $parts = static::simplifyPath($path); 42 | 43 | // remove any ".." from the beginning 44 | foreach ($parts as $i => $part) { 45 | if ('..' === $part) { 46 | unset($parts[$i]); 47 | } else { 48 | break; 49 | } 50 | } 51 | 52 | return implode('/', $parts); 53 | } 54 | 55 | /** 56 | * Simplify a path and return its parts as an array 57 | * 58 | * @param string $path 59 | * @return string[] 60 | */ 61 | public static function simplifyPath(string $path): array 62 | { 63 | $parts = explode('/', parse_url($path, PHP_URL_PATH) ?: ''); 64 | $parts = array_values(array_filter($parts, fn (string $name): bool => '.' !== $name && '' !== $name)); 65 | 66 | // is ".." and previous is not "..", for paths like "../../some/path" 67 | $count = count($parts); 68 | for ($i = 1; $i < $count; $i = $i + 1) { 69 | if ('..' === $parts[$i] && '..' !== $parts[$i - 1]) { 70 | unset($parts[$i - 1]); 71 | unset($parts[$i]); 72 | } 73 | } 74 | return array_values($parts); 75 | } 76 | 77 | public static function readFileContentType(string $path): string 78 | { 79 | $mime = (new finfo())->file($path, FILEINFO_MIME) ?: ''; 80 | if ('inode/x-empty' === substr($mime, 0, 13)) { 81 | $mime = ''; 82 | } 83 | return str_replace('; charset=us-ascii', '; charset=utf-8', $mime); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Actions/ObtainDecoded.php: -------------------------------------------------------------------------------- 1 | captchas = $captchas; 19 | } 20 | 21 | public function execute(array $arguments): JsonSerializable 22 | { 23 | // validate code 24 | $code = $arguments['code'] ?? ''; 25 | if ('' === $code) { 26 | throw ExecuteException::invalidArgument('code'); 27 | } 28 | $captcha = $this->captchas->findByCode($code); 29 | if (null === $captcha) { 30 | throw ExecuteException::codeNotFound($code); 31 | } 32 | if ($captcha->hasAnswer()) { 33 | $this->captchas->remove($code); 34 | } 35 | return $captcha; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Actions/SendImage.php: -------------------------------------------------------------------------------- 1 | captchas = $captchas; 19 | } 20 | 21 | public function execute(array $arguments): JsonSerializable 22 | { 23 | // validate image 24 | $image = $arguments['image'] ?? ''; 25 | if ('' === $image) { 26 | throw ExecuteException::invalidArgument('image', 'empty content'); 27 | } 28 | if (base64_encode(base64_decode($image, true) ?: '') !== $image) { 29 | throw ExecuteException::invalidArgument('image', 'content is not base64'); 30 | } 31 | 32 | return $this->captchas->push($image); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Actions/SetCodeAnswer.php: -------------------------------------------------------------------------------- 1 | captchas = $captchas; 20 | } 21 | 22 | public function execute(array $arguments): ResponseInterface 23 | { 24 | // validate image 25 | $code = $arguments['code'] ?? ''; 26 | if ('' === $code) { 27 | throw ExecuteException::invalidArgument('code'); 28 | } 29 | $answer = $arguments['answer'] ?? ''; 30 | if ('' === $answer) { 31 | throw ExecuteException::invalidArgument('answer'); 32 | } 33 | 34 | if (null === $this->captchas->findByCode($code)) { 35 | throw ExecuteException::codeNotFound($code); 36 | } 37 | $this->captchas->answer($code, $answer); 38 | return new Response(200, ['Content-Type' => 'text/plain; charset=utf-8'], ''); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Application.php: -------------------------------------------------------------------------------- 1 | repository = $repository ?? new Captchas(); 24 | $this->webroot = $webroot ?: __DIR__ . '/../public/'; 25 | } 26 | 27 | public function __invoke(ServerRequestInterface $request): ResponseInterface 28 | { 29 | try { 30 | return $this->serve($request); 31 | } catch (Throwable $exception) { 32 | return new Response( 33 | 500, 34 | ['Content-Type' => 'text/plain; charset=utf-8'], 35 | implode(PHP_EOL, [ 36 | sprintf('ERROR: (%s) %s', get_class($exception), $exception->getMessage()), 37 | sprintf('-- %s:%s', $exception->getFile(), $exception->getLine()), 38 | $exception->getTraceAsString(), 39 | ]) 40 | ); 41 | } 42 | } 43 | 44 | /** 45 | * This method is acting like a front controller: 46 | * - resolve request to action 47 | * - execute action & get a result 48 | * - transform result to a response 49 | * 50 | * @param ServerRequestInterface $request 51 | * @return ResponseInterface 52 | */ 53 | public function serve(ServerRequestInterface $request): ResponseInterface 54 | { 55 | // route request to action 56 | try { 57 | $action = $this->createActionResolver($request)->resolve($this); 58 | } catch (Exceptions\ResolverException $exception) { 59 | return new Response($exception->getCode(), ['Content-Type' => 'text/plain; charset=utf-8'], $exception->getMessage()); 60 | } 61 | 62 | // build arguments 63 | $arguments = $this->extractArgumentsFromRequest($request); 64 | 65 | // execute action 66 | try { 67 | /** 68 | * Redeclared type (add mixed) if action was poorly implemented 69 | * Static analysis will claim that this method has unreachable code 70 | * @var ResponseInterface|JsonSerializable|mixed $result 71 | */ 72 | $result = $action->execute($arguments); 73 | } catch (Exceptions\ExecuteException $exception) { 74 | return new Response($exception->getCode(), ['Content-Type' => 'text/plain; charset=utf-8'], $exception->getMessage()); 75 | } 76 | 77 | // transform action result to a response 78 | if ($result instanceof JsonSerializable) { 79 | $result = new Response( 80 | 200, 81 | ['Content-Type' => 'application/json; charset=utf-8'], 82 | json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_LINE_TERMINATORS) ?: '' 83 | ); 84 | } 85 | if ($result instanceof ResponseInterface) { 86 | return $result; 87 | } 88 | 89 | throw new LogicException(sprintf('Unable to create a response for the result on action %s', get_class($action))); 90 | } 91 | 92 | public function getRepository(): Captchas 93 | { 94 | return $this->repository; 95 | } 96 | 97 | public function getWebroot(): string 98 | { 99 | return $this->webroot; 100 | } 101 | 102 | protected function createActionResolver(ServerRequestInterface $request): ActionResolver 103 | { 104 | return new ActionResolver($request->getMethod(), $request->getUri()->getPath(), $this->webroot); 105 | } 106 | 107 | /** 108 | * @param ServerRequestInterface $request 109 | * @return string[] 110 | */ 111 | protected function extractArgumentsFromRequest(ServerRequestInterface $request): array 112 | { 113 | if ('application/json' === implode('', $request->getHeader('Content-Type'))) { 114 | try { 115 | $arguments = json_decode((string) $request->getBody(), true, 512, JSON_THROW_ON_ERROR); 116 | } catch (JsonException $exception) { 117 | $arguments = null; 118 | } 119 | } else { 120 | $arguments = $request->getParsedBody(); 121 | } 122 | if (is_object($arguments)) { 123 | /** @var array $arguments */ 124 | $arguments = json_decode(json_encode($arguments) ?: '', true); 125 | } 126 | if (! is_array($arguments)) { 127 | $arguments = []; 128 | } 129 | foreach ($arguments as $key => $value) { 130 | if (! is_scalar($value)) { 131 | unset($arguments[$key]); 132 | continue; 133 | } 134 | if (! is_string($value)) { 135 | $arguments[$key] = (string) $value; 136 | } 137 | } 138 | return $arguments; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Captcha.php: -------------------------------------------------------------------------------- 1 | code = $code; 20 | $this->image = $image; 21 | $this->answer = $answer; 22 | } 23 | 24 | public function getCode(): string 25 | { 26 | return $this->code; 27 | } 28 | 29 | public function getImage(): string 30 | { 31 | return $this->image; 32 | } 33 | 34 | public function getAnswer(): string 35 | { 36 | return $this->answer; 37 | } 38 | 39 | public function hasAnswer(): bool 40 | { 41 | return ('' !== $this->answer); 42 | } 43 | 44 | public function withAnswer(string $answer): self 45 | { 46 | $cloned = clone $this; 47 | $cloned->answer = $answer; 48 | return $cloned; 49 | } 50 | 51 | /** @return array */ 52 | public function jsonSerialize(): array 53 | { 54 | return array_filter([ 55 | 'code' => $this->code, 56 | 'answer' => $this->answer, 57 | ]); 58 | } 59 | 60 | /** @return array */ 61 | public function toArray(): array 62 | { 63 | return [ 64 | 'code' => $this->code, 65 | 'image' => $this->image, 66 | 'answer' => $this->answer, 67 | ]; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Captchas.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class Captchas implements JsonSerializable, IteratorAggregate, Countable 18 | { 19 | /** @var ArrayObject|Captcha[] */ 20 | private $captchas; 21 | 22 | private Subscriptors $subscriptors; 23 | 24 | public function __construct() 25 | { 26 | $this->captchas = new ArrayObject(); 27 | $this->subscriptors = new Subscriptors(); 28 | } 29 | 30 | public function push(string $image): Captcha 31 | { 32 | $code = md5($image); 33 | $captcha = new Captcha($code, $image); 34 | $this->captchas[$code] = $captcha; 35 | $this->subscriptors->onAppend($captcha); 36 | return $captcha; 37 | } 38 | 39 | public function findByCode(string $code): ?Captcha 40 | { 41 | return $this->captchas[$code] ?? null; 42 | } 43 | 44 | public function findByCodeOrFail(string $code): Captcha 45 | { 46 | $captcha = $this->findByCode($code); 47 | if (null === $captcha) { 48 | throw new LogicException(sprintf('Captcha with code %s does not exists', $code)); 49 | } 50 | return $captcha; 51 | } 52 | 53 | public function remove(string $code): void 54 | { 55 | if (! isset($this->captchas[$code])) { 56 | return; 57 | } 58 | $captcha = $this->captchas[$code]; 59 | unset($this->captchas[$code]); 60 | $this->subscriptors->onRemove($captcha); 61 | } 62 | 63 | public function answer(string $code, string $answer): void 64 | { 65 | if (! isset($this->captchas[$code])) { 66 | return; 67 | } 68 | $captcha = $this->captchas[$code]->withAnswer($answer); 69 | $this->captchas[$code] = $captcha; 70 | $this->subscriptors->onAnswer($captcha); 71 | } 72 | 73 | public function subscribe(SubscriptorInterface $subscriptor): void 74 | { 75 | $this->subscriptors->subscribe($subscriptor); 76 | } 77 | 78 | /** 79 | * @return array> 80 | */ 81 | public function jsonSerialize(): array 82 | { 83 | // convert captcha to array, remove codes as keys 84 | return array_values( 85 | array_map( 86 | fn (Captcha $captcha): array => $captcha->toArray(), 87 | $this->captchas->getArrayCopy() 88 | ) 89 | ); 90 | } 91 | 92 | public function count(): int 93 | { 94 | return $this->captchas->count(); 95 | } 96 | 97 | /** @return Traversable|Captcha[] */ 98 | public function getIterator(): Traversable 99 | { 100 | return $this->captchas->getIterator(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Exceptions/ExecuteException.php: -------------------------------------------------------------------------------- 1 | |SubscriptorInterface[] */ 16 | private ArrayObject $subscriptors; 17 | 18 | public function __construct() 19 | { 20 | $this->subscriptors = new ArrayObject(); 21 | } 22 | 23 | public function subscribe(SubscriptorInterface $subscriptor): void 24 | { 25 | if ($this === $subscriptor) { 26 | throw new LogicException('You are creating an infinite loop by adding the subscriptors collection to itself'); 27 | } 28 | $this->subscriptors->append($subscriptor); 29 | } 30 | 31 | public function onAppend(Captcha $captcha): void 32 | { 33 | $this->eachSubscriptor( 34 | function (SubscriptorInterface $subscriptor) use ($captcha): void { 35 | $subscriptor->onAppend($captcha); 36 | } 37 | ); 38 | } 39 | 40 | public function onRemove(Captcha $captcha): void 41 | { 42 | $this->eachSubscriptor( 43 | function (SubscriptorInterface $subscriptor) use ($captcha): void { 44 | $subscriptor->onRemove($captcha); 45 | } 46 | ); 47 | } 48 | 49 | public function onAnswer(Captcha $captcha): void 50 | { 51 | $this->eachSubscriptor( 52 | function (SubscriptorInterface $subscriptor) use ($captcha): void { 53 | $subscriptor->onAnswer($captcha); 54 | } 55 | ); 56 | } 57 | 58 | private function eachSubscriptor(Closure $function): void 59 | { 60 | /** @var SubscriptorInterface $subscriptor */ 61 | foreach ($this->subscriptors as $subscriptor) { 62 | try { 63 | // do not generate any error! 64 | $function($subscriptor); 65 | } catch (Throwable $exception) { 66 | unset($exception); 67 | } 68 | } 69 | } 70 | 71 | public function count(): int 72 | { 73 | return $this->subscriptors->count(); 74 | } 75 | } 76 | --------------------------------------------------------------------------------