diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9e9519b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/art export-ignore +/docs export-ignore +/tests export-ignore +/.editorconfig export-ignore +/.php_cs.dist.php export-ignore +/psalm.xml export-ignore +/psalm.xml.dist export-ignore +/testbench.yaml export-ignore +/UPGRADING.md export-ignore +/phpstan.neon.dist export-ignore +/phpstan-baseline.neon export-ignore diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0716119 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: false +contact_links: + - name: Ask a question + url: https://github.com/bvtterfly/sliding-window-rate-limiter/discussions/new?category=q-a + about: Ask the community for help + - name: Request a feature + url: https://github.com/bvtterfly/sliding-window-rate-limiter/discussions/new?category=ideas + about: Share ideas for new features + - name: Report a security issue + url: https://github.com/bvtterfly/sliding-window-rate-limiter/security/policy + about: Learn how to notify us for sensitive bugs + - name: Report a bug + url: https://github.com/bvtterfly/sliding-window-rate-limiter/issues/new + about: Report a reproducable bug diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..30c8a49 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" \ No newline at end of file diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..8aa748d --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,32 @@ +name: dependabot-auto-merge +on: pull_request_target + +permissions: + pull-requests: write + contents: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v1.3.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-merge Dependabot PRs for semver-minor updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + + - name: Auto-merge Dependabot PRs for semver-patch updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/php-cs-fixer.yml b/.github/workflows/php-cs-fixer.yml new file mode 100644 index 0000000..a65832c --- /dev/null +++ b/.github/workflows/php-cs-fixer.yml @@ -0,0 +1,23 @@ +name: Check & fix styling + +on: [push] + +jobs: + php-cs-fixer: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref }} + + - name: Run PHP CS Fixer + uses: docker://oskarstark/php-cs-fixer-ga + with: + args: --config=.php_cs.dist.php --allow-risky=yes + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: 🎨 Fix styling diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..977b975 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,26 @@ +name: PHPStan + +on: + push: + paths: + - '**.php' + - 'phpstan.neon.dist' + +jobs: + phpstan: + name: phpstan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.0' + coverage: none + + - name: Install composer dependencies + uses: ramsey/composer-install@v1 + + - name: Run PHPStan + run: ./vendor/bin/phpstan --error-format=github diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..2d91a07 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,55 @@ +name: run-tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ${{ matrix.os }} + + services: + redis: + image: redis + ports: + - 6379:6379 + options: --entrypoint redis-server + + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest] + php: [8.1, 8.0] + laravel: [9.*] + stability: [prefer-lowest, prefer-stable] + include: + - laravel: 9.* + testbench: 7.* + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, redis, zip + coverage: none + + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + + - name: Execute tests + run: vendor/bin/pest diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 0000000..bb9dc74 --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,28 @@ +name: "Update Changelog" + +on: + release: + types: [released] + +jobs: + update: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: main + + - name: Update Changelog + uses: stefanzweifel/changelog-updater-action@v1 + with: + latest-version: ${{ github.event.release.name }} + release-notes: ${{ github.event.release.body }} + + - name: Commit updated CHANGELOG + uses: stefanzweifel/git-auto-commit-action@v4 + with: + branch: main + commit_message: 📝 Update CHANGELOG + file_pattern: CHANGELOG.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b32ac2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.idea +.php_cs +.php_cs.cache +.phpunit.result.cache +build +composer.lock +coverage +docs +phpunit.xml +phpstan.neon +testbench.yaml +vendor +node_modules +.php-cs-fixer.cache +.editorconfig diff --git a/.php_cs.dist.php b/.php_cs.dist.php new file mode 100644 index 0000000..8d8a790 --- /dev/null +++ b/.php_cs.dist.php @@ -0,0 +1,40 @@ +in([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->name('*.php') + ->notName('*.blade.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PSR12' => true, + 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'no_unused_imports' => true, + 'not_operator_with_successor_space' => true, + 'trailing_comma_in_multiline' => true, + 'phpdoc_scalar' => true, + 'unary_operator_spaces' => true, + 'binary_operator_spaces' => true, + 'blank_line_before_statement' => [ + 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], + ], + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_var_without_name' => true, + 'class_attributes_separation' => [ + 'elements' => [ + 'method' => 'one', + ], + ], + 'method_argument_space' => [ + 'on_multiline' => 'ensure_fully_multiline', + 'keep_multiple_spaces_after_comma' => true, + ], + 'single_trait_insert_per_statement' => true, + ]) + ->setFinder($finder); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..15bb6b4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +All notable changes to `sliding-window-rate-limiter` will be documented in this file. + diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..cdb9b48 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) bvtterfly + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5ca798 --- /dev/null +++ b/README.md @@ -0,0 +1,274 @@ +# Laravel Sliding Window Rate Limiter + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/bvtterfly/sliding-window-rate-limiter.svg?style=flat-square)](https://packagist.org/packages/bvtterfly/sliding-window-rate-limiter) +[![GitHub Tests Action Status](https://img.shields.io/github/workflow/status/bvtterfly/sliding-window-rate-limiter/run-tests?label=tests)](https://github.com/bvtterfly/sliding-window-rate-limiter/actions?query=workflow%3Arun-tests+branch%3Amain) +[![GitHub Code Style Action Status](https://img.shields.io/github/workflow/status/bvtterfly/sliding-window-rate-limiter/Check%20&%20fix%20styling?label=code%20style)](https://github.com/bvtterfly/sliding-window-rate-limiter/actions?query=workflow%3A"Check+%26+fix+styling"+branch%3Amain) +[![Total Downloads](https://img.shields.io/packagist/dt/bvtterfly/sliding-window-rate-limiter.svg?style=flat-square)](https://packagist.org/packages/bvtterfly/sliding-window-rate-limiter) + +This package provides an easy way to limit any action during a specified time window. You may be familiar with Laravel's Rate Limiter, +It has a similar API, but it uses the Sliding Window algorithm and requires Redis. + + +## Installation + +You can install the package via composer: + +```bash +composer require bvtterfly/sliding-window-rate-limiter +``` + + +You can publish the config file with: + +```bash +php artisan vendor:publish --tag="sliding-window-rate-limiter-config" +``` + +This is the contents of the published config file: + +```php +return [ + 'use' => 'default', +]; +``` +The package relies on Redis and requires a Redis connection, and you choose which Redis connection to use. + +## Usage + +The `Bvtterfly\SlidingWindowRateLimiter\Facades\SlidingWindowRateLimiter` facade may be used to interact with the rate limiter. + +The simplest method offered by the rate limiter is the `attempt` method, which rate limits an action for a given number of seconds. +The `attempt` method returns a result object that specifies if an attempt was successful and how many attempts remain. If the attempt is unsuccessful, you can get the number of seconds until the action is available again. + +```php +use Bvtterfly\SlidingWindowRateLimiter\Facades\SlidingWindowRateLimiter; + +$result = SlidingWindowRateLimiter::attempt( + 'send-message:'.$user->id, + $maxAttempts = 5, + $decayInSeconds = 60 +); + +if ($result->successful()) { + // attempt is successful, do awesome thing... +} else { + // attempt is failed, you can get when you can retry again + // use $result->retryAfter for getting the number of seconds until the action is available again + // or use $result->availableAt() for getting UNIX timestamp instead. + +} +``` +You can call the following methods on the `SlidingWindowRateLimiter`: + +### tooManyAttempts +```php +/** + * Determine if the given key has been "accessed" too many times. + * + * @param string $key + * @param int $maxAttempts + * @param int $decay + * + * @return bool + */ +public function tooManyAttempts(string $key, int $maxAttempts, int $decay = 60): bool +``` + +### attempts +```php +/** + * Get the number of attempts for the given key for decay time in seconds. + * + * @param string $key + * @param int $decay + * + * @return int + */ +public function attempts(string $key, int $decay = 60): int +``` + +### resetAttempts +```php +/** + * Reset the number of attempts for the given key. + * + * @param string $key + * + * @return mixed + */ +public function resetAttempts(string $key): mixed +``` + +### remaining +```php +/** + * Get the number of retries left for the given key. + * + * @param string $key + * @param int $maxAttempts + * @param int $decay + * + * @return int + */ +public function remaining(string $key, int $maxAttempts, int $decay = 60): int +``` + +### clear +```php +/** + * Clear the number of attempts for the given key. + * + * @param string $key + * + * @return void + */ +public function clear(string $key) +``` + +### availableIn +```php +/** + * Get the number of seconds until the "key" is accessible again. + * + * @param string $key + * @param int $maxAttempts + * @param int $decay + * + * @return int + */ +public function availableIn(string $key, int $maxAttempts, int $decay = 60): int +``` + +### retriesLeft +```php +/** +* Get the number of retries left for the given key. +* +* @param string $key +* @param int $maxAttempts +* @param int $decay +* +* @return int +*/ +public function retriesLeft(string $key, int $maxAttempts, int $decay = 60): int +``` + +## Route Rate Limiting + +Package designed to rate limit actions in a seconds-based system, so it needs its rate limiters classes and lets you configure rate limiters for less than a minute. Still, for ease of usage of this package, It supports default Laravel's Rate Limiters. + +This package comes with a `throttle` middleware for Route Rate Limiting, which acts as default `throttle` middleware. +This middleware tries to get a named rate limiter from the `SlidingWindowRateLimiter` or, as a fallback, it will take them from Laravel RateLimiter. + +You may wish to change the mapping of `throttle` middleware in your application's HTTP kernel(`App\Http\Kernel`) to use `\Bvtterfly\SlidingWindowRateLimiter\Http\Middleware\ThrottleRequests` class. + +## Defining Rate Limiters + +> `SlidingWindowRateLimiter` rate limiters are heavily based on Laravel's rate limiters. It only differs in the fact that it is seconds-based. So, before getting started, be sure to read about them on [Laravel docs](https://laravel.com/docs/routing#defining-rate-limiters). + +Limit configurations are instances of the `Bvtterfly\SlidingWindowRateLimiter\Limit` class, and It contains helpful "builder" methods to define your rate limits quickly. The rate limiter name may be any string you wish. + +For limiting to 500 requests in 45 seconds: + +```php +use Bvtterfly\SlidingWindowRateLimiter\Limit; +use Bvtterfly\SlidingWindowRateLimiter\Facades\SlidingWindowRateLimiter; + +/** + * Configure the rate limiters for the application. + * + * @return void + */ +protected function configureRateLimiting() +{ + SlidingWindowRateLimiter::for('global', function (Request $request) { + return Limit::perSeconds(45, 500); + }); +} +``` + +If the incoming request exceeds the specified rate limit, a response with a 429 HTTP status code will automatically be returned by Laravel. If you would like to define your response that a rate limit should return, you may use the `response` method: + +```php +SlidingWindowRateLimiter::for('global', function (Request $request) { + return Limit::perSeconds(45, 500)->response(function () { + return response('Custom response...', 429); + }); +}); +``` + +You can have multiple rate limits. This configuration will limit only 100 requests per 30 seconds and 1000 requests per day: + +```php +SlidingWindowRateLimiter::for('global', function (Request $request) { + return [ + Limit::perSeconds(30, 100), + Limit::perDay(1000) + ]; +}); +``` +Incoming HTTP request instances are passed to rate limiter callbacks, and the rate limit may be calculated dynamically depending on the user or request: + +```php +SlidingWindowRateLimiter::for('uploads', function (Request $request) { + return $request->user()->vipCustomer() + ? Limit::none() + : Limit::perMinute(100); +}); +``` + +There may be times when you wish to segment rate limits by some arbitrary value. For example, you may want to allow users to access a given route with 100 requests per minute per authenticated user ID and 10 requests per minute per IP address for guests. Using the `by` a method, you can create your rate limit as follows: + +```php +SlidingWindowRateLimiter::for('uploads', function (Request $request) { + return $request->user() + ? Limit::perMinute(100)->by($request->user()->id) + : Limit::perMinute(10)->by($request->ip()); +}); +``` + +## Attaching Rate Limiters To Routes + +Rate limiters can be attached to routes or route groups using the `throttle` middleware. The `throttle` middleware accepts the name of the rate limiter you wish to assign to the route: + +```php +Route::middleware(['throttle:media'])->group(function () { + + Route::post('/audio', function () { + // + })->middleware('throttle:uploads'); + + Route::post('/video', function () { + // + })->middleware('throttle:uploads'); + +}); +``` + + +## Testing + +```bash +composer test +``` + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Contributing + +Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. + +## Security Vulnerabilities + +Please review [our security policy](../../security/policy) on how to report security vulnerabilities. + +## Credits + +- [Ari](https://github.com/bvtterfly) +- [All Contributors](../../contributors) + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..fc8640d --- /dev/null +++ b/composer.json @@ -0,0 +1,73 @@ +{ + "name": "bvtterfly/sliding-window-rate-limiter", + "description": "a sliding window rate limiter for laravel", + "keywords": [ + "bvtterfly", + "laravel", + "sliding-window-rate-limiter" + ], + "homepage": "https://github.com/bvtterfly/sliding-window-rate-limiter", + "license": "MIT", + "authors": [ + { + "name": "Ari", + "email": "thearihdrn@gmail.com", + "role": "Developer" + } + ], + "require": { + "php": "^8.0", + "spatie/laravel-package-tools": "^1.9.2", + "illuminate/contracts": "^9.0" + }, + "require-dev": { + "nunomaduro/collision": "^6.0", + "nunomaduro/larastan": "^2.0.1", + "orchestra/testbench": "^7.0", + "pestphp/pest": "^1.21", + "pestphp/pest-plugin-laravel": "^1.1", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^9.5", + "predis/predis": "^1.1" + }, + "suggest": { + "ext-redis": "Required to use the Redis PHP driver.", + "predis/predis": "Required when not using the Redis PHP driver (^1.1)." + }, + "autoload": { + "psr-4": { + "Bvtterfly\\SlidingWindowRateLimiter\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Bvtterfly\\SlidingWindowRateLimiter\\Tests\\": "tests" + } + }, + "scripts": { + "analyse": "vendor/bin/phpstan analyse", + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "laravel": { + "providers": [ + "Bvtterfly\\SlidingWindowRateLimiter\\SlidingWindowRateLimiterServiceProvider" + ], + "aliases": { + "SlidingWindowRateLimiter": "Bvtterfly\\SlidingWindowRateLimiter\\Facades\\SlidingWindowRateLimiter" + } + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/sliding-window-rate-limiter.php b/config/sliding-window-rate-limiter.php new file mode 100644 index 0000000..527f9b9 --- /dev/null +++ b/config/sliding-window-rate-limiter.php @@ -0,0 +1,15 @@ + 'default', + +]; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..e69de29 diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..e005ac7 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,13 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 4 + paths: + - src + - config + tmpDir: build/phpstan + checkOctaneCompatibility: true + checkModelProperties: true + checkMissingIterableValueType: false + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..437786d --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,42 @@ + + + + + tests + + + + + + + + ./src + + + + + + + + + + + diff --git a/src/DataTransferObjects/AttemptMiddlewareRuleResult.php b/src/DataTransferObjects/AttemptMiddlewareRuleResult.php new file mode 100644 index 0000000..4bf9e8f --- /dev/null +++ b/src/DataTransferObjects/AttemptMiddlewareRuleResult.php @@ -0,0 +1,17 @@ +retriesLeft >= 0 && $this->retryAfter === 0; + } + + public function availableAt(): int + { + return $this->getAvailableAt($this->retryAfter); + } +} diff --git a/src/Exceptions/InvalidConfigurationException.php b/src/Exceptions/InvalidConfigurationException.php new file mode 100644 index 0000000..75b8e8d --- /dev/null +++ b/src/Exceptions/InvalidConfigurationException.php @@ -0,0 +1,9 @@ +getLimiterByName($maxAttempts))) { + return $this->handleRequestUsingNamedLimiter($request, $next, $maxAttempts, $limiter); + } + + return $this->handleRequest( + $request, + $next, + collect([ + (object) [ + 'key' => $prefix.$this->resolveRequestSignature($request), + 'maxAttempts' => $this->resolveMaxAttempts($request, $maxAttempts), + 'decaySeconds' => $decayMinutes * 60, + 'responseCallback' => null, + ], + ]) + ); + } + + /** + * Handle an incoming request. + * + * @param Request $request + * @param Closure $next + * @param string $limiterName + * @param Closure $limiter + * @return Response + * + * @throws ThrottleRequestsException|HttpResponseException + */ + protected function handleRequestUsingNamedLimiter($request, Closure $next, $limiterName, Closure $limiter): Response + { + $limiterResponse = $limiter($request); + + if ($limiterResponse instanceof Response) { + return $limiterResponse; + } elseif ($limiterResponse instanceof Unlimited || $limiterResponse instanceof SlidingUnlimited) { + return $next($request); + } + + return $this->handleRequest( + $request, + $next, + collect(Arr::wrap($limiterResponse))->map(function ($limit) use ($limiterName) { + $key = md5($limiterName.$limit->key); + $maxAttempts = $limit->maxAttempts; + $responseCallback = $limit->responseCallback; + if ($limit instanceof Limit) { + $decaySeconds = $limit->decayMinutes * 60; + } else { + $decaySeconds = $limit->decay; + } + + return (object) [ + 'key' => $key, + 'maxAttempts' => $maxAttempts, + 'decaySeconds' => $decaySeconds, + 'responseCallback' => $responseCallback, + ]; + }) + ); + } + + /** + * Get the limiter by name from SlidingWindowRateLimiter, or as a fallback, take it from Laravel RateLimiter + * + * @param string $name + * + * @return Closure|null + */ + protected function getLimiterByName(string $name): ?Closure + { + $limiter = $this->limiter->limiter($name); + + if (! $limiter) { + $limiter = $this->laravelLimiter->limiter($name); + } + + return $limiter; + } + + /** + * Resolve the number of attempts if the user is authenticated or not. + * + * @param Request $request + * @param int|string $maxAttempts + * + * @return int + */ + protected function resolveMaxAttempts($request, $maxAttempts) + { + if (is_string($maxAttempts) && str_contains($maxAttempts, '|')) { + $maxAttempts = explode('|', $maxAttempts, 2)[$request->user() ? 1 : 0]; + } + + if (! is_numeric($maxAttempts) && $request->user()) { + $maxAttempts = $request->user()->{$maxAttempts}; + } + + $maxAttempts = (int) $maxAttempts; + + if ($maxAttempts === 0) { + throw new RuntimeException('Unable to rate limit if max attempts equal to 0'); + } + + return $maxAttempts; + } + + /** + * Resolve request signature. + * + * @param Request $request + * @return string + * + * @throws RuntimeException + */ + protected function resolveRequestSignature($request) + { + if ($user = $request->user()) { + return sha1($user->getAuthIdentifier()); + } elseif ($route = $request->route()) { + return sha1($route->getDomain().'|'.$request->ip()); + } + + throw new RuntimeException('Unable to generate the request signature. Route unavailable.'); + } + + /** + * Handle an incoming request. + * + * @param Request $request + * @param Closure $next + * @param Collection $limits + * + * @return Response + * + * @throws ThrottleRequestsException|HttpResponseException + */ + protected function handleRequest($request, Closure $next, Collection $limits) + { + $result = $this->limiter->attemptLimitRules($limits); + + if (! $result->successful()) { + throw $this->buildException($request, $result); + } + + $response = $next($request); + + return $this->addHeaders( + $response, + $result + ); + } + + /** + * Create a 'too many attempts' exception. + * + * @param Request $request + * @param AttemptMiddlewareRuleResult $result + * @return ThrottleRequestsException|HttpResponseException + */ + protected function buildException(Request $request, AttemptMiddlewareRuleResult $result) + { + $headers = $this->getHeaders($result); + + return is_callable($result->responseCallback) + ? new HttpResponseException(($result->responseCallback)($request, $headers)) + : new ThrottleRequestsException('Too Many Attempts.', null, $headers); + } + + /** + * Add the limit header information to the given response. + * + * @param Response $response + * @param AttemptResult $result + * @return Response + */ + protected function addHeaders(Response $response, AttemptResult $result) + { + $response->headers->add( + $this->getHeaders($result) + ); + + return $response; + } + + /** + * Get the limit headers information. + * + * @param AttemptResult $result + * @return array + */ + protected function getHeaders($result) + { + $headers = [ + 'X-RateLimit-Limit' => $result->limit, + 'X-RateLimit-Remaining' => $result->retriesLeft, + ]; + + if (! $result->successful()) { + $headers['Retry-After'] = $result->retryAfter; + $headers['X-RateLimit-Reset'] = $result->availableAt(); + } + + return $headers; + } +} diff --git a/src/LuaScripts.php b/src/LuaScripts.php new file mode 100644 index 0000000..ab67f0d --- /dev/null +++ b/src/LuaScripts.php @@ -0,0 +1,100 @@ += tonumber(max_requests) then + local elements = redis.call('zrange', curr_key, 0, 0, 'WITHSCORES') + local next_ts = elements[2] + window + local available_in = next_ts - tonumber(current_time[1]) + return {available_in, 0, tonumber(max_requests), i/2 - 1} + else + local retries_left = tonumber(max_requests) - request_count + if min_retries_left > retries_left then + min_retries_left = retries_left - 1 + requests_limit = tonumber(max_requests) + requests_limit_key = i/2 - 1 + end + end + end + for i=2, num_windows*2, 2 do + local curr_key = KEYS[i/2] + local window = ARGV[i] + redis.call('ZADD', curr_key, current_time[1], current_time[1] .. current_time[2]) + redis.call('EXPIRE', curr_key, window) + end + return {0, min_retries_left, requests_limit, requests_limit_key} +LUA; + } + + public static function attempt(): string + { + return <<<'LUA' + local current_time = redis.call('TIME') + local key = KEYS[1] + local window = tonumber(ARGV[1]) + local max_requests = tonumber(ARGV[2]) + local trim_time = tonumber(current_time[1]) - window + redis.call('ZREMRANGEBYSCORE', key, 0, trim_time) + local request_count = redis.call('ZCARD',key) + if request_count >= max_requests then + local elements = redis.call('zrange', key, 0, 0, 'WITHSCORES') + local next_ts = elements[2] + window + local available_in = next_ts - tonumber(current_time[1]) + return {available_in, 0, max_requests} + end + redis.call('ZADD', key, current_time[1], current_time[1] .. current_time[2]) + redis.call('EXPIRE', key, window) + return {0, max_requests - request_count - 1, max_requests} +LUA; + } + + public static function attempts(): string + { + return <<<'LUA' + local current_time = redis.call('TIME') + local key = KEYS[1] + local window = ARGV[1] + local trim_time = tonumber(current_time[1]) - window + redis.call('ZREMRANGEBYSCORE', key, 0, trim_time) + local request_count = redis.call('ZCARD',key) + return request_count +LUA; + } + + public static function availableIn(): string + { + return <<<'LUA' + local current_time = redis.call('TIME') + local key = KEYS[1] + local window = tonumber(ARGV[1]) + local max_requests = tonumber(ARGV[2]) + local trim_time = tonumber(current_time[1]) - window + redis.call('ZREMRANGEBYSCORE', key, 0, trim_time) + local request_count = redis.call('ZCARD',key) + if request_count >= max_requests then + local elements = redis.call('zrange', key, 0, 0, 'WITHSCORES') + local next_ts = elements[2] + window + local available_in = next_ts - tonumber(current_time[1]) + return available_in + end + return 0 +LUA; + } +} diff --git a/src/RateLimiting/GlobalLimit.php b/src/RateLimiting/GlobalLimit.php new file mode 100644 index 0000000..d93b86d --- /dev/null +++ b/src/RateLimiting/GlobalLimit.php @@ -0,0 +1,18 @@ +key = $key; + $this->maxAttempts = $maxAttempts; + $this->decay = $decay; + } + + /** + * Create a new rate limit. + * + * @param int $maxAttempts + * + * @return self + */ + public static function perMinute(int $maxAttempts): self + { + return new self('', $maxAttempts); + } + + /** + * Create a new rate limit using minutes as decay time. + * + * @param int $decayMinutes + * @param int $maxAttempts + * + * @return self + */ + public static function perMinutes(int $decayMinutes, int $maxAttempts): self + { + return new self('', $maxAttempts, $decayMinutes * self::MINUTE); + } + + /** + * Create a new rate limit using seconds as decay time. + * + * @param int $decay + * @param int $maxAttempts + * + * @return self + */ + public static function perSeconds(int $decay, int $maxAttempts): self + { + return new self('', $maxAttempts, $decay); + } + + /** + * Create a new rate limit using hours as decay time. + * + * @param int $maxAttempts + * @param int $decayHours + * + * @return self + */ + public static function perHour(int $maxAttempts, int $decayHours = 1): self + { + return new self('', $maxAttempts, $decayHours * self::HOUR); + } + + /** + * Create a new rate limit using days as decay time. + * + * @param int $maxAttempts + * @param int $decayDays + * + * @return self + */ + public static function perDay($maxAttempts, $decayDays = 1): self + { + return new self('', $maxAttempts, $decayDays * self::DAY); + } + + /** + * Create a new unlimited rate limit. + * + * @return Unlimited + */ + public static function none() + { + return new Unlimited(); + } + + /** + * Set the key of the rate limit. + * + * @param string $key + * + * @return $this + */ + public function by($key) + { + $this->key = $key; + + return $this; + } + + /** + * Set the callback that should generate the response when the limit is exceeded. + * + * @param callable $callback + * @return $this + */ + public function response(callable $callback) + { + $this->responseCallback = $callback; + + return $this; + } +} diff --git a/src/RateLimiting/Unlimited.php b/src/RateLimiting/Unlimited.php new file mode 100644 index 0000000..fc8df7d --- /dev/null +++ b/src/RateLimiting/Unlimited.php @@ -0,0 +1,16 @@ +limiters[$name] = $callback; + + return $this; + } + + /** + * Get the given named rate limiter. + * + * @param string $name + * + * @return Closure|null + */ + public function limiter(string $name): ?Closure + { + return $this->limiters[$name] ?? null; + } + + public function attempt(string $key, int $maxAttempts, int $decay = 60): AttemptResult + { + if ($this->tooManyAttempts($key, $maxAttempts, $decay)) { + return new AttemptResult($this->availableIn($key, $maxAttempts, $decay), 0, $maxAttempts); + } + $luaArgs = $this->getLuaArgs($key, $decay, $maxAttempts); + [$retryAfter, $retriesLeft, $limit] = $this->connection()->eval(LuaScripts::attempt(), 1, ...$luaArgs); + + return new AttemptResult($retryAfter, $retriesLeft, $limit); + } + + /** + * Determine if the given key has been "accessed" too many times. + * + * @param string $key + * @param int $maxAttempts + * @param int $decay + * + * @return bool + */ + public function tooManyAttempts(string $key, int $maxAttempts, int $decay = 60): bool + { + if ($this->attempts($key, $decay) >= $maxAttempts) { + return true; + } + + return false; + } + + /** + * Get the number of attempts for the given key for decay time in seconds. + * + * @param string $key + * @param int $decay + * + * @return int + */ + public function attempts(string $key, int $decay = 60): int + { + $luaArgs = $this->getLuaArgs($key, $decay); + + return $this->connection()->eval(LuaScripts::attempts(), 1, ...$luaArgs); + } + + /** + * Reset the number of attempts for the given key. + * + * @param string $key + * + * @return mixed + */ + public function resetAttempts(string $key): mixed + { + $key = $this->getKeyWithPrefix($key); + + return $this->connection()->command('del', [$key]); + } + + /** + * Get the number of retries left for the given key. + * + * @param string $key + * @param int $maxAttempts + * @param int $decay + * + * @return int + */ + public function remaining(string $key, int $maxAttempts, int $decay = 60): int + { + $attempts = $this->attempts($key, $decay); + + return $maxAttempts - $attempts; + } + + /** + * Clear the number of attempts for the given key. + * + * @param string $key + * + * @return void + */ + public function clear(string $key) + { + $this->resetAttempts($key); + } + + /** + * Get the number of seconds until the "key" is accessible again. + * + * @param string $key + * @param int $maxAttempts + * @param int $decay + * + * @return int + */ + public function availableIn(string $key, int $maxAttempts, int $decay = 60): int + { + $luaArgs = $this->getLuaArgs($key, $decay, $maxAttempts); + + return $this->connection()->eval(LuaScripts::availableIn(), 1, ...$luaArgs); + } + + /** + * Get the number of retries left for the given key. + * + * @param string $key + * @param int $maxAttempts + * @param int $decay + * + * @return int + */ + public function retriesLeft(string $key, int $maxAttempts, int $decay = 60): int + { + return $this->remaining($key, $maxAttempts, $decay); + } + + public function attemptLimitRules(Collection $rules): AttemptMiddlewareRuleResult + { + $luaArgs = $this->getLimitRulesLuaArgs($rules); + + [$retryAfter, $retriesLeft, $limit, $key] = $this->connection()->eval(LuaScripts::attemptMiddlewareRules(), ...$luaArgs); + + $responseCallback = data_get($rules->get($key), 'responseCallback'); + + return new AttemptMiddlewareRuleResult($retryAfter, $retriesLeft, $limit, $responseCallback); + } + + private function getKeyWithPrefix(string $key): string + { + return "sliding_rate_limiter:{$key}"; + } + + /** + * @param string $key + * @param int[] $args + * + * @return array + */ + private function getLuaArgs(string $key, ...$args): array + { + return [$this->getKeyWithPrefix($key), ...$args]; + } + + private function getLimitRulesLuaArgs(Collection $rules): array + { + $keys = $rules->map(fn ( + $limit + ) => $this->getKeyWithPrefix($limit->key)); + $args = $rules->map(fn ($limit) => [ + $limit->decaySeconds, $limit->maxAttempts, + ])->flatten(); + $luaArgs = [$keys->count(), ...$keys->all()]; + $luaArgs[] = $keys->count(); + + return [...$luaArgs, ...$args->all()]; + } + + private function connection(): Connection + { + return $this->factory->connection(config('sliding-window-rate-limiter.use')); + } +} diff --git a/src/SlidingWindowRateLimiterServiceProvider.php b/src/SlidingWindowRateLimiterServiceProvider.php new file mode 100644 index 0000000..6b7c0d6 --- /dev/null +++ b/src/SlidingWindowRateLimiterServiceProvider.php @@ -0,0 +1,26 @@ +name('sliding-window-rate-limiter') + ->hasConfigFile(); + } + + public function packageRegistered(): void + { + $this->app->singleton(SlidingWindowRateLimiter::class); + } +} diff --git a/tests/AttemptResultTest.php b/tests/AttemptResultTest.php new file mode 100644 index 0000000..602fd87 --- /dev/null +++ b/tests/AttemptResultTest.php @@ -0,0 +1,20 @@ +successful()->toBeTrue(); + $attemptResult = new AttemptResult(0, 0, 30); + expect($attemptResult)->successful()->toBeTrue(); + $attemptResult = new AttemptResult(10, 0, 30); + expect($attemptResult)->successful()->toBeFalse(); +}); + +it('can return available at', function () { + $attemptResult = new AttemptResult(0, 10, 30); + expect($attemptResult)->availableAt()->toEqual(Carbon::now()->addRealSeconds(0)->getTimestamp()); + $attemptResult = new AttemptResult(11, 0, 30); + expect($attemptResult)->availableAt()->toEqual(Carbon::now()->addRealSeconds(11)->getTimestamp()); +}); diff --git a/tests/LimitTest.php b/tests/LimitTest.php new file mode 100644 index 0000000..531ab7c --- /dev/null +++ b/tests/LimitTest.php @@ -0,0 +1,36 @@ +maxAttempts->toEqual(98); + expect($limiter)->decay->toEqual(Limit::MINUTE); + $limiter = Limit::perMinutes(10, 199); + expect($limiter)->maxAttempts->toEqual(199); + expect($limiter)->decay->toEqual(10 * Limit::MINUTE); +}); + +it('can create a per hour limiter', function () { + $limiter = Limit::perHour(98); + expect($limiter)->maxAttempts->toEqual(98); + expect($limiter)->decay->toEqual(Limit::HOUR); + $limiter = Limit::perHour(100, 12); + expect($limiter)->maxAttempts->toEqual(100); + expect($limiter)->decay->toEqual(12 * Limit::HOUR); +}); + +it('can create a per day limiter', function () { + $limiter = Limit::perDay(1000); + expect($limiter)->maxAttempts->toEqual(1000); + expect($limiter)->decay->toEqual(Limit::DAY); + $limiter = Limit::perHour(1000, 30); + expect($limiter)->maxAttempts->toEqual(1000); + expect($limiter)->decay->toEqual(30 * Limit::HOUR); +}); + +it('can create a limiter with key', function () { + $limiter = Limit::perDay(1000); + $limiter->by('test__key'); + expect($limiter)->key->toEqual('test__key'); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..c31e090 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,5 @@ +in(__DIR__); diff --git a/tests/SlidingWindowRateLimiterTest.php b/tests/SlidingWindowRateLimiterTest.php new file mode 100644 index 0000000..ec7d3f0 --- /dev/null +++ b/tests/SlidingWindowRateLimiterTest.php @@ -0,0 +1,89 @@ +limiter = app(SlidingWindowRateLimiter::class); +}); + +afterEach(function () { + Redis::connection(Config::get('sliding-window-rate-limiter.use'))->del('sliding_rate_limiter:__TEST_KEY__'); +}); + +it('can attempt and get result', function () { + $result = $this->limiter->attempt('__TEST_KEY__', 30, 100); + expect($result)->successful()->toBeTrue(); + expect($result)->availableAt()->toEqual(Carbon::now()->addRealSeconds(0)->getTimestamp()); + expect($result)->retryAfter->toEqual(0); + expect($result)->retriesLeft->toEqual(29); + expect($result)->limit->toEqual(30); +}); + +it('can check too many attempts', function () { + $result = $this->limiter->attempt('__TEST_KEY__', 1, 10); + expect($result)->successful()->toBeTrue(); + $result = $this->limiter->attempt('__TEST_KEY__', 1, 10); + expect($result)->successful()->toBeFalse(); + expect($this->limiter)->tooManyAttempts('__TEST_KEY__', 1, 10)->toBeTrue(); +}); + +it('can get attempts', function () { + $result = $this->limiter->attempt('__TEST_KEY__', 3, 10); + expect($result)->successful()->toBeTrue(); + $result = $this->limiter->attempt('__TEST_KEY__', 3, 10); + expect($result)->successful()->toBeTrue(); + expect($this->limiter)->attempts('__TEST_KEY__', 10)->toEqual(2); +}); + +it('can reset attempts', function () { + $result = $this->limiter->attempt('__TEST_KEY__', 3, 10); + expect($result)->successful()->toBeTrue(); + $result = $this->limiter->attempt('__TEST_KEY__', 3, 10); + expect($result)->successful()->toBeTrue(); + expect($this->limiter)->attempts('__TEST_KEY__', 10)->toEqual(2); + expect($this->limiter)->resetAttempts('__TEST_KEY__')->toEqual(1); + expect($this->limiter)->attempts('__TEST_KEY__', 10)->toEqual(0); +}); + +it('can get remaining', function () { + $result = $this->limiter->attempt('__TEST_KEY__', 3, 10); + expect($result)->successful()->toBeTrue(); + $result = $this->limiter->attempt('__TEST_KEY__', 3, 10); + expect($result)->successful()->toBeTrue(); + expect($this->limiter)->attempts('__TEST_KEY__', 10)->toEqual(2); + expect($this->limiter)->remaining('__TEST_KEY__', 3, 10)->toEqual(1); +}); + +it('can clear key', function () { + $result = $this->limiter->attempt('__TEST_KEY__', 3, 10); + expect($result)->successful()->toBeTrue(); + $result = $this->limiter->attempt('__TEST_KEY__', 3, 10); + expect($result)->successful()->toBeTrue(); + expect($this->limiter)->attempts('__TEST_KEY__', 10)->toEqual(2); + $this->limiter->clear('__TEST_KEY__'); + expect($this->limiter)->attempts('__TEST_KEY__', 10)->toEqual(0); +}); + +it('get available in', function () { + $result = $this->limiter->attempt('__TEST_KEY__', 2, 10); + expect($result)->successful()->toBeTrue(); + $result = $this->limiter->attempt('__TEST_KEY__', 2, 10); + expect($result)->successful()->toBeTrue(); + expect($this->limiter)->attempts('__TEST_KEY__', 10)->toEqual(2); + expect($this->limiter)->tooManyAttempts('__TEST_KEY__', 2, 10)->toBeTrue(); + $window = 10; + expect($this->limiter)->availableIn('__TEST_KEY__', 2, $window)->toBeGreaterThanOrEqual($window - 1)->toBeLessThanOrEqual($window); +}); + +it('get retries left', function () { + $result = $this->limiter->attempt('__TEST_KEY__', 3, 10); + expect($result)->successful()->toBeTrue(); + $result = $this->limiter->attempt('__TEST_KEY__', 3, 10); + expect($result)->successful()->toBeTrue(); + expect($this->limiter)->attempts('__TEST_KEY__', 10)->toEqual(2); + expect($this->limiter)->retriesLeft('__TEST_KEY__', 3, 10)->toEqual(1); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..54dc076 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,25 @@ +middleware([ThrottleRequests::class.':unlimited', ThrottleRequests::class.':unlimited-2']); + + $response = $this->withoutExceptionHandling()->get('/'); + expect($response->headers)->has('X-RateLimit-Limit')->toBeFalse(); +}); + +it('can limit a route by named limiter', function () { + Config::set('database.redis.options.prefix', '__LARAVEL__TEST__'); + SlidingWindowRateLimiter::for('test', function (Request $request) { + return SlidingLimit::perSeconds(30, 3); + }); + + Route::get('/', function () { + return 'yes'; + })->middleware(ThrottleRequests::class.':test'); + + $response = $this->withoutExceptionHandling()->get('/'); + expect($response->headers)->has('X-RateLimit-Limit')->toBeTrue(); + expect($response->headers)->has('X-RateLimit-Remaining')->toBeTrue(); + expect($response->headers)->get('X-RateLimit-Limit')->toEqual(3); + expect($response->headers)->get('X-RateLimit-Remaining')->toEqual(2); + + $result = Redis::connection( + Config::get('sliding-window-rate-limiter.use') + )->del('sliding_rate_limiter:'.md5('test')); + + expect($result)->toEqual(1); +}); + +it('can limit a route by named limiter from laravel rate limiter', function () { + Config::set('database.redis.options.prefix', '__LARAVEL__TEST__'); + \Illuminate\Support\Facades\RateLimiter::for('test', function (Request $request) { + return Limit::perMinute(5); + }); + + Route::get('/', function () { + return 'yes'; + })->middleware(ThrottleRequests::class.':test'); + + $response = $this->withoutExceptionHandling()->get('/'); + expect($response->headers)->has('X-RateLimit-Limit')->toBeTrue(); + expect($response->headers)->has('X-RateLimit-Remaining')->toBeTrue(); + expect($response->headers)->get('X-RateLimit-Limit')->toEqual(5); + expect($response->headers)->get('X-RateLimit-Remaining')->toEqual(4); + + $result = Redis::connection( + Config::get('sliding-window-rate-limiter.use') + )->del('sliding_rate_limiter:'.md5('test')); + + expect($result)->toEqual(1); +}); + +it('can limit a route by named limiter and add necessary headers', function () { + Config::set('database.redis.options.prefix', '__LARAVEL__TEST__'); + + SlidingWindowRateLimiter::for('test', function (Request $request) { + return Limit::perMinute(3); + }); + + Route::get('/', function () { + return 'yes'; + })->middleware(ThrottleRequests::class.':test'); + + $this->withoutExceptionHandling()->get('/'); + $this->withoutExceptionHandling()->get('/'); + $response = $this->withoutExceptionHandling()->get('/'); + expect($response->headers)->has('X-RateLimit-Limit')->toBeTrue(); + expect($response->headers)->has('X-RateLimit-Remaining')->toBeTrue(); + expect($response->headers)->get('X-RateLimit-Limit')->toEqual(3); + expect($response->headers)->get('X-RateLimit-Remaining')->toEqual(0); + + try { + $this->withoutExceptionHandling()->get('/'); + } catch (ThrottleRequestsException $exception) { + $result = Redis::connection( + Config::get('sliding-window-rate-limiter.use') + )->del('sliding_rate_limiter:'.md5('test')); + expect($result)->toEqual(1); + $headers = $exception->getHeaders(); + expect($headers['Retry-After'])->toBeLessThanOrEqual(60)->toBeGreaterThanOrEqual(59); + expect($headers['X-RateLimit-Reset'])->toBeLessThanOrEqual(getAvailableAt(60))->toBeGreaterThanOrEqual(getAvailableAt(59)); + } +}); + +it('must throw an exception for undefined named limiter ($maxAttempts = 0)', function () { + Route::get('/', function () { + return 'yes'; + })->middleware(ThrottleRequests::class.':__test__'); + + try { + $this->withoutExceptionHandling()->get('/'); + } catch (Exception $exception) { + expect($exception)->toBeInstanceOf(RuntimeException::class); + expect($exception->getMessage())->toEqual('Unable to rate limit if max attempts equal to 0'); + } +}); + +it('can limit by passing parameters', function () { + Route::get('/', function () { + return 'yes'; + })->middleware(ThrottleRequests::class.':3,1'); + + $this->withoutExceptionHandling()->get('/'); + $this->withoutExceptionHandling()->get('/'); + $response = $this->withoutExceptionHandling()->get('/'); + expect($response->headers)->has('X-RateLimit-Limit')->toBeTrue(); + expect($response->headers)->has('X-RateLimit-Remaining')->toBeTrue(); + expect($response->headers)->get('X-RateLimit-Limit')->toEqual(3); + expect($response->headers)->get('X-RateLimit-Remaining')->toEqual(0); + + try { + $this->withoutExceptionHandling()->get('/'); + } catch (ThrottleRequestsException $exception) { + $result = Redis::connection( + Config::get('sliding-window-rate-limiter.use') + )->del('sliding_rate_limiter:'.sha1('|127.0.0.1')); + expect($result)->toEqual(1); + $headers = $exception->getHeaders(); + expect($headers['Retry-After'])->toBeLessThanOrEqual(60)->toBeGreaterThanOrEqual(59); + expect($headers['X-RateLimit-Reset'])->toBeLessThanOrEqual(getAvailableAt(60))->toBeGreaterThanOrEqual(getAvailableAt(59)); + } +}); + +it('must return response if named limiter return response', function () { + Config::set('database.redis.options.prefix', '__LARAVEL__TEST__'); + + SlidingWindowRateLimiter::for('test', function (Request $request) { + return response()->json(['ok' => true]); + }); + + Route::get('/', function () { + return 'yes'; + })->middleware(ThrottleRequests::class.':test'); + + $response = $this->withoutExceptionHandling()->get('/'); + + $response->assertJson(['ok' => true]); +}); + +it('must return http response exception if named limiter has response callback', function () { + Config::set('database.redis.options.prefix', '__LARAVEL__TEST__'); + + SlidingWindowRateLimiter::for('test', function (Request $request) { + return SlidingLimit::perMinute(1)->response(fn () => response()->json('HttpResponseException', 421)); + }); + + Route::get('/', function () { + return 'yes'; + })->middleware(ThrottleRequests::class.':test'); + + $this->withoutExceptionHandling()->get('/'); + + try { + $this->withoutExceptionHandling()->get('/'); + } catch (HttpResponseException $exception) { + $result = Redis::connection( + Config::get('sliding-window-rate-limiter.use') + )->del('sliding_rate_limiter:'.md5('test')); + expect($result)->toEqual(1); + $response = $exception->getResponse(); + expect($response)->getStatusCode()->toEqual(421); + expect($response)->getContent()->toEqual('"HttpResponseException"'); + } +}); + +it('can resolveMaxAttempts if it contains "|"', function () { + Config::set('database.redis.options.prefix', '__LARAVEL__TEST__'); + + Route::get('/', function () { + return 'yes'; + })->middleware(ThrottleRequests::class.':3|5,1'); + + Route::get('/user', function () { + return 'yes'; + })->middleware(ThrottleRequests::class.':3|5,1'); + + $this->withoutExceptionHandling()->get('/'); + $this->withoutExceptionHandling()->get('/'); + $response = $this->withoutExceptionHandling()->get('/'); + expect($response->headers)->has('X-RateLimit-Limit')->toBeTrue(); + expect($response->headers)->has('X-RateLimit-Remaining')->toBeTrue(); + expect($response->headers)->get('X-RateLimit-Limit')->toEqual(3); + expect($response->headers)->get('X-RateLimit-Remaining')->toEqual(0); + + $result = Redis::connection( + Config::get('sliding-window-rate-limiter.use') + )->del('sliding_rate_limiter:'.sha1('|127.0.0.1')); + expect($result)->toEqual(1); + + $user = new \Illuminate\Foundation\Auth\User(); + $user->id = 1000; + + $response = $this->withoutExceptionHandling()->actingAs($user)->get('/user'); + + expect($response->headers)->has('X-RateLimit-Limit')->toBeTrue(); + expect($response->headers)->has('X-RateLimit-Remaining')->toBeTrue(); + expect($response->headers)->get('X-RateLimit-Limit')->toEqual(5); + expect($response->headers)->get('X-RateLimit-Remaining')->toEqual(4); + + $result = Redis::connection( + Config::get('sliding-window-rate-limiter.use') + )->del('sliding_rate_limiter:'.sha1('1000')); + expect($result)->toEqual(1); +}); + +it('can resolveMaxAttempts from user property', function () { + Config::set('database.redis.options.prefix', '__LARAVEL__TEST__'); + + Route::get('/user', function () { + return 'yes'; + })->middleware(ThrottleRequests::class.':max_attempts,1'); + + $user = new \Illuminate\Foundation\Auth\User(); + $user->id = 1000; + $user->max_attempts = 17; + + $response = $this->withoutExceptionHandling()->actingAs($user)->get('/user'); + + expect($response->headers)->has('X-RateLimit-Limit')->toBeTrue(); + expect($response->headers)->has('X-RateLimit-Remaining')->toBeTrue(); + expect($response->headers)->get('X-RateLimit-Limit')->toEqual(17); + expect($response->headers)->get('X-RateLimit-Remaining')->toEqual(16); + + $result = Redis::connection( + Config::get('sliding-window-rate-limiter.use') + )->del('sliding_rate_limiter:'.sha1('1000')); + expect($result)->toEqual(1); +}); + +function getAvailableAt($delay): int +{ + return Carbon::now()->addRealSeconds($delay)->getTimestamp(); +}