Passed
Push — main ( 7a8937...9ed332 )
by Greg
10:41 queued 03:14
created

RateLimitService::limitRateForSite()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 3
nc 1
nop 3
dl 0
loc 7
rs 10
c 1
b 0
f 0
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2021 webtrees development team
6
 * This program is free software: you can redistribute it and/or modify
7
 * it under the terms of the GNU General Public License as published by
8
 * the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 * You should have received a copy of the GNU General Public License
15
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
 */
17
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees\Services;
21
22
use Fisharebest\Webtrees\Contracts\UserInterface;
23
use Fisharebest\Webtrees\Http\Exceptions\HttpTooManyRequestsException;
24
use Fisharebest\Webtrees\Site;
25
use LogicException;
26
27
use function array_filter;
28
use function explode;
29
use function intdiv;
30
use function strlen;
31
use function time;
32
33
/**
34
 * Throttle events to prevent abuse.
35
 */
36
class RateLimitService
37
{
38
    private int $now;
39
40
    /**
41
     *
42
     */
43
    public function __construct()
44
    {
45
        $this->now = time();
46
    }
47
48
    /**
49
     * Rate limit for actions related to a user, such as password reset request.
50
     * Allow $num requests every $seconds
51
     *
52
     * @param int    $num     allow this number of events
53
     * @param int    $seconds in a rolling window of this number of seconds
54
     * @param string $limit   name of limit to enforce
55
     *
56
     * @return void
57
     */
58
    public function limitRateForSite(int $num, int $seconds, string $limit): void
59
    {
60
        $history = Site::getPreference($limit);
61
62
        $history = $this->checkLimitReached($num, $seconds, $history);
63
64
        Site::setPreference($limit, $history);
65
    }
66
67
    /**
68
     * Rate limit for actions related to a user, such as password reset request.
69
     * Allow $num requests every $seconds
70
     *
71
     * @param UserInterface $user    limit events for this user
72
     * @param int           $num     allow this number of events
73
     * @param int           $seconds in a rolling window of this number of seconds
74
     * @param string        $limit   name of limit to enforce
75
     *
76
     * @return void
77
     */
78
    public function limitRateForUser(UserInterface $user, int $num, int $seconds, string $limit): void
79
    {
80
        $history = $user->getPreference($limit);
81
82
        $history = $this->checkLimitReached($num, $seconds, $history);
83
84
        $user->setPreference($limit, $history);
85
    }
86
87
    /**
88
     * Rate limit - allow $num requests every $seconds
89
     *
90
     * @param int    $num     allow this number of events
91
     * @param int    $seconds in a rolling window of this number of seconds
92
     * @param string $history comma-separated list of previous timestamps
93
     *
94
     * @return string updated list of timestamps
95
     * @throws HttpTooManyRequestsException
96
     */
97
    private function checkLimitReached(int $num, int $seconds, string $history): string
98
    {
99
        // Make sure we can store enough previous timestamps in a database field.
100
        $max = intdiv(256, strlen($this->now . ','));
101
        if ($num > $max) {
102
            throw new LogicException('Cannot store ' . $num . ' previous events in the database');
103
        }
104
105
        // Extract the timestamps.
106
        $timestamps = array_filter(explode(',', $history));
107
108
        // Filter events within our time window.
109
        $filter    = fn(string $x): bool => (int) $x >= $this->now - $seconds && (int) $x <= $this->now;
110
        $in_window = array_filter($timestamps, $filter);
111
112
        if (count($in_window) >= $num) {
113
            throw new HttpTooManyRequestsException();
114
        }
115
116
        $timestamps[] = (string) $this->now;
117
118
        while (count($timestamps) > $max) {
119
            array_shift($timestamps);
120
        }
121
122
        return implode(',', $timestamps);
123
    }
124
}
125