1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2023 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 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees\Services; 21 22use Fisharebest\Webtrees\Contracts\UserInterface; 23use Fisharebest\Webtrees\Http\Exceptions\HttpTooManyRequestsException; 24use Fisharebest\Webtrees\Site; 25use LogicException; 26 27use function array_filter; 28use function count; 29use function explode; 30use function intdiv; 31use function strlen; 32use function time; 33 34/** 35 * Throttle events to prevent abuse. 36 */ 37class RateLimitService 38{ 39 private int $now; 40 41 /** 42 * 43 */ 44 public function __construct() 45 { 46 $this->now = time(); 47 } 48 49 /** 50 * Rate limit for actions related to a user, such as password reset request. 51 * Allow $num requests every $seconds 52 * 53 * @param int $num allow this number of events 54 * @param int $seconds in a rolling window of this number of seconds 55 * @param string $limit name of limit to enforce 56 * 57 * @return void 58 */ 59 public function limitRateForSite(int $num, int $seconds, string $limit): void 60 { 61 $history = Site::getPreference($limit); 62 63 $history = $this->checkLimitReached($num, $seconds, $history); 64 65 Site::setPreference($limit, $history); 66 } 67 68 /** 69 * Rate limit for actions related to a user, such as password reset request. 70 * Allow $num requests every $seconds 71 * 72 * @param UserInterface $user limit events for this user 73 * @param int $num allow this number of events 74 * @param int $seconds in a rolling window of this number of seconds 75 * @param string $limit name of limit to enforce 76 * 77 * @return void 78 */ 79 public function limitRateForUser(UserInterface $user, int $num, int $seconds, string $limit): void 80 { 81 $history = $user->getPreference($limit); 82 83 $history = $this->checkLimitReached($num, $seconds, $history); 84 85 $user->setPreference($limit, $history); 86 } 87 88 /** 89 * Rate limit - allow $num requests every $seconds 90 * 91 * @param int $num allow this number of events 92 * @param int $seconds in a rolling window of this number of seconds 93 * @param string $history comma-separated list of previous timestamps 94 * 95 * @return string updated list of timestamps 96 * @throws HttpTooManyRequestsException 97 */ 98 private function checkLimitReached(int $num, int $seconds, string $history): string 99 { 100 // Make sure we can store enough previous timestamps in a database field. 101 $max = intdiv(256, strlen($this->now . ',')); 102 if ($num > $max) { 103 throw new LogicException('Cannot store ' . $num . ' previous events in the database'); 104 } 105 106 // Extract the timestamps. 107 $timestamps = array_filter(explode(',', $history)); 108 109 // Filter events within our time window. 110 $filter = fn (string $x): bool => (int) $x >= $this->now - $seconds && (int) $x <= $this->now; 111 $in_window = array_filter($timestamps, $filter); 112 113 if (count($in_window) >= $num) { 114 throw new HttpTooManyRequestsException(); 115 } 116 117 $timestamps[] = (string) $this->now; 118 119 while (count($timestamps) > $max) { 120 array_shift($timestamps); 121 } 122 123 return implode(',', $timestamps); 124 } 125} 126