@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at recaptime-dev/main 277 lines 6.8 kB view raw
1<?php 2 3abstract class PhabricatorClientLimit { 4 5 private $limitKey; 6 private $clientKey; 7 private $limit; 8 9 final public function setLimitKey($limit_key) { 10 $this->limitKey = $limit_key; 11 return $this; 12 } 13 14 final public function getLimitKey() { 15 return $this->limitKey; 16 } 17 18 final public function setClientKey($client_key) { 19 $this->clientKey = $client_key; 20 return $this; 21 } 22 23 final public function getClientKey() { 24 return $this->clientKey; 25 } 26 27 final public function setLimit($limit) { 28 $this->limit = $limit; 29 return $this; 30 } 31 32 final public function getLimit() { 33 return $this->limit; 34 } 35 36 final public function didConnect() { 37 // NOTE: We can not use pht() here because this runs before libraries 38 // load. 39 40 if (!function_exists('apcu_fetch')) { 41 throw new Exception( 42 'You can not configure connection rate limits unless APCu is '. 43 'available. Rate limits rely on APCu to track clients and '. 44 'connections.'); 45 } 46 47 if ($this->getClientKey() === null) { 48 throw new Exception( 49 'You must configure a client key when defining a rate limit.'); 50 } 51 52 if ($this->getLimitKey() === null) { 53 throw new Exception( 54 'You must configure a limit key when defining a rate limit.'); 55 } 56 57 if ($this->getLimit() === null) { 58 throw new Exception( 59 'You must configure a limit when defining a rate limit.'); 60 } 61 62 $points = $this->getConnectScore(); 63 if ($points) { 64 $this->addScore($points); 65 } 66 67 $score = $this->getScore(); 68 if (!$this->shouldRejectConnection($score)) { 69 // Client has not hit the limit, so continue processing the request. 70 return null; 71 } 72 73 $penalty = $this->getPenaltyScore(); 74 if ($penalty) { 75 $this->addScore($penalty); 76 $score += $penalty; 77 } 78 79 return $this->getRateLimitReason($score); 80 } 81 82 final public function didDisconnect(array $request_state) { 83 $score = $this->getDisconnectScore($request_state); 84 if ($score) { 85 $this->addScore($score); 86 } 87 } 88 89 90 /** 91 * Get the number of seconds for each rate bucket. 92 * 93 * For example, a value of 60 will create one-minute buckets. 94 * 95 * @return int Number of seconds per bucket. 96 */ 97 abstract protected function getBucketDuration(); 98 99 100 /** 101 * Get the total number of rate limit buckets to retain. 102 * 103 * @return int Total number of rate limit buckets to retain. 104 */ 105 abstract protected function getBucketCount(); 106 107 108 /** 109 * Get the score to add when a client connects. 110 * 111 * @return double Connection score. 112 */ 113 abstract protected function getConnectScore(); 114 115 116 /** 117 * Get the number of penalty points to add when a client hits a rate limit. 118 * 119 * @return double Penalty score. 120 */ 121 abstract protected function getPenaltyScore(); 122 123 124 /** 125 * Get the score to add when a client disconnects. 126 * 127 * @return double Connection score. 128 */ 129 abstract protected function getDisconnectScore(array $request_state); 130 131 132 /** 133 * Get a human-readable explanation of why the client is being rejected. 134 * 135 * @return string Brief rejection message. 136 */ 137 abstract protected function getRateLimitReason($score); 138 139 140 /** 141 * Determine whether to reject a connection. 142 * 143 * @return bool True to reject the connection. 144 */ 145 abstract protected function shouldRejectConnection($score); 146 147 148 /** 149 * Get the APC key for the smallest stored bucket. 150 * 151 * @return string APC key for the smallest stored bucket. 152 * @task ratelimit 153 */ 154 private function getMinimumBucketCacheKey() { 155 $limit_key = $this->getLimitKey(); 156 return "limit:min:{$limit_key}"; 157 } 158 159 160 /** 161 * Get the current bucket ID for storing rate limit scores. 162 * 163 * @return int The current bucket ID. 164 */ 165 private function getCurrentBucketID() { 166 return (int)(time() / $this->getBucketDuration()); 167 } 168 169 170 /** 171 * Get the APC key for a given bucket. 172 * 173 * @param int $bucket_id Bucket to get the key for. 174 * @return string APC key for the bucket. 175 */ 176 private function getBucketCacheKey($bucket_id) { 177 $limit_key = $this->getLimitKey(); 178 return "limit:bucket:{$limit_key}:{$bucket_id}"; 179 } 180 181 182 /** 183 * Add points to the rate limit score for some client. 184 * 185 * @param float $score The cost for this request; more points pushes them 186 * toward the limit faster. 187 * @return $this 188 */ 189 private function addScore($score) { 190 $is_apcu = (bool)function_exists('apcu_fetch'); 191 192 $current = $this->getCurrentBucketID(); 193 $bucket_key = $this->getBucketCacheKey($current); 194 195 // There's a bit of a race here, if a second process reads the bucket 196 // before this one writes it, but it's fine if we occasionally fail to 197 // record a client's score. If they're making requests fast enough to hit 198 // rate limiting, we'll get them soon enough. 199 200 if ($is_apcu) { 201 $bucket = apcu_fetch($bucket_key); 202 } 203 204 if (!isset($bucket) || !is_array($bucket)) { 205 $bucket = array(); 206 } 207 208 $client_key = $this->getClientKey(); 209 if (empty($bucket[$client_key])) { 210 $bucket[$client_key] = 0; 211 } 212 213 $bucket[$client_key] += $score; 214 215 if ($is_apcu) { 216 @apcu_store($bucket_key, $bucket); 217 } 218 219 return $this; 220 } 221 222 223 /** 224 * Get the current rate limit score for a given client. 225 * 226 * @return float The client's current score. 227 * @task ratelimit 228 */ 229 private function getScore() { 230 $is_apcu = (bool)function_exists('apcu_fetch'); 231 232 // Identify the oldest bucket stored in APC. 233 $min_key = $this->getMinimumBucketCacheKey(); 234 if ($is_apcu) { 235 $min = apcu_fetch($min_key); 236 } 237 238 // If we don't have any buckets stored yet, store the current bucket as 239 // the oldest bucket. 240 $cur = $this->getCurrentBucketID(); 241 if (!isset($min) || !$min) { 242 if ($is_apcu) { 243 @apcu_store($min_key, $cur); 244 } 245 $min = $cur; 246 } 247 248 // Destroy any buckets that are older than the minimum bucket we're keeping 249 // track of. Under load this normally shouldn't do anything, but will clean 250 // up an old bucket once per minute. 251 $count = $this->getBucketCount(); 252 for ($cursor = $min; $cursor < ($cur - $count); $cursor++) { 253 $bucket_key = $this->getBucketCacheKey($cursor); 254 if ($is_apcu) { 255 apcu_delete($bucket_key); 256 @apcu_store($min_key, $cursor + 1); 257 } 258 } 259 260 $client_key = $this->getClientKey(); 261 262 // Now, sum up the client's scores in all of the active buckets. 263 $score = 0; 264 for (; $cursor <= $cur; $cursor++) { 265 $bucket_key = $this->getBucketCacheKey($cursor); 266 if ($is_apcu) { 267 $bucket = apcu_fetch($bucket_key); 268 } 269 if (isset($bucket[$client_key])) { 270 $score += $bucket[$client_key]; 271 } 272 } 273 274 return $score; 275 } 276 277}