1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2022 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 explode; 29use function intdiv; 30use function strlen; 31use function time; 32 33/** 34 * Throttle events to prevent abuse. 35 */ 36class 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