2017-10-11 23:23:09 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
abstract class PhabricatorClientLimit {
|
|
|
|
|
|
|
|
private $limitKey;
|
|
|
|
private $clientKey;
|
|
|
|
private $limit;
|
|
|
|
|
|
|
|
final public function setLimitKey($limit_key) {
|
|
|
|
$this->limitKey = $limit_key;
|
|
|
|
return $this;
|
|
|
|
}
|
|
|
|
|
|
|
|
final public function getLimitKey() {
|
|
|
|
return $this->limitKey;
|
|
|
|
}
|
|
|
|
|
|
|
|
final public function setClientKey($client_key) {
|
|
|
|
$this->clientKey = $client_key;
|
|
|
|
return $this;
|
|
|
|
}
|
|
|
|
|
|
|
|
final public function getClientKey() {
|
|
|
|
return $this->clientKey;
|
|
|
|
}
|
|
|
|
|
|
|
|
final public function setLimit($limit) {
|
|
|
|
$this->limit = $limit;
|
|
|
|
return $this;
|
|
|
|
}
|
|
|
|
|
|
|
|
final public function getLimit() {
|
|
|
|
return $this->limit;
|
|
|
|
}
|
|
|
|
|
|
|
|
final public function didConnect() {
|
|
|
|
// NOTE: We can not use pht() here because this runs before libraries
|
|
|
|
// load.
|
|
|
|
|
|
|
|
if (!function_exists('apc_fetch') && !function_exists('apcu_fetch')) {
|
|
|
|
throw new Exception(
|
|
|
|
'You can not configure connection rate limits unless APC/APCu are '.
|
|
|
|
'available. Rate limits rely on APC/APCu to track clients and '.
|
|
|
|
'connections.');
|
|
|
|
}
|
|
|
|
|
|
|
|
if ($this->getClientKey() === null) {
|
|
|
|
throw new Exception(
|
|
|
|
'You must configure a client key when defining a rate limit.');
|
|
|
|
}
|
|
|
|
|
|
|
|
if ($this->getLimitKey() === null) {
|
|
|
|
throw new Exception(
|
|
|
|
'You must configure a limit key when defining a rate limit.');
|
|
|
|
}
|
|
|
|
|
|
|
|
if ($this->getLimit() === null) {
|
|
|
|
throw new Exception(
|
|
|
|
'You must configure a limit when defining a rate limit.');
|
|
|
|
}
|
|
|
|
|
|
|
|
$points = $this->getConnectScore();
|
|
|
|
if ($points) {
|
|
|
|
$this->addScore($points);
|
|
|
|
}
|
|
|
|
|
|
|
|
$score = $this->getScore();
|
|
|
|
if (!$this->shouldRejectConnection($score)) {
|
|
|
|
// Client has not hit the limit, so continue processing the request.
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
$penalty = $this->getPenaltyScore();
|
|
|
|
if ($penalty) {
|
|
|
|
$this->addScore($penalty);
|
|
|
|
$score += $penalty;
|
|
|
|
}
|
|
|
|
|
|
|
|
return $this->getRateLimitReason($score);
|
|
|
|
}
|
|
|
|
|
|
|
|
final public function didDisconnect(array $request_state) {
|
|
|
|
$score = $this->getDisconnectScore($request_state);
|
|
|
|
if ($score) {
|
|
|
|
$this->addScore($score);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the number of seconds for each rate bucket.
|
|
|
|
*
|
|
|
|
* For example, a value of 60 will create one-minute buckets.
|
|
|
|
*
|
|
|
|
* @return int Number of seconds per bucket.
|
|
|
|
*/
|
|
|
|
abstract protected function getBucketDuration();
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the total number of rate limit buckets to retain.
|
|
|
|
*
|
|
|
|
* @return int Total number of rate limit buckets to retain.
|
|
|
|
*/
|
|
|
|
abstract protected function getBucketCount();
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the score to add when a client connects.
|
|
|
|
*
|
|
|
|
* @return double Connection score.
|
|
|
|
*/
|
|
|
|
abstract protected function getConnectScore();
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the number of penalty points to add when a client hits a rate limit.
|
|
|
|
*
|
|
|
|
* @return double Penalty score.
|
|
|
|
*/
|
|
|
|
abstract protected function getPenaltyScore();
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the score to add when a client disconnects.
|
|
|
|
*
|
|
|
|
* @return double Connection score.
|
|
|
|
*/
|
|
|
|
abstract protected function getDisconnectScore(array $request_state);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get a human-readable explanation of why the client is being rejected.
|
|
|
|
*
|
|
|
|
* @return string Brief rejection message.
|
|
|
|
*/
|
|
|
|
abstract protected function getRateLimitReason($score);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Determine whether to reject a connection.
|
|
|
|
*
|
|
|
|
* @return bool True to reject the connection.
|
|
|
|
*/
|
|
|
|
abstract protected function shouldRejectConnection($score);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the APC key for the smallest stored bucket.
|
|
|
|
*
|
|
|
|
* @return string APC key for the smallest stored bucket.
|
|
|
|
* @task ratelimit
|
|
|
|
*/
|
|
|
|
private function getMinimumBucketCacheKey() {
|
|
|
|
$limit_key = $this->getLimitKey();
|
|
|
|
return "limit:min:{$limit_key}";
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the current bucket ID for storing rate limit scores.
|
|
|
|
*
|
|
|
|
* @return int The current bucket ID.
|
|
|
|
*/
|
|
|
|
private function getCurrentBucketID() {
|
|
|
|
return (int)(time() / $this->getBucketDuration());
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the APC key for a given bucket.
|
|
|
|
*
|
2024-08-20 18:04:23 +02:00
|
|
|
* @param int $bucket_id Bucket to get the key for.
|
2017-10-11 23:23:09 +02:00
|
|
|
* @return string APC key for the bucket.
|
|
|
|
*/
|
|
|
|
private function getBucketCacheKey($bucket_id) {
|
|
|
|
$limit_key = $this->getLimitKey();
|
|
|
|
return "limit:bucket:{$limit_key}:{$bucket_id}";
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Add points to the rate limit score for some client.
|
|
|
|
*
|
2024-08-20 18:04:23 +02:00
|
|
|
* @param float $score The cost for this request; more points pushes them
|
|
|
|
* toward the limit faster.
|
2024-09-06 19:18:57 +02:00
|
|
|
* @return $this
|
2017-10-11 23:23:09 +02:00
|
|
|
*/
|
|
|
|
private function addScore($score) {
|
|
|
|
$is_apcu = (bool)function_exists('apcu_fetch');
|
|
|
|
|
|
|
|
$current = $this->getCurrentBucketID();
|
|
|
|
$bucket_key = $this->getBucketCacheKey($current);
|
|
|
|
|
|
|
|
// There's a bit of a race here, if a second process reads the bucket
|
|
|
|
// before this one writes it, but it's fine if we occasionally fail to
|
|
|
|
// record a client's score. If they're making requests fast enough to hit
|
|
|
|
// rate limiting, we'll get them soon enough.
|
|
|
|
|
|
|
|
if ($is_apcu) {
|
|
|
|
$bucket = apcu_fetch($bucket_key);
|
|
|
|
} else {
|
|
|
|
$bucket = apc_fetch($bucket_key);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!is_array($bucket)) {
|
|
|
|
$bucket = array();
|
|
|
|
}
|
|
|
|
|
|
|
|
$client_key = $this->getClientKey();
|
|
|
|
if (empty($bucket[$client_key])) {
|
|
|
|
$bucket[$client_key] = 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
$bucket[$client_key] += $score;
|
|
|
|
|
|
|
|
if ($is_apcu) {
|
2020-04-28 12:53:40 +02:00
|
|
|
@apcu_store($bucket_key, $bucket);
|
2017-10-11 23:23:09 +02:00
|
|
|
} else {
|
2020-04-28 12:53:40 +02:00
|
|
|
@apc_store($bucket_key, $bucket);
|
2017-10-11 23:23:09 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return $this;
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the current rate limit score for a given client.
|
|
|
|
*
|
|
|
|
* @return float The client's current score.
|
|
|
|
* @task ratelimit
|
|
|
|
*/
|
|
|
|
private function getScore() {
|
|
|
|
$is_apcu = (bool)function_exists('apcu_fetch');
|
|
|
|
|
|
|
|
// Identify the oldest bucket stored in APC.
|
|
|
|
$min_key = $this->getMinimumBucketCacheKey();
|
|
|
|
if ($is_apcu) {
|
|
|
|
$min = apcu_fetch($min_key);
|
|
|
|
} else {
|
|
|
|
$min = apc_fetch($min_key);
|
|
|
|
}
|
|
|
|
|
|
|
|
// If we don't have any buckets stored yet, store the current bucket as
|
|
|
|
// the oldest bucket.
|
|
|
|
$cur = $this->getCurrentBucketID();
|
|
|
|
if (!$min) {
|
|
|
|
if ($is_apcu) {
|
2020-04-28 12:53:40 +02:00
|
|
|
@apcu_store($min_key, $cur);
|
2017-10-11 23:23:09 +02:00
|
|
|
} else {
|
2020-04-28 12:53:40 +02:00
|
|
|
@apc_store($min_key, $cur);
|
2017-10-11 23:23:09 +02:00
|
|
|
}
|
|
|
|
$min = $cur;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Destroy any buckets that are older than the minimum bucket we're keeping
|
|
|
|
// track of. Under load this normally shouldn't do anything, but will clean
|
|
|
|
// up an old bucket once per minute.
|
|
|
|
$count = $this->getBucketCount();
|
|
|
|
for ($cursor = $min; $cursor < ($cur - $count); $cursor++) {
|
|
|
|
$bucket_key = $this->getBucketCacheKey($cursor);
|
|
|
|
if ($is_apcu) {
|
|
|
|
apcu_delete($bucket_key);
|
2020-04-28 12:53:40 +02:00
|
|
|
@apcu_store($min_key, $cursor + 1);
|
2017-10-11 23:23:09 +02:00
|
|
|
} else {
|
|
|
|
apc_delete($bucket_key);
|
2020-04-28 12:53:40 +02:00
|
|
|
@apc_store($min_key, $cursor + 1);
|
2017-10-11 23:23:09 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
$client_key = $this->getClientKey();
|
|
|
|
|
|
|
|
// Now, sum up the client's scores in all of the active buckets.
|
|
|
|
$score = 0;
|
|
|
|
for (; $cursor <= $cur; $cursor++) {
|
|
|
|
$bucket_key = $this->getBucketCacheKey($cursor);
|
|
|
|
if ($is_apcu) {
|
|
|
|
$bucket = apcu_fetch($bucket_key);
|
|
|
|
} else {
|
|
|
|
$bucket = apc_fetch($bucket_key);
|
|
|
|
}
|
|
|
|
if (isset($bucket[$client_key])) {
|
|
|
|
$score += $bucket[$client_key];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return $score;
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|