Skip to content
102 changes: 102 additions & 0 deletions src/Abuse/Adapters/TokenBucket.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

namespace Utopia\Abuse\Adapters;

use Throwable;
use Utopia\Abuse\Adapter;

// token bucket rate limiter
abstract class TokenBucket extends Adapter
{
/**
* Bucket capacity (max tokens). 0 is equal to unlimited.
*
* @var int
*/
protected int $tokens = 0;

/**
* @var int
*/
protected int $timestamp;

/**
* Count
*
* Read-only estimate of the tokens already consumed from the bucket
* (capacity minus the tokens available after refilling for the elapsed time).
*
* @param string $key
* @param int $timestamp
* @return int
*
* @throws \Exception
*/
abstract protected function count(string $key, int $timestamp): int;

/**
* Check
*
* Atomically refills the bucket for the elapsed time and consumes a single
* token if one is available. Storage backends MUST implement this as a single
* atomic operation so the refill-decide-consume sequence cannot race between
* concurrent requests. Returns true when the request is abuse (bucket empty).
* limit 0 is equal to unlimited.
*
* @return bool
*
* @throws \Exception|Throwable
*/
abstract public function check(): bool;

/**
* Remaining
*
* Returns the number of current remaining counts
*
* @return int
*
* @throws \Exception
*/
public function remaining(): int
{
$left = $this->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;
}
57 changes: 57 additions & 0 deletions src/Abuse/Adapters/TokenBucket/None.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace Utopia\Abuse\Adapters\TokenBucket;

use Utopia\Abuse\Adapters\TokenBucket;

class None extends TokenBucket
{
/**
* @param float $refillRate Accepted for parity with the storage adapters; unused here
*/
public function __construct(string $key, int $tokens, float $refillRate) // @phpstan-ignore constructor.unusedParameter
{
$this->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<string, mixed>
*/
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;
}
}
81 changes: 81 additions & 0 deletions src/Abuse/Adapters/TokenBucket/Redis.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

namespace Utopia\Abuse\Adapters\TokenBucket;

class Redis extends RedisBase
{
/**
* @param string $key Abuse key pattern, e.g. "ip:{ip}"; params are substituted via setParam()
* @param int $tokens Bucket capacity (max tokens); 0 means unlimited
* @param float $refillRate Tokens refilled per second
* @param \Redis $redis Redis connection used for storage
*/
public function __construct(protected string $key, protected int $tokens, float $refillRate, protected \Redis $redis)
{
$this->initBucket($refillRate);
}

/**
* @param string $script
* @param list<string> $keys
* @param list<int|float> $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<string, mixed>
*/
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;
}
}
Loading
Loading