Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e073c3e
refactor(abuse): make TimeLimit core immutable with Result and withPa…
ChiragAgg5k Oct 1, 2026
0ee3cec
refactor(abuse): add immutable TimeLimit adapters, Result and unit tests
ChiragAgg5k Oct 1, 2026
1e793e0
refactor(abuse): port Database and TablesDB TimeLimit adapters to imm…
ChiragAgg5k Oct 1, 2026
5bf6b64
refactor(abuse): port SlidingWindow adapters to immutable contract
ChiragAgg5k Oct 1, 2026
86a579b
refactor(abuse): port TokenBucket adapters to immutable contract
ChiragAgg5k Oct 1, 2026
51a17a8
refactor(abuse): move ReCaptcha to standalone class on utopia-php/client
ChiragAgg5k Oct 1, 2026
eb56d88
test(abuse): migrate TimeLimit e2e suites to immutable API
ChiragAgg5k Oct 1, 2026
62909f4
test(abuse): migrate SlidingWindow and TokenBucket e2e suites and ben…
ChiragAgg5k Oct 1, 2026
af4928f
refactor: migrate abuse call sites to immutable TimeLimit API
ChiragAgg5k Oct 1, 2026
44bb8f0
fix(abuse): throw on non-numeric script results in SlidingWindow and …
ChiragAgg5k Oct 1, 2026
4ba1fdf
test(abuse): derive expected keys from the adapter in param-reuse e2e
ChiragAgg5k Oct 1, 2026
ad27b77
style(abuse): apply rector null-coalescing assignment in TimeLimit lo…
ChiragAgg5k Oct 1, 2026
6cfed4d
chore(abuse): drop the phpstan baseline now that level max is clean
ChiragAgg5k Oct 1, 2026
fd0d3e6
test(abuse): give the non-window TimeLimit e2e tests an hour-long window
ChiragAgg5k Oct 1, 2026
3d7852a
fix(abuse): derive token bucket reset from the fractional balance
ChiragAgg5k Oct 1, 2026
34bb27c
test(abuse): assert decoded ReCaptcha form fields instead of exact en…
ChiragAgg5k Oct 1, 2026
5d1c089
Merge main into refactor/abuse-immutable
ChiragAgg5k Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
refactor(abuse): add immutable TimeLimit adapters, Result and unit tests
Adds the readonly Adapter with withParams/key, the Result value object, the
TimeLimit base with a now() clock seam, and the Redis/RedisCluster/RedisPool/None
adapters behind a shared RedisBase that hits through one atomic Lua script.
Unit tests drive the base through an in-memory adapter with a movable clock.
  • Loading branch information
ChiragAgg5k committed Oct 1, 2026
commit 0ee3cec544a86b5184095ec9271bf725e5f443b9
91 changes: 21 additions & 70 deletions packages/abuse/src/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,94 +2,45 @@

namespace Utopia\Abuse;

abstract class Adapter
abstract readonly class Adapter
{
/**
* @var array<string, string>
* @param array<string, string> $params
*/
protected array $params = [];

/**
* @var string
*/
protected string $key = '';
public function __construct(
protected string $key,
protected array $params = [],
) {
}

/**
* Check
*
* Checks if number of counts is bigger or smaller than current limit
*
* @return bool
* @param array<string, string> $params
*/
abstract public function check(): bool;

/**
* Set Param
*
* Set custom param for key pattern parsing
*
* @param string $key
* @param string $value
* @return $this
*/
public function setParam(string $key, string $value): self
public function withParams(array $params): static
{
$this->params[$key] = $value;

return $this;
return clone($this, ['params' => [...$this->params, ...$params]]);
}

/**
* Get Params
*
* Return array of all key params
*
* @return array<string, string>
*/
protected function getParams(): array
public function withParam(string $name, string $value): static
{
return $this->params;
return $this->withParams([$name => $value]);
}

/**
* Parse key with all custom attached params
*
* @return string
*/
protected function parseKey(): string
public function key(): string
{
foreach ($this->getParams() as $key => $value) {
$this->key = \str_replace($key, $value, $this->key);
}

return $this->key;
return \strtr($this->key, $this->params);
}

abstract public function check(): Result;

abstract public function peek(): Result;

abstract 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>
* @return array<mixed>
*/
abstract public function getLogs(?int $offset = null, ?int $limit = 25): array;

/**
* Delete all logs older than $datetime
*
* @param int $timestamp
* @return bool
*/
abstract public function cleanup(int $timestamp): bool;

/**
* Reset
*
* Reset the count to 0
*
* @return void
*/
abstract public function reset(): void;
}
96 changes: 96 additions & 0 deletions packages/abuse/src/Adapter/TimeLimit.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

namespace Utopia\Abuse\Adapter;

use Utopia\Abuse\Adapter;
use Utopia\Abuse\Result;

