diff --git a/src/Abuse/Adapters/SlidingWindow.php b/src/Abuse/Adapters/SlidingWindow.php new file mode 100644 index 0000000..a488db6 --- /dev/null +++ b/src/Abuse/Adapters/SlidingWindow.php @@ -0,0 +1,100 @@ +limit - ($this->count($this->parseKey(), $this->timestamp) + 1); + + return (0 > $left) ? 0 : $left; + } + + /** + * Limit + * + * Return the limit integer + * + * @return int + */ + public function limit(): int + { + return $this->limit; + } + + /** + * Time + * + * Return the timestamp + * + * @return int + */ + public function time(): int + { + return $this->timestamp; + } + + /** + * Reset + * + * Clear the counters for the current key so the limit starts fresh. + * Implementations must clear both the current and previous window buckets. + * + * @return void + * + * @throws \Exception + */ + abstract public function reset(): void; +} diff --git a/src/Abuse/Adapters/SlidingWindow/None.php b/src/Abuse/Adapters/SlidingWindow/None.php new file mode 100644 index 0000000..74f9554 --- /dev/null +++ b/src/Abuse/Adapters/SlidingWindow/None.php @@ -0,0 +1,62 @@ +key = $key; + $this->limit = $limit; + $now = \time(); + $this->timestamp = (int) ($now - ($now % $windowSize)); + } + + protected function count(string $key, int $timestamp): int + { + return 0; + } + + public function check(): bool + { + return false; + } + + public function reset(): void + { + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + return []; + } + + /** + * Delete all logs older than $timestamp + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } +} diff --git a/src/Abuse/Adapters/SlidingWindow/Redis.php b/src/Abuse/Adapters/SlidingWindow/Redis.php new file mode 100644 index 0000000..af22065 --- /dev/null +++ b/src/Abuse/Adapters/SlidingWindow/Redis.php @@ -0,0 +1,94 @@ += $windowSize so the + * previous window's bucket survives long enough to be weighted + * @param \Redis $redis Redis connection used for storage + */ + public function __construct(protected string $key, protected int $limit, int $windowSize, int $ttl, protected \Redis $redis) + { + $this->initWindow($windowSize, $ttl); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + * + * @throws \RedisException + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->redis->eval($script, [...$keys, ...$argv], \count($keys)); + } + + /** + * @param string $key + * @return mixed + * + * @throws \RedisException + */ + protected function get(string $key): mixed + { + return $this->redis->get($key); + } + + /** + * @param string ...$keys + * @return void + * + * @throws \RedisException + */ + protected function delete(string ...$keys): void + { + $this->redis->del(...$keys); + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + + $cursor = null; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + do { + $keys = $this->redis->scan($cursor, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $this->redis->get($key); + } + + return $logs; + } +} diff --git a/src/Abuse/Adapters/SlidingWindow/RedisBase.php b/src/Abuse/Adapters/SlidingWindow/RedisBase.php new file mode 100644 index 0000000..39b357e --- /dev/null +++ b/src/Abuse/Adapters/SlidingWindow/RedisBase.php @@ -0,0 +1,253 @@ += max_requests then + return { 0, 0, math.floor(estimated) } + end + + local new_count = redis.call('INCR', current_key) + redis.call('EXPIRE', current_key, ttl) + + local new_estimate = weighted_prev + new_count + local remaining = math.max(0, math.floor(max_requests - new_estimate)) + return { 1, remaining, math.floor(new_estimate) } + LUA; + + /** + * @var int + */ + protected int $windowSize; + + /** + * @var int + */ + protected int $ttl; + + /** + * Run a Lua script against the storage backend. + * + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed the raw script result + */ + abstract protected function eval(string $script, array $keys, array $argv): mixed; + + /** + * Get the raw value stored at $key (null/false when missing). + * + * @param string $key + * @return mixed + */ + abstract protected function get(string $key): mixed; + + /** + * Delete the given keys. + * + * @param string ...$keys + * @return void + */ + abstract protected function delete(string ...$keys): void; + + /** + * Validate and store the window configuration. The window itself is derived + * live in window(); here we only seed $timestamp so the inherited property is + * initialised before remaining()/time() read it. + * + * @param int $windowSize + * @param int $ttl + * @return void + */ + protected function initWindow(int $windowSize, int $ttl): void + { + if ($windowSize <= 0) { + throw new \InvalidArgumentException('windowSize must be greater than 0'); + } + + // The previous bucket keeps contributing (weighted) throughout the whole + // current window, and its ttl is set from its last write - which in the + // worst case is at the very start of its own window. It therefore needs to + // survive up to two full windows, so ttl must be >= 2 * windowSize. + if ($ttl < $windowSize * 2) { + throw new \InvalidArgumentException('ttl must be at least twice the windowSize so the previous window bucket outlives the current window'); + } + + $this->windowSize = $windowSize; + $this->ttl = $ttl; + [$this->timestamp] = $this->window(); + } + + /** + * Compute the live window from the current time. + * + * @return array{0:int,1:float} [window start timestamp, elapsed fraction in [0,1)] + */ + private function window(): array + { + $now = \time(); + $timestamp = (int)($now - ($now % $this->windowSize)); // start of the current window + + return [$timestamp, ($now - $timestamp) / $this->windowSize]; + } + + /** + * Build a bucket key. The hash tag around $key forces the current and previous + * window buckets into the same cluster slot, so the multi-key Lua script and + * reset() do not raise CROSSSLOT on a Redis Cluster (harmless on single Redis). + * + * @param string $key + * @param int $timestamp + * @return string + */ + protected function bucketKey(string $key, int $timestamp): string + { + return self::NAMESPACE . '__{' . $key . '}__' . $timestamp; + } + + /** + * Time + * + * Start timestamp of the current window, recomputed from the clock. + * + * @return int + */ + public function time(): int + { + [$this->timestamp] = $this->window(); + + return $this->timestamp; + } + + /** + * Check + * + * @return bool + * + * @throws \Throwable + */ + public function check(): bool + { + if ($this->limit === 0) { + return false; + } + + $key = $this->parseKey(); + [$timestamp, $elapsed] = $this->window(); + $this->timestamp = $timestamp; + + /** @var array{0:int,1:int,2:int} $result */ + $result = $this->eval( + self::LIMIT_CHECK_SCRIPT, + [ + $this->bucketKey($key, $timestamp), // KEYS[1] current bucket + $this->bucketKey($key, $timestamp - $this->windowSize), // KEYS[2] previous bucket + ], + [ + $this->limit, // ARGV[1] max_requests + $elapsed, // ARGV[2] elapsed fraction + $this->ttl, // ARGV[3] ttl seconds + ], + ); + + [$allowed] = $result; + + return $allowed === 0; + } + + /** + * Count + * + * Read-only weighted estimate of hits in the current sliding window + * (current bucket + weighted previous bucket). Used by remaining(). + * + * @param string $key + * @param int $timestamp + * @return int + */ + protected function count(string $key, int $timestamp): int + { + if (0 == $this->limit) { + return 0; + } + + [$windowStart, $elapsed] = $this->window(); + $this->timestamp = $windowStart; + + $currentRaw = $this->get($this->bucketKey($key, $windowStart)); + $previousRaw = $this->get($this->bucketKey($key, $windowStart - $this->windowSize)); + + $current = \is_numeric($currentRaw) ? (int) $currentRaw : 0; + $previous = \is_numeric($previousRaw) ? (int) $previousRaw : 0; + + return (int) \floor($current + $previous * (1 - $elapsed)); + } + + /** + * Reset + * + * Clear both the current and previous window buckets so the limit starts fresh. + * + * @return void + */ + public function reset(): void + { + $key = $this->parseKey(); + [$windowStart] = $this->window(); + $this->timestamp = $windowStart; + + $this->delete( + $this->bucketKey($key, $windowStart), + $this->bucketKey($key, $windowStart - $this->windowSize), + ); + } + + /** + * No need for manual cleanup - Redis TTL handles this automatically + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } +} diff --git a/src/Abuse/Adapters/SlidingWindow/RedisCluster.php b/src/Abuse/Adapters/SlidingWindow/RedisCluster.php new file mode 100644 index 0000000..6e519a8 --- /dev/null +++ b/src/Abuse/Adapters/SlidingWindow/RedisCluster.php @@ -0,0 +1,91 @@ += $windowSize so the + * previous window's bucket survives long enough to be weighted + * @param \RedisCluster $redis Redis Cluster connection used for storage + */ + public function __construct(protected string $key, protected int $limit, int $windowSize, int $ttl, protected \RedisCluster $redis) + { + $this->initWindow($windowSize, $ttl); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + * + * @throws \RedisClusterException + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->redis->eval($script, [...$keys, ...$argv], \count($keys)); + } + + /** + * @param string $key + * @return mixed + * + * @throws \RedisClusterException + */ + protected function get(string $key): mixed + { + return $this->redis->get($key); + } + + /** + * @param string ...$keys + * @return void + * + * @throws \RedisClusterException + */ + protected function delete(string ...$keys): void + { + $this->redis->del(...$keys); + } + + /** + * Get abuse logs with cursor-based pagination across masters + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = 0, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + foreach ($this->redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $this->redis->scan($cursor, $master, $pattern, 100); + if ($keys !== false) { + $matches = array_merge($matches, $keys); + } + } while ($cursor > 0 && count($matches) < $offset + $limit); + } + + sort($matches); + $matches = array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $values = $this->redis->mget($matches); + + return array_combine($matches, $values); + } +} diff --git a/src/Abuse/Adapters/SlidingWindow/RedisPool.php b/src/Abuse/Adapters/SlidingWindow/RedisPool.php new file mode 100644 index 0000000..fd0729a --- /dev/null +++ b/src/Abuse/Adapters/SlidingWindow/RedisPool.php @@ -0,0 +1,148 @@ += $windowSize so the + * previous window's bucket survives long enough to be weighted + * @param UtopiaPool<\Redis>|UtopiaPool<\RedisCluster> $pool Pool yielding a Redis or RedisCluster connection + */ + public function __construct( + protected string $key, + protected int $limit, + int $windowSize, + int $ttl, + protected UtopiaPool $pool + ) { + $this->initWindow($windowSize, $ttl); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->pool->use(fn (\Redis|\RedisCluster $redis): mixed => $redis->eval($script, [...$keys, ...$argv], \count($keys))); + } + + /** + * @param string $key + * @return mixed + */ + protected function get(string $key): mixed + { + return $this->pool->use(fn (\Redis|\RedisCluster $redis): mixed => $redis->get($key)); + } + + /** + * @param string ...$keys + * @return void + */ + protected function delete(string ...$keys): void + { + $this->pool->use(function (\Redis|\RedisCluster $redis) use ($keys): void { + $redis->del(...$keys); + }); + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + + /** @var array $result */ + $result = $this->pool->use(function (\Redis|\RedisCluster $redis) use ($offset, $limit): array { + if ($redis instanceof \RedisCluster) { + return $this->getRedisClusterLogs($redis, $offset, $limit); + } + + $cursor = null; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + do { + $keys = $redis->scan($cursor, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $redis->get($key); + } + + return $logs; + }); + + return $result; + } + + /** + * @param \RedisCluster $redis + * @param int $offset + * @param int $limit + * @return array + */ + private function getRedisClusterLogs(\RedisCluster $redis, int $offset, int $limit): array + { + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + foreach ($redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $redis->scan($cursor, $master, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + } + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $values = $redis->mget($matches); + if (!\is_array($values)) { + return []; + } + + $logs = \array_combine($matches, $values); + if (!\is_array($logs)) { + return []; + } + + return $logs; + } +} diff --git a/src/Abuse/Adapters/TokenBucket.php b/src/Abuse/Adapters/TokenBucket.php new file mode 100644 index 0000000..19d50d3 --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket.php @@ -0,0 +1,102 @@ +tokens - ($this->count($this->parseKey(), $this->timestamp) + 1); + + return (0 > $left) ? 0 : $left; + } + + /** + * Limit + * + * Return the bucket capacity + * + * @return int + */ + public function limit(): int + { + return $this->tokens; + } + + /** + * Time + * + * Return the timestamp + * + * @return int + */ + public function time(): int + { + return $this->timestamp; + } + + /** + * Reset + * + * Clear the bucket for the current key so it starts full again. + * + * @return void + * + * @throws \Exception + */ + abstract public function reset(): void; +} diff --git a/src/Abuse/Adapters/TokenBucket/None.php b/src/Abuse/Adapters/TokenBucket/None.php new file mode 100644 index 0000000..cdec354 --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket/None.php @@ -0,0 +1,57 @@ +key = $key; + $this->tokens = $tokens; + $this->timestamp = \time(); + } + + protected function count(string $key, int $timestamp): int + { + return 0; + } + + public function check(): bool + { + return false; + } + + public function reset(): void + { + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + return []; + } + + /** + * Delete all logs older than $timestamp + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } +} diff --git a/src/Abuse/Adapters/TokenBucket/Redis.php b/src/Abuse/Adapters/TokenBucket/Redis.php new file mode 100644 index 0000000..58e2402 --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket/Redis.php @@ -0,0 +1,81 @@ +initBucket($refillRate); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + * + * @throws \RedisException + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->redis->eval($script, [...$keys, ...$argv], \count($keys)); + } + + /** + * @param string ...$keys + * @return void + * + * @throws \RedisException + */ + protected function delete(string ...$keys): void + { + $this->redis->del(...$keys); + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + + $cursor = null; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + do { + $keys = $this->redis->scan($cursor, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $this->redis->hGetAll($key); + } + + return $logs; + } +} diff --git a/src/Abuse/Adapters/TokenBucket/RedisBase.php b/src/Abuse/Adapters/TokenBucket/RedisBase.php new file mode 100644 index 0000000..6fa8ec9 --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket/RedisBase.php @@ -0,0 +1,228 @@ += 1 then + tokens = tokens - 1 + allowed = 1 + end + + redis.call('HSET', key, 'tokens', tostring(tokens), 'last_refill', tostring(now)) + redis.call('EXPIRE', key, math.ceil(max_tokens / refill_rate) + 1) + + return { allowed, tostring(tokens) } + LUA; + + /** + * Read-only token estimate: refills the bucket for the elapsed time without + * consuming anything or writing back. Used by remaining(). + * + * KEYS[1] bucket hash key. + * ARGV[1] max_tokens, ARGV[2] refill_rate, ARGV[3] now. + * + * Returns the available token balance as a string. + */ + protected const string TOKENS_SCRIPT = <<<'LUA' + local key = KEYS[1] + local max_tokens = tonumber(ARGV[1]) + local refill_rate = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + + local data = redis.call('HMGET', key, 'tokens', 'last_refill') + local tokens = tonumber(data[1]) or max_tokens + local last_refill = tonumber(data[2]) or now + + local elapsed = now - last_refill + if elapsed < 0 then elapsed = 0 end + tokens = math.min(max_tokens, tokens + elapsed * refill_rate) + + return tostring(tokens) + LUA; + + /** + * Tokens refilled per second. + * + * @var float + */ + protected float $refillRate; + + /** + * Run a Lua script against the storage backend. + * + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed the raw script result + */ + abstract protected function eval(string $script, array $keys, array $argv): mixed; + + /** + * Delete the given keys. + * + * @param string ...$keys + * @return void + */ + abstract protected function delete(string ...$keys): void; + + /** + * Validate and store the bucket configuration. + * + * @param float $refillRate + * @return void + */ + protected function initBucket(float $refillRate): void + { + if ($refillRate <= 0) { + throw new \InvalidArgumentException('refillRate must be greater than 0'); + } + + $this->refillRate = $refillRate; + $this->timestamp = \time(); + } + + /** + * Build the bucket hash key for a given abuse key. + * + * @param string $key + * @return string + */ + protected function bucketKey(string $key): string + { + return self::NAMESPACE . '__' . $key; + } + + /** + * Check + * + * @return bool + * + * @throws \Throwable + */ + public function check(): bool + { + if ($this->tokens === 0) { + return false; + } + + $key = $this->parseKey(); + $this->timestamp = \time(); + + /** @var array{0:int,1:string} $result */ + $result = $this->eval( + self::LIMIT_CHECK_SCRIPT, + [ + $this->bucketKey($key), // KEYS[1] bucket hash + ], + [ + $this->tokens, // ARGV[1] max_tokens + $this->refillRate, // ARGV[2] refill_rate + \microtime(true), // ARGV[3] now (fractional seconds) + ], + ); + + [$allowed] = $result; + + return (int) $allowed === 0; + } + + /** + * Count + * + * Read-only estimate of the tokens already consumed from the bucket + * (capacity minus the tokens available after refilling). Used by remaining(). + * The bucket refills continuously, so this always reads a fresh estimate + * rather than reusing a cached value that would go stale as tokens refill. + * + * @param string $key + * @param int $timestamp + * @return int + */ + protected function count(string $key, int $timestamp): int + { + if ($this->tokens === 0) { + return 0; + } + + $this->timestamp = \time(); + + $raw = $this->eval( + self::TOKENS_SCRIPT, + [ + $this->bucketKey($key), + ], + [ + $this->tokens, + $this->refillRate, + \microtime(true), + ], + ); + + $balance = \is_numeric($raw) ? (float) $raw : (float) $this->tokens; + + return $this->tokens - (int) \floor($balance); + } + + /** + * Reset + * + * Drop the bucket state so the next request sees a full bucket. + * + * @return void + */ + public function reset(): void + { + $this->delete($this->bucketKey($this->parseKey())); + } + + /** + * No need for manual cleanup - Redis TTL handles this automatically + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } +} diff --git a/src/Abuse/Adapters/TokenBucket/RedisCluster.php b/src/Abuse/Adapters/TokenBucket/RedisCluster.php new file mode 100644 index 0000000..beb86ec --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket/RedisCluster.php @@ -0,0 +1,81 @@ +initBucket($refillRate); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + * + * @throws \RedisClusterException + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->redis->eval($script, [...$keys, ...$argv], \count($keys)); + } + + /** + * @param string ...$keys + * @return void + * + * @throws \RedisClusterException + */ + protected function delete(string ...$keys): void + { + $this->redis->del(...$keys); + } + + /** + * Get abuse logs with cursor-based pagination across masters + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = 0, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + foreach ($this->redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $this->redis->scan($cursor, $master, $pattern, 100); + if ($keys !== false) { + $matches = array_merge($matches, $keys); + } + } while ($cursor > 0); + } + + sort($matches); + $matches = array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $this->redis->hGetAll($key); + } + + return $logs; + } +} diff --git a/src/Abuse/Adapters/TokenBucket/RedisPool.php b/src/Abuse/Adapters/TokenBucket/RedisPool.php new file mode 100644 index 0000000..34cfec9 --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket/RedisPool.php @@ -0,0 +1,131 @@ +|UtopiaPool<\RedisCluster> $pool Pool yielding a Redis or RedisCluster connection + */ + public function __construct( + protected string $key, + protected int $tokens, + float $refillRate, + protected UtopiaPool $pool + ) { + $this->initBucket($refillRate); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->pool->use(fn (\Redis|\RedisCluster $redis): mixed => $redis->eval($script, [...$keys, ...$argv], \count($keys))); + } + + /** + * @param string ...$keys + * @return void + */ + protected function delete(string ...$keys): void + { + $this->pool->use(function (\Redis|\RedisCluster $redis) use ($keys): void { + $redis->del(...$keys); + }); + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + + /** @var array $result */ + $result = $this->pool->use(function (\Redis|\RedisCluster $redis) use ($offset, $limit): array { + if ($redis instanceof \RedisCluster) { + return $this->getRedisClusterLogs($redis, $offset, $limit); + } + + $cursor = null; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + do { + $keys = $redis->scan($cursor, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $redis->hGetAll($key); + } + + return $logs; + }); + + return $result; + } + + /** + * @param \RedisCluster $redis + * @param int $offset + * @param int $limit + * @return array + */ + private function getRedisClusterLogs(\RedisCluster $redis, int $offset, int $limit): array + { + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + foreach ($redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $redis->scan($cursor, $master, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + } + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $redis->hGetAll($key); + } + + return $logs; + } +} diff --git a/tests/Abuse/SlidingWindow/Base.php b/tests/Abuse/SlidingWindow/Base.php new file mode 100644 index 0000000..5781993 --- /dev/null +++ b/tests/Abuse/SlidingWindow/Base.php @@ -0,0 +1,169 @@ +getAdapter('sw-static-key', 2, 1, 2); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a dynamic key with a limit of 2 requests per window + */ + public function testDynamicKey(): void + { + $adapter = $this->getAdapter('sw-dynamic-key-{{ip}}', 2, 1, 2); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a dynamic key with 2 params + */ + public function testDynamicKeyWith2Params(): void + { + $adapter = $this->getAdapter('sw-two-params-{{ip}}-{{email}}', 2, 1, 2); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $adapter->setParam('{{email}}', 'test@test.com'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a higher request rate like 10 requests per window + */ + public function testFastRequests(): void + { + $adapter = $this->getAdapter('sw-fast-requests-{{ip}}', 10, 1, 2); + $adapter->setParam('{{ip}}', '0.0.0.11'); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 10; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that remaining reports the correct number of allowed requests + */ + public function testRemaining(): void + { + $adapter = $this->getAdapter('sw-remaining-{{ip}}', 3, 60, 120); + $adapter->setParam('{{ip}}', '0.0.0.12'); + $abuse = new Abuse($adapter); + + $this->assertSame(2, $adapter->remaining()); // nothing counted yet: limit - (0 + 1) + $this->assertSame(false, $abuse->check()); // 1 used + $this->assertSame(1, $adapter->remaining()); + $this->assertSame(false, $abuse->check()); // 2 used + $this->assertSame(0, $adapter->remaining()); + } + + /** + * Test that the window resets once both buckets expire + */ + public function testWindowExpiry(): void + { + $adapter = $this->getAdapter('sw-window-expiry-{{ip}}', 3, 1, 2); + $adapter->setParam('{{ip}}', '127.0.0.1'); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 3; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + + // Wait for both the current and previous buckets (ttl = 2) to expire + sleep(3); + + // A fresh adapter recomputes the window; the old buckets are gone + $adapter = $this->getAdapter('sw-window-expiry-{{ip}}', 3, 1, 2); + $adapter->setParam('{{ip}}', '127.0.0.1'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + } + + /** + * Verify that time() returns the aligned window start as an int + */ + public function testTimeFormat(): void + { + $windowSize = 1; + $now = \time(); + $adapter = $this->getAdapter('sw-time', 1, $windowSize, 2); + $this->assertSame((int)($now - ($now % $windowSize)), $adapter->time()); + $this->assertSame(true, \is_int($adapter->time())); + } + + /** + * Test the reset functionality clears both buckets + */ + public function testReset(): void + { + $adapter = $this->getAdapter('sw-reset-test-{{ip}}', 5, 600, 1200); + $adapter->setParam('{{ip}}', '192.168.1.1'); + $abuse = new Abuse($adapter); + + // 5 OK, 6th limited + for ($i = 0; $i < 5; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + + // Reset clears the counters + $abuse->reset(); + + // 5 more OK, then limited again + for ($i = 0; $i < 5; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that a ttl smaller than the window size is rejected + */ + public function testTtlGuard(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->getAdapter('sw-guard', 1, 10, 5); + } + + /** + * Test that limit 0 means unlimited + */ + public function testUnlimited(): void + { + $adapter = $this->getAdapter('sw-unlimited', 0, 1, 2); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 20; $i++) { + $this->assertSame(false, $abuse->check()); + } + } +} diff --git a/tests/Abuse/SlidingWindow/RedisClusterTest.php b/tests/Abuse/SlidingWindow/RedisClusterTest.php new file mode 100644 index 0000000..cfaa8f0 --- /dev/null +++ b/tests/Abuse/SlidingWindow/RedisClusterTest.php @@ -0,0 +1,45 @@ +close(); + } + } +} diff --git a/tests/Abuse/SlidingWindow/RedisPoolTest.php b/tests/Abuse/SlidingWindow/RedisPoolTest.php new file mode 100644 index 0000000..20dbb8e --- /dev/null +++ b/tests/Abuse/SlidingWindow/RedisPoolTest.php @@ -0,0 +1,53 @@ +|null + */ + protected static ?Pool $pool = null; + + public static function setUpBeforeClass(): void + { + if (isset(self::$pool)) { + return; + } + + self::$pool = new Pool(new Stack(), 'abuse-sw-redis', 2, function (): \Redis { + $redis = new \Redis(); + $redis->connect('redis', 6379); + + return $redis; + }, timeout: 0.0); + } + + public function getAdapter(string $key, int $limit, int $windowSize, int $ttl): SlidingWindow + { + $pool = self::$pool; + $this->assertInstanceOf(Pool::class, $pool); + + /** @var Pool<\Redis> $pool */ + return new AdapterRedisPool('sw-pool-' . $key, $limit, $windowSize, $ttl, $pool); + } + + public static function tearDownAfterClass(): void + { + if (!isset(self::$pool)) { + return; + } + + self::$pool->use(function (mixed $redis): void { + if ($redis instanceof \Redis) { + $redis->close(); + } + }); + self::$pool = null; + } +} diff --git a/tests/Abuse/SlidingWindow/RedisTest.php b/tests/Abuse/SlidingWindow/RedisTest.php new file mode 100644 index 0000000..126cea2 --- /dev/null +++ b/tests/Abuse/SlidingWindow/RedisTest.php @@ -0,0 +1,43 @@ +connect('redis', 6379); + + return $redis; + } + + public function getAdapter(string $key, int $limit, int $windowSize, int $ttl): SlidingWindow + { + return new AdapterRedis($key, $limit, $windowSize, $ttl, self::$redis); + } + + public static function tearDownAfterClass(): void + { + if (isset(self::$redis)) { + self::$redis->close(); + } + } +} diff --git a/tests/Abuse/TokenBucket/Base.php b/tests/Abuse/TokenBucket/Base.php new file mode 100644 index 0000000..e9ce08b --- /dev/null +++ b/tests/Abuse/TokenBucket/Base.php @@ -0,0 +1,160 @@ +getAdapter('tb-static-key', 2, 0.001); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a dynamic key with a capacity of 2 tokens + */ + public function testDynamicKey(): void + { + $adapter = $this->getAdapter('tb-dynamic-key-{{ip}}', 2, 0.001); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a dynamic key with 2 params + */ + public function testDynamicKeyWith2Params(): void + { + $adapter = $this->getAdapter('tb-two-params-{{ip}}-{{email}}', 2, 0.001); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $adapter->setParam('{{email}}', 'test@test.com'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that a full bucket allows a burst up to its capacity + */ + public function testBurst(): void + { + $adapter = $this->getAdapter('tb-burst-{{ip}}', 10, 0.001); + $adapter->setParam('{{ip}}', '0.0.0.11'); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 10; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that remaining reports the tokens still available + */ + public function testRemaining(): void + { + $adapter = $this->getAdapter('tb-remaining-{{ip}}', 3, 0.001); + $adapter->setParam('{{ip}}', '0.0.0.12'); + $abuse = new Abuse($adapter); + + $this->assertSame(2, $adapter->remaining()); // full bucket: limit - (0 + 1) + $this->assertSame(false, $abuse->check()); // 1 consumed + $this->assertSame(1, $adapter->remaining()); + $this->assertSame(false, $abuse->check()); // 2 consumed + $this->assertSame(0, $adapter->remaining()); + } + + /** + * Test that tokens refill over time + */ + public function testRefill(): void + { + // 1 token/sec, capacity 1: consume it, then a refill lets one more through + $adapter = $this->getAdapter('tb-refill-{{ip}}', 1, 1.0); + $adapter->setParam('{{ip}}', '0.0.0.13'); + $abuse = new Abuse($adapter); + + $this->assertSame(false, $abuse->check()); // consume the only token + $this->assertSame(true, $abuse->check()); // empty, throttled + + sleep(2); // refill ~2 tokens (capped at capacity 1) + + $this->assertSame(false, $abuse->check()); // refilled, allowed again + } + + /** + * Verify that time() returns the current time as an int + */ + public function testTimeFormat(): void + { + $adapter = $this->getAdapter('tb-time', 1, 1.0); + $this->assertSame(true, \is_int($adapter->time())); + } + + /** + * Test the reset functionality refills the bucket + */ + public function testReset(): void + { + $adapter = $this->getAdapter('tb-reset-test-{{ip}}', 5, 0.001); + $adapter->setParam('{{ip}}', '192.168.1.1'); + $abuse = new Abuse($adapter); + + // 5 OK, 6th limited + for ($i = 0; $i < 5; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + + // Reset refills the bucket + $abuse->reset(); + + // 5 more OK, then limited again + for ($i = 0; $i < 5; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that a non-positive refill rate is rejected + */ + public function testRefillRateGuard(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->getAdapter('tb-guard', 1, 0.0); + } + + /** + * Test that limit 0 means unlimited + */ + public function testUnlimited(): void + { + $adapter = $this->getAdapter('tb-unlimited', 0, 1.0); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 20; $i++) { + $this->assertSame(false, $abuse->check()); + } + } +} diff --git a/tests/Abuse/TokenBucket/RedisClusterTest.php b/tests/Abuse/TokenBucket/RedisClusterTest.php new file mode 100644 index 0000000..7b9a0c5 --- /dev/null +++ b/tests/Abuse/TokenBucket/RedisClusterTest.php @@ -0,0 +1,45 @@ +close(); + } + } +} diff --git a/tests/Abuse/TokenBucket/RedisPoolTest.php b/tests/Abuse/TokenBucket/RedisPoolTest.php new file mode 100644 index 0000000..750666f --- /dev/null +++ b/tests/Abuse/TokenBucket/RedisPoolTest.php @@ -0,0 +1,53 @@ +|null + */ + protected static ?Pool $pool = null; + + public static function setUpBeforeClass(): void + { + if (isset(self::$pool)) { + return; + } + + self::$pool = new Pool(new Stack(), 'abuse-tb-redis', 2, function (): \Redis { + $redis = new \Redis(); + $redis->connect('redis', 6379); + + return $redis; + }, timeout: 0.0); + } + + public function getAdapter(string $key, int $tokens, float $refillRate): TokenBucket + { + $pool = self::$pool; + $this->assertInstanceOf(Pool::class, $pool); + + /** @var Pool<\Redis> $pool */ + return new AdapterRedisPool('tb-pool-' . $key, $tokens, $refillRate, $pool); + } + + public static function tearDownAfterClass(): void + { + if (!isset(self::$pool)) { + return; + } + + self::$pool->use(function (mixed $redis): void { + if ($redis instanceof \Redis) { + $redis->close(); + } + }); + self::$pool = null; + } +} diff --git a/tests/Abuse/TokenBucket/RedisTest.php b/tests/Abuse/TokenBucket/RedisTest.php new file mode 100644 index 0000000..bb1b94a --- /dev/null +++ b/tests/Abuse/TokenBucket/RedisTest.php @@ -0,0 +1,43 @@ +connect('redis', 6379); + + return $redis; + } + + public function getAdapter(string $key, int $tokens, float $refillRate): TokenBucket + { + return new AdapterRedis($key, $tokens, $refillRate, self::$redis); + } + + public static function tearDownAfterClass(): void + { + if (isset(self::$redis)) { + self::$redis->close(); + } + } +}