diff --git a/composer.json b/composer.json index 159b0f1ee74..f16a23e3df4 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "psr-4": { "Appwrite\\": "src/Appwrite", "Executor\\": "src/Executor", + "Utopia\\Abuse\\": "packages/abuse/src", "Utopia\\Agents\\": "packages/agents/src", "Utopia\\Audit\\": "packages/audit/src", "Utopia\\Auth\\": "packages/auth/src", @@ -78,6 +79,7 @@ "Tests\\E2E\\": "tests/e2e", "Tests\\Unit\\": "tests/unit", "Appwrite\\Tests\\": "tests/extensions", + "Utopia\\Abuse\\Tests\\": "packages/abuse/tests", "Utopia\\Agents\\Tests\\": "packages/agents/tests", "Utopia\\Audit\\Tests\\": "packages/audit/tests", "Utopia\\Auth\\Tests\\": "packages/auth/tests", @@ -144,7 +146,6 @@ "ext-sockets": "*", "appwrite/php-runtimes": "0.20.*", "appwrite/php-clamav": "2.0.*", - "utopia-php/abuse": "2.0.*", "utopia-php/config": "1.*", "utopia-php/database": "^7.3.11", "utopia-php/migration": "^2.0.0", @@ -164,7 +165,8 @@ "psr/http-factory": "^1.0", "psr/http-client": "^1.0", "psr/http-message": "^2.0", - "psr/container": "^2.0" + "psr/container": "^2.0", + "appwrite/appwrite": "^27.1" }, "require-dev": { "ext-fileinfo": "*", @@ -193,6 +195,7 @@ } }, "replace": { + "utopia-php/abuse": "*", "utopia-php/agents": "*", "utopia-php/audit": "*", "utopia-php/auth": "*", diff --git a/composer.lock b/composer.lock index e8f89b76bef..221c4d1f144 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8a44d1fb1f4674bf3d8b83f4b9c978b3", + "content-hash": "ca7dc18250a147b8f99c6644007025a9", "packages": [ { "name": "adhocore/jwt", @@ -2778,59 +2778,6 @@ }, "time": "2025-06-29T15:42:06+00:00" }, - { - "name": "utopia-php/abuse", - "version": "2.0.1", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/abuse.git", - "reference": "821a1884c8067736e1a27d9fc70bdb2cc84d7146" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/abuse/zipball/821a1884c8067736e1a27d9fc70bdb2cc84d7146", - "reference": "821a1884c8067736e1a27d9fc70bdb2cc84d7146", - "shasum": "" - }, - "require": { - "appwrite/appwrite": "^27.1", - "ext-curl": "*", - "ext-pdo": "*", - "ext-redis": "*", - "php": ">=8.4.1", - "utopia-php/database": "^7.0.0", - "utopia-php/pools": "2.*" - }, - "require-dev": { - "laravel/pint": "1.*", - "phpbench/phpbench": "1.*", - "phpstan/phpstan": "1.*", - "phpunit/phpunit": "9.*" - }, - "type": "library", - "autoload": { - "psr-4": { - "Utopia\\Abuse\\": "src/Abuse" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A simple abuse library to manage application usage limits", - "keywords": [ - "Abuse", - "framework", - "php", - "upf", - "utopia" - ], - "support": { - "issues": "https://github.com/utopia-php/abuse/issues", - "source": "https://github.com/utopia-php/abuse/tree/2.0.1" - }, - "time": "2026-08-13T11:44:26+00:00" - }, { "name": "utopia-php/config", "version": "1.0.0", diff --git a/packages/abuse/.env.example b/packages/abuse/.env.example new file mode 100644 index 00000000000..013b71041f3 --- /dev/null +++ b/packages/abuse/.env.example @@ -0,0 +1,3 @@ +APPWRITE_ENDPOINT= +APPWRITE_PROJECT_ID= +APPWRITE_API_KEY= \ No newline at end of file diff --git a/packages/abuse/.github/workflows/mirror.yml b/packages/abuse/.github/workflows/mirror.yml new file mode 100644 index 00000000000..6b646ca6610 --- /dev/null +++ b/packages/abuse/.github/workflows/mirror.yml @@ -0,0 +1,17 @@ +name: Mirror + +on: + pull_request_target: + types: [opened] + issues: + types: [opened] + +permissions: + issues: write + pull-requests: write + +jobs: + redirect: + uses: appwrite/appwrite/.github/workflows/mirror-redirect.yml@main + with: + package: abuse diff --git a/packages/abuse/.gitignore b/packages/abuse/.gitignore new file mode 100755 index 00000000000..6963341a694 --- /dev/null +++ b/packages/abuse/.gitignore @@ -0,0 +1,5 @@ +/vendor/ +/.idea/ +.env +.phpunit.cache +composer.lock diff --git a/packages/abuse/CODE_OF_CONDUCT.md b/packages/abuse/CODE_OF_CONDUCT.md new file mode 100644 index 00000000000..2dec654fbbc --- /dev/null +++ b/packages/abuse/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to make participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity, expression, +level of experience, education, socio-economic status, 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 team@appwrite.io. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and 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 https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/packages/abuse/LICENSE.md b/packages/abuse/LICENSE.md new file mode 100755 index 00000000000..27feb0854cd --- /dev/null +++ b/packages/abuse/LICENSE.md @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2013 Eldad Fux + +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/packages/abuse/README.md b/packages/abuse/README.md new file mode 100644 index 00000000000..efa3542663d --- /dev/null +++ b/packages/abuse/README.md @@ -0,0 +1,135 @@ +# Utopia Abuse + +> [!IMPORTANT] +> This repository is a read-only mirror of [`packages/abuse`](https://github.com/appwrite/appwrite/tree/main/packages/abuse) in [appwrite/appwrite](https://github.com/appwrite/appwrite). Development happens there — please open issues and pull requests against appwrite/appwrite. + +![Total Downloads](https://img.shields.io/packagist/dt/utopia-php/abuse.svg) +[![Discord](https://img.shields.io/discord/564160730845151244)](https://appwrite.io/discord) + +Utopia framework abuse library is simple and lite library for managing application usage limits. This library is aiming to be as simple and easy to learn and use. This library is maintained by the [Appwrite team](https://appwrite.io). + +Although this library is part of the [Utopia Framework](https://github.com/utopia-php/framework) project it is dependency free, and can be used as standalone with any other PHP project or framework. + +## Getting Started + +Install using composer: + +```bash +composer require utopia-php/abuse +``` + +**Time Limit Abuse** + +The time limit abuse allow each key (action) to be performed [X] times in given time frame. +This adapter uses a MySQL / MariaDB to store usage attempts. Before using it, call `$adapter->setup()` once to create the collection it stores attempts in. + +### Database adapter + +```php + 3, // Seconds + PDO::ATTR_PERSISTENT => true, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => true, + PDO::ATTR_STRINGIFY_FETCHES => true, +]); + +$db = new Database(new MySQL($pdo), new Cache(new NoCache())); +$db->setNamespace('namespace'); + +// Limit login attempts to 10 time in 5 minutes time frame +$adapter = new TimeLimit('login-attempt-from-{{ip}}', 10, (60 * 5), $db); + +$adapter->setup(); //setup database as required +$adapter->setParam('{{ip}}', '127.0.0.1') +; + +$abuse = new Abuse($adapter); + +// Use vars to resolve adapter key + +if($abuse->check()) { + throw new Exception('Service was abused!'); // throw error and return X-Rate limit headers here +} +``` + +### Appwrite TablesDB adapter + +```php +setEndpoint('[YOUR_ENDPOINT]') + ->setProject('[YOUR_PROJECT_ID]') + ->setKey('[YOUR_API_KEY]'); +$databaseId = 'abuse'; + +// Limit login attempts to 10 time in 5 minutes time frame +$adapter = new TablesDBAdapter('login-attempt-from-{{ip}}', 10, (60 * 5), $client, $databaseId); + +$adapter->setup(); //setup database as required +$adapter->setParam('{{ip}}', '127.0.0.1'); + +$abuse = new Abuse($adapter); + +// Use vars to resolve adapter key + +if($abuse->check()) { + throw new Exception('Service was abused!'); // throw error and return X-Rate limit headers here +} +``` + +**ReCaptcha Abuse** + +The ReCaptcha abuse controller is using Google ReCaptcha service to detect when service is being abused by bots. +To use this adapter you need to create an API key from the Google ReCaptcha service [admin console](https://www.google.com/recaptcha/admin). + +```php +check()) { + throw new Exception('Service was abused!'); // throw error and return X-Rate limit headers here +} +``` + +*Notice: The code above is for example purpose only. It is always recommended to validate user input before using it in your code. If you are using a load balancer or any proxy server you might need to get user IP from the HTTP_X_FORWARDE‌​D_FOR header.* + +## System Requirements + +Utopia Framework requires PHP 8.0 or later. We recommend using the latest PHP version whenever possible. + +## Copyright and license + +The MIT License (MIT) [http://www.opensource.org/licenses/mit-license.php](http://www.opensource.org/licenses/mit-license.php) diff --git a/packages/abuse/composer.json b/packages/abuse/composer.json new file mode 100644 index 00000000000..cb78a60e73f --- /dev/null +++ b/packages/abuse/composer.json @@ -0,0 +1,47 @@ +{ + "name": "utopia-php/abuse", + "description": "A simple abuse library to manage application usage limits", + "type": "library", + "keywords": [ + "php", + "framework", + "upf", + "utopia", + "abuse" + ], + "license": "MIT", + "minimum-stability": "stable", + "autoload": { + "psr-4": { + "Utopia\\Abuse\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Utopia\\Abuse\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit --testsuite unit", + "test:e2e": "phpunit --testsuite e2e", + "bench": "vendor/bin/phpbench run --report=aggregate" + }, + "require": { + "php": ">=8.4.1", + "ext-pdo": "*", + "ext-curl": "*", + "ext-redis": "*", + "utopia-php/database": "^7.0.0", + "utopia-php/pools": "2.*", + "appwrite/appwrite": "^27.1" + }, + "require-dev": { + "phpbench/phpbench": "1.*" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true, + "tbachert/spi": true + } + } +} diff --git a/packages/abuse/docker-compose.yml b/packages/abuse/docker-compose.yml new file mode 100644 index 00000000000..9c6f60bc3b4 --- /dev/null +++ b/packages/abuse/docker-compose.yml @@ -0,0 +1,40 @@ +name: utopia-abuse + +services: + mysql: + image: mysql:8 + environment: + MYSQL_ROOT_PASSWORD: password + ports: + - "13308:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-ppassword"] + interval: 2s + timeout: 3s + retries: 60 + + redis: + image: redis:7-alpine + command: ["redis-server", "--save", "", "--appendonly", "no"] + ports: + - "16386:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 1s + timeout: 1s + retries: 30 + + # The cluster advertises the addresses clients dial, so its published ports + # match the ones inside the container. + redis-cluster: + image: grokzen/redis-cluster:7.0.10 + environment: + IP: 127.0.0.1 + INITIAL_PORT: 17010 + ports: + - "17010-17015:17010-17015" + healthcheck: + test: ["CMD-SHELL", "redis-cli -p 17010 cluster info | grep -q cluster_state:ok"] + interval: 2s + timeout: 3s + retries: 60 diff --git a/packages/abuse/phpbench.json b/packages/abuse/phpbench.json new file mode 100644 index 00000000000..f09975f9c4a --- /dev/null +++ b/packages/abuse/phpbench.json @@ -0,0 +1,6 @@ +{ + "$schema": "vendor/phpbench/phpbench/phpbench.schema.json", + "runner.bootstrap": "vendor/autoload.php", + "runner.path": "tests/bench", + "runner.file_pattern": "*.php" +} \ No newline at end of file diff --git a/packages/abuse/phpstan-baseline.neon b/packages/abuse/phpstan-baseline.neon new file mode 100644 index 00000000000..9546ee2864f --- /dev/null +++ b/packages/abuse/phpstan-baseline.neon @@ -0,0 +1,265 @@ +parameters: + ignoreErrors: + - + message: '#^Parameter \#3 \$value of function curl_setopt expects bool, int given\.$#' + identifier: argument.type + count: 1 + path: src/Adapters/ReCaptcha.php + + - + message: '#^Method Utopia\\Abuse\\Adapters\\SlidingWindow\\RedisCluster\:\:getLogs\(\) should return array\ but returns array\\.$#' + identifier: return.type + count: 1 + path: src/Adapters/SlidingWindow/RedisCluster.php + + - + message: '#^Parameter \#1 \$array of function sort contains unresolvable type\.$#' + identifier: argument.unresolvableType + count: 1 + path: src/Adapters/SlidingWindow/RedisCluster.php + + - + message: '#^Parameter \#1 \$keys of function array_combine expects an array of values castable to string, list\ given\.$#' + identifier: argument.type + count: 1 + path: src/Adapters/SlidingWindow/RedisCluster.php + + - + message: '#^Parameter \#1 \$keys of function array_combine expects array\, list\ given\.$#' + identifier: argument.type + count: 1 + path: src/Adapters/SlidingWindow/RedisCluster.php + + - + message: '#^Parameter \#2 \.\.\.\$arrays of function array_merge expects array, array\|true given\.$#' + identifier: argument.type + count: 1 + path: src/Adapters/SlidingWindow/RedisCluster.php + + - + message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Adapters/SlidingWindow/RedisPool.php + + - + message: '#^Call to function is_array\(\) with array\ will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Adapters/SlidingWindow/RedisPool.php + + - + message: '#^Method Utopia\\Abuse\\Adapters\\SlidingWindow\\RedisPool\:\:getRedisClusterLogs\(\) should return array\ but returns array\\.$#' + identifier: return.type + count: 1 + path: src/Adapters/SlidingWindow/RedisPool.php + + - + message: '#^Only iterables can be unpacked, array\|true given in argument \#2\.$#' + identifier: argument.unpackNonIterable + count: 1 + path: src/Adapters/SlidingWindow/RedisPool.php + + - + message: '#^Parameter \#1 \$array of function sort contains unresolvable type\.$#' + identifier: argument.unresolvableType + count: 1 + path: src/Adapters/SlidingWindow/RedisPool.php + + - + message: '#^Parameter \#1 \$keys of function array_combine expects an array of values castable to string, list\ given\.$#' + identifier: argument.type + count: 1 + path: src/Adapters/SlidingWindow/RedisPool.php + + - + message: '#^Parameter \#1 \$keys of function array_combine expects array\, list\ given\.$#' + identifier: argument.type + count: 1 + path: src/Adapters/SlidingWindow/RedisPool.php + + - + message: '#^Method Utopia\\Abuse\\Adapters\\TimeLimit\\RedisCluster\:\:getLogs\(\) should return array\ but returns array\\.$#' + identifier: return.type + count: 1 + path: src/Adapters/TimeLimit/RedisCluster.php + + - + message: '#^Parameter \#1 \$array of function sort contains unresolvable type\.$#' + identifier: argument.unresolvableType + count: 1 + path: src/Adapters/TimeLimit/RedisCluster.php + + - + message: '#^Parameter \#1 \$keys of function array_combine expects an array of values castable to string, list\ given\.$#' + identifier: argument.type + count: 1 + path: src/Adapters/TimeLimit/RedisCluster.php + + - + message: '#^Parameter \#1 \$keys of function array_combine expects array\, list\ given\.$#' + identifier: argument.type + count: 1 + path: src/Adapters/TimeLimit/RedisCluster.php + + - + message: '#^Parameter \#2 \.\.\.\$arrays of function array_merge expects array, array\|true given\.$#' + identifier: argument.type + count: 1 + path: src/Adapters/TimeLimit/RedisCluster.php + + - + message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Adapters/TimeLimit/RedisPool.php + + - + message: '#^Call to function is_array\(\) with array\ will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Adapters/TimeLimit/RedisPool.php + + - + message: '#^Method Utopia\\Abuse\\Adapters\\TimeLimit\\RedisPool\:\:getRedisClusterLogs\(\) should return array\ but returns array\\.$#' + identifier: return.type + count: 1 + path: src/Adapters/TimeLimit/RedisPool.php + + - + message: '#^Only iterables can be unpacked, array\|true given in argument \#2\.$#' + identifier: argument.unpackNonIterable + count: 1 + path: src/Adapters/TimeLimit/RedisPool.php + + - + message: '#^Parameter \#1 \$array of function sort contains unresolvable type\.$#' + identifier: argument.unresolvableType + count: 1 + path: src/Adapters/TimeLimit/RedisPool.php + + - + message: '#^Parameter \#1 \$keys of function array_combine expects an array of values castable to string, list\ given\.$#' + identifier: argument.type + count: 1 + path: src/Adapters/TimeLimit/RedisPool.php + + - + message: '#^Parameter \#1 \$keys of function array_combine expects array\, list\ given\.$#' + identifier: argument.type + count: 1 + path: src/Adapters/TimeLimit/RedisPool.php + + - + message: '#^Method Utopia\\Abuse\\Adapters\\TokenBucket\\RedisCluster\:\:getLogs\(\) should return array\ but returns array\\.$#' + identifier: return.type + count: 1 + path: src/Adapters/TokenBucket/RedisCluster.php + + - + message: '#^Parameter \#1 \$array of function sort contains unresolvable type\.$#' + identifier: argument.unresolvableType + count: 1 + path: src/Adapters/TokenBucket/RedisCluster.php + + - + message: '#^Parameter \#1 \$key of method RedisCluster\:\:hGetAll\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Adapters/TokenBucket/RedisCluster.php + + - + message: '#^Parameter \#2 \.\.\.\$arrays of function array_merge expects array, array\|true given\.$#' + identifier: argument.type + count: 1 + path: src/Adapters/TokenBucket/RedisCluster.php + + - + message: '#^Possibly invalid array key type mixed\.$#' + identifier: offsetAccess.invalidOffset + count: 1 + path: src/Adapters/TokenBucket/RedisCluster.php + + - + message: '#^Method Utopia\\Abuse\\Adapters\\TokenBucket\\RedisPool\:\:getRedisClusterLogs\(\) should return array\ but returns array\\.$#' + identifier: return.type + count: 1 + path: src/Adapters/TokenBucket/RedisPool.php + + - + message: '#^Only iterables can be unpacked, array\|true given in argument \#2\.$#' + identifier: argument.unpackNonIterable + count: 1 + path: src/Adapters/TokenBucket/RedisPool.php + + - + message: '#^Parameter \#1 \$array of function sort contains unresolvable type\.$#' + identifier: argument.unresolvableType + count: 1 + path: src/Adapters/TokenBucket/RedisPool.php + + - + message: '#^Parameter \#1 \$key of method RedisCluster\:\:hGetAll\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Adapters/TokenBucket/RedisPool.php + + - + message: '#^Possibly invalid array key type mixed\.$#' + identifier: offsetAccess.invalidOffset + count: 1 + path: src/Adapters/TokenBucket/RedisPool.php + + - + message: '#^Method Utopia\\Abuse\\Tests\\E2E\\Appwrite\\TablesDBTest\:\:columnsByKey\(\) should return array\\> but returns array\\>\.$#' + identifier: return.type + count: 1 + path: tests/E2E/Appwrite/TablesDBTest.php + + - + message: '#^Call to function is_int\(\) with int will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: tests/E2E/Base.php + + - + message: '#^Argument of an invalid type array\|true supplied for foreach, only iterables are supported\.$#' + identifier: foreach.nonIterable + count: 1 + path: tests/E2E/RedisPoolClusterTest.php + + - + message: '#^Instanceof between RedisCluster and RedisCluster will always evaluate to true\.$#' + identifier: instanceof.alwaysTrue + count: 1 + path: tests/E2E/RedisPoolClusterTest.php + + - + message: '#^Parameter \#1 \$key of method RedisCluster\:\:del\(\) expects int\|string, mixed given\.$#' + identifier: argument.type + count: 1 + path: tests/E2E/RedisPoolClusterTest.php + + - + message: '#^Instanceof between Redis and Redis will always evaluate to true\.$#' + identifier: instanceof.alwaysTrue + count: 1 + path: tests/E2E/RedisPoolTest.php + + - + message: '#^Instanceof between Redis and Redis will always evaluate to true\.$#' + identifier: instanceof.alwaysTrue + count: 1 + path: tests/E2E/SlidingWindow/RedisPoolTest.php + + - + message: '#^Call to function is_int\(\) with int will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: tests/E2E/TokenBucket/Base.php + + - + message: '#^Instanceof between Redis and Redis will always evaluate to true\.$#' + identifier: instanceof.alwaysTrue + count: 1 + path: tests/E2E/TokenBucket/RedisPoolTest.php diff --git a/packages/abuse/phpstan.neon b/packages/abuse/phpstan.neon new file mode 100644 index 00000000000..210b39af9ee --- /dev/null +++ b/packages/abuse/phpstan.neon @@ -0,0 +1,10 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: max + paths: + - src + - tests + excludePaths: + - tests/bench diff --git a/packages/abuse/phpunit.xml b/packages/abuse/phpunit.xml new file mode 100755 index 00000000000..4c54595ff57 --- /dev/null +++ b/packages/abuse/phpunit.xml @@ -0,0 +1,22 @@ + + + + + tests + tests/E2E + tests/bench + + + tests/E2E + + + + + src + + + diff --git a/packages/abuse/rector.php b/packages/abuse/rector.php new file mode 100644 index 00000000000..c9819ac6ced --- /dev/null +++ b/packages/abuse/rector.php @@ -0,0 +1,38 @@ +withPaths([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->withPhpSets() + ->withPreparedSets( + typeDeclarations: true, + ) + // Absorbing moves code: keep src exactly as released. + ->withSkip([ + // Rector's PHP 8.4 printer drops the parentheses in `(new \DateTime())->` + // whenever it reprints these files. + __DIR__ . '/src/Adapters/TimeLimit/Appwrite/TablesDB.php', + __DIR__ . '/src/Adapters/TimeLimit/Database.php', + AddArrayFunctionClosureParamTypeRector::class => [__DIR__ . '/src'], + AddArrowFunctionReturnTypeRector::class => [__DIR__ . '/src'], + AddClosureVoidReturnTypeWhereNoReturnRector::class => [__DIR__ . '/src'], + ClassPropertyAssignToConstructorPromotionRector::class => [__DIR__ . '/src'], + ClosureReturnTypeRector::class => [__DIR__ . '/src'], + ClosureToArrowFunctionRector::class => [__DIR__ . '/src'], + NullCoalescingOperatorRector::class => [__DIR__ . '/src'], + RemoveUnusedVariableInCatchRector::class => [__DIR__ . '/src'], + ]); diff --git a/packages/abuse/src/Abuse.php b/packages/abuse/src/Abuse.php new file mode 100644 index 00000000000..cdb6f9c54b2 --- /dev/null +++ b/packages/abuse/src/Abuse.php @@ -0,0 +1,68 @@ +adapter = $adapter; + } + + /** + * Check + * + * Checks if request is considered abuse or not + * + * @return bool + */ + public function check(): bool + { + return $this->adapter->check(); + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + return $this->adapter->getLogs($offset, $limit); + } + + /** + * Delete all logs older than $timestamp + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return $this->adapter->cleanup($timestamp); + } + + /** + * Reset + * + * Reset the count to 0 for the current adapter + * + * @return void + */ + public function reset(): void + { + $this->adapter->reset(); + } +} diff --git a/packages/abuse/src/Adapter.php b/packages/abuse/src/Adapter.php new file mode 100644 index 00000000000..b511d0319b2 --- /dev/null +++ b/packages/abuse/src/Adapter.php @@ -0,0 +1,95 @@ + + */ + protected array $params = []; + + /** + * @var string + */ + protected string $key = ''; + + /** + * Check + * + * Checks if number of counts is bigger or smaller than current limit + * + * @return bool + */ + abstract public function check(): bool; + + /** + * Set Param + * + * Set custom param for key pattern parsing + * + * @param string $key + * @param string $value + * @return $this + */ + public function setParam(string $key, string $value): self + { + $this->params[$key] = $value; + + return $this; + } + + /** + * Get Params + * + * Return array of all key params + * + * @return array + */ + protected function getParams(): array + { + return $this->params; + } + + /** + * Parse key with all custom attached params + * + * @return string + */ + protected function parseKey(): string + { + foreach ($this->getParams() as $key => $value) { + $this->key = \str_replace($key, $value, $this->key); + } + + return $this->key; + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + abstract public function getLogs(?int $offset = null, ?int $limit = 25): array; + + /** + * Delete all logs older than $datetime + * + * @param int $timestamp + * @return bool + */ + abstract public function cleanup(int $timestamp): bool; + + /** + * Reset + * + * Reset the count to 0 + * + * @return void + */ + abstract public function reset(): void; +} diff --git a/packages/abuse/src/Adapters/ReCaptcha.php b/packages/abuse/src/Adapters/ReCaptcha.php new file mode 100644 index 00000000000..7e1f67c7570 --- /dev/null +++ b/packages/abuse/src/Adapters/ReCaptcha.php @@ -0,0 +1,132 @@ +secret = $secret; + $this->response = $response; + $this->remoteIP = $remoteIP; + } + + /** + * Check + * + * Check if user is human or not, compared to score + * + * @param float $score + * @return bool + */ + public function check(float $score = 0.5): bool + { + $url = 'https://www.google.com/recaptcha/api/siteverify'; + $fields = [ + 'secret' => \urlencode($this->secret), + 'response' => \urlencode($this->response), + 'remoteip' => \urlencode($this->remoteIP), + ]; + + //open connection + $ch = \curl_init(); + + //set the url, number of POST vars, POST data + \curl_setopt($ch, CURLOPT_URL, $url); + \curl_setopt($ch, CURLOPT_POST, \count($fields)); + \curl_setopt($ch, CURLOPT_POSTFIELDS, \http_build_query($fields)); + \curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + //execute post + /** @var array $result */ + $result = \json_decode((string) \curl_exec($ch), true); + + if ($result['success'] && $result['score'] >= $score) { + return true; + } else { + return false; + } + } + + /** + * Delete logs older than $timestamp + * + * @param int $timestamp + * @return bool + * + * @throws Exception + */ + public function cleanup(int $timestamp): bool + { + throw new Exception('Method not supported'); + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + * + * @throws Exception + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + throw new Exception('Method not supported'); + } + + /** + * Reset + * + * Reset is not applicable for ReCaptcha adapter + * + * @return void + * + * @throws Exception + */ + public function reset(): void + { + throw new Exception('Method not supported'); + } +} diff --git a/packages/abuse/src/Adapters/SlidingWindow.php b/packages/abuse/src/Adapters/SlidingWindow.php new file mode 100644 index 00000000000..a488db643da --- /dev/null +++ b/packages/abuse/src/Adapters/SlidingWindow.php @@ -0,0 +1,100 @@ +limit - ($this->count($this->parseKey(), $this->timestamp) + 1); + + return (0 > $left) ? 0 : $left; + } + + /** + * Limit + * + * Return the limit integer + * + * @return int + */ + public function limit(): int + { + return $this->limit; + } + + /** + * Time + * + * Return the timestamp + * + * @return int + */ + public function time(): int + { + return $this->timestamp; + } + + /** + * Reset + * + * Clear the counters for the current key so the limit starts fresh. + * Implementations must clear both the current and previous window buckets. + * + * @return void + * + * @throws \Exception + */ + abstract public function reset(): void; +} diff --git a/packages/abuse/src/Adapters/SlidingWindow/None.php b/packages/abuse/src/Adapters/SlidingWindow/None.php new file mode 100644 index 00000000000..74f9554d072 --- /dev/null +++ b/packages/abuse/src/Adapters/SlidingWindow/None.php @@ -0,0 +1,62 @@ +key = $key; + $this->limit = $limit; + $now = \time(); + $this->timestamp = (int) ($now - ($now % $windowSize)); + } + + protected function count(string $key, int $timestamp): int + { + return 0; + } + + public function check(): bool + { + return false; + } + + public function reset(): void + { + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + return []; + } + + /** + * Delete all logs older than $timestamp + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } +} diff --git a/packages/abuse/src/Adapters/SlidingWindow/Redis.php b/packages/abuse/src/Adapters/SlidingWindow/Redis.php new file mode 100644 index 00000000000..af22065c7ed --- /dev/null +++ b/packages/abuse/src/Adapters/SlidingWindow/Redis.php @@ -0,0 +1,94 @@ += $windowSize so the + * previous window's bucket survives long enough to be weighted + * @param \Redis $redis Redis connection used for storage + */ + public function __construct(protected string $key, protected int $limit, int $windowSize, int $ttl, protected \Redis $redis) + { + $this->initWindow($windowSize, $ttl); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + * + * @throws \RedisException + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->redis->eval($script, [...$keys, ...$argv], \count($keys)); + } + + /** + * @param string $key + * @return mixed + * + * @throws \RedisException + */ + protected function get(string $key): mixed + { + return $this->redis->get($key); + } + + /** + * @param string ...$keys + * @return void + * + * @throws \RedisException + */ + protected function delete(string ...$keys): void + { + $this->redis->del(...$keys); + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + + $cursor = null; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + do { + $keys = $this->redis->scan($cursor, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $this->redis->get($key); + } + + return $logs; + } +} diff --git a/packages/abuse/src/Adapters/SlidingWindow/RedisBase.php b/packages/abuse/src/Adapters/SlidingWindow/RedisBase.php new file mode 100644 index 00000000000..39b357e0796 --- /dev/null +++ b/packages/abuse/src/Adapters/SlidingWindow/RedisBase.php @@ -0,0 +1,253 @@ += max_requests then + return { 0, 0, math.floor(estimated) } + end + + local new_count = redis.call('INCR', current_key) + redis.call('EXPIRE', current_key, ttl) + + local new_estimate = weighted_prev + new_count + local remaining = math.max(0, math.floor(max_requests - new_estimate)) + return { 1, remaining, math.floor(new_estimate) } + LUA; + + /** + * @var int + */ + protected int $windowSize; + + /** + * @var int + */ + protected int $ttl; + + /** + * Run a Lua script against the storage backend. + * + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed the raw script result + */ + abstract protected function eval(string $script, array $keys, array $argv): mixed; + + /** + * Get the raw value stored at $key (null/false when missing). + * + * @param string $key + * @return mixed + */ + abstract protected function get(string $key): mixed; + + /** + * Delete the given keys. + * + * @param string ...$keys + * @return void + */ + abstract protected function delete(string ...$keys): void; + + /** + * Validate and store the window configuration. The window itself is derived + * live in window(); here we only seed $timestamp so the inherited property is + * initialised before remaining()/time() read it. + * + * @param int $windowSize + * @param int $ttl + * @return void + */ + protected function initWindow(int $windowSize, int $ttl): void + { + if ($windowSize <= 0) { + throw new \InvalidArgumentException('windowSize must be greater than 0'); + } + + // The previous bucket keeps contributing (weighted) throughout the whole + // current window, and its ttl is set from its last write - which in the + // worst case is at the very start of its own window. It therefore needs to + // survive up to two full windows, so ttl must be >= 2 * windowSize. + if ($ttl < $windowSize * 2) { + throw new \InvalidArgumentException('ttl must be at least twice the windowSize so the previous window bucket outlives the current window'); + } + + $this->windowSize = $windowSize; + $this->ttl = $ttl; + [$this->timestamp] = $this->window(); + } + + /** + * Compute the live window from the current time. + * + * @return array{0:int,1:float} [window start timestamp, elapsed fraction in [0,1)] + */ + private function window(): array + { + $now = \time(); + $timestamp = (int)($now - ($now % $this->windowSize)); // start of the current window + + return [$timestamp, ($now - $timestamp) / $this->windowSize]; + } + + /** + * Build a bucket key. The hash tag around $key forces the current and previous + * window buckets into the same cluster slot, so the multi-key Lua script and + * reset() do not raise CROSSSLOT on a Redis Cluster (harmless on single Redis). + * + * @param string $key + * @param int $timestamp + * @return string + */ + protected function bucketKey(string $key, int $timestamp): string + { + return self::NAMESPACE . '__{' . $key . '}__' . $timestamp; + } + + /** + * Time + * + * Start timestamp of the current window, recomputed from the clock. + * + * @return int + */ + public function time(): int + { + [$this->timestamp] = $this->window(); + + return $this->timestamp; + } + + /** + * Check + * + * @return bool + * + * @throws \Throwable + */ + public function check(): bool + { + if ($this->limit === 0) { + return false; + } + + $key = $this->parseKey(); + [$timestamp, $elapsed] = $this->window(); + $this->timestamp = $timestamp; + + /** @var array{0:int,1:int,2:int} $result */ + $result = $this->eval( + self::LIMIT_CHECK_SCRIPT, + [ + $this->bucketKey($key, $timestamp), // KEYS[1] current bucket + $this->bucketKey($key, $timestamp - $this->windowSize), // KEYS[2] previous bucket + ], + [ + $this->limit, // ARGV[1] max_requests + $elapsed, // ARGV[2] elapsed fraction + $this->ttl, // ARGV[3] ttl seconds + ], + ); + + [$allowed] = $result; + + return $allowed === 0; + } + + /** + * Count + * + * Read-only weighted estimate of hits in the current sliding window + * (current bucket + weighted previous bucket). Used by remaining(). + * + * @param string $key + * @param int $timestamp + * @return int + */ + protected function count(string $key, int $timestamp): int + { + if (0 == $this->limit) { + return 0; + } + + [$windowStart, $elapsed] = $this->window(); + $this->timestamp = $windowStart; + + $currentRaw = $this->get($this->bucketKey($key, $windowStart)); + $previousRaw = $this->get($this->bucketKey($key, $windowStart - $this->windowSize)); + + $current = \is_numeric($currentRaw) ? (int) $currentRaw : 0; + $previous = \is_numeric($previousRaw) ? (int) $previousRaw : 0; + + return (int) \floor($current + $previous * (1 - $elapsed)); + } + + /** + * Reset + * + * Clear both the current and previous window buckets so the limit starts fresh. + * + * @return void + */ + public function reset(): void + { + $key = $this->parseKey(); + [$windowStart] = $this->window(); + $this->timestamp = $windowStart; + + $this->delete( + $this->bucketKey($key, $windowStart), + $this->bucketKey($key, $windowStart - $this->windowSize), + ); + } + + /** + * No need for manual cleanup - Redis TTL handles this automatically + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } +} diff --git a/packages/abuse/src/Adapters/SlidingWindow/RedisCluster.php b/packages/abuse/src/Adapters/SlidingWindow/RedisCluster.php new file mode 100644 index 00000000000..6e519a8a6d4 --- /dev/null +++ b/packages/abuse/src/Adapters/SlidingWindow/RedisCluster.php @@ -0,0 +1,91 @@ += $windowSize so the + * previous window's bucket survives long enough to be weighted + * @param \RedisCluster $redis Redis Cluster connection used for storage + */ + public function __construct(protected string $key, protected int $limit, int $windowSize, int $ttl, protected \RedisCluster $redis) + { + $this->initWindow($windowSize, $ttl); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + * + * @throws \RedisClusterException + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->redis->eval($script, [...$keys, ...$argv], \count($keys)); + } + + /** + * @param string $key + * @return mixed + * + * @throws \RedisClusterException + */ + protected function get(string $key): mixed + { + return $this->redis->get($key); + } + + /** + * @param string ...$keys + * @return void + * + * @throws \RedisClusterException + */ + protected function delete(string ...$keys): void + { + $this->redis->del(...$keys); + } + + /** + * Get abuse logs with cursor-based pagination across masters + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = 0, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + foreach ($this->redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $this->redis->scan($cursor, $master, $pattern, 100); + if ($keys !== false) { + $matches = array_merge($matches, $keys); + } + } while ($cursor > 0 && count($matches) < $offset + $limit); + } + + sort($matches); + $matches = array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $values = $this->redis->mget($matches); + + return array_combine($matches, $values); + } +} diff --git a/packages/abuse/src/Adapters/SlidingWindow/RedisPool.php b/packages/abuse/src/Adapters/SlidingWindow/RedisPool.php new file mode 100644 index 00000000000..fd0729a7bba --- /dev/null +++ b/packages/abuse/src/Adapters/SlidingWindow/RedisPool.php @@ -0,0 +1,148 @@ += $windowSize so the + * previous window's bucket survives long enough to be weighted + * @param UtopiaPool<\Redis>|UtopiaPool<\RedisCluster> $pool Pool yielding a Redis or RedisCluster connection + */ + public function __construct( + protected string $key, + protected int $limit, + int $windowSize, + int $ttl, + protected UtopiaPool $pool + ) { + $this->initWindow($windowSize, $ttl); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->pool->use(fn (\Redis|\RedisCluster $redis): mixed => $redis->eval($script, [...$keys, ...$argv], \count($keys))); + } + + /** + * @param string $key + * @return mixed + */ + protected function get(string $key): mixed + { + return $this->pool->use(fn (\Redis|\RedisCluster $redis): mixed => $redis->get($key)); + } + + /** + * @param string ...$keys + * @return void + */ + protected function delete(string ...$keys): void + { + $this->pool->use(function (\Redis|\RedisCluster $redis) use ($keys): void { + $redis->del(...$keys); + }); + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + + /** @var array $result */ + $result = $this->pool->use(function (\Redis|\RedisCluster $redis) use ($offset, $limit): array { + if ($redis instanceof \RedisCluster) { + return $this->getRedisClusterLogs($redis, $offset, $limit); + } + + $cursor = null; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + do { + $keys = $redis->scan($cursor, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $redis->get($key); + } + + return $logs; + }); + + return $result; + } + + /** + * @param \RedisCluster $redis + * @param int $offset + * @param int $limit + * @return array + */ + private function getRedisClusterLogs(\RedisCluster $redis, int $offset, int $limit): array + { + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + foreach ($redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $redis->scan($cursor, $master, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + } + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $values = $redis->mget($matches); + if (!\is_array($values)) { + return []; + } + + $logs = \array_combine($matches, $values); + if (!\is_array($logs)) { + return []; + } + + return $logs; + } +} diff --git a/packages/abuse/src/Adapters/TimeLimit.php b/packages/abuse/src/Adapters/TimeLimit.php new file mode 100644 index 00000000000..073c8f3dff8 --- /dev/null +++ b/packages/abuse/src/Adapters/TimeLimit.php @@ -0,0 +1,121 @@ +limit) { + return false; + } + + $key = $this->parseKey(); + + if ($this->limit > $this->count($key, $this->timestamp)) { + $this->hit($key, $this->timestamp); + + return false; + } + + return true; + } + + /** + * Remaining + * + * Returns the number of current remaining counts + * + * @return int + * + * @throws \Exception + */ + public function remaining(): int + { + $left = $this->limit - ($this->count($this->parseKey(), $this->timestamp) + 1); // Add one because we need to say how many left not how many done + + return (0 > $left) ? 0 : $left; + } + + /** + * Limit + * + * Return the limit integer + * + * @return int + */ + public function limit(): int + { + return $this->limit; + } + + /** + * Time + * + * Return the timestamp + * + * @return int + */ + public function time(): int + { + return $this->timestamp; + } + + /** + * Reset + * + * Reset the count to 0 for the current key and timestamp + * + * @return void + * + * @throws \Exception + */ + public function reset(): void + { + $this->set($this->parseKey(), $this->timestamp, 0); + } +} diff --git a/packages/abuse/src/Adapters/TimeLimit/Appwrite/TablesDB.php b/packages/abuse/src/Adapters/TimeLimit/Appwrite/TablesDB.php new file mode 100644 index 00000000000..fcd2563b1df --- /dev/null +++ b/packages/abuse/src/Adapters/TimeLimit/Appwrite/TablesDB.php @@ -0,0 +1,450 @@ +key = $key; + $now = \time(); + $this->timestamp = (int)($now - ($now % $seconds)); + $this->limit = $limit; + $this->tablesDB = new TablesDBService($client); + $this->databaseId = $databaseId; + } + + /** + * @throws \Exception + */ + public function setup(): void + { + if ($this->isSetupComplete()) { + return; + } + + $this->createDatabase(); + + if (! $this->createTable()) { + // The table is left over from a setup that did not run to completion, + // so some of its columns or indexes may be missing. Inline definitions + // only apply while the table is being created, so add them one by one. + $this->createColumns(); + $this->waitForResourcesReady('columns'); + $this->createIndexes(); + $this->waitForResourcesReady('indexes'); + } + + $this->createLockTable(); + } + + protected function isSetupComplete(): bool + { + try { + $this->tablesDB->getTable($this->databaseId, self::TABLE_LOCK); + return true; + } catch (\Throwable $err) { + return false; + } + } + + protected function createDatabase(): void + { + $this->executeWithSilentError( + fn () => $this->tablesDB->create($this->databaseId, self::DATABASE_NAME), + 'database_already_exists' + ); + } + + /** + * Create the abuse table along with its columns and indexes in one request. + * + * Inline columns and indexes are created synchronously and come back + * available, so there is nothing to poll for afterwards. + * + * @return bool false when the table already existed + */ + protected function createTable(): bool + { + return $this->executeWithSilentError( + fn () => $this->tablesDB->createTable( + $this->databaseId, + self::TABLE_ID, + self::TABLE_NAME, + columns: $this->columnDefinitions(), + indexes: $this->indexDefinitions(), + ), + 'table_already_exists' + ); + } + + /** + * Columns sent inline when the table is created. + * + * createColumns() repairs a table that already exists from the same list. + * + * @return array + */ + protected function columnDefinitions(): array + { + return [ + ['key' => 'key', 'type' => 'string', 'size' => 255, 'required' => true], + ['key' => 'time', 'type' => 'datetime', 'required' => true], + ['key' => 'count', 'type' => 'integer', 'required' => true, 'min' => 0, 'max' => PHP_INT_MAX], + ]; + } + + /** + * Indexes sent inline when the table is created. + * + * createIndexes() repairs a table that already exists from the same list. + * + * An inline definition names its columns under 'attributes', even though + * the index that comes back reports them under 'columns'. + * + * @return array}> + */ + protected function indexDefinitions(): array + { + return [ + ['key' => 'unique1', 'type' => (string) TablesDBIndexType::UNIQUE(), 'attributes' => ['key', 'time']], + ['key' => 'index2', 'type' => (string) TablesDBIndexType::KEY(), 'attributes' => ['time']], + ]; + } + + /** + * Add the columns to a table that already exists, one endpoint per type. + */ + protected function createColumns(): void + { + foreach ($this->columnDefinitions() as $column) { + $key = $column['key']; + $required = $column['required']; + + $createColumnFunction = match ($column['type']) { + 'string' => fn () => $this->tablesDB->createStringColumn($this->databaseId, self::TABLE_ID, $key, $column['size'] ?? 0, $required), + 'datetime' => fn () => $this->tablesDB->createDatetimeColumn($this->databaseId, self::TABLE_ID, $key, $required), + 'integer' => fn () => $this->tablesDB->createIntegerColumn($this->databaseId, self::TABLE_ID, $key, $required, $column['min'] ?? null, $column['max'] ?? null), + default => throw new \Exception("No endpoint for column '{$key}'."), + }; + + $this->executeWithSilentError($createColumnFunction, 'column_already_exists'); + } + } + + /** + * Add the indexes to a table that already exists. + */ + protected function createIndexes(): void + { + foreach ($this->indexDefinitions() as $index) { + $this->executeWithSilentError( + fn () => $this->tablesDB->createIndex( + $this->databaseId, + self::TABLE_ID, + $index['key'], + TablesDBIndexType::from($index['type']), + $index['attributes'], + ), + 'index_already_exists' + ); + } + } + + protected function waitForResourcesReady(string $resourceType): void + { + $attempts = 0; + $maxAttempts = 15; + + while ($attempts < $maxAttempts) { + $attempts++; + + $resources = $resourceType === 'columns' + ? $this->tablesDB->listColumns($this->databaseId, self::TABLE_ID, [Query::notEqual('status', 'available'), Query::limit(1)])->columns + : $this->tablesDB->listIndexes($this->databaseId, self::TABLE_ID, [Query::notEqual('status', 'available'), Query::limit(1)])->indexes; + + $resources = \array_filter($resources, fn ($resource) => $this->resourceStatus($resource) !== 'available'); + + if (\count($resources) === 0) { + return; + } + + \sleep(1); + } + + throw new \Exception("Failed to setup {$resourceType}."); + } + + /** + * Read the status off a listed column or index. + * + * A listed column arrives as the raw payload, since the SDK has no single + * model to hydrate the union of column types into, while a listed index + * arrives as a ColumnIndex. Accept either shape. + */ + protected function resourceStatus(mixed $resource): string + { + $status = null; + + if (\is_array($resource)) { + $status = $resource['status'] ?? null; + } elseif (\is_object($resource) && \property_exists($resource, 'status')) { + $status = $resource->status; + } + + return \is_scalar($status) || $status instanceof \Stringable ? (string) $status : ''; + } + + protected function createLockTable(): void + { + $this->executeWithSilentError( + fn () => $this->tablesDB->createTable($this->databaseId, self::TABLE_LOCK, name: self::TABLE_LOCK), + 'table_already_exists' + ); + } + + /** + * @return bool false when the call failed with the tolerated error + */ + protected function executeWithSilentError(callable $callback, string $allowedErrorType): bool + { + try { + $callback(); + + return true; + } catch (AppwriteException $err) { + if ($err->getType() !== $allowedErrorType) { + throw $err; + } + + return false; + } + } + + /** + * Check + * + * Checks if number of counts is bigger or smaller than current limit + * + * @param string $key + * @param int $timestamp + * @return int + * + * @throws \Exception + */ + protected function count(string $key, int $timestamp): int + { + if (0 == $this->limit) { // No limit no point for counting + return 0; + } + + if (! \is_null($this->count)) { // Get fetched result + return $this->count; + } + + $timestamp = $this->toDateTime($timestamp); + + $rows = $this->tablesDB->listRows($this->databaseId, self::TABLE_ID, [ + Query::equal('key', [$key]), + Query::equal('time', [$timestamp]), + ])->rows; + + $this->count = 0; + + if (\count($rows) === 1) { // Unique Index + $count = $rows[0]->data['count'] ?? 0; + if (\is_numeric($count)) { + $this->count = intval($count); + } + } + + return $this->count; + } + + /** + * @param string $key + * @param int $timestamp + * @return void + * + * @throws \Throwable + */ + protected function hit(string $key, int $timestamp): void + { + if (0 == $this->limit) { // No limit no point for counting + return; + } + + $timestamp = $this->toDateTime($timestamp); + + $rows = $this->tablesDB->listRows($this->databaseId, self::TABLE_ID, [ + Query::equal('key', [$key]), + Query::equal('time', [$timestamp]), + ])->rows; + $row = $rows[0] ?? null; + + if (\is_null($row)) { + $data = [ + 'key' => $key, + 'time' => $timestamp, + 'count' => 1, + ]; + + try { + $this->tablesDB->createRow($this->databaseId, self::TABLE_ID, ID::unique(), $data); + } catch (AppwriteException $err) { + if ($err->getType() !== 'row_already_exists') { + throw $err; + } + + $rows = $this->tablesDB->listRows($this->databaseId, self::TABLE_ID, [ + Query::equal('key', [$key]), + Query::equal('time', [$timestamp]), + ])->rows; + + $row = $rows[0] ?? null; + + if (!is_null($row)) { + $count = $row->data['count'] ?? 0; + if (\is_numeric($count)) { + $this->count = intval($count); + } + + $this->tablesDB->incrementRowColumn($this->databaseId, self::TABLE_ID, $row->id, 'count', 1); + } else { + throw new \Exception('Document Not Found'); + } + } + } else { + $this->tablesDB->incrementRowColumn($this->databaseId, self::TABLE_ID, $row->id, 'count', 1); + } + + $this->count++; + } + + /** + * @param string $key + * @param int $timestamp + * @param int $value + * @return void + * + * @throws \Throwable + */ + protected function set(string $key, int $timestamp, int $value): void + { + $timestamp = $this->toDateTime($timestamp); + + $rows = $this->tablesDB->listRows($this->databaseId, self::TABLE_ID, [ + Query::equal('key', [$key]), + Query::equal('time', [$timestamp]), + ])->rows; + $row = $rows[0] ?? null; + + if (\is_null($row)) { + $data = [ + 'key' => $key, + 'time' => $timestamp, + 'count' => $value, + ]; + + try { + $this->tablesDB->createRow($this->databaseId, self::TABLE_ID, ID::unique(), $data); + } catch (AppwriteException $err) { + if ($err->getType() !== 'row_already_exists') { + throw $err; + } + + $rows = $this->tablesDB->listRows($this->databaseId, self::TABLE_ID, [ + Query::equal('key', [$key]), + Query::equal('time', [$timestamp]), + ])->rows; + + $row = $rows[0] ?? null; + + if (!is_null($row)) { + $this->tablesDB->updateRow($this->databaseId, self::TABLE_ID, $row->id, ['count' => $value]); + } else { + throw new \Exception('Unable to find abuse tracking row after race condition handling'); + } + } + } else { + $this->tablesDB->updateRow($this->databaseId, self::TABLE_ID, $row->id, ['count' => $value]); + } + + $this->count = $value; + } + + /** + * Get abuse logs + * + * Return logs with an optional offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + * + * @throws \Exception + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $queries = []; + + $queries[] = Query::orderDesc(''); + + if (! \is_null($offset)) { + $queries[] = Query::offset($offset); + } + if (! \is_null($limit)) { + $queries[] = Query::limit($limit); + } + + $rows = $this->tablesDB->listRows($this->databaseId, self::TABLE_ID, $queries)->rows; + + return \array_map(fn (Row $row) => new Document($row->toArray()), $rows); + } + + /** + * Delete logs older than $timestamp seconds + * + * @param int $timestamp + * @return bool + * + * @throws \Exception + */ + public function cleanup(int $timestamp): bool + { + $timestamp = $this->toDateTime($timestamp); + + do { + $response = $this->tablesDB->deleteRows($this->databaseId, self::TABLE_ID, [ + Query::lessThan('time', $timestamp), + ]); + } while ($response->total > 0); + + return true; + } + + protected function toDateTime(int $timestamp): string + { + return (new \DateTime())->setTimestamp($timestamp)->format('Y-m-d H:i:s.v'); + } +} diff --git a/packages/abuse/src/Adapters/TimeLimit/Database.php b/packages/abuse/src/Adapters/TimeLimit/Database.php new file mode 100644 index 00000000000..cf9c30cf618 --- /dev/null +++ b/packages/abuse/src/Adapters/TimeLimit/Database.php @@ -0,0 +1,332 @@ + 'key', + 'type' => UtopiaDB::VAR_STRING, + 'size' => UtopiaDB::LENGTH_KEY, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], [ + '$id' => 'time', + 'type' => UtopiaDB::VAR_DATETIME, + 'size' => 0, + 'required' => true, + 'signed' => false, + 'array' => false, + 'filters' => ['datetime'], + ], [ + '$id' => 'count', + 'type' => UtopiaDB::VAR_INTEGER, + 'size' => 11, + 'required' => true, + 'signed' => false, + 'array' => false, + 'filters' => [], + ], + ]; + + public const INDEXES = [ + [ + '$id' => 'unique1', + 'type' => UtopiaDB::INDEX_UNIQUE, + 'attributes' => ['key', 'time'], + 'lengths' => [], + 'orders' => [], + ], [ + '$id' => 'index2', + 'type' => UtopiaDB::INDEX_KEY, + 'attributes' => ['time'], + 'lengths' => [], + 'orders' => [], + ], + ]; + + /** + * @var UtopiaDB + */ + protected UtopiaDB $db; + + /** + * @var int|null + */ + protected ?int $count = null; + + /** + * @param string $key + * @param int $seconds + * @param int $limit + * @param UtopiaDB $db + */ + public function __construct(string $key, int $limit, int $seconds, UtopiaDB $db) + { + $this->key = $key; + $now = \time(); + $this->timestamp = (int)($now - ($now % $seconds)); + $this->limit = $limit; + $this->db = $db; + } + + /** + * @throws Duplicate + * @throws \Exception + */ + public function setup(): void + { + if (! $this->db->exists($this->db->getDatabase())) { + throw new \Exception('You need to create database before running timelimit setup'); + } + + $attributes = \array_map(function ($attribute) { + return new Document($attribute); + }, self::ATTRIBUTES); + + $indexes = \array_map(function ($index) { + return new Document($index); + }, self::INDEXES); + + try { + $this->db->createCollection( + self::COLLECTION, + $attributes, + $indexes + ); + } catch (Duplicate) { + // Collection already exists + } + } + + /** + * Check + * + * Checks if number of counts is bigger or smaller than current limit + * + * @param string $key + * @param int $timestamp + * @return int + * + * @throws \Exception + */ + protected function count(string $key, int $timestamp): int + { + if (0 == $this->limit) { // No limit no point for counting + return 0; + } + + if (! \is_null($this->count)) { // Get fetched result + return $this->count; + } + + $timestamp = $this->toDateTime($timestamp); + + /** @var array $result */ + $result = $this->db->getAuthorization()->skip(function () use ($key, $timestamp) { + return $this->db->find(self::COLLECTION, [ + Query::equal('key', [$key]), + Query::equal('time', [$timestamp]), + ]); + }); + + $this->count = 0; + + if (\count($result) === 1) { // Unique Index + $count = $result[0]->getAttribute('count', 0); + if (\is_numeric($count)) { + $this->count = intval($count); + } + } + + return $this->count; + } + + /** + * @param string $key + * @param int $timestamp + * @return void + * + * @throws AuthorizationException|Structure|\Exception|\Throwable + */ + protected function hit(string $key, int $timestamp): void + { + if (0 == $this->limit) { // No limit no point for counting + return; + } + + $timestamp = $this->toDateTime($timestamp); + $this->db->getAuthorization()->skip(function () use ($timestamp, $key) { + $data = $this->db->findOne(self::COLLECTION, [ + Query::equal('key', [$key]), + Query::equal('time', [$timestamp]), + ]); + + if ($data->isEmpty()) { + $data = [ + '$permissions' => [], + 'key' => $key, + 'time' => $timestamp, + 'count' => 1, + '$collection' => self::COLLECTION, + ]; + + try { + $this->db->createDocument(self::COLLECTION, new Document($data)); + } catch (Duplicate $e) { + // Duplicate in case of race condition + $data = $this->db->findOne(self::COLLECTION, [ + Query::equal('key', [$key]), + Query::equal('time', [$timestamp]), + ]); + + if (!$data->isEmpty()) { + $count = $data->getAttribute('count', 0); + if (\is_numeric($count)) { + $this->count = intval($count); + } + $this->db->increaseDocumentAttribute(self::COLLECTION, $data->getId(), 'count'); + } else { + throw new \Exception('Document Not Found'); + } + } + } else { + /** @var Document $data */ + $this->db->increaseDocumentAttribute(self::COLLECTION, $data->getId(), 'count'); + } + }); + + $this->count++; + } + + /** + * @param string $key + * @param int $timestamp + * @param int $value + * @return void + * + * @throws AuthorizationException|Structure|\Exception|\Throwable + */ + protected function set(string $key, int $timestamp, int $value): void + { + $timestamp = $this->toDateTime($timestamp); + $this->db->getAuthorization()->skip(function () use ($timestamp, $key, $value) { + $data = $this->db->findOne(self::COLLECTION, [ + Query::equal('key', [$key]), + Query::equal('time', [$timestamp]), + ]); + + if ($data->isEmpty()) { + $data = [ + '$permissions' => [], + 'key' => $key, + 'time' => $timestamp, + 'count' => $value, + '$collection' => self::COLLECTION, + ]; + + try { + $this->db->createDocument(self::COLLECTION, new Document($data)); + } catch (Duplicate $e) { + // Duplicate in case of race condition - update existing document + $data = $this->db->findOne(self::COLLECTION, [ + Query::equal('key', [$key]), + Query::equal('time', [$timestamp]), + ]); + + if (!$data->isEmpty()) { + /** @var Document $data */ + $this->db->updateDocument(self::COLLECTION, $data->getId(), new Document([ + 'count' => $value, + ])); + } else { + throw new \Exception('Unable to find abuse tracking document after race condition handling'); + } + } + } else { + /** @var Document $data */ + $this->db->updateDocument(self::COLLECTION, $data->getId(), new Document([ + 'count' => $value, + ])); + } + }); + + $this->count = $value; + } + + /** + * Get abuse logs + * + * Return logs with an optional offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + * + * @throws \Exception + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + /** @var array $results */ + $results = $this->db->getAuthorization()->skip(function () use ($offset, $limit) { + $queries = []; + $queries[] = Query::orderDesc(''); + + if (! \is_null($offset)) { + $queries[] = Query::offset($offset); + } + if (! \is_null($limit)) { + $queries[] = Query::limit($limit); + } + + return $this->db->find(self::COLLECTION, $queries); + }); + + return $results; + } + + /** + * Delete logs older than $timestamp seconds + * + * @param int $timestamp + * @return bool + * + * @throws AuthorizationException|\Exception + */ + public function cleanup(int $timestamp): bool + { + $timestamp = $this->toDateTime($timestamp); + $this->db->getAuthorization()->skip(function () use ($timestamp) { + do { + $documents = $this->db->find(self::COLLECTION, [ + Query::lessThan('time', $timestamp), + ]); + + foreach ($documents as $document) { + $this->db->deleteDocument(self::COLLECTION, $document->getId()); + } + } while (! empty($documents)); + }); + + return true; + } + + protected function toDateTime(int $timestamp): string + { + return DateTime::format((new \DateTime())->setTimestamp($timestamp)); + } +} diff --git a/packages/abuse/src/Adapters/TimeLimit/None.php b/packages/abuse/src/Adapters/TimeLimit/None.php new file mode 100644 index 00000000000..37629440e9b --- /dev/null +++ b/packages/abuse/src/Adapters/TimeLimit/None.php @@ -0,0 +1,60 @@ +key = $key; + $this->ttl = $seconds; + $now = \time(); + $this->timestamp = (int) ($now - ($now % $seconds)); + $this->limit = $limit; + } + + protected function count(string $key, int $timestamp): int + { + return 0; + } + + protected function hit(string $key, int $timestamp): void + { + } + + protected function set(string $key, int $timestamp, int $value): void + { + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + return []; + } + + /** + * Delete all logs older than $timestamp + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } +} diff --git a/packages/abuse/src/Adapters/TimeLimit/Redis.php b/packages/abuse/src/Adapters/TimeLimit/Redis.php new file mode 100644 index 00000000000..2ffb02090ba --- /dev/null +++ b/packages/abuse/src/Adapters/TimeLimit/Redis.php @@ -0,0 +1,135 @@ +redis = $redis; + $this->key = $key; + $this->ttl = $seconds; + $now = \time(); + $this->timestamp = (int)($now - ($now % $seconds)); + $this->limit = $limit; + } + + /** + * Undocumented function + * + * @param string $key + * @param int $timestamp + * @return integer + */ + protected function count(string $key, int $timestamp): int + { + if (0 == $this->limit) { // No limit no point for counting + return 0; + } + + if (! \is_null($this->count)) { // Get fetched result + return $this->count; + } + + /** @var string $count */ + $count = $this->redis->get(self::NAMESPACE . '__'. $key .'__'. $timestamp); + if (!$count) { + $this->count = 0; + } else { + $this->count = intval($count); + } + + return $this->count; + } + + /** + * @param string $key + * @param int $timestamp + * @return void + * + */ + protected function hit(string $key, int $timestamp): void + { + if (0 == $this->limit) { // No limit no point for counting + return; + } + + $key = self::NAMESPACE . '__' . $key . '__' . $timestamp; + $this->redis->multi() + ->incr($key) + ->expire($key, $this->ttl) + ->exec(); + + $this->count = ($this->count ?? 0) + 1; + } + + /** + * Set count for a key at specific timestamp + * + * @param string $key + * @param int $timestamp + * @param int $value + * @return void + */ + protected function set(string $key, int $timestamp, int $value): void + { + $key = self::NAMESPACE . '__' . $key . '__' . $timestamp; + $this->redis->multi() + ->set($key, (string)$value) + ->expire($key, $this->ttl) + ->exec(); + + $this->count = $value; + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + // TODO limit potential is SCAN but needs cursor no offset + $cursor = null; + $keys = $this->redis->scan($cursor, self::NAMESPACE . '__*', $limit); + if (!$keys) { + return []; + } + + $logs = []; + foreach ($keys as $key) { + $logs[$key] = $this->redis->get($key); + } + return $logs; + } + + /** + * Delete all logs older than $timestamp + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + // No need for manual cleanup - Redis TTL handles this automatically + return true; + } +} diff --git a/packages/abuse/src/Adapters/TimeLimit/RedisCluster.php b/packages/abuse/src/Adapters/TimeLimit/RedisCluster.php new file mode 100644 index 00000000000..731415e9ae0 --- /dev/null +++ b/packages/abuse/src/Adapters/TimeLimit/RedisCluster.php @@ -0,0 +1,154 @@ +redis = $redis; + $this->key = $key; + $this->ttl = $seconds; + $now = \time(); + $this->timestamp = (int)($now - ($now % $seconds)); + $this->limit = $limit; + } + + /** + * Get count for a key at specific timestamp + * + * @param string $key + * @param int $timestamp + * @return integer + */ + protected function count(string $key, int $timestamp): int + { + if (0 == $this->limit) { // No limit no point for counting + return 0; + } + + if (! \is_null($this->count)) { // Get fetched result + return $this->count; + } + + /** @var string|false $count */ + $count = $this->redis->get(self::NAMESPACE . '__'. $key .'__'. $timestamp); + if ($count === false) { + $this->count = 0; + } else { + $this->count = intval($count); + } + + return $this->count; + } + + /** + * Record a hit for a key at specific timestamp + * + * @param string $key + * @param int $timestamp + * @return void + */ + protected function hit(string $key, int $timestamp): void + { + if (0 == $this->limit) { // No limit no point for counting + return; + } + + $key = self::NAMESPACE . '__'. $key .'__'. $timestamp; + + $this->redis->multi(); + $this->redis->incr($key); + $this->redis->expire($key, $this->ttl); + $this->redis->exec(); + + $this->count = ($this->count ?? 0) + 1; + } + + /** + * Set count for a key at specific timestamp + * + * @param string $key + * @param int $timestamp + * @param int $value + * @return void + */ + protected function set(string $key, int $timestamp, int $value): void + { + + $key = self::NAMESPACE . '__' . $key . '__' . $timestamp; + + $this->redis->multi(); + $this->redis->set($key, (string)$value); + $this->redis->expire($key, $this->ttl); + $this->redis->exec(); + + $this->count = $value; + } + + /** + * Get abuse logs with proper cursor-based pagination + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = 0, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + // Get all keys from each master + foreach ($this->redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $this->redis->scan($cursor, $master, $pattern, 100); + if ($keys !== false) { + $matches = array_merge($matches, $keys); + } + } while ($cursor > 0 && count($matches) < $offset + $limit); + } + + // Sort to ensure consistent ordering + sort($matches); + + // Apply offset and limit + $matches = array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + // Batch fetch values using mget + $values = $this->redis->mget($matches); + return array_combine($matches, $values); + } + + /** + * No need for manual cleanup - using Redis TTL + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } +} diff --git a/packages/abuse/src/Adapters/TimeLimit/RedisPool.php b/packages/abuse/src/Adapters/TimeLimit/RedisPool.php new file mode 100644 index 00000000000..a7a4cc87f20 --- /dev/null +++ b/packages/abuse/src/Adapters/TimeLimit/RedisPool.php @@ -0,0 +1,215 @@ +|UtopiaPool<\RedisCluster> $pool + */ + public function __construct( + string $key, + int $limit, + int $seconds, + protected UtopiaPool $pool + ) { + $this->key = $key; + $this->ttl = $seconds; + $now = \time(); + $this->timestamp = (int) ($now - ($now % $seconds)); + $this->limit = $limit; + } + + protected function count(string $key, int $timestamp): int + { + if (0 == $this->limit) { + return 0; + } + + if (!\is_null($this->count)) { + return $this->count; + } + + /** @var int $count */ + $count = $this->pool->use(function (\Redis|\RedisCluster $redis) use ($key, $timestamp): int { + $count = $redis->get(Redis::NAMESPACE . '__' . $key . '__' . $timestamp); + + return \is_numeric($count) ? (int) $count : 0; + }); + + $this->count = $count; + + return $this->count; + } + + protected function hit(string $key, int $timestamp): void + { + if (0 == $this->limit) { + return; + } + + $ttl = $this->ttl; + $key = Redis::NAMESPACE . '__' . $key . '__' . $timestamp; + + $this->pool->use(function (\Redis|\RedisCluster $redis) use ($key, $ttl): void { + $redis->multi(); + try { + $redis->incr($key); + $redis->expire($key, $ttl); + $result = $redis->exec(); + } catch (Throwable $th) { + $this->discard($redis); + throw $th; + } + + if (!\is_array($result) || \in_array(false, $result, true)) { + $this->discard($redis); + throw new RuntimeException('Redis transaction failed.'); + } + }); + + $this->count = ($this->count ?? 0) + 1; + } + + protected function set(string $key, int $timestamp, int $value): void + { + $ttl = $this->ttl; + $key = Redis::NAMESPACE . '__' . $key . '__' . $timestamp; + + $this->pool->use(function (\Redis|\RedisCluster $redis) use ($key, $ttl, $value): void { + $redis->multi(); + try { + $redis->set($key, (string) $value); + $redis->expire($key, $ttl); + $result = $redis->exec(); + } catch (Throwable $th) { + $this->discard($redis); + throw $th; + } + + if (!\is_array($result) || \in_array(false, $result, true)) { + $this->discard($redis); + throw new RuntimeException('Redis transaction failed.'); + } + }); + + $this->count = $value; + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + + /** @var array $result */ + $result = $this->pool->use(function (\Redis|\RedisCluster $redis) use ($offset, $limit): array { + if ($redis instanceof \RedisCluster) { + return $this->getRedisClusterLogs($redis, $offset, $limit); + } + + $cursor = null; + $matches = []; + $pattern = Redis::NAMESPACE . '__*'; + + do { + $keys = $redis->scan($cursor, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $redis->get($key); + } + + return $logs; + }); + + return $result; + } + + /** + * Delete all logs older than $timestamp + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } + + private function discard(\Redis|\RedisCluster $redis): void + { + try { + $redis->discard(); + } catch (Throwable) { + } + } + + /** + * @return array + */ + private function getRedisClusterLogs(\RedisCluster $redis, int $offset, int $limit): array + { + $matches = []; + $pattern = Redis::NAMESPACE . '__*'; + + foreach ($redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $redis->scan($cursor, $master, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + } + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $values = $redis->mget($matches); + if (!\is_array($values)) { + return []; + } + + $logs = \array_combine($matches, $values); + if (!\is_array($logs)) { + return []; + } + + return $logs; + } +} diff --git a/packages/abuse/src/Adapters/TokenBucket.php b/packages/abuse/src/Adapters/TokenBucket.php new file mode 100644 index 00000000000..19d50d30352 --- /dev/null +++ b/packages/abuse/src/Adapters/TokenBucket.php @@ -0,0 +1,102 @@ +tokens - ($this->count($this->parseKey(), $this->timestamp) + 1); + + return (0 > $left) ? 0 : $left; + } + + /** + * Limit + * + * Return the bucket capacity + * + * @return int + */ + public function limit(): int + { + return $this->tokens; + } + + /** + * Time + * + * Return the timestamp + * + * @return int + */ + public function time(): int + { + return $this->timestamp; + } + + /** + * Reset + * + * Clear the bucket for the current key so it starts full again. + * + * @return void + * + * @throws \Exception + */ + abstract public function reset(): void; +} diff --git a/packages/abuse/src/Adapters/TokenBucket/None.php b/packages/abuse/src/Adapters/TokenBucket/None.php new file mode 100644 index 00000000000..cdec3544cc1 --- /dev/null +++ b/packages/abuse/src/Adapters/TokenBucket/None.php @@ -0,0 +1,57 @@ +key = $key; + $this->tokens = $tokens; + $this->timestamp = \time(); + } + + protected function count(string $key, int $timestamp): int + { + return 0; + } + + public function check(): bool + { + return false; + } + + public function reset(): void + { + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + return []; + } + + /** + * Delete all logs older than $timestamp + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } +} diff --git a/packages/abuse/src/Adapters/TokenBucket/Redis.php b/packages/abuse/src/Adapters/TokenBucket/Redis.php new file mode 100644 index 00000000000..58e24026d5e --- /dev/null +++ b/packages/abuse/src/Adapters/TokenBucket/Redis.php @@ -0,0 +1,81 @@ +initBucket($refillRate); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + * + * @throws \RedisException + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->redis->eval($script, [...$keys, ...$argv], \count($keys)); + } + + /** + * @param string ...$keys + * @return void + * + * @throws \RedisException + */ + protected function delete(string ...$keys): void + { + $this->redis->del(...$keys); + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + + $cursor = null; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + do { + $keys = $this->redis->scan($cursor, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $this->redis->hGetAll($key); + } + + return $logs; + } +} diff --git a/packages/abuse/src/Adapters/TokenBucket/RedisBase.php b/packages/abuse/src/Adapters/TokenBucket/RedisBase.php new file mode 100644 index 00000000000..6fa8ec9cdfa --- /dev/null +++ b/packages/abuse/src/Adapters/TokenBucket/RedisBase.php @@ -0,0 +1,228 @@ += 1 then + tokens = tokens - 1 + allowed = 1 + end + + redis.call('HSET', key, 'tokens', tostring(tokens), 'last_refill', tostring(now)) + redis.call('EXPIRE', key, math.ceil(max_tokens / refill_rate) + 1) + + return { allowed, tostring(tokens) } + LUA; + + /** + * Read-only token estimate: refills the bucket for the elapsed time without + * consuming anything or writing back. Used by remaining(). + * + * KEYS[1] bucket hash key. + * ARGV[1] max_tokens, ARGV[2] refill_rate, ARGV[3] now. + * + * Returns the available token balance as a string. + */ + protected const string TOKENS_SCRIPT = <<<'LUA' + local key = KEYS[1] + local max_tokens = tonumber(ARGV[1]) + local refill_rate = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + + local data = redis.call('HMGET', key, 'tokens', 'last_refill') + local tokens = tonumber(data[1]) or max_tokens + local last_refill = tonumber(data[2]) or now + + local elapsed = now - last_refill + if elapsed < 0 then elapsed = 0 end + tokens = math.min(max_tokens, tokens + elapsed * refill_rate) + + return tostring(tokens) + LUA; + + /** + * Tokens refilled per second. + * + * @var float + */ + protected float $refillRate; + + /** + * Run a Lua script against the storage backend. + * + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed the raw script result + */ + abstract protected function eval(string $script, array $keys, array $argv): mixed; + + /** + * Delete the given keys. + * + * @param string ...$keys + * @return void + */ + abstract protected function delete(string ...$keys): void; + + /** + * Validate and store the bucket configuration. + * + * @param float $refillRate + * @return void + */ + protected function initBucket(float $refillRate): void + { + if ($refillRate <= 0) { + throw new \InvalidArgumentException('refillRate must be greater than 0'); + } + + $this->refillRate = $refillRate; + $this->timestamp = \time(); + } + + /** + * Build the bucket hash key for a given abuse key. + * + * @param string $key + * @return string + */ + protected function bucketKey(string $key): string + { + return self::NAMESPACE . '__' . $key; + } + + /** + * Check + * + * @return bool + * + * @throws \Throwable + */ + public function check(): bool + { + if ($this->tokens === 0) { + return false; + } + + $key = $this->parseKey(); + $this->timestamp = \time(); + + /** @var array{0:int,1:string} $result */ + $result = $this->eval( + self::LIMIT_CHECK_SCRIPT, + [ + $this->bucketKey($key), // KEYS[1] bucket hash + ], + [ + $this->tokens, // ARGV[1] max_tokens + $this->refillRate, // ARGV[2] refill_rate + \microtime(true), // ARGV[3] now (fractional seconds) + ], + ); + + [$allowed] = $result; + + return (int) $allowed === 0; + } + + /** + * Count + * + * Read-only estimate of the tokens already consumed from the bucket + * (capacity minus the tokens available after refilling). Used by remaining(). + * The bucket refills continuously, so this always reads a fresh estimate + * rather than reusing a cached value that would go stale as tokens refill. + * + * @param string $key + * @param int $timestamp + * @return int + */ + protected function count(string $key, int $timestamp): int + { + if ($this->tokens === 0) { + return 0; + } + + $this->timestamp = \time(); + + $raw = $this->eval( + self::TOKENS_SCRIPT, + [ + $this->bucketKey($key), + ], + [ + $this->tokens, + $this->refillRate, + \microtime(true), + ], + ); + + $balance = \is_numeric($raw) ? (float) $raw : (float) $this->tokens; + + return $this->tokens - (int) \floor($balance); + } + + /** + * Reset + * + * Drop the bucket state so the next request sees a full bucket. + * + * @return void + */ + public function reset(): void + { + $this->delete($this->bucketKey($this->parseKey())); + } + + /** + * No need for manual cleanup - Redis TTL handles this automatically + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } +} diff --git a/packages/abuse/src/Adapters/TokenBucket/RedisCluster.php b/packages/abuse/src/Adapters/TokenBucket/RedisCluster.php new file mode 100644 index 00000000000..beb86ec0a1c --- /dev/null +++ b/packages/abuse/src/Adapters/TokenBucket/RedisCluster.php @@ -0,0 +1,81 @@ +initBucket($refillRate); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + * + * @throws \RedisClusterException + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->redis->eval($script, [...$keys, ...$argv], \count($keys)); + } + + /** + * @param string ...$keys + * @return void + * + * @throws \RedisClusterException + */ + protected function delete(string ...$keys): void + { + $this->redis->del(...$keys); + } + + /** + * Get abuse logs with cursor-based pagination across masters + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = 0, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + foreach ($this->redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $this->redis->scan($cursor, $master, $pattern, 100); + if ($keys !== false) { + $matches = array_merge($matches, $keys); + } + } while ($cursor > 0); + } + + sort($matches); + $matches = array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $this->redis->hGetAll($key); + } + + return $logs; + } +} diff --git a/packages/abuse/src/Adapters/TokenBucket/RedisPool.php b/packages/abuse/src/Adapters/TokenBucket/RedisPool.php new file mode 100644 index 00000000000..34cfec93c27 --- /dev/null +++ b/packages/abuse/src/Adapters/TokenBucket/RedisPool.php @@ -0,0 +1,131 @@ +|UtopiaPool<\RedisCluster> $pool Pool yielding a Redis or RedisCluster connection + */ + public function __construct( + protected string $key, + protected int $tokens, + float $refillRate, + protected UtopiaPool $pool + ) { + $this->initBucket($refillRate); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->pool->use(fn (\Redis|\RedisCluster $redis): mixed => $redis->eval($script, [...$keys, ...$argv], \count($keys))); + } + + /** + * @param string ...$keys + * @return void + */ + protected function delete(string ...$keys): void + { + $this->pool->use(function (\Redis|\RedisCluster $redis) use ($keys): void { + $redis->del(...$keys); + }); + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + + /** @var array $result */ + $result = $this->pool->use(function (\Redis|\RedisCluster $redis) use ($offset, $limit): array { + if ($redis instanceof \RedisCluster) { + return $this->getRedisClusterLogs($redis, $offset, $limit); + } + + $cursor = null; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + do { + $keys = $redis->scan($cursor, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $redis->hGetAll($key); + } + + return $logs; + }); + + return $result; + } + + /** + * @param \RedisCluster $redis + * @param int $offset + * @param int $limit + * @return array + */ + private function getRedisClusterLogs(\RedisCluster $redis, int $offset, int $limit): array + { + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + foreach ($redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $redis->scan($cursor, $master, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + } + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $redis->hGetAll($key); + } + + return $logs; + } +} diff --git a/packages/abuse/tests/E2E/Appwrite/TablesDBTest.php b/packages/abuse/tests/E2E/Appwrite/TablesDBTest.php new file mode 100755 index 00000000000..1d9c023ba4e --- /dev/null +++ b/packages/abuse/tests/E2E/Appwrite/TablesDBTest.php @@ -0,0 +1,184 @@ +setEndpoint(\getenv('APPWRITE_ENDPOINT') ?: '') + ->setProject(\getenv('APPWRITE_PROJECT_ID') ?: '') + ->setKey(\getenv('APPWRITE_API_KEY') ?: ''); + + $adapter = new TablesDB('', 1, 1, self::$client, self::$databaseId); + $adapter->setup(); + } + + public function getAdapter(string $key, int $limit, int $seconds): TimeLimit + { + return new TablesDB($key, $limit, $seconds, self::$client, self::$databaseId); + } + + /** + * The schema is sent inline with the table, so assert it lands exactly as + * the dedicated per-column endpoints would have created it. + */ + public function testSetupCreatesSchema(): void + { + $tablesDB = new TablesDBService(self::$client); + + $columns = $this->columnsByKey($tablesDB->listColumns(self::$databaseId, TablesDB::TABLE_ID)->columns); + + $this->assertCount(3, $columns); + + $this->assertSame('string', $columns['key']['type']); + $this->assertSame(255, $columns['key']['size']); + $this->assertTrue($columns['key']['required']); + + $this->assertSame('datetime', $columns['time']['type']); + $this->assertTrue($columns['time']['required']); + + $this->assertSame('integer', $columns['count']['type']); + $this->assertTrue($columns['count']['required']); + $this->assertEquals(0, $columns['count']['min']); + $this->assertEquals(PHP_INT_MAX, $columns['count']['max']); + + $indexes = $this->indexesByKey($tablesDB->listIndexes(self::$databaseId, TablesDB::TABLE_ID)->indexes); + + $this->assertCount(2, $indexes); + + $this->assertSame('unique', $indexes['unique1']->type); + $this->assertSame(['key', 'time'], $indexes['unique1']->columns); + + $this->assertSame('key', $indexes['index2']->type); + $this->assertSame(['time'], $indexes['index2']->columns); + } + + /** + * A table left behind by a setup that did not run to completion is missing + * its columns and indexes, and they can no longer be sent inline. Setup has + * to fill them in one by one instead. + */ + public function testSetupRepairsPartiallyCreatedTable(): void + { + $databaseId = 'abuse-cicd-repair-' . \uniqid(); + $tablesDB = new TablesDBService(self::$client); + + $tablesDB->create($databaseId, TablesDB::DATABASE_NAME); + + try { + $tablesDB->createTable($databaseId, TablesDB::TABLE_ID, TablesDB::TABLE_NAME); + + $adapter = new TablesDB('repair-{{ip}}', 2, 60, self::$client, $databaseId); + $adapter->setup(); + + $columns = $this->columnsByKey($tablesDB->listColumns($databaseId, TablesDB::TABLE_ID)->columns); + $indexes = $this->indexesByKey($tablesDB->listIndexes($databaseId, TablesDB::TABLE_ID)->indexes); + + $this->assertCount(3, $columns); + $this->assertArrayHasKey('key', $columns); + $this->assertArrayHasKey('time', $columns); + $this->assertArrayHasKey('count', $columns); + + $this->assertCount(2, $indexes); + $this->assertArrayHasKey('unique1', $indexes); + $this->assertArrayHasKey('index2', $indexes); + + $adapter->setParam('{{ip}}', '0.0.0.20'); + $abuse = new Abuse($adapter); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), true); + } finally { + $tablesDB->delete($databaseId); + } + } + + /** + * Setup runs on every boot, so it has to be a no-op once the table is there. + */ + public function testSetupIsIdempotent(): void + { + $adapter = new TablesDB('', 1, 1, self::$client, self::$databaseId); + $adapter->setup(); + + $tablesDB = new TablesDBService(self::$client); + + $this->assertCount(3, $tablesDB->listColumns(self::$databaseId, TablesDB::TABLE_ID)->columns); + $this->assertCount(2, $tablesDB->listIndexes(self::$databaseId, TablesDB::TABLE_ID)->indexes); + } + + /** + * A listed column arrives as the raw payload: the SDK has no single model + * to hydrate the union of column types into. + * + * @param array $columns + * @return array> + */ + private function columnsByKey(array $columns): array + { + $byKey = []; + + foreach ($columns as $column) { + $this->assertIsArray($column); + $this->assertSame('available', $column['status']); + + $key = $column['key']; + $this->assertIsString($key); + + $byKey[$key] = $column; + } + + return $byKey; + } + + /** + * A listed index, unlike a column, arrives hydrated. + * + * @param array $indexes + * @return array + */ + private function indexesByKey(array $indexes): array + { + $byKey = []; + + foreach ($indexes as $index) { + $this->assertInstanceOf(ColumnIndex::class, $index); + $this->assertSame('available', $index->status); + + $byKey[$index->key] = $index; + } + + return $byKey; + } + + public static function tearDownAfterClass(): void + { + } +} diff --git a/packages/abuse/tests/E2E/Base.php b/packages/abuse/tests/E2E/Base.php new file mode 100644 index 00000000000..4390245b304 --- /dev/null +++ b/packages/abuse/tests/E2E/Base.php @@ -0,0 +1,138 @@ +getAdapter('static-key', 2, 1); + $abuse = new Abuse($adapter); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), true); + } + + /** + * Test a dynamic key with a limit of 2 requests per second + */ + public function testDynamicKey(): void + { + $adapter = $this->getAdapter('dynamic-key-{{ip}}', 2, 1); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $abuse = new Abuse($adapter); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), true); + } + + /** + * Test a dynamic key with 2 params + */ + public function testDynamicKeyWith2Params(): void + { + $adapter = $this->getAdapter('two-params-{{ip}}-{{email}}', 2, 1); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $adapter->setParam('{{email}}', 'test@test.com'); + $abuse = new Abuse($adapter); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), true); + } + + /** + * Test a dynamic key with higher request rate like 10 requests per second + */ + public function testDynamicKeyFastRequests(): void + { + $adapter = $this->getAdapter('fast-requests-{{ip}}', 10, 1); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 10; $i++) { + $this->assertSame($abuse->check(), false); + } + $this->assertSame($abuse->check(), true); + } + + /** + * Test that the limit is reset after the time limit + */ + public function testLimitReset(): void + { + $adapter = $this->getAdapter('limit-reset-{{ip}}', 10, 2); + $adapter->setParam('{{ip}}', '127.0.0.1'); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 10; $i++) { + $this->assertSame($abuse->check(), false); + } + $this->assertSame($abuse->check(), true); + + // Wait for the limit to reset + sleep(2); + + /** Seems to be a bug in the code where if use the same adapter, it caches the result of the previous check */ + $adapter = $this->getAdapter('limit-reset-{{ip}}', 10, 1); + $adapter->setParam('{{ip}}', '127.0.0.1'); + $abuse = new Abuse($adapter); + $this->assertSame($abuse->check(), false); + } + + /** + * Verify that the time format is correct + */ + public function testTimeFormat(): void + { + $now = time(); + $adapter = $this->getAdapter('', 1, 1); + $this->assertSame($adapter->time(), $now); + $this->assertSame(true, \is_int($adapter->time())); + } + + /** + * Test the reset functionality + */ + public function testReset(): void + { + $adapter = $this->getAdapter('reset-test-{{ip}}', 5, 600); + $adapter->setParam('{{ip}}', '192.168.1.1'); + $abuse = new Abuse($adapter); + + // 5 OK, 6th has limit + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), true); + + // Reset the count + $abuse->reset(); + + // Should be 5 more OK, then 6th limit + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), false); + $this->assertSame($abuse->check(), true); + + // TO be sure, lets do bunch of requests with resets + // All should pass successfully + $adapter = $this->getAdapter('reset-test-{{ip}}', 2, 600); + $adapter->setParam('{{ip}}', '192.168.1.2'); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 15; $i++) { + $this->assertSame($abuse->check(), false); + $abuse->reset(); + } + } +} diff --git a/packages/abuse/tests/E2E/DatabaseTest.php b/packages/abuse/tests/E2E/DatabaseTest.php new file mode 100755 index 00000000000..82cad70549e --- /dev/null +++ b/packages/abuse/tests/E2E/DatabaseTest.php @@ -0,0 +1,59 @@ +setDatabase('utopiaTests'); + $db->setNamespace('namespace'); + + $adapter = new AdapterDatabase('', 1, 1, $db); + if (!$db->exists('utopiaTests')) { + $db->create(); + $adapter->setup(); + } + + return $db; + } + + public function getAdapter(string $key, int $limit, int $seconds): TimeLimit + { + return new AdapterDatabase($key, $limit, $seconds, self::$db); + } + + public static function tearDownAfterClass(): void + { + if (isset(self::$db)) { + self::$db->delete(); + } + } +} diff --git a/packages/abuse/tests/E2E/RedisClusterTest.php b/packages/abuse/tests/E2E/RedisClusterTest.php new file mode 100644 index 00000000000..dbcda580c52 --- /dev/null +++ b/packages/abuse/tests/E2E/RedisClusterTest.php @@ -0,0 +1,43 @@ +close(); + } + } +} diff --git a/packages/abuse/tests/E2E/RedisPoolClusterTest.php b/packages/abuse/tests/E2E/RedisPoolClusterTest.php new file mode 100644 index 00000000000..b394941ec92 --- /dev/null +++ b/packages/abuse/tests/E2E/RedisPoolClusterTest.php @@ -0,0 +1,104 @@ +|null + */ + protected static ?Pool $pool = null; + + public static function setUpBeforeClass(): void + { + if (isset(self::$pool)) { + return; + } + + self::$pool = new Pool(new Stack(), 'abuse-redis-cluster', 2, fn (): \RedisCluster => new \RedisCluster(null, Services::CLUSTER_SEEDS), timeout: 0.0); + } + + public function getAdapter(string $key, int $limit, int $seconds): TimeLimit + { + $pool = self::$pool; + $this->assertInstanceOf(Pool::class, $pool); + + /** @var Pool<\RedisCluster> $pool */ + return new AdapterRedisPool('redis-cluster-pool-' . $key, $limit, $seconds, $pool); + } + + public function testGetLogsSupportsNullableLimit(): void + { + $adapter = $this->getAdapter('logs-null-limit', 1, 60); + $abuse = new \Utopia\Abuse\Abuse($adapter); + + $this->assertSame(false, $abuse->check()); + $this->assertNotEmpty($adapter->getLogs(null, null)); + } + + public function testGetLogsAppliesOffset(): void + { + $this->clearRedisClusterPoolLogs(); + $adapter = $this->getAdapter('logs-offset', 1, 60); + + $this->setRedisClusterPoolLog('a', '1'); + $this->setRedisClusterPoolLog('b', '2'); + $this->setRedisClusterPoolLog('c', '3'); + + $logs = $adapter->getLogs(1, 1); + + $this->assertSame(['abuse__redis-cluster-pool-logs-offset-b__1' => '2'], $logs); + } + + public static function tearDownAfterClass(): void + { + if (!isset(self::$pool)) { + return; + } + + self::$pool->use(function (mixed $redis): void { + if ($redis instanceof \RedisCluster) { + $redis->close(); + } + }); + self::$pool = null; + } + + private function clearRedisClusterPoolLogs(): void + { + $pool = self::$pool; + $this->assertInstanceOf(Pool::class, $pool); + + $pool->use(function (\RedisCluster $redis): void { + foreach ($redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $redis->scan($cursor, $master, 'abuse__*', 100); + if ($keys === false) { + continue; + } + + foreach ($keys as $key) { + $redis->del($key); + } + } while ($cursor > 0); + } + }); + } + + private function setRedisClusterPoolLog(string $key, string $value): void + { + $pool = self::$pool; + $this->assertInstanceOf(Pool::class, $pool); + + $pool->use(function (\RedisCluster $redis) use ($key, $value): void { + $redis->set('abuse__redis-cluster-pool-logs-offset-' . $key . '__1', $value); + }); + } +} diff --git a/packages/abuse/tests/E2E/RedisPoolTest.php b/packages/abuse/tests/E2E/RedisPoolTest.php new file mode 100644 index 00000000000..7fd4e57ea88 --- /dev/null +++ b/packages/abuse/tests/E2E/RedisPoolTest.php @@ -0,0 +1,106 @@ +|null + */ + protected static ?Pool $pool = null; + + public static function setUpBeforeClass(): void + { + if (isset(self::$pool)) { + return; + } + + self::$pool = new Pool(new Stack(), 'abuse-redis', 2, function (): \Redis { + $redis = new \Redis(); + $redis->connect(Services::HOST, Services::REDIS_PORT); + + return $redis; + }, timeout: 0.0); + } + + public function getAdapter(string $key, int $limit, int $seconds): TimeLimit + { + $pool = self::$pool; + $this->assertInstanceOf(Pool::class, $pool); + + /** @var Pool<\Redis> $pool */ + return new AdapterRedisPool('redis-pool-' . $key, $limit, $seconds, $pool); + } + + public function testGetLogsSupportsNullableLimit(): void + { + $adapter = $this->getAdapter('logs-null-limit', 1, 60); + $abuse = new \Utopia\Abuse\Abuse($adapter); + + $this->assertSame(false, $abuse->check()); + $this->assertNotEmpty($adapter->getLogs(null, null)); + } + + public function testGetLogsAppliesOffset(): void + { + $this->clearRedisPoolLogs(); + $adapter = $this->getAdapter('logs-offset', 1, 60); + + $this->setRedisPoolLog('a', '1'); + $this->setRedisPoolLog('b', '2'); + $this->setRedisPoolLog('c', '3'); + + $logs = $adapter->getLogs(1, 1); + + $this->assertSame(['abuse__redis-pool-logs-offset-b__1' => '2'], $logs); + } + + public static function tearDownAfterClass(): void + { + if (!isset(self::$pool)) { + return; + } + + self::$pool->use(function (mixed $redis): void { + if ($redis instanceof \Redis) { + $redis->close(); + } + }); + self::$pool = null; + } + + private function clearRedisPoolLogs(): void + { + $pool = self::$pool; + $this->assertInstanceOf(Pool::class, $pool); + + $pool->use(function (\Redis $redis): void { + $cursor = null; + do { + $keys = $redis->scan($cursor, 'abuse__*', 100); + if ($keys === false) { + continue; + } + + foreach ($keys as $key) { + $redis->del($key); + } + } while ($cursor > 0); + }); + } + + private function setRedisPoolLog(string $key, string $value): void + { + $pool = self::$pool; + $this->assertInstanceOf(Pool::class, $pool); + + $pool->use(function (\Redis $redis) use ($key, $value): void { + $redis->set('abuse__redis-pool-logs-offset-' . $key . '__1', $value); + }); + } +} diff --git a/packages/abuse/tests/E2E/RedisTest.php b/packages/abuse/tests/E2E/RedisTest.php new file mode 100644 index 00000000000..fe8d0a7cc3a --- /dev/null +++ b/packages/abuse/tests/E2E/RedisTest.php @@ -0,0 +1,46 @@ +connect(Services::HOST, Services::REDIS_PORT); + return $redis; + } + + public function getAdapter(string $key, int $limit, int $seconds): TimeLimit + { + return new AdapterRedis($key, $limit, $seconds, self::$redis); + } + + /** + * Clean up Redis connection after all tests + */ + public static function tearDownAfterClass(): void + { + if (isset(self::$redis)) { + self::$redis->close(); + } + } +} diff --git a/packages/abuse/tests/E2E/Services.php b/packages/abuse/tests/E2E/Services.php new file mode 100644 index 00000000000..0b8375e8fa7 --- /dev/null +++ b/packages/abuse/tests/E2E/Services.php @@ -0,0 +1,18 @@ +getAdapter('sw-static-key', 2, 1, 2); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a dynamic key with a limit of 2 requests per window + */ + public function testDynamicKey(): void + { + $adapter = $this->getAdapter('sw-dynamic-key-{{ip}}', 2, 1, 2); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a dynamic key with 2 params + */ + public function testDynamicKeyWith2Params(): void + { + $adapter = $this->getAdapter('sw-two-params-{{ip}}-{{email}}', 2, 1, 2); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $adapter->setParam('{{email}}', 'test@test.com'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a higher request rate like 10 requests per window + */ + public function testFastRequests(): void + { + $adapter = $this->getAdapter('sw-fast-requests-{{ip}}', 10, 1, 2); + $adapter->setParam('{{ip}}', '0.0.0.11'); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 10; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that remaining reports the correct number of allowed requests + */ + public function testRemaining(): void + { + $adapter = $this->getAdapter('sw-remaining-{{ip}}', 3, 60, 120); + $adapter->setParam('{{ip}}', '0.0.0.12'); + $abuse = new Abuse($adapter); + + $this->assertSame(2, $adapter->remaining()); // nothing counted yet: limit - (0 + 1) + $this->assertSame(false, $abuse->check()); // 1 used + $this->assertSame(1, $adapter->remaining()); + $this->assertSame(false, $abuse->check()); // 2 used + $this->assertSame(0, $adapter->remaining()); + } + + /** + * Test that the window resets once both buckets expire + */ + public function testWindowExpiry(): void + { + $adapter = $this->getAdapter('sw-window-expiry-{{ip}}', 3, 1, 2); + $adapter->setParam('{{ip}}', '127.0.0.1'); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 3; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + + // Wait for both the current and previous buckets (ttl = 2) to expire + sleep(3); + + // A fresh adapter recomputes the window; the old buckets are gone + $adapter = $this->getAdapter('sw-window-expiry-{{ip}}', 3, 1, 2); + $adapter->setParam('{{ip}}', '127.0.0.1'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + } + + /** + * Verify that time() returns the aligned window start as an int + */ + public function testTimeFormat(): void + { + $windowSize = 1; + $now = \time(); + $adapter = $this->getAdapter('sw-time', 1, $windowSize, 2); + $this->assertSame((int)($now - ($now % $windowSize)), $adapter->time()); + $this->assertSame(true, \is_int($adapter->time())); + } + + /** + * Test the reset functionality clears both buckets + */ + public function testReset(): void + { + $adapter = $this->getAdapter('sw-reset-test-{{ip}}', 5, 600, 1200); + $adapter->setParam('{{ip}}', '192.168.1.1'); + $abuse = new Abuse($adapter); + + // 5 OK, 6th limited + for ($i = 0; $i < 5; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + + // Reset clears the counters + $abuse->reset(); + + // 5 more OK, then limited again + for ($i = 0; $i < 5; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that a ttl smaller than the window size is rejected + */ + public function testTtlGuard(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->getAdapter('sw-guard', 1, 10, 5); + } + + /** + * Test that limit 0 means unlimited + */ + public function testUnlimited(): void + { + $adapter = $this->getAdapter('sw-unlimited', 0, 1, 2); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 20; $i++) { + $this->assertSame(false, $abuse->check()); + } + } +} diff --git a/packages/abuse/tests/E2E/SlidingWindow/RedisClusterTest.php b/packages/abuse/tests/E2E/SlidingWindow/RedisClusterTest.php new file mode 100644 index 00000000000..fe1554072bd --- /dev/null +++ b/packages/abuse/tests/E2E/SlidingWindow/RedisClusterTest.php @@ -0,0 +1,41 @@ +close(); + } + } +} diff --git a/packages/abuse/tests/E2E/SlidingWindow/RedisPoolTest.php b/packages/abuse/tests/E2E/SlidingWindow/RedisPoolTest.php new file mode 100644 index 00000000000..54cb9acb727 --- /dev/null +++ b/packages/abuse/tests/E2E/SlidingWindow/RedisPoolTest.php @@ -0,0 +1,54 @@ +|null + */ + protected static ?Pool $pool = null; + + public static function setUpBeforeClass(): void + { + if (isset(self::$pool)) { + return; + } + + self::$pool = new Pool(new Stack(), 'abuse-sw-redis', 2, function (): \Redis { + $redis = new \Redis(); + $redis->connect(Services::HOST, Services::REDIS_PORT); + + return $redis; + }, timeout: 0.0); + } + + public function getAdapter(string $key, int $limit, int $windowSize, int $ttl): SlidingWindow + { + $pool = self::$pool; + $this->assertInstanceOf(Pool::class, $pool); + + /** @var Pool<\Redis> $pool */ + return new AdapterRedisPool('sw-pool-' . $key, $limit, $windowSize, $ttl, $pool); + } + + public static function tearDownAfterClass(): void + { + if (!isset(self::$pool)) { + return; + } + + self::$pool->use(function (mixed $redis): void { + if ($redis instanceof \Redis) { + $redis->close(); + } + }); + self::$pool = null; + } +} diff --git a/packages/abuse/tests/E2E/SlidingWindow/RedisTest.php b/packages/abuse/tests/E2E/SlidingWindow/RedisTest.php new file mode 100644 index 00000000000..ba8008edb22 --- /dev/null +++ b/packages/abuse/tests/E2E/SlidingWindow/RedisTest.php @@ -0,0 +1,44 @@ +connect(Services::HOST, Services::REDIS_PORT); + + return $redis; + } + + public function getAdapter(string $key, int $limit, int $windowSize, int $ttl): SlidingWindow + { + return new AdapterRedis($key, $limit, $windowSize, $ttl, self::$redis); + } + + public static function tearDownAfterClass(): void + { + if (isset(self::$redis)) { + self::$redis->close(); + } + } +} diff --git a/packages/abuse/tests/E2E/TokenBucket/Base.php b/packages/abuse/tests/E2E/TokenBucket/Base.php new file mode 100644 index 00000000000..2f840349899 --- /dev/null +++ b/packages/abuse/tests/E2E/TokenBucket/Base.php @@ -0,0 +1,160 @@ +getAdapter('tb-static-key', 2, 0.001); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a dynamic key with a capacity of 2 tokens + */ + public function testDynamicKey(): void + { + $adapter = $this->getAdapter('tb-dynamic-key-{{ip}}', 2, 0.001); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a dynamic key with 2 params + */ + public function testDynamicKeyWith2Params(): void + { + $adapter = $this->getAdapter('tb-two-params-{{ip}}-{{email}}', 2, 0.001); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $adapter->setParam('{{email}}', 'test@test.com'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that a full bucket allows a burst up to its capacity + */ + public function testBurst(): void + { + $adapter = $this->getAdapter('tb-burst-{{ip}}', 10, 0.001); + $adapter->setParam('{{ip}}', '0.0.0.11'); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 10; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that remaining reports the tokens still available + */ + public function testRemaining(): void + { + $adapter = $this->getAdapter('tb-remaining-{{ip}}', 3, 0.001); + $adapter->setParam('{{ip}}', '0.0.0.12'); + $abuse = new Abuse($adapter); + + $this->assertSame(2, $adapter->remaining()); // full bucket: limit - (0 + 1) + $this->assertSame(false, $abuse->check()); // 1 consumed + $this->assertSame(1, $adapter->remaining()); + $this->assertSame(false, $abuse->check()); // 2 consumed + $this->assertSame(0, $adapter->remaining()); + } + + /** + * Test that tokens refill over time + */ + public function testRefill(): void + { + // 1 token/sec, capacity 1: consume it, then a refill lets one more through + $adapter = $this->getAdapter('tb-refill-{{ip}}', 1, 1.0); + $adapter->setParam('{{ip}}', '0.0.0.13'); + $abuse = new Abuse($adapter); + + $this->assertSame(false, $abuse->check()); // consume the only token + $this->assertSame(true, $abuse->check()); // empty, throttled + + sleep(2); // refill ~2 tokens (capped at capacity 1) + + $this->assertSame(false, $abuse->check()); // refilled, allowed again + } + + /** + * Verify that time() returns the current time as an int + */ + public function testTimeFormat(): void + { + $adapter = $this->getAdapter('tb-time', 1, 1.0); + $this->assertSame(true, \is_int($adapter->time())); + } + + /** + * Test the reset functionality refills the bucket + */ + public function testReset(): void + { + $adapter = $this->getAdapter('tb-reset-test-{{ip}}', 5, 0.001); + $adapter->setParam('{{ip}}', '192.168.1.1'); + $abuse = new Abuse($adapter); + + // 5 OK, 6th limited + for ($i = 0; $i < 5; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + + // Reset refills the bucket + $abuse->reset(); + + // 5 more OK, then limited again + for ($i = 0; $i < 5; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that a non-positive refill rate is rejected + */ + public function testRefillRateGuard(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->getAdapter('tb-guard', 1, 0.0); + } + + /** + * Test that limit 0 means unlimited + */ + public function testUnlimited(): void + { + $adapter = $this->getAdapter('tb-unlimited', 0, 1.0); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 20; $i++) { + $this->assertSame(false, $abuse->check()); + } + } +} diff --git a/packages/abuse/tests/E2E/TokenBucket/RedisClusterTest.php b/packages/abuse/tests/E2E/TokenBucket/RedisClusterTest.php new file mode 100644 index 00000000000..6fd8779f238 --- /dev/null +++ b/packages/abuse/tests/E2E/TokenBucket/RedisClusterTest.php @@ -0,0 +1,41 @@ +close(); + } + } +} diff --git a/packages/abuse/tests/E2E/TokenBucket/RedisPoolTest.php b/packages/abuse/tests/E2E/TokenBucket/RedisPoolTest.php new file mode 100644 index 00000000000..84fd48d53e0 --- /dev/null +++ b/packages/abuse/tests/E2E/TokenBucket/RedisPoolTest.php @@ -0,0 +1,54 @@ +|null + */ + protected static ?Pool $pool = null; + + public static function setUpBeforeClass(): void + { + if (isset(self::$pool)) { + return; + } + + self::$pool = new Pool(new Stack(), 'abuse-tb-redis', 2, function (): \Redis { + $redis = new \Redis(); + $redis->connect(Services::HOST, Services::REDIS_PORT); + + return $redis; + }, timeout: 0.0); + } + + public function getAdapter(string $key, int $tokens, float $refillRate): TokenBucket + { + $pool = self::$pool; + $this->assertInstanceOf(Pool::class, $pool); + + /** @var Pool<\Redis> $pool */ + return new AdapterRedisPool('tb-pool-' . $key, $tokens, $refillRate, $pool); + } + + public static function tearDownAfterClass(): void + { + if (!isset(self::$pool)) { + return; + } + + self::$pool->use(function (mixed $redis): void { + if ($redis instanceof \Redis) { + $redis->close(); + } + }); + self::$pool = null; + } +} diff --git a/packages/abuse/tests/E2E/TokenBucket/RedisTest.php b/packages/abuse/tests/E2E/TokenBucket/RedisTest.php new file mode 100644 index 00000000000..51fc968b100 --- /dev/null +++ b/packages/abuse/tests/E2E/TokenBucket/RedisTest.php @@ -0,0 +1,44 @@ +connect(Services::HOST, Services::REDIS_PORT); + + return $redis; + } + + public function getAdapter(string $key, int $tokens, float $refillRate): TokenBucket + { + return new AdapterRedis($key, $tokens, $refillRate, self::$redis); + } + + public static function tearDownAfterClass(): void + { + if (isset(self::$redis)) { + self::$redis->close(); + } + } +} diff --git a/packages/abuse/tests/NoneTest.php b/packages/abuse/tests/NoneTest.php new file mode 100644 index 00000000000..fa833fa99dd --- /dev/null +++ b/packages/abuse/tests/NoneTest.php @@ -0,0 +1,38 @@ +assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + } + + public function testReturnsNoLogsAndCleanupSucceeds(): void + { + $adapter = new None('none-key', 1, 60); + + $this->assertSame([], $adapter->getLogs()); + $this->assertSame(true, $adapter->cleanup(time())); + } + + public function testResetIsNoop(): void + { + $adapter = new None('none-key', 1, 60); + $abuse = new Abuse($adapter); + + $abuse->reset(); + + $this->assertSame(false, $abuse->check()); + } +} diff --git a/packages/abuse/tests/bench/Base.php b/packages/abuse/tests/bench/Base.php new file mode 100644 index 00000000000..9299d189958 --- /dev/null +++ b/packages/abuse/tests/bench/Base.php @@ -0,0 +1,32 @@ +adapter->setParam('{{ip}}', $ip); + $this->abuse->check(); + } +} diff --git a/packages/abuse/tests/bench/Database.php b/packages/abuse/tests/bench/Database.php new file mode 100644 index 00000000000..1dab385717f --- /dev/null +++ b/packages/abuse/tests/bench/Database.php @@ -0,0 +1,50 @@ +setDatabase('utopiaTests'); + $db->setNamespace('namespace'); + $this->db = $db; + + $adapter = new TimeLimit('login-attempt-from-{{ip}}', 3, 60 * 5, $db); + if (!$db->exists('utopiaTests')) { + $db->create(); + $adapter->setup(); + } + $this->adapter = $adapter; + $this->abuse = new Abuse($this->adapter); + } +} diff --git a/packages/abuse/tests/bench/Redis.php b/packages/abuse/tests/bench/Redis.php new file mode 100644 index 00000000000..d2dae253943 --- /dev/null +++ b/packages/abuse/tests/bench/Redis.php @@ -0,0 +1,24 @@ +redis = new Client(); + $this->redis->connect(Services::HOST, Services::REDIS_PORT); + $this->adapter = new TimeLimit('login-attempt-from-{{ip}}', 3, 60 * 5, $this->redis); + $this->abuse = new Abuse($this->adapter); + } +} diff --git a/packages/abuse/tests/bench/RedisCluster.php b/packages/abuse/tests/bench/RedisCluster.php new file mode 100644 index 00000000000..badb048ae4d --- /dev/null +++ b/packages/abuse/tests/bench/RedisCluster.php @@ -0,0 +1,23 @@ +redis = new Client(null, Services::CLUSTER_SEEDS); + $this->adapter = new RedisClusterAdapter('login-attempt-from-{{ip}}', 3, 60 * 5, $this->redis); + $this->abuse = new Abuse($this->adapter); + } +} diff --git a/rfc/monorepo.md b/rfc/monorepo.md index 9e16bff5630..5a86c9f202c 100644 --- a/rfc/monorepo.md +++ b/rfc/monorepo.md @@ -272,6 +272,7 @@ Exit: `composer.lock` contains no `utopia-php/*` package. - Collapse `||` compatibility constraints in package manifests to single ranges once every sibling is on the current major. - Delete duplicated test helpers (`tests/extensions/Queue/InMemoryConnection.php` versus the queue package's own fakes) and every Appwrite-side workaround that existed only because a library fix was waiting on a release. - Burn down every `packages/*/phpstan-baseline.neon` a package arrives with (abuse's Redis cluster log adapters need one under PHPStan 2). + - `abuse`: 44 findings (its standalone repository analysed it at level max under PHPStan 1): 35 in `src`, `array|true` `scan()` and `_masters()` replies merged, sorted and combined into log maps in the `RedisCluster` and `RedisPool` adapters of all three strategies, plus an integer passed to `curl_setopt()` in `ReCaptcha`; 9 in its e2e tests, always-true `instanceof` and `is_int()` checks, the cluster `scan()` reply iterated unnarrowed, and a column shape in `TablesDBTest`. - `audit`: 5 findings: `Log::getData()` returning the decoded `mixed` array against its `array` docblock, Pint's `simplified_null_return` turning the untyped `SQL::getAttribute()`'s `return null;` into `return;`, and the batch fixtures in its e2e tests typed as plain arrays against `logBatch()`'s event shape. - `auth`: 33 findings (it had no PHPStan config of its own): `mixed` out-parameters and results from `openssl_pkey_export()`, `openssl_pkey_get_details()` and `openssl_sign()` in the asymmetric issuer and verifier, integer arithmetic in the PHPass encoder, and array shapes in `AuthorizationDetails` and `ResourceIndicators`, plus decoded-claim arithmetic in its tests. - `cache`: 25 findings (level 5 in the monorepo): `mixed` from the Memcached and Hazelcast server stats and the `RedisCluster` node addresses, values passed to `Envelope::encode()` untyped, and casts of Redis replies in its multiplexing and leasable e2e tests.