From 947d16c01560ac381b5e0b1d9abb6caec3d5c6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 13 Nov 2025 21:18:14 +0100 Subject: [PATCH 1/5] Implement AppwriteTablesDB adapter --- composer.json | 3 +- composer.lock | 210 ++++++----- .../Adapters/TimeLimit/AppwriteTablesDB.php | 337 ++++++++++++++++++ tests/Abuse/AppwriteTablesDBTest.php | 43 +++ tests/Abuse/Base.php | 12 +- 5 files changed, 516 insertions(+), 89 deletions(-) create mode 100644 src/Abuse/Adapters/TimeLimit/AppwriteTablesDB.php create mode 100755 tests/Abuse/AppwriteTablesDBTest.php diff --git a/composer.json b/composer.json index 80a4df5..1d2c910 100755 --- a/composer.json +++ b/composer.json @@ -22,7 +22,8 @@ "ext-pdo": "*", "ext-curl": "*", "ext-redis": "*", - "utopia-php/database": "*" + "utopia-php/database": "3.*.*", + "appwrite/appwrite": "18.*.*" }, "require-dev": { "phpunit/phpunit": "9.*", diff --git a/composer.lock b/composer.lock index 4cec497..3dc63a5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,50 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0c308f58a2b6a5c71a18543ae5879dbf", + "content-hash": "835e092b19aef86b434e86b94fbf39ca", "packages": [ + { + "name": "appwrite/appwrite", + "version": "18.0.1", + "source": { + "type": "git", + "url": "https://github.com/appwrite/sdk-for-php.git", + "reference": "950112a73c05297e23fa2eddb68f4ce8a6024e03" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/950112a73c05297e23fa2eddb68f4ce8a6024e03", + "reference": "950112a73c05297e23fa2eddb68f4ce8a6024e03", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "php": ">=7.1.0" + }, + "require-dev": { + "mockery/mockery": "^1.6.12", + "phpunit/phpunit": "^10" + }, + "type": "library", + "autoload": { + "psr-4": { + "Appwrite\\": "src/Appwrite" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Appwrite is an open-source self-hosted backend server that abstract and simplify complex and repetitive development tasks behind a very simple REST API", + "support": { + "email": "team@appwrite.io", + "issues": "https://github.com/appwrite/sdk-for-php/issues", + "source": "https://github.com/appwrite/sdk-for-php/tree/18.0.1", + "url": "https://appwrite.io/support" + }, + "time": "2025-11-10T12:50:46+00:00" + }, { "name": "brick/math", "version": "0.14.0", @@ -145,16 +187,16 @@ }, { "name": "google/protobuf", - "version": "v4.33.0", + "version": "v4.33.1", "source": { "type": "git", "url": "https://github.com/protocolbuffers/protobuf-php.git", - "reference": "b50269e23204e5ae859a326ec3d90f09efe3047d" + "reference": "0cd73ccf0cd26c3e72299cce1ea6144091a57e12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/b50269e23204e5ae859a326ec3d90f09efe3047d", - "reference": "b50269e23204e5ae859a326ec3d90f09efe3047d", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/0cd73ccf0cd26c3e72299cce1ea6144091a57e12", + "reference": "0cd73ccf0cd26c3e72299cce1ea6144091a57e12", "shasum": "" }, "require": { @@ -183,9 +225,9 @@ "proto" ], "support": { - "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.0" + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.1" }, - "time": "2025-10-15T20:10:28+00:00" + "time": "2025-11-12T21:58:05+00:00" }, { "name": "mongodb/mongodb", @@ -1383,16 +1425,16 @@ }, { "name": "symfony/http-client", - "version": "v7.3.4", + "version": "v7.3.6", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62" + "reference": "3c0a55a2c8e21e30a37022801c11c7ab5a6cb2de" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/4b62871a01c49457cf2a8e560af7ee8a94b87a62", - "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62", + "url": "https://api.github.com/repos/symfony/http-client/zipball/3c0a55a2c8e21e30a37022801c11c7ab5a6cb2de", + "reference": "3c0a55a2c8e21e30a37022801c11c7ab5a6cb2de", "shasum": "" }, "require": { @@ -1459,7 +1501,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.3.4" + "source": "https://github.com/symfony/http-client/tree/v7.3.6" }, "funding": [ { @@ -1479,7 +1521,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2025-11-05T17:41:46+00:00" }, { "name": "symfony/http-client-contracts", @@ -1886,16 +1928,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -1949,7 +1991,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -1960,12 +2002,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-25T09:37:31+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "tbachert/spi", @@ -2119,16 +2165,16 @@ }, { "name": "utopia-php/database", - "version": "3.0.0", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "d41fd5649e01cc84be840dee95a7bf47eec891a5" + "reference": "e10b4faa4f3a3ef30a5f6d76acdb605469924aec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/d41fd5649e01cc84be840dee95a7bf47eec891a5", - "reference": "d41fd5649e01cc84be840dee95a7bf47eec891a5", + "url": "https://api.github.com/repos/utopia-php/database/zipball/e10b4faa4f3a3ef30a5f6d76acdb605469924aec", + "reference": "e10b4faa4f3a3ef30a5f6d76acdb605469924aec", "shasum": "" }, "require": { @@ -2138,7 +2184,7 @@ "php": ">=8.1", "utopia-php/cache": "0.13.*", "utopia-php/framework": "0.33.*", - "utopia-php/mongo": "0.10.*", + "utopia-php/mongo": "0.11.*", "utopia-php/pools": "0.8.*" }, "require-dev": { @@ -2171,9 +2217,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/3.0.0" + "source": "https://github.com/utopia-php/database/tree/3.4.0" }, - "time": "2025-10-20T05:51:31+00:00" + "time": "2025-11-13T06:34:20+00:00" }, { "name": "utopia-php/framework", @@ -2224,16 +2270,16 @@ }, { "name": "utopia-php/mongo", - "version": "0.10.0", + "version": "0.11.0", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "ecfad6aad2e2e3fe5899ac2ebf1009a21b4d6b18" + "reference": "34bc0cda8ea368cde68702a6fffe2c3ac625398e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/ecfad6aad2e2e3fe5899ac2ebf1009a21b4d6b18", - "reference": "ecfad6aad2e2e3fe5899ac2ebf1009a21b4d6b18", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/34bc0cda8ea368cde68702a6fffe2c3ac625398e", + "reference": "34bc0cda8ea368cde68702a6fffe2c3ac625398e", "shasum": "" }, "require": { @@ -2279,9 +2325,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/0.10.0" + "source": "https://github.com/utopia-php/mongo/tree/0.11.0" }, - "time": "2025-10-02T04:50:07+00:00" + "time": "2025-10-20T11:11:23+00:00" }, { "name": "utopia-php/pools", @@ -2739,16 +2785,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.6.1", + "version": "v5.6.2", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" + "reference": "3a454ca033b9e06b63282ce19562e892747449bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", + "reference": "3a454ca033b9e06b63282ce19562e892747449bb", "shasum": "" }, "require": { @@ -2791,9 +2837,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" }, - "time": "2025-08-13T20:13:15+00:00" + "time": "2025-10-21T19:32:17+00:00" }, { "name": "phar-io/manifest", @@ -2915,24 +2961,24 @@ }, { "name": "phpbench/container", - "version": "2.2.2", + "version": "2.2.3", "source": { "type": "git", "url": "https://github.com/phpbench/container.git", - "reference": "a59b929e00b87b532ca6d0edd8eca0967655af33" + "reference": "0c7b2d36c1ea53fe27302fb8873ded7172047196" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpbench/container/zipball/a59b929e00b87b532ca6d0edd8eca0967655af33", - "reference": "a59b929e00b87b532ca6d0edd8eca0967655af33", + "url": "https://api.github.com/repos/phpbench/container/zipball/0c7b2d36c1ea53fe27302fb8873ded7172047196", + "reference": "0c7b2d36c1ea53fe27302fb8873ded7172047196", "shasum": "" }, "require": { "psr/container": "^1.0|^2.0", - "symfony/options-resolver": "^4.2 || ^5.0 || ^6.0 || ^7.0" + "symfony/options-resolver": "^4.2 || ^5.0 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^2.16", + "php-cs-fixer/shim": "^3.89", "phpstan/phpstan": "^0.12.52", "phpunit/phpunit": "^8" }, @@ -2960,22 +3006,22 @@ "description": "Simple, configurable, service container.", "support": { "issues": "https://github.com/phpbench/container/issues", - "source": "https://github.com/phpbench/container/tree/2.2.2" + "source": "https://github.com/phpbench/container/tree/2.2.3" }, - "time": "2023-10-30T13:38:26+00:00" + "time": "2025-11-06T09:05:13+00:00" }, { "name": "phpbench/phpbench", - "version": "1.4.1", + "version": "1.4.3", "source": { "type": "git", "url": "https://github.com/phpbench/phpbench.git", - "reference": "78cd98a9aa34e0f8f80ca01972a8b88d2c30194b" + "reference": "b641dde59d969ea42eed70a39f9b51950bc96878" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpbench/phpbench/zipball/78cd98a9aa34e0f8f80ca01972a8b88d2c30194b", - "reference": "78cd98a9aa34e0f8f80ca01972a8b88d2c30194b", + "url": "https://api.github.com/repos/phpbench/phpbench/zipball/b641dde59d969ea42eed70a39f9b51950bc96878", + "reference": "b641dde59d969ea42eed70a39f9b51950bc96878", "shasum": "" }, "require": { @@ -2990,26 +3036,26 @@ "phpbench/container": "^2.2", "psr/log": "^1.1 || ^2.0 || ^3.0", "seld/jsonlint": "^1.1", - "symfony/console": "^6.1 || ^7.0", - "symfony/filesystem": "^6.1 || ^7.0", - "symfony/finder": "^6.1 || ^7.0", - "symfony/options-resolver": "^6.1 || ^7.0", - "symfony/process": "^6.1 || ^7.0", + "symfony/console": "^6.1 || ^7.0 || ^8.0", + "symfony/filesystem": "^6.1 || ^7.0 || ^8.0", + "symfony/finder": "^6.1 || ^7.0 || ^8.0", + "symfony/options-resolver": "^6.1 || ^7.0 || ^8.0", + "symfony/process": "^6.1 || ^7.0 || ^8.0", "webmozart/glob": "^4.6" }, "require-dev": { "dantleech/invoke": "^2.0", "ergebnis/composer-normalize": "^2.39", - "friendsofphp/php-cs-fixer": "^3.0", "jangregor/phpstan-prophecy": "^1.0", - "phpspec/prophecy": "dev-master", + "php-cs-fixer/shim": "^3.9", + "phpspec/prophecy": "^1.22", "phpstan/extension-installer": "^1.1", "phpstan/phpstan": "^1.0", "phpstan/phpstan-phpunit": "^1.0", "phpunit/phpunit": "^10.4 || ^11.0", "rector/rector": "^1.2", - "symfony/error-handler": "^6.1 || ^7.0", - "symfony/var-dumper": "^6.1 || ^7.0" + "symfony/error-handler": "^6.1 || ^7.0 || ^8.0", + "symfony/var-dumper": "^6.1 || ^7.0 || ^8.0" }, "suggest": { "ext-xdebug": "For Xdebug profiling extension." @@ -3052,7 +3098,7 @@ ], "support": { "issues": "https://github.com/phpbench/phpbench/issues", - "source": "https://github.com/phpbench/phpbench/tree/1.4.1" + "source": "https://github.com/phpbench/phpbench/tree/1.4.3" }, "funding": [ { @@ -3060,7 +3106,7 @@ "type": "github" } ], - "time": "2025-03-12T08:01:40+00:00" + "time": "2025-11-06T19:07:31+00:00" }, { "name": "phpstan/phpstan", @@ -4671,16 +4717,16 @@ }, { "name": "symfony/console", - "version": "v7.3.4", + "version": "v7.3.6", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db" + "reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/2b9c5fafbac0399a20a2e82429e2bd735dcfb7db", - "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db", + "url": "https://api.github.com/repos/symfony/console/zipball/c28ad91448f86c5f6d9d2c70f0cf68bf135f252a", + "reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a", "shasum": "" }, "require": { @@ -4745,7 +4791,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.4" + "source": "https://github.com/symfony/console/tree/v7.3.6" }, "funding": [ { @@ -4765,20 +4811,20 @@ "type": "tidelift" } ], - "time": "2025-09-22T15:31:00+00:00" + "time": "2025-11-04T01:21:42+00:00" }, { "name": "symfony/filesystem", - "version": "v7.3.2", + "version": "v7.3.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd" + "reference": "e9bcfd7837928ab656276fe00464092cc9e1826a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/edcbb768a186b5c3f25d0643159a787d3e63b7fd", - "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/e9bcfd7837928ab656276fe00464092cc9e1826a", + "reference": "e9bcfd7837928ab656276fe00464092cc9e1826a", "shasum": "" }, "require": { @@ -4815,7 +4861,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.3.2" + "source": "https://github.com/symfony/filesystem/tree/v7.3.6" }, "funding": [ { @@ -4835,20 +4881,20 @@ "type": "tidelift" } ], - "time": "2025-07-07T08:17:47+00:00" + "time": "2025-11-05T09:52:27+00:00" }, { "name": "symfony/finder", - "version": "v7.3.2", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" + "reference": "9f696d2f1e340484b4683f7853b273abff94421f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", + "url": "https://api.github.com/repos/symfony/finder/zipball/9f696d2f1e340484b4683f7853b273abff94421f", + "reference": "9f696d2f1e340484b4683f7853b273abff94421f", "shasum": "" }, "require": { @@ -4883,7 +4929,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.2" + "source": "https://github.com/symfony/finder/tree/v7.3.5" }, "funding": [ { @@ -4903,7 +4949,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2025-10-15T18:45:57+00:00" }, { "name": "symfony/options-resolver", @@ -5483,7 +5529,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -5492,6 +5538,6 @@ "ext-curl": "*", "ext-redis": "*" }, - "platform-dev": {}, - "plugin-api-version": "2.6.0" + "platform-dev": [], + "plugin-api-version": "2.3.0" } diff --git a/src/Abuse/Adapters/TimeLimit/AppwriteTablesDB.php b/src/Abuse/Adapters/TimeLimit/AppwriteTablesDB.php new file mode 100644 index 0000000..d65a5bf --- /dev/null +++ b/src/Abuse/Adapters/TimeLimit/AppwriteTablesDB.php @@ -0,0 +1,337 @@ +key = $key; + $now = \time(); + $this->timestamp = (int)($now - ($now % $seconds)); + $this->limit = $limit; + $this->tablesDB = new TablesDB($client); + $this->databaseId = $databaseId; + } + + /** + * @throws Exception|\Exception + */ + public function setup(): void + { + try { + $this->tablesDB->getTable($this->databaseId, self::TABLE_LOCK); + // Schema indication table exists, we can safely assume database is setup + return; + } catch (\Throwable $err) { + // Error occured, we do in-depth setup + } + + try { + $this->tablesDB->create($this->databaseId, self::DATABASE_NAME); + } catch (AppwriteException $err) { + // Silence error if database is already present + if ($err->getType() !== 'database_already_exists') { + throw $err; + } + } + + try { + $this->tablesDB->createTable($this->databaseId, self::TABLE_ID, self::TABLE_NAME); + } catch (AppwriteException $err) { + // Silence error if table is already present + if ($err->getType() !== 'table_already_exists') { + throw $err; + } + } + + try { + $this->tablesDB->createStringColumn($this->databaseId, self::TABLE_ID, 'key', 255, true); + } catch (AppwriteException $err) { + // Silence error if column is already present + if ($err->getType() !== 'column_already_exists') { + throw $err; + } + } + + try { + $this->tablesDB->createDatetimeColumn($this->databaseId, self::TABLE_ID, 'time', true); + } catch (AppwriteException $err) { + // Silence error if column is already present + if ($err->getType() !== 'column_already_exists') { + throw $err; + } + } + + try { + $this->tablesDB->createIntegerColumn($this->databaseId, self::TABLE_ID, 'count', true, 0, PHP_INT_MAX); + } catch (AppwriteException $err) { + // Silence error if column is already present + if ($err->getType() !== 'column_already_exists') { + throw $err; + } + } + + // Await till all attributes are ready + $ready = false; + $attempts = 0; + while ($attempts < 15) { + $attempts++; + + $response = $this->tablesDB->listColumns($this->databaseId, self::TABLE_ID, [ + Query::notEqual('status', 'available'), + Query::limit(1) + ]); + + $columns = $response['columns']; + + // Temporary fix due to bug in Appwrite listColumns queries + $columns = \array_filter($columns, fn ($column) => $column['status'] !== 'available'); + + if (\count($columns) === 0) { + $ready = true; + break; + } + + \sleep(1); + } + + if (!$ready) { + throw new \Exception('Failed to setup tables.'); + } + + try { + $this->tablesDB->createIndex($this->databaseId, self::TABLE_ID, 'unique1', IndexType::UNIQUE(), ['key', 'time']); + } catch (AppwriteException $err) { + // Silence error if index is already present + if ($err->getType() !== 'index_already_exists') { + throw $err; + } + } + + try { + $this->tablesDB->createIndex($this->databaseId, self::TABLE_ID, 'index2', IndexType::KEY(), ['time']); + } catch (AppwriteException $err) { + // Silence error if index is already present + if ($err->getType() !== 'index_already_exists') { + throw $err; + } + } + + // Await till all indexes are ready + $ready = false; + $attempts = 0; + while ($attempts < 15) { + $attempts++; + + $response = $this->tablesDB->listIndexes($this->databaseId, self::TABLE_ID, [ + Query::notEqual('status', 'available'), + Query::limit(1) + ]); + + $indexes = $response['indexes']; + + // Temporary fix due to bug in Appwrite listColumns queries + $indexes = \array_filter($indexes, fn ($index) => $index['status'] !== 'available'); + + if (\count($indexes) === 0) { + $ready = true; + break; + } + + \sleep(1); + } + + if (!$ready) { + throw new \Exception('Failed to setup tables.'); + } + + // Optimize future setup checks + try { + $this->tablesDB->createTable($this->databaseId, self::TABLE_LOCK, name: self::TABLE_LOCK); + } catch (AppwriteException $err) { + // Silence error if table is already present + if ($err->getType() !== 'table_already_exists') { + throw $err; + } + } + } + + /** + * Check + * + * Checks if number of counts is bigger or smaller than current limit + * + * @param string $key + * @param int $timestamp + * @return int + * + * @throws \Exception + */ + protected function count(string $key, int $timestamp): int + { + if (0 == $this->limit) { // No limit no point for counting + return 0; + } + + if (! \is_null($this->count)) { // Get fetched result + return $this->count; + } + + $timestamp = $this->toDateTime($timestamp); + + $response = $this->tablesDB->listRows($this->databaseId, self::TABLE_ID, [ + Query::equal('key', [$key]), + Query::equal('time', [$timestamp]), + ]); + $rows = $response['rows']; + + $this->count = 0; + + if (\count($rows) === 1) { // Unique Index + $count = $rows[0]['count'] ?? 0; + if (\is_numeric($count)) { + $this->count = intval($count); + } + } + + return $this->count; + } + + /** + * @param string $key + * @param int $timestamp + * @return void + * + * @throws \Throwable + */ + protected function hit(string $key, int $timestamp): void + { + if (0 == $this->limit) { // No limit no point for counting + return; + } + + $timestamp = $this->toDateTime($timestamp); + + $response = $this->tablesDB->listRows($this->databaseId, self::TABLE_ID, [ + Query::equal('key', [$key]), + Query::equal('time', [$timestamp]), + ]); + $rows = $response['rows']; + $data = $rows[0] ?? null; + + if (\is_null($data)) { + $data = [ + 'key' => $key, + 'time' => $timestamp, + 'count' => 1, + ]; + + try { + $this->tablesDB->createRow($this->databaseId, self::TABLE_ID, ID::unique(), $data); + } catch (AppwriteException $err) { + if ($err->getType() !== 'row_already_exists') { + throw $err; + } + + $response = $this->tablesDB->listRows($this->databaseId, self::TABLE_ID, [ + Query::equal('key', [$key]), + Query::equal('time', [$timestamp]), + ]); + $rows = $response['rows']; + + $data = $rows[0] ?? null; + + if (!is_null($data)) { + $count = $data['count'] ?? 0; + if (\is_numeric($count)) { + $this->count = intval($count); + } + + $this->tablesDB->incrementRowColumn($this->databaseId, self::TABLE_ID, $data['$id'], 'count', 1); + } else { + throw new \Exception('Document Not Found'); + } + } + } else { + $this->tablesDB->incrementRowColumn($this->databaseId, self::TABLE_ID, $data['$id'], 'count', 1); + } + + $this->count++; + } + + /** + * Get abuse logs + * + * Return logs with an optional offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + * + * @throws \Exception + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $queries = []; + + $queries[] = Query::orderDesc(''); + + if (! \is_null($offset)) { + $queries[] = Query::offset($offset); + } + if (! \is_null($limit)) { + $queries[] = Query::limit($limit); + } + + $response = $this->tablesDB->listRows($this->databaseId, self::TABLE_ID, $queries); + + return \array_map(fn ($document) => new Document($document), $response['documents']); + } + + /** + * Delete logs older than $timestamp seconds + * + * @param int $timestamp + * @return bool + * + * @throws \Exception + */ + public function cleanup(int $timestamp): bool + { + $timestamp = $this->toDateTime($timestamp); + + do { + $response = $this->tablesDB->deleteRows($this->databaseId, self::TABLE_ID, [ + Query::lessThan('time', $timestamp), + ]); + } while ($response['total'] > 0); + + return true; + } + + protected function toDateTime(int $timestamp): string + { + return (new \DateTime())->setTimestamp($timestamp)->format('Y-m-d H:i:s.v'); + } +} diff --git a/tests/Abuse/AppwriteTablesDBTest.php b/tests/Abuse/AppwriteTablesDBTest.php new file mode 100755 index 0000000..eef5313 --- /dev/null +++ b/tests/Abuse/AppwriteTablesDBTest.php @@ -0,0 +1,43 @@ +setEndpoint('https://fra.cloud.appwrite.io/v1') + ->setProject('68f0b93e003404ce2e31') // Utopia PHP + ->setKey('standard_6e7fe493f9dbe734c77eb701982baa223bc95149b61496306ce9e03276e0f79112cd8738a178d78cc5c66f0ae8ad912a71260086f1ab6b5c18271be3c58f66c9b5e3cca22a470a220093c585a5c5b24831c3fdac6ee8fdda7b19a5a63316bf45cbb30d9bc7e84a5e2580fac24acb6273fd13d86e2b2cf830276df9afd43f59f2'); + + $adapter = new AdapterAppwriteTablesDB('', 1, 1, self::$client, self::$databaseId); + $adapter->setup(); + } + + public function getAdapter(string $key, int $limit, int $seconds): TimeLimit + { + return new AdapterAppwriteTablesDB($key, $limit, $seconds, self::$client, self::$databaseId); + } + + public static function tearDownAfterClass(): void + { + } +} diff --git a/tests/Abuse/Base.php b/tests/Abuse/Base.php index 5cb59c7..f0bc337 100644 --- a/tests/Abuse/Base.php +++ b/tests/Abuse/Base.php @@ -50,14 +50,14 @@ public function testDynamicKeyWith2Params(): void } /** - * Test a dynamic key with higher request rate like 100 requests per second + * Test a dynamic key with higher request rate like 10 requests per second */ public function testDynamicKeyFastRequests(): void { - $adapter = $this->getAdapter('fast-requests-{{ip}}', 100, 1); + $adapter = $this->getAdapter('fast-requests-{{ip}}', 10, 1); $adapter->setParam('{{ip}}', '0.0.0.10'); $abuse = new Abuse($adapter); - for ($i = 0; $i < 100; $i++) { + for ($i = 0; $i < 10; $i++) { $this->assertEquals($abuse->check(), false); } $this->assertEquals($abuse->check(), true); @@ -68,10 +68,10 @@ public function testDynamicKeyFastRequests(): void */ public function testLimitReset(): void { - $adapter = $this->getAdapter('limit-reset-{{ip}}', 100, 2); + $adapter = $this->getAdapter('limit-reset-{{ip}}', 10, 2); $adapter->setParam('{{ip}}', '127.0.0.1'); $abuse = new Abuse($adapter); - for ($i = 0; $i < 100; $i++) { + for ($i = 0; $i < 10; $i++) { $this->assertEquals($abuse->check(), false); } $this->assertEquals($abuse->check(), true); @@ -80,7 +80,7 @@ public function testLimitReset(): void sleep(2); /** Seems to be a bug in the code where if use the same adapter, it caches the result of the previous check */ - $adapter = $this->getAdapter('limit-reset-{{ip}}', 100, 1); + $adapter = $this->getAdapter('limit-reset-{{ip}}', 10, 1); $adapter->setParam('{{ip}}', '127.0.0.1'); $abuse = new Abuse($adapter); $this->assertEquals($abuse->check(), false); From 04dc655c56ae799fa5051e3e29ce0a300db91202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 13 Nov 2025 21:23:16 +0100 Subject: [PATCH 2/5] Shorten code --- .../Adapters/TimeLimit/AppwriteTablesDB.php | 182 ++++++++---------- 1 file changed, 76 insertions(+), 106 deletions(-) diff --git a/src/Abuse/Adapters/TimeLimit/AppwriteTablesDB.php b/src/Abuse/Adapters/TimeLimit/AppwriteTablesDB.php index d65a5bf..102c65d 100644 --- a/src/Abuse/Adapters/TimeLimit/AppwriteTablesDB.php +++ b/src/Abuse/Adapters/TimeLimit/AppwriteTablesDB.php @@ -39,139 +39,109 @@ public function __construct(string $key, int $limit, int $seconds, Client $clien */ public function setup(): void { - try { - $this->tablesDB->getTable($this->databaseId, self::TABLE_LOCK); - // Schema indication table exists, we can safely assume database is setup + if ($this->isSetupComplete()) { return; - } catch (\Throwable $err) { - // Error occured, we do in-depth setup - } - - try { - $this->tablesDB->create($this->databaseId, self::DATABASE_NAME); - } catch (AppwriteException $err) { - // Silence error if database is already present - if ($err->getType() !== 'database_already_exists') { - throw $err; - } - } - - try { - $this->tablesDB->createTable($this->databaseId, self::TABLE_ID, self::TABLE_NAME); - } catch (AppwriteException $err) { - // Silence error if table is already present - if ($err->getType() !== 'table_already_exists') { - throw $err; - } - } - - try { - $this->tablesDB->createStringColumn($this->databaseId, self::TABLE_ID, 'key', 255, true); - } catch (AppwriteException $err) { - // Silence error if column is already present - if ($err->getType() !== 'column_already_exists') { - throw $err; - } } - try { - $this->tablesDB->createDatetimeColumn($this->databaseId, self::TABLE_ID, 'time', true); - } catch (AppwriteException $err) { - // Silence error if column is already present - if ($err->getType() !== 'column_already_exists') { - throw $err; - } - } + $this->createDatabase(); + $this->createTable(); + $this->createColumns(); + $this->waitForResourcesReady('columns'); + $this->createIndexes(); + $this->waitForResourcesReady('indexes'); + $this->createLockTable(); + } + protected function isSetupComplete(): bool + { try { - $this->tablesDB->createIntegerColumn($this->databaseId, self::TABLE_ID, 'count', true, 0, PHP_INT_MAX); - } catch (AppwriteException $err) { - // Silence error if column is already present - if ($err->getType() !== 'column_already_exists') { - throw $err; - } + $this->tablesDB->getTable($this->databaseId, self::TABLE_LOCK); + return true; + } catch (\Throwable $err) { + return false; } + } - // Await till all attributes are ready - $ready = false; - $attempts = 0; - while ($attempts < 15) { - $attempts++; - - $response = $this->tablesDB->listColumns($this->databaseId, self::TABLE_ID, [ - Query::notEqual('status', 'available'), - Query::limit(1) - ]); - - $columns = $response['columns']; - - // Temporary fix due to bug in Appwrite listColumns queries - $columns = \array_filter($columns, fn ($column) => $column['status'] !== 'available'); - - if (\count($columns) === 0) { - $ready = true; - break; - } + protected function createDatabase(): void + { + $this->executeWithSilentError( + fn () => $this->tablesDB->create($this->databaseId, self::DATABASE_NAME), + 'database_already_exists' + ); + } - \sleep(1); - } + protected function createTable(): void + { + $this->executeWithSilentError( + fn () => $this->tablesDB->createTable($this->databaseId, self::TABLE_ID, self::TABLE_NAME), + 'table_already_exists' + ); + } - if (!$ready) { - throw new \Exception('Failed to setup tables.'); + protected function createColumns(): void + { + $columns = [ + fn () => $this->tablesDB->createStringColumn($this->databaseId, self::TABLE_ID, 'key', 255, true), + fn () => $this->tablesDB->createDatetimeColumn($this->databaseId, self::TABLE_ID, 'time', true), + fn () => $this->tablesDB->createIntegerColumn($this->databaseId, self::TABLE_ID, 'count', true, 0, PHP_INT_MAX) + ]; + + foreach ($columns as $createColumnFunction) { + $this->executeWithSilentError($createColumnFunction, 'column_already_exists'); } + } - try { - $this->tablesDB->createIndex($this->databaseId, self::TABLE_ID, 'unique1', IndexType::UNIQUE(), ['key', 'time']); - } catch (AppwriteException $err) { - // Silence error if index is already present - if ($err->getType() !== 'index_already_exists') { - throw $err; - } - } + protected function createIndexes(): void + { + $indexes = [ + fn () => $this->tablesDB->createIndex($this->databaseId, self::TABLE_ID, 'unique1', IndexType::UNIQUE(), ['key', 'time']), + fn () => $this->tablesDB->createIndex($this->databaseId, self::TABLE_ID, 'index2', IndexType::KEY(), ['time']) + ]; - try { - $this->tablesDB->createIndex($this->databaseId, self::TABLE_ID, 'index2', IndexType::KEY(), ['time']); - } catch (AppwriteException $err) { - // Silence error if index is already present - if ($err->getType() !== 'index_already_exists') { - throw $err; - } + foreach ($indexes as $createIndexFunction) { + $this->executeWithSilentError($createIndexFunction, 'index_already_exists'); } + } - // Await till all indexes are ready - $ready = false; + protected function waitForResourcesReady(string $resourceType): void + { $attempts = 0; - while ($attempts < 15) { - $attempts++; + $maxAttempts = 15; - $response = $this->tablesDB->listIndexes($this->databaseId, self::TABLE_ID, [ - Query::notEqual('status', 'available'), - Query::limit(1) - ]); + while ($attempts < $maxAttempts) { + $attempts++; - $indexes = $response['indexes']; + $response = $resourceType === 'columns' + ? $this->tablesDB->listColumns($this->databaseId, self::TABLE_ID, [Query::notEqual('status', 'available'), Query::limit(1)]) + : $this->tablesDB->listIndexes($this->databaseId, self::TABLE_ID, [Query::notEqual('status', 'available'), Query::limit(1)]); - // Temporary fix due to bug in Appwrite listColumns queries - $indexes = \array_filter($indexes, fn ($index) => $index['status'] !== 'available'); + $resources = $response[$resourceType]; + $resources = \array_filter($resources, fn ($resource) => $resource['status'] !== 'available'); - if (\count($indexes) === 0) { - $ready = true; - break; + if (\count($resources) === 0) { + return; } \sleep(1); } - if (!$ready) { - throw new \Exception('Failed to setup tables.'); - } + throw new \Exception("Failed to setup {$resourceType}."); + } - // Optimize future setup checks + protected function createLockTable(): void + { + $this->executeWithSilentError( + fn () => $this->tablesDB->createTable($this->databaseId, self::TABLE_LOCK, name: self::TABLE_LOCK), + 'table_already_exists' + ); + } + + protected function executeWithSilentError(callable $callback, string $allowedErrorType): void + { try { - $this->tablesDB->createTable($this->databaseId, self::TABLE_LOCK, name: self::TABLE_LOCK); + $callback(); } catch (AppwriteException $err) { - // Silence error if table is already present - if ($err->getType() !== 'table_already_exists') { + if ($err->getType() !== $allowedErrorType) { throw $err; } } From b1a231960864b6773f15c35e9849c3deb47cd9d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 13 Nov 2025 22:20:01 +0100 Subject: [PATCH 3/5] Update adapter namespace --- .env.example | 3 +++ .github/workflows/tests.yml | 6 ++++++ .gitignore | 3 ++- docker-compose.yml | 4 ++++ .../{AppwriteTablesDB.php => Appwrite/TablesDB.php} | 10 +++++----- .../TablesDBTest.php} | 12 ++++++------ 6 files changed, 26 insertions(+), 12 deletions(-) create mode 100644 .env.example rename src/Abuse/Adapters/TimeLimit/{AppwriteTablesDB.php => Appwrite/TablesDB.php} (97%) rename tests/Abuse/{AppwriteTablesDBTest.php => Appwrite/TablesDBTest.php} (51%) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..013b710 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +APPWRITE_ENDPOINT= +APPWRITE_PROJECT_ID= +APPWRITE_API_KEY= \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 880da7f..c17c12f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,6 +17,12 @@ jobs: - run: git checkout HEAD^2 + - name: Generate .env file + run: | + echo "APPWRITE_ENDPOINT=${{ secrets.APPWRITE_ENDPOINT }}" > .env + echo "APPWRITE_PROJECT_ID=${{ secrets.APPWRITE_PROJECT_ID }}" >> .env + echo "APPWRITE_API_KEY=${{ secrets.APPWRITE_API_KEY }}" >> .env + - name: Build run: | export PHP_VERSION=${{ matrix.php-versions }} diff --git a/.gitignore b/.gitignore index e244eda..0ec0248 100755 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /vendor/ -/.idea/ \ No newline at end of file +/.idea/ +.env \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index f3a0998..d1345c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,10 @@ services: depends_on: - redis - mysql + environment: + - APPWRITE_ENDPOINT + - APPWRITE_PROJECT_ID + - APPWRITE_API_KEY volumes: - ./phpunit.xml:/code/phpunit.xml - ./src:/code/src diff --git a/src/Abuse/Adapters/TimeLimit/AppwriteTablesDB.php b/src/Abuse/Adapters/TimeLimit/Appwrite/TablesDB.php similarity index 97% rename from src/Abuse/Adapters/TimeLimit/AppwriteTablesDB.php rename to src/Abuse/Adapters/TimeLimit/Appwrite/TablesDB.php index 102c65d..d731813 100644 --- a/src/Abuse/Adapters/TimeLimit/AppwriteTablesDB.php +++ b/src/Abuse/Adapters/TimeLimit/Appwrite/TablesDB.php @@ -1,18 +1,18 @@ timestamp = (int)($now - ($now % $seconds)); $this->limit = $limit; - $this->tablesDB = new TablesDB($client); + $this->tablesDB = new TablesDBService($client); $this->databaseId = $databaseId; } diff --git a/tests/Abuse/AppwriteTablesDBTest.php b/tests/Abuse/Appwrite/TablesDBTest.php similarity index 51% rename from tests/Abuse/AppwriteTablesDBTest.php rename to tests/Abuse/Appwrite/TablesDBTest.php index eef5313..10708c2 100755 --- a/tests/Abuse/AppwriteTablesDBTest.php +++ b/tests/Abuse/Appwrite/TablesDBTest.php @@ -4,7 +4,7 @@ use Appwrite\Client; use Utopia\Abuse\Adapters\TimeLimit; -use Utopia\Abuse\Adapters\TimeLimit\AppwriteTablesDB as AdapterAppwriteTablesDB; +use Utopia\Abuse\Adapters\TimeLimit\Appwrite\TablesDB; class AppwriteTablesDBTest extends Base { @@ -24,17 +24,17 @@ private static function initialiseDatabase(): void { self::$databaseId = 'abuse-cicd-' . \uniqid(); self::$client = (new Client()) - ->setEndpoint('https://fra.cloud.appwrite.io/v1') - ->setProject('68f0b93e003404ce2e31') // Utopia PHP - ->setKey('standard_6e7fe493f9dbe734c77eb701982baa223bc95149b61496306ce9e03276e0f79112cd8738a178d78cc5c66f0ae8ad912a71260086f1ab6b5c18271be3c58f66c9b5e3cca22a470a220093c585a5c5b24831c3fdac6ee8fdda7b19a5a63316bf45cbb30d9bc7e84a5e2580fac24acb6273fd13d86e2b2cf830276df9afd43f59f2'); + ->setEndpoint(\getenv('APPWRITE_ENDPOINT')) + ->setProject(\getenv('APPWRITE_PROJECT_ID')) + ->setKey(\getenv('APPWRITE_API_KEY')); - $adapter = new AdapterAppwriteTablesDB('', 1, 1, self::$client, self::$databaseId); + $adapter = new TablesDB('', 1, 1, self::$client, self::$databaseId); $adapter->setup(); } public function getAdapter(string $key, int $limit, int $seconds): TimeLimit { - return new AdapterAppwriteTablesDB($key, $limit, $seconds, self::$client, self::$databaseId); + return new TablesDB($key, $limit, $seconds, self::$client, self::$databaseId); } public static function tearDownAfterClass(): void From 25e4c022cfc92b6e1f3897d32c4bbd316e1a4109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 13 Nov 2025 22:26:37 +0100 Subject: [PATCH 4/5] Update docs --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index e17e4ce..4016404 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ composer require utopia-php/abuse The time limit abuse allow each key (action) to be performed [X] times in given time frame. This adapter uses a MySQL / MariaDB to store usage attempts. Before using it create the table schema as documented in this repository (./data/schema.sql) +### Database adapter + ```php check()) { } ``` +### Appwrite TablesDB adapter + +```php +setEndpoint('[YOUR_ENDPOINT]') + ->setProject('[YOUR_PROJECT_ID]') + ->setKey('[YOUR_API_KEY]'); +$databaseId = 'abuse'; + +// Limit login attempts to 10 time in 5 minutes time frame +$adapter = new TablesDBAdapter('login-attempt-from-{{ip}}', 10, (60 * 5), $client, $databaseId); + +$adapter->setup(); //setup database as required +$adapter->setParam('{{ip}}', '127.0.0.1'); + +$abuse = new Abuse($adapter); + +// Use vars to resolve adapter key + +if($abuse->check()) { + throw new Exception('Service was abused!'); // throw error and return X-Rate limit headers here +} +``` + **ReCaptcha Abuse** The ReCaptcha abuse controller is using Google ReCaptcha service to detect when service is being abused by bots. From 6520245f007f93d937b442f540775059c11a29d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 13 Nov 2025 22:27:02 +0100 Subject: [PATCH 5/5] Fix linter --- tests/Abuse/Appwrite/TablesDBTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Abuse/Appwrite/TablesDBTest.php b/tests/Abuse/Appwrite/TablesDBTest.php index 10708c2..f45897d 100755 --- a/tests/Abuse/Appwrite/TablesDBTest.php +++ b/tests/Abuse/Appwrite/TablesDBTest.php @@ -24,9 +24,9 @@ private static function initialiseDatabase(): void { self::$databaseId = 'abuse-cicd-' . \uniqid(); self::$client = (new Client()) - ->setEndpoint(\getenv('APPWRITE_ENDPOINT')) - ->setProject(\getenv('APPWRITE_PROJECT_ID')) - ->setKey(\getenv('APPWRITE_API_KEY')); + ->setEndpoint(\getenv('APPWRITE_ENDPOINT') ?: '') + ->setProject(\getenv('APPWRITE_PROJECT_ID') ?: '') + ->setKey(\getenv('APPWRITE_API_KEY') ?: ''); $adapter = new TablesDB('', 1, 1, self::$client, self::$databaseId); $adapter->setup();