Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat Redis Adapter #70

Merged
merged 21 commits into from
Jul 7, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
17 changes: 17 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ LABEL maintainer="team@appwrite.io"

RUN docker-php-ext-install pdo_mysql

RUN \
apk update \
&& apk add --no-cache make automake autoconf gcc g++ git zlib-dev libmemcached-dev \
&& rm -rf /var/cache/apk/*

RUN \
# Redis Extension
git clone https://github.com/phpredis/phpredis.git && \
cd phpredis && \
git checkout $PHP_REDIS_VERSION && \
phpize && \
./configure && \
make && make install && \
cd ..

RUN echo extension=redis.so >> /usr/local/etc/php/conf.d/redis.ini

WORKDIR /code

COPY --from=step0 /src/vendor /code/vendor
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"php": ">=8.0",
"ext-pdo": "*",
"ext-curl": "*",
"ext-redis": "*",
"utopia-php/database": "0.49.*"
},
"require-dev": {
Expand Down
8 changes: 7 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ services:
- abuse
ports:
- "9307:3306"


redis:
image: redis:6.0-alpine
container_name: redis
networks:
- abuse

tests:
build:
context: .
Expand Down
230 changes: 230 additions & 0 deletions src/Abuse/Adapters/Redis.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
<?php

namespace Utopia\Abuse\Adapters;

use Utopia\Abuse\Adapter;
use Redis as Client;

class Redis implements Adapter
{
public const NAMESPACE = 'abuse';

/**
* @var Client
*/
protected Client $redis;

/**
* @var string
*/
protected string $key = '';

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

/**
* @var int
*/
protected int $limit = 0;

/**
* @var int|null
*/
protected ?int $count = null;

/**
* @var array<string, string>
*/
protected array $params = [];


public function __construct(string $key, int $limit, int $seconds, Client $redis)
{
$this->redis = $redis;
$this->key = $key;
$time = (int) \date('U', (int) (\floor(\time() / $seconds)) * $seconds); // todo: any good Idea without time()?
eldadfux marked this conversation as resolved.
Show resolved Hide resolved
$this->time = $time;
$this->limit = $limit;
}

/**
* 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
{
$this->params[$key] = $value;

return $this;
}

/**
* Get Params
*
* Return array of all key params
*
* @return array<string, string>
*/
protected function getParams(): array
{
return $this->params;
}

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

return $this->key;
}

/**
* Undocumented function
*
* @param string $key
* @param int $datetime
* @return integer
*/
protected function count(string $key, int $datetime): int
{
if (0 == $this->limit) { // No limit no point for counting
return 0;
}

if (! \is_null($this->count)) { // Get fetched result
return $this->count;
}

$count = $this->redis->get(self::NAMESPACE . ':'. $key .':'. $datetime);
if (!$count) {
$this->count = 0;
} else {
$this->count = (int) $count;
}

return $this->count;
}

/**
* @param string $key
* @param int $datetime
* @return void
*
*/
protected function hit(string $key, int $datetime): void
{
if (0 == $this->limit) { // No limit no point for counting
return;
}

$count = $this->redis->get(self::NAMESPACE . ':'. $key .':'. $datetime);
eldadfux marked this conversation as resolved.
Show resolved Hide resolved
if (!$count) {
$this->count = 0;
} else {
$this->count = (int) $count;
}

$this->redis->incr(self::NAMESPACE . ':'. $key .':'. $datetime);
$this->count++;
}

/**
* Check
*
* Checks if number of counts is bigger or smaller than current limit
*
* @return bool
*/
public function check(): bool
{
if (0 == $this->limit) {
return false;
}

$key = $this->parseKey();

if ($this->limit > $this->count($key, $this->time)) {
$this->hit($key, $this->time);

return false;
}

return true;
}

/**
* 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
{
// TODO limit potential is SCAN but needs cursor no offset
$cursor = null;
$keys = $this->redis->scan($cursor, self::NAMESPACE . ':*', $limit);
if (!$keys) {
return [];
}

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

/**
* Delete all logs older than $datetime
*
* @param string $datetime
* @return bool
*/
public function cleanup(string $datetime): bool
{
// TODO
$iterator = null;
while ($iterator !== 0) {
$keys = $this->redis->scan($iterator, self::NAMESPACE . ':*:*', 1000);
$keys = $this->filterKeys($keys ? $keys : [], (int) $datetime);
$this->redis->del($keys);
}
return true;
}

/**
* Filter keys
*
* @param array<string> $keys
* @param integer $timestamp
* @return array<string>
*/
protected function filterKeys(array $keys, int $timestamp): array
{
$filteredKeys = [];
foreach ($keys as $key) {
$parts = explode(':', $key);
$keyTimestamp = (int)end($parts); // Assuming the last part is always the timestamp
if ($keyTimestamp < $timestamp) {
$filteredKeys[] = $key;
}
}
return $filteredKeys;
}
}
87 changes: 87 additions & 0 deletions tests/Abuse/AbuseRedisTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

namespace Utopia\Tests;

use DateInterval;
use PHPUnit\Framework\TestCase;
use Utopia\Abuse\Abuse;
use Utopia\Abuse\Adapters\Redis;
use Redis as Client;
use Utopia\Exception;

class AbuseRedisTest extends TestCase
eldadfux marked this conversation as resolved.
Show resolved Hide resolved
{
protected Abuse $abuse;

protected Abuse $abuseIp;

protected Client $redis;

protected string $format = 'Y-m-d H:i:s.v';

/**
* @throws Exception
* @throws \Exception
*/
public function setUp(): void
{
$this->redis = new Client();
$this->redis->connect('redis', 6379);
$adapter = new Redis('login-attempt-from-{{ip}}', 3, 60 * 5, $this->redis);
$adapter->setParam('{{ip}}', '127.0.0.1');
$this->abuse = new Abuse($adapter);
}

public function tearDown(): void
{
unset($this->abuse);
}

public function testImitate2Requests(): void
{
$key = '{{ip}}';
$value = '0.0.0.10';

$adapter = new Redis($key, 1, 1, $this->redis);
$adapter->setParam($key, $value);
$this->abuseIp = new Abuse($adapter);
$this->assertEquals($this->abuseIp->check(), false);
$this->assertEquals($this->abuseIp->check(), true);

sleep(1);

$adapter = new Redis($key, 1, 1, $this->redis);
$adapter->setParam($key, $value);
$this->abuseIp = new Abuse($adapter);

$this->assertEquals($this->abuseIp->check(), false);
$this->assertEquals($this->abuseIp->check(), true);
}

public function testIsValid(): void
{
// Use vars to resolve adapter key
$this->assertEquals($this->abuse->check(), false);
$this->assertEquals($this->abuse->check(), false);
$this->assertEquals($this->abuse->check(), false);
$this->assertEquals($this->abuse->check(), true);
}

public function testCleanup(): void
{
// Check that there is only one log
$logs = $this->abuse->getLogs(0, 10);
$this->assertEquals(3, \count($logs));

sleep(5);
// Delete the log
$interval = DateInterval::createFromDateString(1 . ' seconds');
$timestamp = (new \DateTime())->sub($interval)->getTimestamp();
$status = $this->abuse->cleanup(strval($timestamp));
$this->assertEquals($status, true);

// Check that there are no logs in the DB
$logs = $this->abuse->getLogs(0, 10);
$this->assertEquals(0, \count($logs));
}
}
Loading