Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.65% covered (success)
95.65%
22 / 23
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiRateLimiterListener
95.65% covered (success)
95.65%
22 / 23
50.00% covered (danger)
50.00%
1 / 2
9
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onKernelRequest
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
8
1<?php
2
3/**
4 * Rate limiter listener for public API requests.
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-03-16
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\EventListener;
21
22use phpMyFAQ\Configuration;
23use phpMyFAQ\Http\RateLimiter;
24use Symfony\Component\HttpFoundation\JsonResponse;
25use Symfony\Component\HttpFoundation\Response;
26use Symfony\Component\HttpKernel\Event\RequestEvent;
27
28readonly class ApiRateLimiterListener
29{
30    public function __construct(
31        private Configuration $configuration,
32        private RateLimiter $rateLimiter,
33    ) {
34    }
35
36    public function onKernelRequest(RequestEvent $event): void
37    {
38        if (!$event->isMainRequest()) {
39            return;
40        }
41
42        $request = $event->getRequest();
43
44        // Rate limiting curbs abusive, state-changing requests (login attempts,
45        // registration, comments, ...). It must not throttle:
46        //  - safe/idempotent reads (GET/HEAD/OPTIONS), e.g., config endpoints, or
47        //  - authenticated "private" API routes used by logged-in users.
48        if ($request->isMethodSafe()) {
49            return;
50        }
51
52        $route = (string) $request->attributes->get('_route', '');
53        if (str_starts_with($route, 'api.private.')) {
54            return;
55        }
56
57        $requestLimit = (int) $this->configuration->get('api.rateLimit.requests');
58        $interval = (int) $this->configuration->get('api.rateLimit.interval');
59
60        if ($requestLimit < 1 || $interval < 1) {
61            return;
62        }
63
64        $clientIdentifier = $request->getClientIp() ?? 'anonymous';
65
66        if ($this->rateLimiter->check($clientIdentifier, $requestLimit, $interval)) {
67            return;
68        }
69
70        $response = new JsonResponse(data: [
71            'error' => 'Too many requests.',
72            'message' => 'Rate limit exceeded. Please retry later.',
73        ], status: Response::HTTP_TOO_MANY_REQUESTS);
74
75        foreach ($this->rateLimiter->getHeaders() as $header => $value) {
76            $response->headers->set($header, (string) $value);
77        }
78
79        $event->setResponse($response);
80    }
81}