├── .github └── workflows │ └── ci.yml ├── .php-cs-fixer.dist.php ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml.dist └── src ├── PrefixRateLimit.php ├── RateLimit.php └── RedisRateLimit.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | tests: 9 | strategy: 10 | matrix: 11 | include: 12 | - operating-system: 'ubuntu-latest' 13 | php-version: '8.1' 14 | 15 | - operating-system: 'ubuntu-latest' 16 | php-version: '8.2' 17 | 18 | name: PHP ${{ matrix.php-version }} ${{ matrix.job-description }} 19 | 20 | runs-on: ${{ matrix.operating-system }} 21 | 22 | steps: 23 | - name: Set git to use LF 24 | run: | 25 | git config --global core.autocrlf false 26 | git config --global core.eol lf 27 | 28 | - name: Checkout code 29 | uses: actions/checkout@v3 30 | 31 | - name: Setup PHP 32 | uses: shivammathur/setup-php@v2 33 | with: 34 | php-version: ${{ matrix.php-version }} 35 | 36 | - name: Setup redis 37 | uses: shogo82148/actions-setup-redis@v1 38 | with: 39 | redis-version: latest 40 | auto-start: "true" 41 | 42 | - name: Get Composer cache directory 43 | id: composer-cache 44 | run: echo "dir=$(composer config cache-dir)" >> $GITHUB_OUTPUT 45 | shell: bash 46 | 47 | - name: Cache dependencies 48 | uses: actions/cache@v3 49 | with: 50 | path: ${{ steps.composer-cache.outputs.dir }} 51 | key: composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }}-${{ matrix.composer-flags }} 52 | restore-keys: | 53 | composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }}- 54 | composer-${{ runner.os }}-${{ matrix.php-version }}- 55 | composer-${{ runner.os }}- 56 | composer- 57 | 58 | - name: Install dependencies 59 | uses: nick-invision/retry@v2 60 | with: 61 | timeout_minutes: 5 62 | max_attempts: 5 63 | retry_wait_seconds: 30 64 | command: | 65 | composer update --optimize-autoloader --no-interaction --no-progress ${{ matrix.composer-flags }} 66 | composer info -D 67 | 68 | - name: Run tests 69 | run: vendor/bin/phpunit ${{ matrix.phpunit-flags }} 70 | 71 | - name: Run style fixer 72 | env: 73 | PHP_CS_FIXER_IGNORE_ENV: 1 74 | run: vendor/bin/php-cs-fixer --diff --dry-run -v fix 75 | if: runner.os != 'Windows' 76 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | getFinder() 5 | ->in(__DIR__ . '/src') 6 | ->in(__DIR__ . '/test'); 7 | 8 | $config->setCacheFile(__DIR__ . '/.php_cs.cache'); 9 | 10 | return $config; 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Submitting useful bug reports 2 | 3 | Please search existing issues first to make sure this is not a duplicate. 4 | Every issue report has a cost for the developers required to field it; be 5 | respectful of others' time and ensure your report isn't spurious prior to 6 | submission. Please adhere to [sound bug reporting principles](http://www.chiark.greenend.org.uk/~sgtatham/bugs.html). 7 | 8 | ## Development ideology 9 | 10 | Truths which we believe to be self-evident: 11 | 12 | - **It's an asynchronous world.** Be wary of anything that undermines 13 | async principles. 14 | 15 | - **The answer is not more options.** If you feel compelled to expose 16 | new preferences to the user it's very possible you've made a wrong 17 | turn somewhere. 18 | 19 | - **There are no power users.** The idea that some users "understand" 20 | concepts better than others has proven to be, for the most part, false. 21 | If anything, "power users" are more dangerous than the rest, and we 22 | should avoid exposing dangerous functionality to them. 23 | 24 | ## Code style 25 | 26 | The project adheres to the PSR-2 style guide with the exception that 27 | opening braces for classes and methods must appear on the same line as 28 | the declaration: 29 | 30 | https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2023 Niklas Keller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rate-limit 2 | 3 | ![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square) 4 | 5 | `kelunik/rate-limit` is a rate limiting library for [Amp](https://github.com/amphp/amp). 6 | 7 | ## Installation 8 | 9 | ```bash 10 | composer require kelunik/rate-limit 11 | ``` 12 | 13 | ## Usage 14 | 15 | You're in full control of any actions when the rate limit is exceeded. You can also already warn the user before he exceeds the limit. 16 | 17 | ```php 18 | $current = $this->rateLimit->increment("{$userId}:{$action}"); 19 | 20 | if ($current > $this->limit) { 21 | // show captcha or error page or do anything you want 22 | } else { 23 | // request is within the limit, continue normally 24 | } 25 | ``` 26 | 27 | If you want to expose the limits, e.g. in an HTTP API, you can also request the reset time for a given key. 28 | 29 | ```php 30 | $current = $this->rateLimit->increment("{$userId}:{$action}"); 31 | 32 | $response->setHeader("x-ratelimit-limit", $this->limit); 33 | $response->setHeader("x-ratelimit-remaining", $this->limit - $current); 34 | $response->setHeader("x-ratelimit-reset", $this->rateLimit->getTtl("{$userId}:{$action}")); 35 | ``` 36 | 37 | `RateLimit::getTtl()` returns the seconds until the limit is reset. If you want to return the absolute time, you can just add `time()` to that value. 38 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kelunik/rate-limit", 3 | "description": "Rate Limiting for Amp.", 4 | "license": "MIT", 5 | "keywords": [ 6 | "amp", 7 | "amphp", 8 | "limit", 9 | "rate-limit", 10 | "redis" 11 | ], 12 | "authors": [ 13 | { 14 | "name": "Niklas Keller", 15 | "email": "me@kelunik.com" 16 | } 17 | ], 18 | "autoload": { 19 | "psr-4": { 20 | "Kelunik\\RateLimit\\": "src" 21 | } 22 | }, 23 | "autoload-dev": { 24 | "psr-4": { 25 | "Kelunik\\RateLimit\\": "test" 26 | } 27 | }, 28 | "require": { 29 | "php": ">=8.1", 30 | "amphp/redis": "^2" 31 | }, 32 | "require-dev": { 33 | "amphp/amp": "^3", 34 | "phpunit/phpunit": "^9", 35 | "amphp/php-cs-fixer-config": "^2" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./src 6 | 7 | 8 | 9 | 10 | ./test 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/PrefixRateLimit.php: -------------------------------------------------------------------------------- 1 | rateLimit = $rateLimit; 22 | $this->prefix = $prefix; 23 | } 24 | 25 | /** @inheritdoc */ 26 | public function get(string $id): int 27 | { 28 | return $this->rateLimit->get($this->prefix . $id); 29 | } 30 | 31 | /** @inheritdoc */ 32 | public function increment(string $id): int 33 | { 34 | return $this->rateLimit->increment($this->prefix . $id); 35 | } 36 | 37 | /** @inheritdoc */ 38 | public function getTtl(string $id): int 39 | { 40 | return $this->rateLimit->getTtl($this->prefix . $id); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/RateLimit.php: -------------------------------------------------------------------------------- 1 | redis = $redis; 23 | $this->ttl = $ttl; 24 | } 25 | 26 | /** @inheritdoc */ 27 | public function get(string $id): int 28 | { 29 | $count = $this->redis->get($id); 30 | 31 | return (int) $count; 32 | } 33 | 34 | /** @inheritdoc */ 35 | public function increment(string $id): int 36 | { 37 | $count = $this->redis->increment($id); 38 | 39 | if ($count === 1) { 40 | $this->redis->expireIn($id, $this->ttl); 41 | } 42 | 43 | return $count; 44 | } 45 | 46 | /** @inheritdoc */ 47 | public function getTtl(string $id): int 48 | { 49 | $ttl = $this->redis->getTtl($id); 50 | 51 | if ($ttl < 0) { 52 | return $this->ttl; 53 | } 54 | 55 | return $ttl; 56 | } 57 | } 58 | --------------------------------------------------------------------------------