abstract readonly class TimeLimit extends Adapter
{
/**
* @param string $key Key pattern, e.g. "ip:{ip}"; placeholders are filled by withParams()
* @param int $limit Maximum hits per window; 0 means unlimited
* @param int $seconds Window length in seconds
*
* @throws \InvalidArgumentException
*/
public function __construct(string $key, protected int $limit, protected int $seconds)
{
if ($seconds <= 0) {
throw new \InvalidArgumentException('seconds must be greater than 0');
}

parent::__construct($key);
}

/**
* Record a hit in the window unless the limit is reached.
*
* @return int the count before this call
*/
abstract protected function hit(string $key, int $window): int;

abstract protected function count(string $key, int $window): int;

abstract protected function set(string $key, int $window, int $value): void;

protected function now(): int
{
return \time();
}

final protected function window(int $now): int
{
return $now - ($now % $this->seconds);
}

#[\Override]
final public function check(): Result
{
$window = $this->window($this->now());

if ($this->limit === 0) {
return $this->unlimited($window);
}

return $this->result($this->hit($this->key(), $window), $window);
}

#[\Override]
final public function peek(): Result
{
$window = $this->window($this->now());

if ($this->limit === 0) {
return $this->unlimited($window);
}

return $this->result($this->count($this->key(), $window), $window);
}

#[\Override]
final public function reset(): void
{
$this->set($this->key(), $this->window($this->now()), 0);
}

private function result(int $used, int $window): Result
{
return new Result(
limited: $used >= $this->limit,
limit: $this->limit,
remaining: \max(0, $this->limit - $used - 1),
reset: $window + $this->seconds,
);
}

private function unlimited(int $window): Result
{
return new Result(
limited: false,
limit: 0,
remaining: 0,
reset: $window + $this->seconds,
);
}
}
40 changes: 40 additions & 0 deletions packages/abuse/src/Adapter/TimeLimit/None.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace Utopia\Abuse\Adapter\TimeLimit;

use Utopia\Abuse\Adapter\TimeLimit;

final readonly class None extends TimeLimit
{
#[\Override]
protected function hit(string $key, int $window): int
{
return 0;
}

#[\Override]
protected function count(string $key, int $window): int
{
return 0;
}

#[\Override]
protected function set(string $key, int $window, int $value): void
{
}

/**
* @return array{}
*/
#[\Override]
public function getLogs(?int $offset = null, ?int $limit = 25): array
{
return [];
}

#[\Override]
public function cleanup(int $timestamp): bool
{
return true;
}
}
55 changes: 55 additions & 0 deletions packages/abuse/src/Adapter/TimeLimit/Redis.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace Utopia\Abuse\Adapter\TimeLimit;

final readonly class Redis extends RedisBase
{
public function __construct(string $key, int $limit, int $seconds, private \Redis $redis)
{
parent::__construct($key, $limit, $seconds);
}

/**
* @param list<string> $keys
* @param list<int|string> $argv
*
* @throws \RedisException
*/
#[\Override]
protected function eval(string $script, array $keys, array $argv): mixed
{
return $this->redis->eval($script, [...$keys, ...$argv], \count($keys));
}

/**
* @throws \RedisException
*/
#[\Override]
protected function get(string $key): mixed
{
return $this->redis->get($key);
}

/**
* @return array<string, mixed>
*
* @throws \RedisException
*/
#[\Override]
public function getLogs(?int $offset = null, ?int $limit = 25): array
{
$cursor = null;

$keys = $this->redis->scan($cursor, self::NAMESPACE . '__*', $limit ?? 0);
if (!$keys) {
return [];
}

$logs = [];
foreach ($keys as $key) {
$logs[$key] = $this->redis->get($key);
}

return $logs;
}
}
78 changes: 78 additions & 0 deletions packages/abuse/src/Adapter/TimeLimit/RedisBase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

namespace Utopia\Abuse\Adapter\TimeLimit;

use Utopia\Abuse\Adapter\TimeLimit;

abstract readonly class RedisBase extends TimeLimit
{
public const string NAMESPACE = 'abuse';

/**
* KEYS[1] window counter. ARGV[1] limit, ARGV[2] ttl seconds. Returns the count before this call.
*/
protected const string HIT_SCRIPT = <<<'LUA'
local c = tonumber(redis.call('GET', KEYS[1]) or '0') or 0
if c >= tonumber(ARGV[1]) then return c end
redis.call('INCR', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[2])
return c
LUA;

/**
* KEYS[1] window counter. ARGV[1] value, ARGV[2] ttl seconds.
*/
protected const string SET_SCRIPT = <<<'LUA'
redis.call('SET', KEYS[1], ARGV[1])
redis.call('EXPIRE', KEYS[1], ARGV[2])
return 1
LUA;

/**
* @param list<string> $keys
* @param list<int|string> $argv
*/
abstract protected function eval(string $script, array $keys, array $argv): mixed;

abstract protected function get(string $key): mixed;

/**
* @throws \RuntimeException
*/
#[\Override]
protected function hit(string $key, int $window): int
{
$used = $this->eval(self::HIT_SCRIPT, [$this->windowKey($key, $window)], [$this->limit, $this->seconds]);

if (!\is_numeric($used)) {
throw new \RuntimeException('Redis script failed.');
}

return (int) $used;
}

#[\Override]
protected function count(string $key, int $window): int
{
$count = $this->get($this->windowKey($key, $window));

return \is_numeric($count) ? (int) $count : 0;
}

#[\Override]
protected function set(string $key, int $window, int $value): void
{
$this->eval(self::SET_SCRIPT, [$this->windowKey($key, $window)], [$value, $this->seconds]);
}

#[\Override]
public function cleanup(int $timestamp): bool
{
return true;
}

final protected function windowKey(string $key, int $window): string
{
return self::NAMESPACE . '__' . $key . '__' . $window;
}
}
Loading