Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
RateLimiter
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
4 / 4
6
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 check
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
2
 peek
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * API rate limiter using symfony/rate-limiter.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-09
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Http;
21
22use Psr\Cache\CacheItemPoolInterface;
23use Symfony\Component\RateLimiter\RateLimiterFactory;
24use Symfony\Component\RateLimiter\Storage\CacheStorage;
25use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
26use Symfony\Component\RateLimiter\Storage\StorageInterface;
27
28final class RateLimiter
29{
30    /** @var array<string, int|string> */
31    private array $headersStorage = [];
32
33    /** @var array<string, int|string> */
34    public array $headers {
35        get => $this->headersStorage;
36    }
37
38    private readonly StorageInterface $storage;
39
40    public function __construct(?CacheItemPoolInterface $cache = null, ?StorageInterface $storage = null)
41    {
42        $this->storage = $storage ?? ($cache !== null ? new CacheStorage($cache) : new InMemoryStorage());
43    }
44
45    /**
46     * Checks if a request should be allowed for the given key.
47     */
48    public function check(string $key, int $limit, int $intervalSeconds): bool
49    {
50        $limit = max(1, $limit);
51        $intervalSeconds = max(1, $intervalSeconds);
52
53        $factory = new RateLimiterFactory(config: [
54            'id' => 'api',
55            'policy' => 'fixed_window',
56            'limit' => $limit,
57            'interval' => $intervalSeconds . ' seconds',
58        ], storage: $this->storage);
59
60        $limiter = $factory->create($key);
61        $rateLimit = $limiter->consume(1);
62
63        $resetTime = $rateLimit->getRetryAfter()->getTimestamp();
64
65        if ($rateLimit->isAccepted()) {
66            $this->headersStorage = [
67                'X-RateLimit-Limit' => $limit,
68                'X-RateLimit-Remaining' => $rateLimit->getRemainingTokens(),
69                'X-RateLimit-Reset' => $resetTime,
70            ];
71
72            return true;
73        }
74
75        $this->headersStorage = [
76            'X-RateLimit-Limit' => $limit,
77            'X-RateLimit-Remaining' => 0,
78            'X-RateLimit-Reset' => $resetTime,
79            'Retry-After' => max(1, $resetTime - time()),
80        ];
81
82        return false;
83    }
84
85    /**
86     * Reports whether the given key still has budget left, without consuming a token.
87     */
88    public function peek(string $key, int $limit, int $intervalSeconds): bool
89    {
90        $factory = new RateLimiterFactory(config: [
91            'id' => 'api',
92            'policy' => 'fixed_window',
93            'limit' => max(1, $limit),
94            'interval' => max(1, $intervalSeconds) . ' seconds',
95        ], storage: $this->storage);
96
97        // consume(0) never rejects, so exhaustion shows up as zero remaining tokens
98        return $factory->create($key)->consume(0)->getRemainingTokens() > 0;
99    }
100
101    /**
102     * @return array<string, int|string>
103     */
104    public function getHeaders(): array
105    {
106        return $this->headers;
107    }
108}