From 065a0fddae12966f0d513219362652009a3f6129 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 12 Mar 2026 13:41:00 +0000 Subject: [PATCH 001/402] Add backup policy migration support --- src/Appwrite/Platform/Workers/Migrations.php | 60 +++++++++++--------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index e2185a6676..359d533c21 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -309,6 +309,39 @@ class Migrations extends Action ); } + /** + * @return array + */ + protected function getAPIKeyScopes(): array + { + return [ + 'users.read', + 'users.write', + 'teams.read', + 'teams.write', + 'buckets.read', + 'buckets.write', + 'files.read', + 'files.write', + 'functions.read', + 'functions.write', + 'sites.read', + 'sites.write', + 'tokens.read', + 'tokens.write', + 'providers.read', + 'providers.write', + 'topics.read', + 'topics.write', + 'subscribers.read', + 'subscribers.write', + 'messages.read', + 'messages.write', + 'targets.read', + 'targets.write', + ]; + } + /** * @throws Exception */ @@ -329,32 +362,7 @@ class Migrations extends Action METRIC_NETWORK_INBOUND, METRIC_NETWORK_OUTBOUND, ], - 'scopes' => [ - 'users.read', - 'users.write', - 'teams.read', - 'teams.write', - 'buckets.read', - 'buckets.write', - 'files.read', - 'files.write', - 'functions.read', - 'functions.write', - 'sites.read', - 'sites.write', - 'tokens.read', - 'tokens.write', - 'providers.read', - 'providers.write', - 'topics.read', - 'topics.write', - 'subscribers.read', - 'subscribers.write', - 'messages.read', - 'messages.write', - 'targets.read', - 'targets.write', - ] + 'scopes' => $this->getAPIKeyScopes() ]); return API_KEY_DYNAMIC . '_' . $apiKey; From 6128a049f2b17dcb231d03ae3832dce9d04d6595 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 24 Mar 2026 13:33:05 +0000 Subject: [PATCH 002/402] Point migration dep to feature branch --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 65838a1615..5e743b82f0 100644 --- a/composer.json +++ b/composer.json @@ -73,7 +73,7 @@ "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.20.*", - "utopia-php/migration": "1.8.*", + "utopia-php/migration": "dev-backup-migration-multitype as 1.8.0", "utopia-php/platform": "0.7.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", From bd03ec7a500cb2f042b85a6a366951ec6a10bec4 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 24 Mar 2026 13:36:42 +0000 Subject: [PATCH 003/402] Update composer.lock for migration feature branch --- composer.lock | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/composer.lock b/composer.lock index 7a30e2f265..fab609ba6a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f9225f2b580de0ccb796b2fb8c881384", + "content-hash": "baa3a90aacf18e57e2025e79eb6cbf64", "packages": [ { "name": "adhocore/jwt", @@ -4518,16 +4518,16 @@ }, { "name": "utopia-php/migration", - "version": "1.8.3", + "version": "dev-backup-migration-multitype", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "8633523b3343d492427331b6eec53f020f6ab7a7" + "reference": "0075ba97a64dca8885a7d767aab036269bf826f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/8633523b3343d492427331b6eec53f020f6ab7a7", - "reference": "8633523b3343d492427331b6eec53f020f6ab7a7", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/0075ba97a64dca8885a7d767aab036269bf826f0", + "reference": "0075ba97a64dca8885a7d767aab036269bf826f0", "shasum": "" }, "require": { @@ -4567,9 +4567,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.8.3" + "source": "https://github.com/utopia-php/migration/tree/backup-migration-multitype" }, - "time": "2026-03-19T09:18:47+00:00" + "time": "2026-03-24T11:53:33+00:00" }, { "name": "utopia-php/mongo", @@ -8433,9 +8433,18 @@ "time": "2024-11-07T12:36:22+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "utopia-php/migration", + "version": "dev-backup-migration-multitype", + "alias": "1.8.0", + "alias_normalized": "1.8.0.0" + } + ], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "utopia-php/migration": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { From 2838642bae059f050f1a63aec5c4e44c1971b3c2 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 24 Mar 2026 17:09:27 +0000 Subject: [PATCH 004/402] Add policies.read/write scopes to test API key --- tests/e2e/Scopes/ProjectCustom.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index b7037267c5..d32ab25c03 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -163,6 +163,8 @@ trait ProjectCustom 'tokens.write', 'webhooks.read', 'webhooks.write', + 'policies.read', + 'policies.write', 'project.read', 'project.write' ], From d993214810e6e6e4cc54516ff7efe4635b7c372a Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 24 Mar 2026 17:22:31 +0000 Subject: [PATCH 005/402] Revert "Add policies.read/write scopes to test API key" This reverts commit 2838642bae059f050f1a63aec5c4e44c1971b3c2. --- tests/e2e/Scopes/ProjectCustom.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index d32ab25c03..b7037267c5 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -163,8 +163,6 @@ trait ProjectCustom 'tokens.write', 'webhooks.read', 'webhooks.write', - 'policies.read', - 'policies.write', 'project.read', 'project.write' ], From c52ca659055055aaaccf755e0f29db5efb1933a7 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 14 Apr 2026 19:15:41 +1200 Subject: [PATCH 006/402] Revert "Safe delete shared tables v1" --- src/Appwrite/Platform/Workers/Deletes.php | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index f4978780a1..c420444112 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -759,19 +759,11 @@ class Deletes extends Action $databasesToClean )); } elseif ($sharedTablesV1) { - /** - * Temporary disabling deletes for internal collections - */ - $queries = \array_map( - fn ($id) => Query::notEqual('$id', $id), - $projectCollectionIds - ); - - $queries[] = Query::orderAsc(); - $this->deleteByGroup( Database::METADATA, - $queries, + [ + Query::orderAsc() + ], $dbForProject ); } elseif ($sharedTablesV2) { From fd8fedca180a317d3664a88f0526a7cef07f8e66 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 14 Apr 2026 22:51:36 +1200 Subject: [PATCH 007/402] (refactor): Remove shared tables V1/V2 versioning --- .github/workflows/ci.yml | 11 +-- CHANGES.md | 2 +- app/http.php | 20 ++--- docker-compose.yml | 2 - .../Databases/Http/Databases/Create.php | 23 +---- .../Modules/Projects/Http/Projects/Create.php | 86 +++---------------- src/Appwrite/Platform/Workers/Deletes.php | 13 +-- 7 files changed, 24 insertions(+), 133 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2aeae21655..1527e15710 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,7 +210,7 @@ jobs: with: script: | const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB']; - const allModes = ['dedicated', 'shared_v1', 'shared_v2']; + const allModes = ['dedicated', 'shared']; const defaultDatabases = ['MongoDB']; const defaultModes = ['dedicated']; @@ -479,11 +479,8 @@ jobs: env: _APP_BROWSER_HOST: http://invalid-browser/v1 _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} - _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} - _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }} _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} - _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }} run: | docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable @@ -557,11 +554,8 @@ jobs: env: _APP_OPTIONS_ABUSE: enabled _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} - _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} - _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }} _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} - _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }} run: | docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable @@ -618,11 +612,8 @@ jobs: timeout-minutes: 5 env: _APP_DATABASE_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'database_db_main' || '' }} - _APP_DATABASE_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'database_db_main' || '' }} _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} - _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'documentsdb_db_main' || '' }} _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} - _APP_DATABASE_VECTORSDB_SHARED_TABLES_V1: ${{ matrix.mode == 'shared_v1' && 'vectorsdb_db_main' || '' }} run: | docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable diff --git a/CHANGES.md b/CHANGES.md index 548c0d72b0..6894322043 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -892,7 +892,7 @@ * Unset index length by @fogelito in https://github.com/appwrite/appwrite/pull/8978 * Update base to 0.9.5 by @basert in https://github.com/appwrite/appwrite/pull/9005 * Sync main into 1.6.x by @TorstenDittmann in https://github.com/appwrite/appwrite/pull/9011 -* Improved shared tables V2 by @abnegate in https://github.com/appwrite/appwrite/pull/9013 +* Improved shared tables by @abnegate in https://github.com/appwrite/appwrite/pull/9013 * Ensure backwards compatibility for 1.6.x by @christyjacob4 in https://github.com/appwrite/appwrite/pull/9018 # Version 1.6.0 diff --git a/app/http.php b/app/http.php index 67da67376d..31fea23c32 100644 --- a/app/http.php +++ b/app/http.php @@ -415,27 +415,19 @@ $http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorke $projectCollections = $collections['projects']; $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); - $sharedTablesV2 = \array_diff($sharedTables, $sharedTablesV1); - $documentsSharedTables = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', '')); - $documentsSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', '')); - $documentsSharedTablesV2 = \array_diff($documentsSharedTables, $documentsSharedTablesV1); - $vectorSharedTables = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', '')); - $vectorSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', '')); - $vectorSharedTablesV2 = \array_diff($vectorSharedTables, $vectorSharedTablesV1); $cache = $app->getResource('cache'); - // All shared tables V2 pools that need project metadata collections - $sharedTablesV2All = \array_values(\array_unique(\array_filter([ - ...$sharedTablesV2, - ...$documentsSharedTablesV2, - ...$vectorSharedTablesV2, + // All shared tables pools that need project metadata collections + $allSharedTables = \array_values(\array_unique(\array_filter([ + ...$sharedTables, + ...$documentsSharedTables, + ...$vectorSharedTables, ]))); - foreach ($sharedTablesV2All as $hostname) { + foreach ($allSharedTables as $hostname) { Span::init('database.setup'); Span::add('database.hostname', $hostname); diff --git a/docker-compose.yml b/docker-compose.yml index aa2bfdd16a..42bc0bd7e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -242,7 +242,6 @@ services: - _APP_EXPERIMENT_LOGGING_PROVIDER - _APP_EXPERIMENT_LOGGING_CONFIG - _APP_DATABASE_SHARED_TABLES - - _APP_DATABASE_SHARED_TABLES_V1 - _APP_DATABASE_SHARED_NAMESPACE - _APP_FUNCTIONS_CREATION_ABUSE_LIMIT - _APP_CUSTOM_DOMAIN_DENY_LIST @@ -462,7 +461,6 @@ services: - _APP_EXECUTOR_SECRET - _APP_EXECUTOR_HOST - _APP_DATABASE_SHARED_TABLES - - _APP_DATABASE_SHARED_TABLES_V1 - _APP_EMAIL_CERTIFICATES - _APP_MAINTENANCE_RETENTION_AUDIT - _APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php index 3d07c65250..294a6712a9 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php @@ -49,11 +49,6 @@ class Create extends Action $databaseOverride = ''; $dbScheme = ''; $databaseSharedTables = []; - $databaseSharedTablesV1 = []; - $databaseSharedTablesV2 = []; - $projectSharedTables = []; - $projectSharedTablesV1 = []; - $projectSharedTablesV2 = []; switch ($databasetype) { case DOCUMENTSDB: @@ -62,7 +57,6 @@ class Create extends Action $databaseOverride = System::getEnv('_APP_DATABASE_DOCUMENTSDB_OVERRIDE'); $dbScheme = System::getEnv('_APP_DB_HOST_DOCUMENTSDB', 'mongodb'); $databaseSharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''))); - $databaseSharedTablesV1 = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', ''))); break; case VECTORSDB: $databases = Config::getParam('pools-vectorsdb', []); @@ -70,7 +64,6 @@ class Create extends Action $databaseOverride = System::getEnv('_APP_DATABASE_VECTORSDB_OVERRIDE'); $dbScheme = System::getEnv('_APP_DB_HOST_VECTORSDB', 'postgresql'); $databaseSharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''))); - $databaseSharedTablesV1 = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', ''))); break; default: // legacy/tablesdb @@ -78,8 +71,7 @@ class Create extends Action return $dsn; } - $isSharedTablesV1 = false; - $isSharedTablesV2 = false; + $isSharedTables = false; if (!empty($dsn)) { try { @@ -90,10 +82,7 @@ class Create extends Action } $projectSharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - $projectSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); - $projectSharedTablesV2 = \array_diff($projectSharedTables, $projectSharedTablesV1); - $isSharedTablesV1 = \in_array($dsnHost, $projectSharedTablesV1); - $isSharedTablesV2 = \in_array($dsnHost, $projectSharedTablesV2); + $isSharedTables = \in_array($dsnHost, $projectSharedTables); } if ($region !== 'default') { @@ -102,18 +91,14 @@ class Create extends Action return str_contains($value, $region); }); } - $databaseSharedTablesV2 = \array_diff($databaseSharedTables, $databaseSharedTablesV1); $index = \array_search($databaseOverride, $databases); if ($index !== false) { $selectedDsn = $databases[$index]; } else { if (!empty($dsn) && !empty($databaseSharedTables)) { - $beforeFilter = \array_values($databases); - if ($isSharedTablesV1) { - $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV1)); - } elseif ($isSharedTablesV2) { - $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV2)); + if ($isSharedTables) { + $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTables)); } else { $databases = array_filter($databases, fn ($value) => !\in_array($value, $databaseSharedTables)); } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index 9070962e7d..c509a565cd 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -21,8 +21,6 @@ use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate; use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; use Utopia\Database\Validator\UID; use Utopia\DSN\DSN; use Utopia\Platform\Scope\HTTP; @@ -209,32 +207,16 @@ class Create extends Action } $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); $projectTables = !\in_array($dsn->getHost(), $sharedTables); - $sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1); - $sharedTablesV2 = !$projectTables && !$sharedTablesV1; - $sharedTables = $sharedTablesV1 || $sharedTablesV2; - if (!$sharedTablesV2) { + if ($projectTables) { $adapter = new DatabasePool($pools->get($dsn->getHost())); $dbForProject = new Database($adapter, $cache); - $dbForProject->setDatabase(APP_DATABASE); - - if ($sharedTables) { - $tenant = null; - if ($sharedTablesV1) { - $tenant = $project->getSequence(); - } - $dbForProject - ->setSharedTables(true) - ->setTenant($tenant) - ->setNamespace($dsn->getParam('namespace')); - } else { - $dbForProject - ->setSharedTables(false) - ->setTenant(null) - ->setNamespace('_' . $project->getSequence()); - } + $dbForProject + ->setDatabase(APP_DATABASE) + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getSequence()); $create = true; @@ -244,27 +226,11 @@ class Create extends Action $create = false; } - if ($create || $projectTables) { - $adapter = new AdapterDatabase($dbForProject); - $audit = new Audit($adapter); - $audit->setup(); - } + $adapter = new AdapterDatabase($dbForProject); + $audit = new Audit($adapter); + $audit->setup(); - if (!$create && $sharedTablesV1) { - $adapter = new AdapterDatabase($dbForProject); - $attributes = $adapter->getAttributeDocuments(); - $indexes = $adapter->getIndexDocuments(); - $dbForProject->createDocument(Database::METADATA, new Document([ - '$id' => ID::custom('audit'), - '$permissions' => [Permission::create(Role::any())], - 'name' => 'audit', - 'attributes' => $attributes, - 'indexes' => $indexes, - 'documentSecurity' => true - ])); - } - - if ($create || $sharedTablesV1) { + if ($create) { /** @var array $collections */ $collections = Config::getParam('collections', [])['projects'] ?? []; @@ -279,37 +245,7 @@ class Create extends Action try { $dbForProject->createCollection($key, $attributes, $indexes); } catch (Duplicate) { - try { - $dbForProject->createDocument(Database::METADATA, new Document([ - '$id' => ID::custom($key), - '$permissions' => [Permission::create(Role::any())], - 'name' => $key, - 'attributes' => $attributes, - 'indexes' => $indexes, - 'documentSecurity' => true - ])); - } catch (Duplicate) { - // Metadata already exists from concurrent creation - } - } catch (\Throwable $e) { - // PostgreSQL adapter may throw a non-Duplicate exception when - // a table or index already exists during concurrent project - // creation in shared mode. Treat as duplicate if metadata - // can be created successfully. - try { - $dbForProject->createDocument(Database::METADATA, new Document([ - '$id' => ID::custom($key), - '$permissions' => [Permission::create(Role::any())], - 'name' => $key, - 'attributes' => $attributes, - 'indexes' => $indexes, - 'documentSecurity' => true - ])); - } catch (Duplicate) { - // Metadata already exists from concurrent creation - } catch (\Throwable) { - throw $e; // Rethrow original if metadata creation also fails - } + // Collection already exists } } } diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index c420444112..6801d12b77 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -651,11 +651,8 @@ class Deletes extends Action ]; $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); $projectTables = !\in_array($dsn->getHost(), $sharedTables); - $sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1); - $sharedTablesV2 = !$projectTables && !$sharedTablesV1; $allDatabases = [ new Document([ @@ -758,15 +755,7 @@ class Deletes extends Action ), $databasesToClean )); - } elseif ($sharedTablesV1) { - $this->deleteByGroup( - Database::METADATA, - [ - Query::orderAsc() - ], - $dbForProject - ); - } elseif ($sharedTablesV2) { + } else { $queries = \array_map( fn ($id) => Query::notEqual('$id', $id), $projectCollectionIds From 193beb76fe3ada995af20e5d1db43c36f05c2bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 16:50:07 +0200 Subject: [PATCH 008/402] add SMTP endpoints --- app/controllers/api/projects.php | 218 ------------------ .../Project/Http/Project/Labels/Update.php | 7 +- .../Http/Project/SMTP/Status/Update.php | 74 ++++++ .../Project/Http/Project/SMTP/Test/Create.php | 142 ++++++++++++ .../Project/Http/Project/SMTP/Update.php | 142 ++++++++++++ 5 files changed, 363 insertions(+), 220 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 5b82e6c1a3..e964933686 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -3,7 +3,6 @@ use Ahc\Jwt\JWT; use Appwrite\Auth\Validator\MockNumber; use Appwrite\Event\Delete; -use Appwrite\Event\Mail; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -13,7 +12,6 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; use Appwrite\Utopia\Database\Validator\Queries\Keys; use Appwrite\Utopia\Response; -use PHPMailer\PHPMailer\PHPMailer; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; @@ -24,8 +22,6 @@ use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; -use Utopia\Validator\Hostname; -use Utopia\Validator\Integer; use Utopia\Validator\Nullable; use Utopia\Validator\Range; use Utopia\Validator\Text; @@ -619,220 +615,6 @@ Http::post('/v1/projects/:projectId/jwts') ])]), Response::MODEL_JWT); }); -// CUSTOM SMTP and Templates -Http::patch('/v1/projects/:projectId/smtp') - ->desc('Update SMTP') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSmtp', - description: '/docs/references/projects/update-smtp.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateSMTP', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSMTP', - description: '/docs/references/projects/update-smtp.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(), 'Enable custom SMTP service') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->param('host', '', new HostName(), 'SMTP server host name', true) - ->param('port', 587, new Integer(), 'SMTP server port', true) - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - // Ensure required params for when enabling SMTP - if ($enabled) { - if (empty($senderName)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender name is required when enabling SMTP.'); - } elseif (empty($senderEmail)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender email is required when enabling SMTP.'); - } elseif (empty($host)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Host is required when enabling SMTP.'); - } elseif (empty($port)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Port is required when enabling SMTP.'); - } - } - - // validate SMTP settings - if ($enabled) { - $mail = new PHPMailer(true); - $mail->isSMTP(); - $mail->SMTPAuth = (!empty($username) && !empty($password)); - $mail->Username = $username; - $mail->Password = $password; - $mail->Host = $host; - $mail->Port = $port; - $mail->SMTPSecure = $secure; - $mail->SMTPAutoTLS = false; - $mail->Timeout = 5; - - try { - $valid = $mail->SmtpConnect(); - - if (!$valid) { - throw new Exception('Connection is not valid.'); - } - } catch (Throwable $error) { - throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); - } - } - - // Save SMTP settings - if ($enabled) { - $smtp = [ - 'enabled' => $enabled, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'replyTo' => $replyTo, - 'host' => $host, - 'port' => $port, - 'username' => $username, - 'password' => $password, - 'secure' => $secure, - ]; - } else { - $smtp = [ - 'enabled' => false - ]; - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::post('/v1/projects/:projectId/smtp/tests') - ->desc('Create SMTP test') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'createSmtpTest', - description: '/docs/references/projects/create-smtp-test.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.createSMTPTest', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'createSMTPTest', - description: '/docs/references/projects/create-smtp-test.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.') - ->param('senderName', System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'), new Text(255, 0), 'Name of the email sender') - ->param('senderEmail', System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM), new Email(), 'Email of the sender') - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->param('host', '', new HostName(), 'SMTP server host name') - ->param('port', 587, new Integer(), 'SMTP server port', true) - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('queueForMails') - ->inject('plan') - ->action(function (string $projectId, array $emails, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform, Mail $queueForMails, array $plan) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail; - - $subject = 'Custom SMTP email sample'; - $template = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-smtp-test.tpl'); - $template - ->setParam('{{from}}', "{$senderName} ({$senderEmail})") - ->setParam('{{replyTo}}', "{$senderName} ({$replyToEmail})") - ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL) - ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR) - ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER) - ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD) - ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE) - ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL) - ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL); - - foreach ($emails as $email) { - $queueForMails - ->setSmtpHost($host) - ->setSmtpPort($port) - ->setSmtpUsername($username) - ->setSmtpPassword($password) - ->setSmtpSecure($secure) - ->setSmtpReplyTo($replyTo) - ->setSmtpSenderEmail($senderEmail) - ->setSmtpSenderName($senderName) - ->setRecipient($email) - ->setName('') - ->setBodyTemplate(__DIR__ . '/../../config/locale/templates/email-base-styled.tpl') - ->setBody($template->render()) - ->setVariables([]) - ->setSubject($subject) - ->trigger(); - } - - $response->noContent(); - }); - Http::get('/v1/projects/:projectId/templates/sms/:type/:locale') ->desc('Get custom SMS template') ->groups(['api', 'projects']) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php index 24d1c48cf1..304d9dc8a6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php @@ -9,6 +9,7 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Validator\Authorization; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\ArrayList; use Utopia\Validator\Text; @@ -53,6 +54,7 @@ class Update extends Action ->inject('response') ->inject('dbForPlatform') ->inject('project') + ->inject('authorization') ->callback($this->action(...)); } @@ -63,11 +65,12 @@ class Update extends Action array $labels, Response $response, Database $dbForPlatform, - Document $project + Document $project, + Authorization $authorization ): void { $labels = (array) \array_values(\array_unique($labels)); - $project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels])); + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels]))); $response->dynamic($project, Response::MODEL_PROJECT); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php new file mode 100644 index 0000000000..0669f6344f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php @@ -0,0 +1,74 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/smtp/status') + ->desc('Update project SMTP status') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'smtp.*.update') + ->label('audits.event', 'project.smtp.update') + ->label('audits.resource', 'project.smtp/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'smtp', + name: 'updateSMTPStatus', + description: <<param('enabled', null, new Boolean(), 'SMTP status.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $smtp = $project->getAttribute('smtp', []); + + $smtp['enabled'] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php new file mode 100644 index 0000000000..a9a59cba85 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php @@ -0,0 +1,142 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/smtp/tests') + ->httpAlias('/v1/projects/:projectId/smtp/tests') + ->desc('Create project SMTP test') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'smtp.*.update') + ->label('audits.event', 'project.smtp.update') + ->label('audits.resource', 'project.smtp/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'smtp', + name: 'createSMTPTest', + description: <<param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.') + ->inject('response') + ->inject('project') + ->inject('queueForMails') + ->callback($this->action(...)); + } + + /** + * @param array $emails + */ + public function action( + array $emails, + Response $response, + Document $project, + Mail $queueForMails + ): void { + + $smtp = $project->getAttribute('smtp', []); + + if ($smtp['enabled'] !== true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to send a test email.'); + } + + $senderName = $smtp['senderName'] ?? ''; + $senderEmail = $smtp['senderEmail'] ?? ''; + $replyTo = $smtp['replyTo'] ?? ''; + $host = $smtp['host'] ?? ''; + $port = $smtp['port'] ?? ''; + $username = $smtp['username'] ?? ''; + $password = $smtp['password'] ?? ''; + $secure = $smtp['secure'] ?? ''; + + if (empty($senderName)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender name must be configured on the project to send a test email.'); + } + + if (empty($senderEmail)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender email must be configured on the project to send a test email.'); + } + + if (empty($host)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP host must be configured on the project to send a test email.'); + } + + if (empty($port)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP port must be configured on the project to send a test email.'); + } + + $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail; + + $subject = 'Custom SMTP email sample'; + $template = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-smtp-test.tpl'); + $template + ->setParam('{{from}}', "{$senderName} ({$senderEmail})") + ->setParam('{{replyTo}}', "{$senderName} ({$replyToEmail})") + ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL) + ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR) + ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER) + ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD) + ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE) + ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL) + ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL); + + foreach ($emails as $email) { + $queueForMails + ->setSmtpHost($host) + ->setSmtpPort($port) + ->setSmtpUsername($username) + ->setSmtpPassword($password) + ->setSmtpSecure($secure) + ->setSmtpReplyTo($replyTo) + ->setSmtpSenderEmail($senderEmail) + ->setSmtpSenderName($senderName) + ->setRecipient($email) + ->setName('') + ->setBodyTemplate(__DIR__ . '/../../config/locale/templates/email-base-styled.tpl') + ->setBody($template->render()) + ->setVariables([]) + ->setSubject($subject) + ->trigger(); + } + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php new file mode 100644 index 0000000000..027ced5664 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -0,0 +1,142 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/smtp') + ->httpAlias('/v1/projects/:projectId/smtp') + ->desc('Update project SMTP details') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'smtp.*.update') + ->label('audits.event', 'project.smtp.update') + ->label('audits.resource', 'project.smtp/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'smtp', + name: 'updateSMTP', + description: <<param('senderName', '', new Text(255, 0), 'Name of the email sender') + ->param('senderEmail', '', new Email(), 'Email of the sender') + ->param('replyTo', '', new Email(), 'Reply to email', true) + ->param('host', '', new Hostname(), 'SMTP server host name') + ->param('port', 587, new Integer(), 'SMTP server port') + ->param('username', '', new Text(0, 0), 'SMTP server username', true) + ->param('password', '', new Text(0, 0), 'SMTP server password', true) + ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) + ->param('enabled', null, new Nullable(new Boolean()), 'Enable custom SMTP service', optional: true, deprecated: true) // Backwards compatibility + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + + public function action( + string $senderName, + string $senderEmail, + string $replyTo, + string $host, + int $port, + string $username, + string $password, + string $secure, + ?bool $enabled, // Backwards compatibility + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + // Backwards compatibility + if (!\is_null($enabled) && $enabled === false) { + $smtp = $project->getAttribute('smtp', []); + + $smtp['enabled'] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } + + // Validate SMTP settings + $mail = new PHPMailer(true); + $mail->isSMTP(); + $mail->SMTPAuth = (!empty($username) && !empty($password)); + $mail->Username = $username; + $mail->Password = $password; + $mail->Host = $host; + $mail->Port = $port; + $mail->SMTPSecure = $secure; + $mail->SMTPAutoTLS = false; + $mail->Timeout = 5; + + try { + $valid = $mail->SmtpConnect(); + + if (!$valid) { + throw new \Exception('Connection is not valid.'); + } + } catch (Throwable $error) { + throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); + } + + $smtp = [ + 'enabled' => true, + 'senderName' => $senderName, + 'senderEmail' => $senderEmail, + 'replyTo' => $replyTo, + 'host' => $host, + 'port' => $port, + 'username' => $username, + 'password' => $password, + 'secure' => $secure, + ]; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} From 556ca10ed9f2774eb20d044c21a5e61be5651df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 16:52:38 +0200 Subject: [PATCH 009/402] Register endpoints --- src/Appwrite/Platform/Modules/Project/Services/Http.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index a2c94928e2..d63be56632 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -23,6 +23,9 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as C use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Status\Update as UpdateSMTPStatus; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Test\Create as CreateSMTPTest; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; @@ -45,6 +48,11 @@ class Http extends Service $this->addAction(UpdateProjectProtocolStatus::getName(), new UpdateProjectProtocolStatus()); $this->addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); + // SMTP + $this->addAction(UpdateSMTP::getName(), new UpdateSMTP()); + $this->addAction(UpdateSMTPStatus::getName(), new UpdateSMTPStatus()); + $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); + // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); $this->addAction(ListVariables::getName(), new ListVariables()); From ffdfe4bbf802d4c48fe86a16906eccbffff0b1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 16:55:39 +0200 Subject: [PATCH 010/402] Add new tests --- tests/e2e/Services/Project/SMTPBase.php | 572 ++++++++++++++++++ .../Project/SMTPConsoleClientTest.php | 14 + .../Services/Project/SMTPCustomServerTest.php | 14 + 3 files changed, 600 insertions(+) create mode 100644 tests/e2e/Services/Project/SMTPBase.php create mode 100644 tests/e2e/Services/Project/SMTPConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/SMTPCustomServerTest.php diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php new file mode 100644 index 0000000000..7089162e39 --- /dev/null +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -0,0 +1,572 @@ +updateSMTPStatus(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPStatusDisable(): void + { + $this->updateSMTPStatus(true); + + $response = $this->updateSMTPStatus(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['smtpEnabled']); + } + + public function testUpdateSMTPStatusEnableIdempotent(): void + { + $first = $this->updateSMTPStatus(true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['smtpEnabled']); + + $second = $this->updateSMTPStatus(true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPStatusDisableIdempotent(): void + { + $first = $this->updateSMTPStatus(false); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['smtpEnabled']); + + $second = $this->updateSMTPStatus(false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['smtpEnabled']); + } + + public function testUpdateSMTPStatusResponseModel(): void + { + $response = $this->updateSMTPStatus(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('smtpEnabled', $response['body']); + $this->assertArrayHasKey('smtpSenderName', $response['body']); + $this->assertArrayHasKey('smtpSenderEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyTo', $response['body']); + $this->assertArrayHasKey('smtpHost', $response['body']); + $this->assertArrayHasKey('smtpPort', $response['body']); + $this->assertArrayHasKey('smtpUsername', $response['body']); + $this->assertArrayHasKey('smtpPassword', $response['body']); + $this->assertArrayHasKey('smtpSecure', $response['body']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPStatusWithoutAuthentication(): void + { + $response = $this->updateSMTPStatus(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // Update SMTP tests + + public function testUpdateSMTP(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['smtpEnabled']); + $this->assertSame('Test Sender', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPWithAllOptionalFields(): void + { + $response = $this->updateSMTP( + senderName: 'Full Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + replyTo: 'reply@example.com', + username: 'smtpuser', + password: 'smtppass', + secure: '', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + $this->assertSame('Full Sender', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('reply@example.com', $response['body']['smtpReplyTo']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + $this->assertSame('smtpuser', $response['body']['smtpUsername']); + $this->assertSame('smtppass', $response['body']['smtpPassword']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPOverwritesPreviousSettings(): void + { + $this->updateSMTP( + senderName: 'First Sender', + senderEmail: 'first@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->updateSMTP( + senderName: 'Second Sender', + senderEmail: 'second@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Second Sender', $response['body']['smtpSenderName']); + $this->assertSame('second@example.com', $response['body']['smtpSenderEmail']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPEnablesSMTP(): void + { + // Ensure SMTP is disabled + $this->updateSMTPStatus(false); + + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPResponseModel(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('smtpEnabled', $response['body']); + $this->assertArrayHasKey('smtpSenderName', $response['body']); + $this->assertArrayHasKey('smtpSenderEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyTo', $response['body']); + $this->assertArrayHasKey('smtpHost', $response['body']); + $this->assertArrayHasKey('smtpPort', $response['body']); + $this->assertArrayHasKey('smtpUsername', $response['body']); + $this->assertArrayHasKey('smtpPassword', $response['body']); + $this->assertArrayHasKey('smtpSecure', $response['body']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPWithoutAuthentication(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + authenticated: false, + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidSenderEmail(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'not-an-email', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPEmptySenderName(): void + { + $response = $this->updateSMTP( + senderName: '', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPEmptySenderEmail(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: '', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPEmptyHost(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: '', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidHost(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'not a valid host!@#', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidReplyToEmail(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + replyTo: 'not-an-email', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidSecure(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + secure: 'invalid', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPValidSecureTLS(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + secure: '', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['smtpSecure']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPInvalidConnectionRefused(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_smtp_config_invalid', $response['body']['type']); + } + + public function testUpdateSMTPBackwardsCompatibilityDisable(): void + { + // First enable SMTP + $this->updateSMTPStatus(true); + + // Use the deprecated enabled=false parameter to disable + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + } + + // Create SMTP test tests + + public function testCreateSMTPTest(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest(['recipient@example.com']); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testCreateSMTPTestMultipleRecipients(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest([ + 'recipient1@example.com', + 'recipient2@example.com', + 'recipient3@example.com', + ]); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testCreateSMTPTestWhenSMTPDisabled(): void + { + // Ensure SMTP is disabled + $this->updateSMTPStatus(false); + + $response = $this->createSMTPTest(['recipient@example.com']); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateSMTPTestWithoutAuthentication(): void + { + $response = $this->createSMTPTest(['recipient@example.com'], false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateSMTPTestEmptyEmails(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest([]); + + $this->assertSame(400, $response['headers']['status-code']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testCreateSMTPTestInvalidEmail(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest(['not-an-email']); + + $this->assertSame(400, $response['headers']['status-code']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testCreateSMTPTestExceedsMaxEmails(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $emails = []; + for ($i = 1; $i <= 11; $i++) { + $emails[] = "recipient{$i}@example.com"; + } + + $response = $this->createSMTPTest($emails); + + $this->assertSame(400, $response['headers']['status-code']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testCreateSMTPTestMaxEmails(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $emails = []; + for ($i = 1; $i <= 10; $i++) { + $emails[] = "recipient{$i}@example.com"; + } + + $response = $this->createSMTPTest($emails); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + // Helpers + + protected function updateSMTPStatus(bool $enabled, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PATCH, '/project/smtp/status', $headers, [ + 'enabled' => $enabled, + ]); + } + + protected function updateSMTP( + string $senderName = '', + string $senderEmail = '', + string $host = '', + int $port = 587, + string $replyTo = '', + string $username = '', + string $password = '', + string $secure = '', + ?bool $enabled = null, + bool $authenticated = true, + ): mixed { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + $params = [ + 'senderName' => $senderName, + 'senderEmail' => $senderEmail, + 'host' => $host, + 'port' => $port, + 'replyTo' => $replyTo, + 'username' => $username, + 'password' => $password, + 'secure' => $secure, + ]; + + if (!\is_null($enabled)) { + $params['enabled'] = $enabled; + } + + return $this->client->call(Client::METHOD_PATCH, '/project/smtp', $headers, $params); + } + + /** + * @param array $emails + */ + protected function createSMTPTest(array $emails, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/smtp/tests', $headers, [ + 'emails' => $emails, + ]); + } +} diff --git a/tests/e2e/Services/Project/SMTPConsoleClientTest.php b/tests/e2e/Services/Project/SMTPConsoleClientTest.php new file mode 100644 index 0000000000..e5962c0960 --- /dev/null +++ b/tests/e2e/Services/Project/SMTPConsoleClientTest.php @@ -0,0 +1,14 @@ + Date: Tue, 14 Apr 2026 17:01:46 +0200 Subject: [PATCH 011/402] Fix bugs --- .../Modules/Project/Http/Project/SMTP/Test/Create.php | 8 +++++--- .../Platform/Modules/Project/Http/Project/SMTP/Update.php | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php index a9a59cba85..fb93806983 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php @@ -59,6 +59,7 @@ class Create extends Action ->inject('response') ->inject('project') ->inject('queueForMails') + ->inject('plan') ->callback($this->action(...)); } @@ -69,7 +70,8 @@ class Create extends Action array $emails, Response $response, Document $project, - Mail $queueForMails + Mail $queueForMails, + array $plan ): void { $smtp = $project->getAttribute('smtp', []); @@ -106,7 +108,7 @@ class Create extends Action $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail; $subject = 'Custom SMTP email sample'; - $template = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-smtp-test.tpl'); + $template = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-smtp-test.tpl'); $template ->setParam('{{from}}', "{$senderName} ({$senderEmail})") ->setParam('{{replyTo}}', "{$senderName} ({$replyToEmail})") @@ -130,7 +132,7 @@ class Create extends Action ->setSmtpSenderName($senderName) ->setRecipient($email) ->setName('') - ->setBodyTemplate(__DIR__ . '/../../config/locale/templates/email-base-styled.tpl') + ->setBodyTemplate(APP_CE_CONFIG_DIR . '/locale/templates/email-base-styled.tpl') ->setBody($template->render()) ->setVariables([]) ->setSubject($subject) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 027ced5664..43bedb87c2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -99,6 +99,8 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); $response->dynamic($project, Response::MODEL_PROJECT); + + return; } // Validate SMTP settings From 56bcc0d09f15439726f04244b33b43104ee98075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:01:56 +0200 Subject: [PATCH 012/402] Fix tests --- tests/e2e/Services/Project/SMTPBase.php | 32 ++++++++++++++++--------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 7089162e39..ee341e583f 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -117,7 +117,6 @@ trait SMTPBase replyTo: 'reply@example.com', username: 'smtpuser', password: 'smtppass', - secure: '', ); $this->assertSame(200, $response['headers']['status-code']); @@ -302,14 +301,13 @@ trait SMTPBase $this->assertSame(400, $response['headers']['status-code']); } - public function testUpdateSMTPValidSecureTLS(): void + public function testUpdateSMTPWithoutSecure(): void { $response = $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', port: 1025, - secure: '', ); $this->assertSame(200, $response['headers']['status-code']); @@ -517,10 +515,10 @@ trait SMTPBase string $senderEmail = '', string $host = '', int $port = 587, - string $replyTo = '', - string $username = '', - string $password = '', - string $secure = '', + ?string $replyTo = null, + ?string $username = null, + ?string $password = null, + ?string $secure = null, ?bool $enabled = null, bool $authenticated = true, ): mixed { @@ -538,12 +536,24 @@ trait SMTPBase 'senderEmail' => $senderEmail, 'host' => $host, 'port' => $port, - 'replyTo' => $replyTo, - 'username' => $username, - 'password' => $password, - 'secure' => $secure, ]; + if (!\is_null($replyTo)) { + $params['replyTo'] = $replyTo; + } + + if (!\is_null($username)) { + $params['username'] = $username; + } + + if (!\is_null($password)) { + $params['password'] = $password; + } + + if (!\is_null($secure)) { + $params['secure'] = $secure; + } + if (!\is_null($enabled)) { $params['enabled'] = $enabled; } From d7f8ca3f0146db1a8f5285563be1c401bd58e11a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:04:22 +0200 Subject: [PATCH 013/402] Improve endpoint quality --- .../Platform/Modules/Project/Http/Project/SMTP/Update.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 43bedb87c2..28c4929694 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -58,13 +58,13 @@ class Update extends Action ) ], )) - ->param('senderName', '', new Text(255, 0), 'Name of the email sender') + ->param('senderName', '', new Text(256), 'Name of the email sender') ->param('senderEmail', '', new Email(), 'Email of the sender') ->param('replyTo', '', new Email(), 'Reply to email', true) ->param('host', '', new Hostname(), 'SMTP server host name') ->param('port', 587, new Integer(), 'SMTP server port') - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) + ->param('username', '', new Text(256), 'SMTP server username', true) + ->param('password', '', new Text(256), 'SMTP server password', true) ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) ->param('enabled', null, new Nullable(new Boolean()), 'Enable custom SMTP service', optional: true, deprecated: true) // Backwards compatibility ->inject('response') From 9d6428d5d52b02d062f61e983c8df14813484811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:06:57 +0200 Subject: [PATCH 014/402] Improve tests --- tests/e2e/Services/Project/SMTPBase.php | 176 +++++++++++++++++++++++- 1 file changed, 170 insertions(+), 6 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index ee341e583f..7eee0ccaa1 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -107,7 +107,7 @@ trait SMTPBase $this->updateSMTPStatus(false); } - public function testUpdateSMTPWithAllOptionalFields(): void + public function testUpdateSMTPWithOptionalReplyTo(): void { $response = $this->updateSMTP( senderName: 'Full Sender', @@ -115,8 +115,6 @@ trait SMTPBase host: 'maildev', port: 1025, replyTo: 'reply@example.com', - username: 'smtpuser', - password: 'smtppass', ); $this->assertSame(200, $response['headers']['status-code']); @@ -126,8 +124,6 @@ trait SMTPBase $this->assertSame('reply@example.com', $response['body']['smtpReplyTo']); $this->assertSame('maildev', $response['body']['smtpHost']); $this->assertSame(1025, $response['body']['smtpPort']); - $this->assertSame('smtpuser', $response['body']['smtpUsername']); - $this->assertSame('smtppass', $response['body']['smtpPassword']); // Cleanup $this->updateSMTPStatus(false); @@ -301,6 +297,173 @@ trait SMTPBase $this->assertSame(400, $response['headers']['status-code']); } + public function testUpdateSMTPSenderNameMinLength(): void + { + $response = $this->updateSMTP( + senderName: 'A', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('A', $response['body']['smtpSenderName']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPSenderNameMaxLength(): void + { + $name = str_repeat('a', 256); + $response = $this->updateSMTP( + senderName: $name, + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($name, $response['body']['smtpSenderName']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPSenderNameTooLong(): void + { + $response = $this->updateSMTP( + senderName: str_repeat('a', 257), + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPUsernameMinLength(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: 'u', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('u', $response['body']['smtpUsername']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPUsernameMaxLength(): void + { + $username = str_repeat('a', 256); + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: $username, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($username, $response['body']['smtpUsername']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPUsernameTooLong(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: str_repeat('a', 257), + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPUsernameEmpty(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPPasswordMinLength(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: 'p', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('p', $response['body']['smtpPassword']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPPasswordMaxLength(): void + { + $password = str_repeat('a', 256); + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: $password, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($password, $response['body']['smtpPassword']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPPasswordTooLong(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: str_repeat('a', 257), + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPPasswordEmpty(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + public function testUpdateSMTPWithoutSecure(): void { $response = $this->updateSMTP( @@ -421,7 +584,8 @@ trait SMTPBase $response = $this->createSMTPTest([]); - $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); // Cleanup $this->updateSMTPStatus(false); From a3a8ad88fe5e0f3833c996bb164870a618c47ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:07:06 +0200 Subject: [PATCH 015/402] formatting fix --- src/Appwrite/Platform/Modules/Project/Services/Http.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index d63be56632..54787c2782 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -23,10 +23,10 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as C use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; +use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Status\Update as UpdateSMTPStatus; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Test\Create as CreateSMTPTest; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; -use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; From b5e46c1a60a5eae54e5f916b6b347a4b501415e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:09:37 +0200 Subject: [PATCH 016/402] E2E integration tests --- tests/e2e/Services/Project/SMTPBase.php | 86 +++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 7eee0ccaa1..2f3762cfa8 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -3,6 +3,7 @@ namespace Tests\E2E\Services\Project; use Tests\E2E\Client; +use Utopia\Database\Helpers\ID; trait SMTPBase { @@ -656,6 +657,91 @@ trait SMTPBase $this->updateSMTPStatus(false); } + // Integration tests + + public function testCreateSMTPTestEmailDelivery(): void + { + $senderName = 'SMTP Test Sender'; + $senderEmail = 'smtptest@appwrite.io'; + $replyToEmail = 'smtpreply@appwrite.io'; + $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; + + // Configure SMTP with replyTo + $response = $this->updateSMTP( + senderName: $senderName, + senderEmail: $senderEmail, + host: 'maildev', + port: 1025, + replyTo: $replyToEmail, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Trigger test email + $response = $this->createSMTPTest([$recipientEmail]); + + $this->assertSame(204, $response['headers']['status-code']); + + // Verify email arrived via maildev + $email = $this->getLastEmailByAddress($recipientEmail, function ($email) { + $this->assertSame('Custom SMTP email sample', $email['subject']); + }); + + $this->assertSame($senderEmail, $email['from'][0]['address']); + $this->assertSame($senderName, $email['from'][0]['name']); + $this->assertSame($replyToEmail, $email['replyTo'][0]['address']); + $this->assertSame($senderName, $email['replyTo'][0]['name']); + $this->assertSame('Custom SMTP email sample', $email['subject']); + $this->assertStringContainsStringIgnoringCase('working correctly', $email['text']); + $this->assertStringContainsStringIgnoringCase('working correctly', $email['html']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testMagicURLLoginUsesCustomSMTP(): void + { + $senderName = 'Custom Auth Mailer'; + $senderEmail = 'authmailer@appwrite.io'; + $recipientEmail = 'magicurl-' . \uniqid() . '@appwrite.io'; + + // Configure custom SMTP + $response = $this->updateSMTP( + senderName: $senderName, + senderEmail: $senderEmail, + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Trigger MagicURL login as a client (no auth headers needed) + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => ID::unique(), + 'email' => $recipientEmail, + ]); + + $this->assertSame(201, $response['headers']['status-code']); + + // Verify the email arrived with custom SMTP sender details + $email = $this->getLastEmailByAddress($recipientEmail, function ($email) { + $this->assertStringContainsString('Login', $email['subject']); + }); + + $this->assertSame($senderEmail, $email['from'][0]['address']); + $this->assertSame($senderName, $email['from'][0]['name']); + $this->assertSame($this->getProject()['name'] . ' Login', $email['subject']); + + // Cleanup + $this->updateSMTPStatus(false); + } + // Helpers protected function updateSMTPStatus(bool $enabled, bool $authenticated = true): mixed From 905d2a8eaa69bc5810fe3f914f1ec14d459aa6e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:20:03 +0200 Subject: [PATCH 017/402] Fix tests --- tests/e2e/Services/Project/SMTPBase.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 2f3762cfa8..da1325fac3 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -666,13 +666,15 @@ trait SMTPBase $replyToEmail = 'smtpreply@appwrite.io'; $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; - // Configure SMTP with replyTo + // Configure SMTP with replyTo and auth credentials $response = $this->updateSMTP( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', port: 1025, replyTo: $replyToEmail, + username: 'user', + password: 'password', ); $this->assertSame(200, $response['headers']['status-code']); @@ -706,12 +708,14 @@ trait SMTPBase $senderEmail = 'authmailer@appwrite.io'; $recipientEmail = 'magicurl-' . \uniqid() . '@appwrite.io'; - // Configure custom SMTP + // Configure custom SMTP with auth credentials $response = $this->updateSMTP( senderName: $senderName, senderEmail: $senderEmail, - host: 'maildev', - port: 1025, + host: $smtpHost, + port: $smtpPort, + username: $smtpUsername, + password: $smtpPassword, ); $this->assertSame(200, $response['headers']['status-code']); From d3dcbfe567b8e7ec719277af9df3e43f1ef05b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:20:22 +0200 Subject: [PATCH 018/402] Leftover chaneges, --- tests/e2e/Services/Project/SMTPBase.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index da1325fac3..da5be3869d 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -712,10 +712,10 @@ trait SMTPBase $response = $this->updateSMTP( senderName: $senderName, senderEmail: $senderEmail, - host: $smtpHost, - port: $smtpPort, - username: $smtpUsername, - password: $smtpPassword, + host: 'maildev', + port: 1025, + username: 'user', + password: 'password', ); $this->assertSame(200, $response['headers']['status-code']); From 732538504dde3e6e3b6a9d31b1d5c5f73c37f93f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:42:26 +0200 Subject: [PATCH 019/402] Fix backwards compatibility --- .../Project/Http/Project/SMTP/Test/Create.php | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php index fb93806983..e1d4b3f847 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php @@ -17,6 +17,10 @@ use Utopia\Emails\Validator\Email; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\ArrayList; +use Utopia\Validator\Hostname; +use Utopia\Validator\Integer; +use Utopia\Validator\Text; +use Utopia\Validator\WhiteList; class Create extends Action { @@ -56,6 +60,14 @@ class Create extends Action contentType: ContentType::NONE, )) ->param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.') + ->param('senderName', '', new Text(256), 'Name of the email sender', optional: true, deprecated: true) // Backwards compatibility + ->param('senderEmail', '', new Email(), 'Email of the sender', optional: true, deprecated: true) // Backwards compatibility + ->param('replyTo', '', new Email(), 'Reply to email', optional: true, deprecated: true) // Backwards compatibility + ->param('host', '', new Hostname(), 'SMTP server host name', optional: true, deprecated: true) // Backwards compatibility + ->param('port', null, new Integer(), 'SMTP server port', optional: true, deprecated: true) // Backwards compatibility + ->param('username', '', new Text(256), 'SMTP server username', optional: true, deprecated: true) // Backwards compatibility + ->param('password', '', new Text(256), 'SMTP server password', optional: true, deprecated: true) // Backwards compatibility + ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', optional: true, deprecated: true) // Backwards compatibility ->inject('response') ->inject('project') ->inject('queueForMails') @@ -68,26 +80,36 @@ class Create extends Action */ public function action( array $emails, + string $paramSenderName, // Backwards compatibility + string $paramSenderEmail, // Backwards compatibility + string $paramReplyTo, // Backwards compatibility + string $paramHost, // Backwards compatibility + ?int $paramPort, // Backwards compatibility + string $paramUsername, // Backwards compatibility + string $paramPassword, // Backwards compatibility + string $paramSecure, // Backwards compatibility Response $response, Document $project, Mail $queueForMails, array $plan ): void { + // Backwards compatibility: use inline params if provided, otherwise fall back to project SMTP config + $hasInlineParams = !empty($paramHost); $smtp = $project->getAttribute('smtp', []); - if ($smtp['enabled'] !== true) { + if (!$hasInlineParams && ($smtp['enabled'] ?? false) !== true) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to send a test email.'); } - $senderName = $smtp['senderName'] ?? ''; - $senderEmail = $smtp['senderEmail'] ?? ''; - $replyTo = $smtp['replyTo'] ?? ''; - $host = $smtp['host'] ?? ''; - $port = $smtp['port'] ?? ''; - $username = $smtp['username'] ?? ''; - $password = $smtp['password'] ?? ''; - $secure = $smtp['secure'] ?? ''; + $senderName = $paramSenderName ?: ($smtp['senderName'] ?? ''); + $senderEmail = $paramSenderEmail ?: ($smtp['senderEmail'] ?? ''); + $replyTo = $paramReplyTo ?: ($smtp['replyTo'] ?? ''); + $host = $paramHost ?: ($smtp['host'] ?? ''); + $port = $paramPort ?? ($smtp['port'] ?? ''); + $username = $paramUsername ?: ($smtp['username'] ?? ''); + $password = $paramPassword ?: ($smtp['password'] ?? ''); + $secure = $paramSecure ?: ($smtp['secure'] ?? ''); if (empty($senderName)) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender name must be configured on the project to send a test email.'); From db0a971384978b92e59f6cd4c96bac6e4a9af56d Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 15 Apr 2026 11:43:53 +0530 Subject: [PATCH 020/402] feat(sites): add screenshot success telemetry to usage stats --- app/init/constants.php | 6 ++ .../Modules/Functions/Workers/Screenshots.php | 55 ++++++++++++++++++- .../Platform/Modules/Sites/Http/Usage/Get.php | 22 ++++++-- .../Modules/Sites/Http/Usage/XList.php | 23 ++++++-- .../Utopia/Response/Model/UsageSite.php | 32 +++++++++++ .../Utopia/Response/Model/UsageSites.php | 32 +++++++++++ tests/e2e/General/UsageTest.php | 14 ++++- 7 files changed, 169 insertions(+), 15 deletions(-) diff --git a/app/init/constants.php b/app/init/constants.php index f2127cd666..57763df911 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -351,6 +351,8 @@ const METRIC_BUILDS_STORAGE = 'builds.storage'; const METRIC_BUILDS_COMPUTE = 'builds.compute'; const METRIC_BUILDS_COMPUTE_SUCCESS = 'builds.compute.success'; const METRIC_BUILDS_COMPUTE_FAILED = 'builds.compute.failed'; +const METRIC_SCREENSHOTS_SUCCESS = 'screenshots.success'; +const METRIC_SCREENSHOTS_FAILED = 'screenshots.failed'; const METRIC_BUILDS_MB_SECONDS = 'builds.mbSeconds'; const METRIC_EXECUTIONS = 'executions'; const METRIC_EXECUTIONS_COMPUTE = 'executions.compute'; @@ -363,6 +365,8 @@ const METRIC_RESOURCE_TYPE_ID_BUILDS_FAILED = '{resourceType}.{resourceInternal const METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE = '{resourceType}.{resourceInternalId}.builds.compute'; const METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE_SUCCESS = '{resourceType}.{resourceInternalId}.builds.compute.success'; const METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE_FAILED = '{resourceType}.{resourceInternalId}.builds.compute.failed'; +const METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_SUCCESS = '{resourceType}.{resourceInternalId}.screenshots.success'; +const METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_FAILED = '{resourceType}.{resourceInternalId}.screenshots.failed'; const METRIC_RESOURCE_TYPE_ID_BUILDS_MB_SECONDS = '{resourceType}.{resourceInternalId}.builds.mbSeconds'; const METRIC_RESOURCE_TYPE_ID_BUILDS = '{resourceType}.{resourceInternalId}.builds'; const METRIC_RESOURCE_TYPE_ID_BUILDS_STORAGE = '{resourceType}.{resourceInternalId}.builds.storage'; @@ -376,6 +380,8 @@ const METRIC_RESOURCE_TYPE_BUILDS_FAILED = '{resourceType}.builds.failed'; const METRIC_RESOURCE_TYPE_BUILDS_COMPUTE = '{resourceType}.builds.compute'; const METRIC_RESOURCE_TYPE_BUILDS_COMPUTE_SUCCESS = '{resourceType}.builds.compute.success'; const METRIC_RESOURCE_TYPE_BUILDS_COMPUTE_FAILED = '{resourceType}.builds.compute.failed'; +const METRIC_RESOURCE_TYPE_SCREENSHOTS_SUCCESS = '{resourceType}.screenshots.success'; +const METRIC_RESOURCE_TYPE_SCREENSHOTS_FAILED = '{resourceType}.screenshots.failed'; const METRIC_RESOURCE_TYPE_BUILDS_MB_SECONDS = '{resourceType}.builds.mbSeconds'; const METRIC_RESOURCE_TYPE_BUILDS = '{resourceType}.builds'; const METRIC_RESOURCE_TYPE_BUILDS_STORAGE = '{resourceType}.builds.storage'; diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index 065fe477eb..fa7e394710 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -3,9 +3,12 @@ namespace Appwrite\Platform\Modules\Functions\Workers; use Ahc\Jwt\JWT; +use Appwrite\Event\Message\Usage as UsageMessage; +use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; use Appwrite\Permission; use Appwrite\Role; +use Appwrite\Usage\Context; use Exception; use Utopia\Compression\Compression; use Utopia\Config\Config; @@ -43,6 +46,7 @@ class Screenshots extends Action ->inject('dbForProject') ->inject('project') ->inject('deviceForFiles') + ->inject('publisherForUsage') ->callback($this->action(...)); } @@ -52,7 +56,8 @@ class Screenshots extends Action Database $dbForPlatform, Database $dbForProject, Document $project, - Device $deviceForFiles + Device $deviceForFiles, + UsagePublisher $publisherForUsage ): void { Console::log('Screenshot action started'); @@ -257,6 +262,13 @@ class Screenshots extends Action 'deploymentScreenshotDark' => $deployment->getAttribute('screenshotDark', ''), 'deploymentScreenshotLight' => $deployment->getAttribute('screenshotLight', ''), ])); + + $this->publishUsage( + project: $project, + site: $site, + publisherForUsage: $publisherForUsage, + metric: METRIC_SCREENSHOTS_SUCCESS + ); } catch (\Throwable $th) { Console::warning("Screenshot failed to generate:"); Console::warning($th->getMessage()); @@ -265,10 +277,51 @@ class Screenshots extends Action $date = \date('H:i:s'); $this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[$date] [appwrite] Screenshot capturing failed. Deployment will continue. \n"); + $this->publishUsage( + project: $project, + site: $site, + publisherForUsage: $publisherForUsage, + metric: METRIC_SCREENSHOTS_FAILED + ); + throw $th; } } + protected function publishUsage( + Document $project, + Document $site, + UsagePublisher $publisherForUsage, + string $metric + ): void { + [$resourceMetric, $resourceIdMetric] = match ($metric) { + METRIC_SCREENSHOTS_SUCCESS => [ + METRIC_RESOURCE_TYPE_SCREENSHOTS_SUCCESS, + METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_SUCCESS, + ], + METRIC_SCREENSHOTS_FAILED => [ + METRIC_RESOURCE_TYPE_SCREENSHOTS_FAILED, + METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_FAILED, + ], + default => throw new \InvalidArgumentException('Unknown screenshot metric: ' . $metric), + }; + + $usage = (new Context()) + ->addMetric($metric, 1) + ->addMetric(str_replace('{resourceType}', RESOURCE_TYPE_SITES, $resourceMetric), 1) + ->addMetric(str_replace( + ['{resourceType}', '{resourceInternalId}'], + [RESOURCE_TYPE_SITES, $site->getSequence()], + $resourceIdMetric + ), 1); + + $publisherForUsage->enqueue(new UsageMessage( + project: $project, + metrics: $usage->getMetrics(), + reduce: $usage->getReduce() + )); + } + protected function appendToLogs(Database $dbForProject, string $deploymentId, Realtime $queueForRealtime, string $logs) { $deployment = $dbForProject->getDocument('deployments', $deploymentId); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php index a6768462d1..a1af4bd8fd 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php @@ -87,6 +87,8 @@ class Get extends Base str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS), str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_SUCCESS), str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_FAILED), + str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_SUCCESS), + str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_FAILED), str_replace(['{siteInternalId}'], [$site->getSequence()], METRIC_SITES_ID_REQUESTS), str_replace(['{siteInternalId}'], [$site->getSequence()], METRIC_SITES_ID_INBOUND), str_replace(['{siteInternalId}'], [$site->getSequence()], METRIC_SITES_ID_OUTBOUND), @@ -139,6 +141,9 @@ class Get extends Base $buildsTimeTotal = $usage[$metrics[4]]['total'] ?? 0; $buildsTotal = $usage[$metrics[2]]['total'] ?? 0; + $screenshotsSuccessTotal = $usage[$metrics[11]]['total'] ?? 0; + $screenshotsFailedTotal = $usage[$metrics[12]]['total'] ?? 0; + $screenshotsTotal = $screenshotsSuccessTotal + $screenshotsFailedTotal; $response->dynamic(new Document([ 'range' => $range, 'deploymentsTotal' => $usage[$metrics[0]]['total'], @@ -153,9 +158,12 @@ class Get extends Base 'executionsMbSecondsTotal' => $usage[$metrics[8]]['total'], 'buildsSuccessTotal' => $usage[$metrics[9]]['total'], 'buildsFailedTotal' => $usage[$metrics[10]]['total'], - 'requestsTotal' => $usage[$metrics[11]]['total'], - 'inboundTotal' => $usage[$metrics[12]]['total'], - 'outboundTotal' => $usage[$metrics[13]]['total'], + 'screenshotsSuccessTotal' => $screenshotsSuccessTotal, + 'screenshotsFailedTotal' => $screenshotsFailedTotal, + 'screenshotsSuccessRate' => $screenshotsTotal === 0 ? 0 : $screenshotsSuccessTotal / $screenshotsTotal, + 'requestsTotal' => $usage[$metrics[13]]['total'], + 'inboundTotal' => $usage[$metrics[14]]['total'], + 'outboundTotal' => $usage[$metrics[15]]['total'], 'deployments' => $usage[$metrics[0]]['data'], 'deploymentsStorage' => $usage[$metrics[1]]['data'], 'builds' => $usage[$metrics[2]]['data'], @@ -167,9 +175,11 @@ class Get extends Base 'executionsMbSeconds' => $usage[$metrics[8]]['data'], 'buildsSuccess' => $usage[$metrics[9]]['data'], 'buildsFailed' => $usage[$metrics[10]]['data'], - 'requests' => $usage[$metrics[11]]['data'], - 'inbound' => $usage[$metrics[12]]['data'], - 'outbound' => $usage[$metrics[13]]['data'], + 'screenshotsSuccess' => $usage[$metrics[11]]['data'], + 'screenshotsFailed' => $usage[$metrics[12]]['data'], + 'requests' => $usage[$metrics[13]]['data'], + 'inbound' => $usage[$metrics[14]]['data'], + 'outbound' => $usage[$metrics[15]]['data'], ]), Response::MODEL_USAGE_SITE); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php index a90cb0cab9..3b3c759fd5 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php @@ -74,6 +74,8 @@ class XList extends Base str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_EXECUTIONS_MB_SECONDS), str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_BUILDS_SUCCESS), str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_BUILDS_FAILED), + str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_SCREENSHOTS_SUCCESS), + str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_SCREENSHOTS_FAILED), METRIC_SITES_REQUESTS, METRIC_SITES_INBOUND, METRIC_SITES_OUTBOUND, @@ -122,6 +124,10 @@ class XList extends Base ]; } } + $screenshotsSuccessTotal = $usage[$metrics[12]]['total'] ?? 0; + $screenshotsFailedTotal = $usage[$metrics[13]]['total'] ?? 0; + $screenshotsTotal = $screenshotsSuccessTotal + $screenshotsFailedTotal; + $response->dynamic(new Document([ 'range' => $range, 'sitesTotal' => $usage[$metrics[0]]['total'], @@ -136,9 +142,12 @@ class XList extends Base 'executionsMbSecondsTotal' => $usage[$metrics[9]]['total'], 'buildsSuccessTotal' => $usage[$metrics[10]]['total'], 'buildsFailedTotal' => $usage[$metrics[11]]['total'], - 'requestsTotal' => $usage[$metrics[12]]['total'], - 'inboundTotal' => $usage[$metrics[13]]['total'], - 'outboundTotal' => $usage[$metrics[14]]['total'], + 'screenshotsSuccessTotal' => $screenshotsSuccessTotal, + 'screenshotsFailedTotal' => $screenshotsFailedTotal, + 'screenshotsSuccessRate' => $screenshotsTotal === 0 ? 0 : $screenshotsSuccessTotal / $screenshotsTotal, + 'requestsTotal' => $usage[$metrics[14]]['total'], + 'inboundTotal' => $usage[$metrics[15]]['total'], + 'outboundTotal' => $usage[$metrics[16]]['total'], 'sites' => $usage[$metrics[0]]['data'], 'deployments' => $usage[$metrics[1]]['data'], 'deploymentsStorage' => $usage[$metrics[2]]['data'], @@ -151,9 +160,11 @@ class XList extends Base 'executionsMbSeconds' => $usage[$metrics[9]]['data'], 'buildsSuccess' => $usage[$metrics[10]]['data'], 'buildsFailed' => $usage[$metrics[11]]['data'], - 'requests' => $usage[$metrics[12]]['data'], - 'inbound' => $usage[$metrics[13]]['data'], - 'outbound' => $usage[$metrics[14]]['data'], + 'screenshotsSuccess' => $usage[$metrics[12]]['data'], + 'screenshotsFailed' => $usage[$metrics[13]]['data'], + 'requests' => $usage[$metrics[14]]['data'], + 'inbound' => $usage[$metrics[15]]['data'], + 'outbound' => $usage[$metrics[16]]['data'], ]), Response::MODEL_USAGE_SITES); } } diff --git a/src/Appwrite/Utopia/Response/Model/UsageSite.php b/src/Appwrite/Utopia/Response/Model/UsageSite.php index c45dc831cd..27828426d3 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageSite.php +++ b/src/Appwrite/Utopia/Response/Model/UsageSite.php @@ -49,6 +49,38 @@ class UsageSite extends UsageFunction 'example' => [], 'array' => true ]) + ->addRule('screenshotsSuccessTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of successful site screenshots.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsFailedTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of failed site screenshots.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsSuccessRate', [ + 'type' => self::TYPE_FLOAT, + 'description' => 'Success rate of site screenshots from 0 to 1.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsSuccess', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of successful site screenshots per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('screenshotsFailed', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of failed site screenshots per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) ; } diff --git a/src/Appwrite/Utopia/Response/Model/UsageSites.php b/src/Appwrite/Utopia/Response/Model/UsageSites.php index fea87c7718..241e08e6ba 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageSites.php +++ b/src/Appwrite/Utopia/Response/Model/UsageSites.php @@ -122,6 +122,38 @@ class UsageSites extends Model 'example' => [], 'array' => true ]) + ->addRule('screenshotsSuccessTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of successful site screenshots.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsFailedTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of failed site screenshots.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsSuccessRate', [ + 'type' => self::TYPE_FLOAT, + 'description' => 'Success rate of site screenshots from 0 to 1.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsSuccess', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of successful site screenshots per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('screenshotsFailed', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of failed site screenshots per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) ->addRule('deployments', [ 'type' => Response::MODEL_METRIC, 'description' => 'Aggregated number of sites deployment per period.', diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php index f6eb963967..0d2c0a7ed7 100644 --- a/tests/e2e/General/UsageTest.php +++ b/tests/e2e/General/UsageTest.php @@ -1675,11 +1675,14 @@ class UsageTest extends Scope ); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(30, count($response['body'])); + $this->assertEquals(35, count($response['body'])); $this->assertEquals('30d', $response['body']['range']); $this->assertIsArray($response['body']['deployments']); $this->assertEquals($deploymentsSuccess, $response['body']['buildsSuccessTotal']); $this->assertEquals($deploymentsFailed, $response['body']['buildsFailedTotal']); + $this->assertGreaterThanOrEqual(1, $response['body']['screenshotsSuccessTotal']); + $this->assertEquals(0, $response['body']['screenshotsFailedTotal']); + $this->assertEquals(1.0, $response['body']['screenshotsSuccessRate']); $this->assertIsArray($response['body']['deploymentsStorage']); $this->assertIsNumeric($response['body']['deploymentsStorageTotal']); $this->assertIsNumeric($response['body']['buildsMbSecondsTotal']); @@ -1692,6 +1695,8 @@ class UsageTest extends Scope $this->assertIsArray($response['body']['executionsMbSeconds']); $this->assertIsArray($response['body']['buildsSuccess']); $this->assertIsArray($response['body']['buildsFailed']); + $this->assertIsArray($response['body']['screenshotsSuccess']); + $this->assertIsArray($response['body']['screenshotsFailed']); $this->assertIsArray($response['body']['requests']); $this->assertIsArray($response['body']['inbound']); $this->assertIsArray($response['body']['outbound']); @@ -1709,7 +1714,7 @@ class UsageTest extends Scope ); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(31, count($response['body'])); + $this->assertEquals(36, count($response['body'])); $this->assertEquals($response['body']['range'], '30d'); $this->assertIsArray($response['body']['sites']); $this->assertIsArray($response['body']['deployments']); @@ -1722,6 +1727,11 @@ class UsageTest extends Scope $this->assertIsArray($response['body']['executionsMbSeconds']); $this->assertIsArray($response['body']['buildsSuccess']); $this->assertIsArray($response['body']['buildsFailed']); + $this->assertGreaterThanOrEqual(1, $response['body']['screenshotsSuccessTotal']); + $this->assertEquals(0, $response['body']['screenshotsFailedTotal']); + $this->assertEquals(1.0, $response['body']['screenshotsSuccessRate']); + $this->assertIsArray($response['body']['screenshotsSuccess']); + $this->assertIsArray($response['body']['screenshotsFailed']); $this->assertIsArray($response['body']['requests']); $this->assertIsArray($response['body']['inbound']); $this->assertIsArray($response['body']['outbound']); From 37540aa4f5448009525de189d1e4bda98440bb65 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 15 Apr 2026 11:52:39 +0530 Subject: [PATCH 021/402] addressed comments --- .../Modules/Functions/Workers/Screenshots.php | 30 +++-- .../Utopia/Response/Model/UsageSite.php | 64 +++++----- .../Utopia/Response/Model/UsageSites.php | 116 +++++++++--------- 3 files changed, 107 insertions(+), 103 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index fa7e394710..dbd0e217c9 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -62,6 +62,7 @@ class Screenshots extends Action Console::log('Screenshot action started'); $payload = $message->getPayload() ?? []; + $screenshotCompleted = false; if (empty($payload)) { throw new \Exception('Missing payload'); @@ -262,13 +263,7 @@ class Screenshots extends Action 'deploymentScreenshotDark' => $deployment->getAttribute('screenshotDark', ''), 'deploymentScreenshotLight' => $deployment->getAttribute('screenshotLight', ''), ])); - - $this->publishUsage( - project: $project, - site: $site, - publisherForUsage: $publisherForUsage, - metric: METRIC_SCREENSHOTS_SUCCESS - ); + $screenshotCompleted = true; } catch (\Throwable $th) { Console::warning("Screenshot failed to generate:"); Console::warning($th->getMessage()); @@ -277,15 +272,24 @@ class Screenshots extends Action $date = \date('H:i:s'); $this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[$date] [appwrite] Screenshot capturing failed. Deployment will continue. \n"); - $this->publishUsage( - project: $project, - site: $site, - publisherForUsage: $publisherForUsage, - metric: METRIC_SCREENSHOTS_FAILED - ); + if (!$screenshotCompleted) { + $this->publishUsage( + project: $project, + site: $site, + publisherForUsage: $publisherForUsage, + metric: METRIC_SCREENSHOTS_FAILED + ); + } throw $th; } + + $this->publishUsage( + project: $project, + site: $site, + publisherForUsage: $publisherForUsage, + metric: METRIC_SCREENSHOTS_SUCCESS + ); } protected function publishUsage( diff --git a/src/Appwrite/Utopia/Response/Model/UsageSite.php b/src/Appwrite/Utopia/Response/Model/UsageSite.php index 27828426d3..af41a34639 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageSite.php +++ b/src/Appwrite/Utopia/Response/Model/UsageSite.php @@ -10,6 +10,38 @@ class UsageSite extends UsageFunction { parent::__construct(); $this + ->addRule('screenshotsSuccessTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of successful site screenshots.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsFailedTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of failed site screenshots.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsSuccessRate', [ + 'type' => self::TYPE_FLOAT, + 'description' => 'Success rate of site screenshots from 0 to 1.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsSuccess', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of successful site screenshots per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('screenshotsFailed', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of failed site screenshots per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) ->addRule('requestsTotal', [ 'type' => self::TYPE_INTEGER, 'description' => 'Total aggregated number of requests.', @@ -49,38 +81,6 @@ class UsageSite extends UsageFunction 'example' => [], 'array' => true ]) - ->addRule('screenshotsSuccessTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of successful site screenshots.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsFailedTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of failed site screenshots.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsSuccessRate', [ - 'type' => self::TYPE_FLOAT, - 'description' => 'Success rate of site screenshots from 0 to 1.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsSuccess', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of successful site screenshots per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) - ->addRule('screenshotsFailed', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of failed site screenshots per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) ; } diff --git a/src/Appwrite/Utopia/Response/Model/UsageSites.php b/src/Appwrite/Utopia/Response/Model/UsageSites.php index 241e08e6ba..94ee6e645f 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageSites.php +++ b/src/Appwrite/Utopia/Response/Model/UsageSites.php @@ -83,6 +83,36 @@ class UsageSites extends Model 'default' => 0, 'example' => 0, ]) + ->addRule('buildsSuccessTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of successful site builds.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('buildsFailedTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of failed site builds.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsSuccessTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of successful site screenshots.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsFailedTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of failed site screenshots.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('screenshotsSuccessRate', [ + 'type' => self::TYPE_FLOAT, + 'description' => 'Success rate of site screenshots from 0 to 1.', + 'default' => 0, + 'example' => 0, + ]) ->addRule('requestsTotal', [ 'type' => self::TYPE_INTEGER, 'description' => 'Total aggregated number of requests.', @@ -122,38 +152,6 @@ class UsageSites extends Model 'example' => [], 'array' => true ]) - ->addRule('screenshotsSuccessTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of successful site screenshots.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsFailedTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of failed site screenshots.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsSuccessRate', [ - 'type' => self::TYPE_FLOAT, - 'description' => 'Success rate of site screenshots from 0 to 1.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsSuccess', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of successful site screenshots per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) - ->addRule('screenshotsFailed', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of failed site screenshots per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) ->addRule('deployments', [ 'type' => Response::MODEL_METRIC, 'description' => 'Aggregated number of sites deployment per period.', @@ -168,18 +166,6 @@ class UsageSites extends Model 'example' => [], 'array' => true ]) - ->addRule('buildsSuccessTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of successful site builds.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('buildsFailedTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of failed site builds.', - 'default' => 0, - 'example' => 0, - ]) ->addRule('builds', [ 'type' => Response::MODEL_METRIC, 'description' => 'Aggregated number of sites build per period.', @@ -208,6 +194,34 @@ class UsageSites extends Model 'example' => [], 'array' => true ]) + ->addRule('buildsSuccess', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of successful site builds per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('buildsFailed', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of failed site builds per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('screenshotsSuccess', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of successful site screenshots per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('screenshotsFailed', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of failed site screenshots per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) ->addRule('executions', [ 'type' => Response::MODEL_METRIC, 'description' => 'Aggregated number of sites execution per period.', @@ -229,20 +243,6 @@ class UsageSites extends Model 'example' => [], 'array' => true ]) - ->addRule('buildsSuccess', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of successful site builds per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) - ->addRule('buildsFailed', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of failed site builds per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) ; } From 23233d0dcb2b9550e56d1cc2f6dc9cf47888aef5 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 15 Apr 2026 11:59:02 +0530 Subject: [PATCH 022/402] add try catch --- .../Modules/Functions/Workers/Screenshots.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index dbd0e217c9..d1f2b0d0bf 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -273,12 +273,16 @@ class Screenshots extends Action $this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[$date] [appwrite] Screenshot capturing failed. Deployment will continue. \n"); if (!$screenshotCompleted) { - $this->publishUsage( - project: $project, - site: $site, - publisherForUsage: $publisherForUsage, - metric: METRIC_SCREENSHOTS_FAILED - ); + try { + $this->publishUsage( + project: $project, + site: $site, + publisherForUsage: $publisherForUsage, + metric: METRIC_SCREENSHOTS_FAILED + ); + } catch (\Throwable) { + // Usage publish is best-effort; preserve the original screenshot exception. + } } throw $th; From 730c62bda60cbb32da196c01b01c7ac4d6c98c22 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 15 Apr 2026 18:35:09 +0530 Subject: [PATCH 023/402] remove usage stats --- app/init/constants.php | 6 -- .../Modules/Functions/Workers/Screenshots.php | 74 ++++------------ .../Platform/Modules/Sites/Http/Usage/Get.php | 22 ++--- .../Modules/Sites/Http/Usage/XList.php | 23 ++--- .../Utopia/Response/Model/UsageSite.php | 32 ------- .../Utopia/Response/Model/UsageSites.php | 84 ++++++------------- tests/e2e/General/UsageTest.php | 14 +--- 7 files changed, 57 insertions(+), 198 deletions(-) diff --git a/app/init/constants.php b/app/init/constants.php index 57763df911..f2127cd666 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -351,8 +351,6 @@ const METRIC_BUILDS_STORAGE = 'builds.storage'; const METRIC_BUILDS_COMPUTE = 'builds.compute'; const METRIC_BUILDS_COMPUTE_SUCCESS = 'builds.compute.success'; const METRIC_BUILDS_COMPUTE_FAILED = 'builds.compute.failed'; -const METRIC_SCREENSHOTS_SUCCESS = 'screenshots.success'; -const METRIC_SCREENSHOTS_FAILED = 'screenshots.failed'; const METRIC_BUILDS_MB_SECONDS = 'builds.mbSeconds'; const METRIC_EXECUTIONS = 'executions'; const METRIC_EXECUTIONS_COMPUTE = 'executions.compute'; @@ -365,8 +363,6 @@ const METRIC_RESOURCE_TYPE_ID_BUILDS_FAILED = '{resourceType}.{resourceInternal const METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE = '{resourceType}.{resourceInternalId}.builds.compute'; const METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE_SUCCESS = '{resourceType}.{resourceInternalId}.builds.compute.success'; const METRIC_RESOURCE_TYPE_ID_BUILDS_COMPUTE_FAILED = '{resourceType}.{resourceInternalId}.builds.compute.failed'; -const METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_SUCCESS = '{resourceType}.{resourceInternalId}.screenshots.success'; -const METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_FAILED = '{resourceType}.{resourceInternalId}.screenshots.failed'; const METRIC_RESOURCE_TYPE_ID_BUILDS_MB_SECONDS = '{resourceType}.{resourceInternalId}.builds.mbSeconds'; const METRIC_RESOURCE_TYPE_ID_BUILDS = '{resourceType}.{resourceInternalId}.builds'; const METRIC_RESOURCE_TYPE_ID_BUILDS_STORAGE = '{resourceType}.{resourceInternalId}.builds.storage'; @@ -380,8 +376,6 @@ const METRIC_RESOURCE_TYPE_BUILDS_FAILED = '{resourceType}.builds.failed'; const METRIC_RESOURCE_TYPE_BUILDS_COMPUTE = '{resourceType}.builds.compute'; const METRIC_RESOURCE_TYPE_BUILDS_COMPUTE_SUCCESS = '{resourceType}.builds.compute.success'; const METRIC_RESOURCE_TYPE_BUILDS_COMPUTE_FAILED = '{resourceType}.builds.compute.failed'; -const METRIC_RESOURCE_TYPE_SCREENSHOTS_SUCCESS = '{resourceType}.screenshots.success'; -const METRIC_RESOURCE_TYPE_SCREENSHOTS_FAILED = '{resourceType}.screenshots.failed'; const METRIC_RESOURCE_TYPE_BUILDS_MB_SECONDS = '{resourceType}.builds.mbSeconds'; const METRIC_RESOURCE_TYPE_BUILDS = '{resourceType}.builds'; const METRIC_RESOURCE_TYPE_BUILDS_STORAGE = '{resourceType}.builds.storage'; diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index bac72b993f..0cb42822b3 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -4,12 +4,9 @@ namespace Appwrite\Platform\Modules\Functions\Workers; use Ahc\Jwt\JWT; use Appwrite\Event\Message\Screenshot; -use Appwrite\Event\Message\Usage as UsageMessage; -use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; use Appwrite\Permission; use Appwrite\Role; -use Appwrite\Usage\Context; use Exception; use Utopia\Compression\Compression; use Utopia\Config\Config; @@ -23,6 +20,7 @@ use Utopia\Platform\Action; use Utopia\Queue\Message; use Utopia\Storage\Device; use Utopia\System\System; +use Utopia\Telemetry\Adapter as Telemetry; use function Swoole\Coroutine\batch; @@ -47,7 +45,7 @@ class Screenshots extends Action ->inject('dbForProject') ->inject('project') ->inject('deviceForFiles') - ->inject('publisherForUsage') + ->inject('telemetry') ->callback($this->action(...)); } @@ -58,12 +56,11 @@ class Screenshots extends Action Database $dbForProject, Document $project, Device $deviceForFiles, - UsagePublisher $publisherForUsage + Telemetry $telemetry ): void { Console::log('Screenshot action started'); $payload = $message->getPayload() ?? []; - $screenshotCompleted = false; if (empty($payload)) { throw new \Exception('Missing payload'); @@ -266,7 +263,6 @@ class Screenshots extends Action 'deploymentScreenshotDark' => $deployment->getAttribute('screenshotDark', ''), 'deploymentScreenshotLight' => $deployment->getAttribute('screenshotLight', ''), ])); - $screenshotCompleted = true; } catch (\Throwable $th) { Console::warning("Screenshot failed to generate:"); Console::warning($th->getMessage()); @@ -275,62 +271,26 @@ class Screenshots extends Action $date = \date('H:i:s'); $this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[$date] [appwrite] Screenshot capturing failed. Deployment will continue. \n"); - if (!$screenshotCompleted) { - try { - $this->publishUsage( - project: $project, - site: $site, - publisherForUsage: $publisherForUsage, - metric: METRIC_SCREENSHOTS_FAILED - ); - } catch (\Throwable) { - // Usage publish is best-effort; preserve the original screenshot exception. - } - } + $this->recordTelemetry($telemetry, 'failure'); throw $th; } - $this->publishUsage( - project: $project, - site: $site, - publisherForUsage: $publisherForUsage, - metric: METRIC_SCREENSHOTS_SUCCESS - ); + $this->recordTelemetry($telemetry, 'success'); } - protected function publishUsage( - Document $project, - Document $site, - UsagePublisher $publisherForUsage, - string $metric - ): void { - [$resourceMetric, $resourceIdMetric] = match ($metric) { - METRIC_SCREENSHOTS_SUCCESS => [ - METRIC_RESOURCE_TYPE_SCREENSHOTS_SUCCESS, - METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_SUCCESS, - ], - METRIC_SCREENSHOTS_FAILED => [ - METRIC_RESOURCE_TYPE_SCREENSHOTS_FAILED, - METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_FAILED, - ], - default => throw new \InvalidArgumentException('Unknown screenshot metric: ' . $metric), - }; - - $usage = (new Context()) - ->addMetric($metric, 1) - ->addMetric(str_replace('{resourceType}', RESOURCE_TYPE_SITES, $resourceMetric), 1) - ->addMetric(str_replace( - ['{resourceType}', '{resourceInternalId}'], - [RESOURCE_TYPE_SITES, $site->getSequence()], - $resourceIdMetric - ), 1); - - $publisherForUsage->enqueue(new UsageMessage( - project: $project, - metrics: $usage->getMetrics(), - reduce: $usage->getReduce() - )); + protected function recordTelemetry(Telemetry $telemetry, string $result): void + { + try { + $telemetry + ->createCounter('worker.screenshots.capture') + ->add(1, [ + 'resourceType' => RESOURCE_TYPE_SITES, + 'result' => $result, + ]); + } catch (\Throwable) { + // Telemetry should never affect screenshot processing. + } } protected function appendToLogs(Database $dbForProject, string $deploymentId, Realtime $queueForRealtime, string $logs) diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php index a1af4bd8fd..a6768462d1 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php @@ -87,8 +87,6 @@ class Get extends Base str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_EXECUTIONS_MB_SECONDS), str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_SUCCESS), str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_BUILDS_FAILED), - str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_SUCCESS), - str_replace(['{resourceType}', '{resourceInternalId}'], [RESOURCE_TYPE_SITES, $site->getSequence()], METRIC_RESOURCE_TYPE_ID_SCREENSHOTS_FAILED), str_replace(['{siteInternalId}'], [$site->getSequence()], METRIC_SITES_ID_REQUESTS), str_replace(['{siteInternalId}'], [$site->getSequence()], METRIC_SITES_ID_INBOUND), str_replace(['{siteInternalId}'], [$site->getSequence()], METRIC_SITES_ID_OUTBOUND), @@ -141,9 +139,6 @@ class Get extends Base $buildsTimeTotal = $usage[$metrics[4]]['total'] ?? 0; $buildsTotal = $usage[$metrics[2]]['total'] ?? 0; - $screenshotsSuccessTotal = $usage[$metrics[11]]['total'] ?? 0; - $screenshotsFailedTotal = $usage[$metrics[12]]['total'] ?? 0; - $screenshotsTotal = $screenshotsSuccessTotal + $screenshotsFailedTotal; $response->dynamic(new Document([ 'range' => $range, 'deploymentsTotal' => $usage[$metrics[0]]['total'], @@ -158,12 +153,9 @@ class Get extends Base 'executionsMbSecondsTotal' => $usage[$metrics[8]]['total'], 'buildsSuccessTotal' => $usage[$metrics[9]]['total'], 'buildsFailedTotal' => $usage[$metrics[10]]['total'], - 'screenshotsSuccessTotal' => $screenshotsSuccessTotal, - 'screenshotsFailedTotal' => $screenshotsFailedTotal, - 'screenshotsSuccessRate' => $screenshotsTotal === 0 ? 0 : $screenshotsSuccessTotal / $screenshotsTotal, - 'requestsTotal' => $usage[$metrics[13]]['total'], - 'inboundTotal' => $usage[$metrics[14]]['total'], - 'outboundTotal' => $usage[$metrics[15]]['total'], + 'requestsTotal' => $usage[$metrics[11]]['total'], + 'inboundTotal' => $usage[$metrics[12]]['total'], + 'outboundTotal' => $usage[$metrics[13]]['total'], 'deployments' => $usage[$metrics[0]]['data'], 'deploymentsStorage' => $usage[$metrics[1]]['data'], 'builds' => $usage[$metrics[2]]['data'], @@ -175,11 +167,9 @@ class Get extends Base 'executionsMbSeconds' => $usage[$metrics[8]]['data'], 'buildsSuccess' => $usage[$metrics[9]]['data'], 'buildsFailed' => $usage[$metrics[10]]['data'], - 'screenshotsSuccess' => $usage[$metrics[11]]['data'], - 'screenshotsFailed' => $usage[$metrics[12]]['data'], - 'requests' => $usage[$metrics[13]]['data'], - 'inbound' => $usage[$metrics[14]]['data'], - 'outbound' => $usage[$metrics[15]]['data'], + 'requests' => $usage[$metrics[11]]['data'], + 'inbound' => $usage[$metrics[12]]['data'], + 'outbound' => $usage[$metrics[13]]['data'], ]), Response::MODEL_USAGE_SITE); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php index 3b3c759fd5..a90cb0cab9 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php @@ -74,8 +74,6 @@ class XList extends Base str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_EXECUTIONS_MB_SECONDS), str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_BUILDS_SUCCESS), str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_BUILDS_FAILED), - str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_SCREENSHOTS_SUCCESS), - str_replace("{resourceType}", RESOURCE_TYPE_SITES, METRIC_RESOURCE_TYPE_SCREENSHOTS_FAILED), METRIC_SITES_REQUESTS, METRIC_SITES_INBOUND, METRIC_SITES_OUTBOUND, @@ -124,10 +122,6 @@ class XList extends Base ]; } } - $screenshotsSuccessTotal = $usage[$metrics[12]]['total'] ?? 0; - $screenshotsFailedTotal = $usage[$metrics[13]]['total'] ?? 0; - $screenshotsTotal = $screenshotsSuccessTotal + $screenshotsFailedTotal; - $response->dynamic(new Document([ 'range' => $range, 'sitesTotal' => $usage[$metrics[0]]['total'], @@ -142,12 +136,9 @@ class XList extends Base 'executionsMbSecondsTotal' => $usage[$metrics[9]]['total'], 'buildsSuccessTotal' => $usage[$metrics[10]]['total'], 'buildsFailedTotal' => $usage[$metrics[11]]['total'], - 'screenshotsSuccessTotal' => $screenshotsSuccessTotal, - 'screenshotsFailedTotal' => $screenshotsFailedTotal, - 'screenshotsSuccessRate' => $screenshotsTotal === 0 ? 0 : $screenshotsSuccessTotal / $screenshotsTotal, - 'requestsTotal' => $usage[$metrics[14]]['total'], - 'inboundTotal' => $usage[$metrics[15]]['total'], - 'outboundTotal' => $usage[$metrics[16]]['total'], + 'requestsTotal' => $usage[$metrics[12]]['total'], + 'inboundTotal' => $usage[$metrics[13]]['total'], + 'outboundTotal' => $usage[$metrics[14]]['total'], 'sites' => $usage[$metrics[0]]['data'], 'deployments' => $usage[$metrics[1]]['data'], 'deploymentsStorage' => $usage[$metrics[2]]['data'], @@ -160,11 +151,9 @@ class XList extends Base 'executionsMbSeconds' => $usage[$metrics[9]]['data'], 'buildsSuccess' => $usage[$metrics[10]]['data'], 'buildsFailed' => $usage[$metrics[11]]['data'], - 'screenshotsSuccess' => $usage[$metrics[12]]['data'], - 'screenshotsFailed' => $usage[$metrics[13]]['data'], - 'requests' => $usage[$metrics[14]]['data'], - 'inbound' => $usage[$metrics[15]]['data'], - 'outbound' => $usage[$metrics[16]]['data'], + 'requests' => $usage[$metrics[12]]['data'], + 'inbound' => $usage[$metrics[13]]['data'], + 'outbound' => $usage[$metrics[14]]['data'], ]), Response::MODEL_USAGE_SITES); } } diff --git a/src/Appwrite/Utopia/Response/Model/UsageSite.php b/src/Appwrite/Utopia/Response/Model/UsageSite.php index af41a34639..c45dc831cd 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageSite.php +++ b/src/Appwrite/Utopia/Response/Model/UsageSite.php @@ -10,38 +10,6 @@ class UsageSite extends UsageFunction { parent::__construct(); $this - ->addRule('screenshotsSuccessTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of successful site screenshots.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsFailedTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of failed site screenshots.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsSuccessRate', [ - 'type' => self::TYPE_FLOAT, - 'description' => 'Success rate of site screenshots from 0 to 1.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsSuccess', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of successful site screenshots per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) - ->addRule('screenshotsFailed', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of failed site screenshots per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) ->addRule('requestsTotal', [ 'type' => self::TYPE_INTEGER, 'description' => 'Total aggregated number of requests.', diff --git a/src/Appwrite/Utopia/Response/Model/UsageSites.php b/src/Appwrite/Utopia/Response/Model/UsageSites.php index 94ee6e645f..fea87c7718 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageSites.php +++ b/src/Appwrite/Utopia/Response/Model/UsageSites.php @@ -83,36 +83,6 @@ class UsageSites extends Model 'default' => 0, 'example' => 0, ]) - ->addRule('buildsSuccessTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of successful site builds.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('buildsFailedTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of failed site builds.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsSuccessTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of successful site screenshots.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsFailedTotal', [ - 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of failed site screenshots.', - 'default' => 0, - 'example' => 0, - ]) - ->addRule('screenshotsSuccessRate', [ - 'type' => self::TYPE_FLOAT, - 'description' => 'Success rate of site screenshots from 0 to 1.', - 'default' => 0, - 'example' => 0, - ]) ->addRule('requestsTotal', [ 'type' => self::TYPE_INTEGER, 'description' => 'Total aggregated number of requests.', @@ -166,6 +136,18 @@ class UsageSites extends Model 'example' => [], 'array' => true ]) + ->addRule('buildsSuccessTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of successful site builds.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('buildsFailedTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of failed site builds.', + 'default' => 0, + 'example' => 0, + ]) ->addRule('builds', [ 'type' => Response::MODEL_METRIC, 'description' => 'Aggregated number of sites build per period.', @@ -194,34 +176,6 @@ class UsageSites extends Model 'example' => [], 'array' => true ]) - ->addRule('buildsSuccess', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of successful site builds per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) - ->addRule('buildsFailed', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of failed site builds per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) - ->addRule('screenshotsSuccess', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of successful site screenshots per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) - ->addRule('screenshotsFailed', [ - 'type' => Response::MODEL_METRIC, - 'description' => 'Aggregated number of failed site screenshots per period.', - 'default' => [], - 'example' => [], - 'array' => true - ]) ->addRule('executions', [ 'type' => Response::MODEL_METRIC, 'description' => 'Aggregated number of sites execution per period.', @@ -243,6 +197,20 @@ class UsageSites extends Model 'example' => [], 'array' => true ]) + ->addRule('buildsSuccess', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of successful site builds per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('buildsFailed', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of failed site builds per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) ; } diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php index 0d2c0a7ed7..f6eb963967 100644 --- a/tests/e2e/General/UsageTest.php +++ b/tests/e2e/General/UsageTest.php @@ -1675,14 +1675,11 @@ class UsageTest extends Scope ); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(35, count($response['body'])); + $this->assertEquals(30, count($response['body'])); $this->assertEquals('30d', $response['body']['range']); $this->assertIsArray($response['body']['deployments']); $this->assertEquals($deploymentsSuccess, $response['body']['buildsSuccessTotal']); $this->assertEquals($deploymentsFailed, $response['body']['buildsFailedTotal']); - $this->assertGreaterThanOrEqual(1, $response['body']['screenshotsSuccessTotal']); - $this->assertEquals(0, $response['body']['screenshotsFailedTotal']); - $this->assertEquals(1.0, $response['body']['screenshotsSuccessRate']); $this->assertIsArray($response['body']['deploymentsStorage']); $this->assertIsNumeric($response['body']['deploymentsStorageTotal']); $this->assertIsNumeric($response['body']['buildsMbSecondsTotal']); @@ -1695,8 +1692,6 @@ class UsageTest extends Scope $this->assertIsArray($response['body']['executionsMbSeconds']); $this->assertIsArray($response['body']['buildsSuccess']); $this->assertIsArray($response['body']['buildsFailed']); - $this->assertIsArray($response['body']['screenshotsSuccess']); - $this->assertIsArray($response['body']['screenshotsFailed']); $this->assertIsArray($response['body']['requests']); $this->assertIsArray($response['body']['inbound']); $this->assertIsArray($response['body']['outbound']); @@ -1714,7 +1709,7 @@ class UsageTest extends Scope ); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(36, count($response['body'])); + $this->assertEquals(31, count($response['body'])); $this->assertEquals($response['body']['range'], '30d'); $this->assertIsArray($response['body']['sites']); $this->assertIsArray($response['body']['deployments']); @@ -1727,11 +1722,6 @@ class UsageTest extends Scope $this->assertIsArray($response['body']['executionsMbSeconds']); $this->assertIsArray($response['body']['buildsSuccess']); $this->assertIsArray($response['body']['buildsFailed']); - $this->assertGreaterThanOrEqual(1, $response['body']['screenshotsSuccessTotal']); - $this->assertEquals(0, $response['body']['screenshotsFailedTotal']); - $this->assertEquals(1.0, $response['body']['screenshotsSuccessRate']); - $this->assertIsArray($response['body']['screenshotsSuccess']); - $this->assertIsArray($response['body']['screenshotsFailed']); $this->assertIsArray($response['body']['requests']); $this->assertIsArray($response['body']['inbound']); $this->assertIsArray($response['body']['outbound']); From 6da132db4697a6f3c38873b5f8e6abce68efe04e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:05:27 +0200 Subject: [PATCH 024/402] Remove SMS templates and support null locale for mail templates --- app/controllers/api/projects.php | 226 ++----------------------------- 1 file changed, 11 insertions(+), 215 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 5b82e6c1a3..3b83c37acd 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -833,74 +833,6 @@ Http::post('/v1/projects/:projectId/smtp/tests') $response->noContent(); }); -Http::get('/v1/projects/:projectId/templates/sms/:type/:locale') - ->desc('Get custom SMS template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'getSmsTemplate', - description: '/docs/references/projects/get-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.getSMSTemplate', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'getSMSTemplate', - description: '/docs/references/projects/get-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { - - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['sms.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - $template = [ - 'message' => Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl')->render(), - ]; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $response->dynamic(new Document($template), Response::MODEL_SMS_TEMPLATE); - }); - - Http::get('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Get custom email template') ->groups(['api', 'projects']) @@ -920,11 +852,12 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) + ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { - + ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { + $locale = $locale ?? 'worldwide'; + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -1000,73 +933,6 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); }); -Http::patch('/v1/projects/:projectId/templates/sms/:type/:locale') - ->desc('Update custom SMS template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSmsTemplate', - description: '/docs/references/projects/update-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateSMSTemplate', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSMSTemplate', - description: '/docs/references/projects/update-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) - ->param('message', '', new Text(0), 'Template message') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, string $message, Response $response, Database $dbForPlatform) { - - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $templates['sms.' . $type . '-' . $locale] = [ - 'message' => $message - ]; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'message' => $message, - 'type' => $type, - 'locale' => $locale, - ]), Response::MODEL_SMS_TEMPLATE); - }); - Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Update custom email templates') ->groups(['api', 'projects']) @@ -1086,7 +952,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) + ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) ->param('subject', '', new Text(255), 'Email Subject') ->param('message', '', new Text(0), 'Template message') ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) @@ -1094,7 +960,8 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->param('replyTo', '', new Email(), 'Reply to email', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, string $type, ?string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { + $locale = $locale ?? 'worldwide'; $project = $dbForPlatform->getDocument('projects', $projectId); @@ -1124,78 +991,6 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ]), Response::MODEL_EMAIL_TEMPLATE); }); -Http::delete('/v1/projects/:projectId/templates/sms/:type/:locale') - ->desc('Reset custom SMS template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteSmsTemplate', - description: '/docs/references/projects/delete-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ], - contentType: ContentType::JSON, - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.deleteSMSTemplate', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteSMSTemplate', - description: '/docs/references/projects/delete-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ], - contentType: ContentType::JSON - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { - - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['sms.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); - } - - unset($template['sms.' . $type . '-' . $locale]); - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'message' => $template['message'] - ]), Response::MODEL_SMS_TEMPLATE); - }); - Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Delete custom email template') ->groups(['api', 'projects']) @@ -1216,11 +1011,12 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) + ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { - + ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { + $locale = $locale ?? 'worldwide'; + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { From dc39af50a12f2b74d75ffbdef0173beb3c667144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:05:46 +0200 Subject: [PATCH 025/402] Support for worldwide fallback custom template for all project emails --- app/controllers/api/account.php | 25 ++++++++++++++----- src/Appwrite/Bus/Listeners/Mails.php | 4 ++- .../Http/Account/MFA/Challenges/Create.php | 8 ++++-- .../Modules/Teams/Http/Memberships/Create.php | 8 ++++-- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 0035778523..357c071c85 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2265,7 +2265,10 @@ Http::post('/v1/account/tokens/magic-url') $subject = $locale->getText("emails.magicSession.subject"); $preview = $locale->getText("emails.magicSession.preview"); - $customTemplate = $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? []; + + $customTemplate = + $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.magicSession-' . 'worldwide'] ?? []; $detector = new Detector($request->getUserAgent('UNKNOWN')); $agentOs = $detector->getOS(); @@ -2575,7 +2578,9 @@ Http::post('/v1/account/tokens/email') $preview = $locale->getText("emails.otpSession.preview"); $heading = $locale->getText("emails.otpSession.heading"); - $customTemplate = $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.otpSession-worldwide'] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); @@ -2968,7 +2973,9 @@ Http::post('/v1/account/tokens/phone') if ($sendSMS) { $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl'); - $customTemplate = $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? + $project->getAttribute('templates', [])['sms.login-worldwide'] ?? []; if (!empty($customTemplate)) { $message = $customTemplate['message'] ?? $message; } @@ -3726,7 +3733,9 @@ Http::post('/v1/account/recovery') $body = $locale->getText("emails.recovery.body"); $subject = $locale->getText("emails.recovery.subject"); $preview = $locale->getText("emails.recovery.preview"); - $customTemplate = $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.recovery-worldwide'] ?? []; $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl'); $message @@ -4034,7 +4043,9 @@ Http::post('/v1/account/verifications/email') $subject = $locale->getText("emails.verification.subject"); $heading = $locale->getText("emails.verification.heading"); - $customTemplate = $project->getAttribute('templates', [])['email.verification-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.verification-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.verification-worldwide'] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); @@ -4333,7 +4344,9 @@ Http::post('/v1/account/verifications/phone') if ($sendSMS) { $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl'); - $customTemplate = $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? + $project->getAttribute('templates', [])['sms.verification-worldwide'] ?? []; if (!empty($customTemplate)) { $message = $customTemplate['message'] ?? $message; } diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index 2ffcbc9aa4..7b33baced5 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -71,7 +71,9 @@ class Mails extends Listener throw new \Exception('Invalid template path'); } - $customTemplate = $project->getAttribute('templates', [])["email.sessionAlert-$event->locale"] ?? []; + $customTemplate = + $project->getAttribute('templates', [])["email.sessionAlert-$event->locale"] ?? + $project->getAttribute('templates', [])['email.sessionAlert-worldwide'] ?? []; $isBranded = $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE; $subject = $customTemplate['subject'] ?? $locale->getText('emails.sessionAlert.subject'); diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 20a6afed2e..6dc4f024a7 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -170,7 +170,9 @@ class Create extends Action $message = Template::fromFile($templatesPath . '/sms-base.tpl'); - $customTemplate = $project->getAttribute('templates', [])['sms.mfaChallenge-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['sms.mfaChallenge-' . $locale->default] ?? + $project->getAttribute('templates', [])['sms.mfaChallenge-worldwide'] ?? []; if (!empty($customTemplate)) { $message = $customTemplate['message'] ?? $message; } @@ -223,7 +225,9 @@ class Create extends Action $preview = $locale->getText("emails.mfaChallenge.preview"); $heading = $locale->getText("emails.mfaChallenge.heading"); - $customTemplate = $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.mfaChallenge-worldwide'] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 5edc69f445..3a8d4460cd 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -324,7 +324,9 @@ class Create extends Action $body = $locale->getText('emails.invitation.body'); $preview = $locale->getText('emails.invitation.preview'); $subject = $locale->getText('emails.invitation.subject'); - $customTemplate = $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.invitation-worldwide'] ?? []; $message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-inner-base.tpl'); $message @@ -407,7 +409,9 @@ class Create extends Action $message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/sms-base.tpl'); - $customTemplate = $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? + $project->getAttribute('templates', [])['sms.invitation-worldwide'] ?? []; if (! empty($customTemplate)) { $message = $customTemplate['message']; } From 0da185e6894d82f6156ca3fe82e20e718206332c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:17:55 +0200 Subject: [PATCH 026/402] Refactor fixes --- app/controllers/api/account.php | 4 +-- app/controllers/api/projects.php | 3 +- app/init/models.php | 2 -- src/Appwrite/SDK/Specification/Format.php | 10 ------ src/Appwrite/Utopia/Response.php | 1 - .../Utopia/Response/Model/TemplateSMS.php | 32 ------------------- 6 files changed, 4 insertions(+), 48 deletions(-) delete mode 100644 src/Appwrite/Utopia/Response/Model/TemplateSMS.php diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 357c071c85..a913a3bd90 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2267,8 +2267,8 @@ Http::post('/v1/account/tokens/magic-url') $preview = $locale->getText("emails.magicSession.preview"); $customTemplate = - $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.magicSession-' . 'worldwide'] ?? []; + $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.magicSession-worldwide'] ?? []; $detector = new Detector($request->getUserAgent('UNKNOWN')); $agentOs = $detector->getOS(); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 3b83c37acd..0cd5a27c3a 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -865,7 +865,8 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') } $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; + $template = $templates['email.' . $type . '-' . $locale] + ?? ($locale !== 'worldwide' ? ($templates['email.' . $type . '-worldwide'] ?? null) : null); $localeObj = new Locale($locale); $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); diff --git a/app/init/models.php b/app/init/models.php index dd97b03652..9aa21e992b 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -135,7 +135,6 @@ use Appwrite\Utopia\Response\Model\TemplateFramework; use Appwrite\Utopia\Response\Model\TemplateFunction; use Appwrite\Utopia\Response\Model\TemplateRuntime; use Appwrite\Utopia\Response\Model\TemplateSite; -use Appwrite\Utopia\Response\Model\TemplateSMS; use Appwrite\Utopia\Response\Model\TemplateVariable; use Appwrite\Utopia\Response\Model\Token; use Appwrite\Utopia\Response\Model\Topic; @@ -373,7 +372,6 @@ Response::setModel(new Headers()); Response::setModel(new Specification()); Response::setModel(new Rule()); Response::setModel(new Schedule()); -Response::setModel(new TemplateSMS()); Response::setModel(new TemplateEmail()); Response::setModel(new ConsoleVariables()); Response::setModel(new MFAChallenge()); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 91b090a9f6..eb1b45142e 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -595,16 +595,6 @@ abstract class Format return 'EmailTemplateLocale'; } break; - case 'getSmsTemplate': - case 'updateSmsTemplate': - case 'deleteSmsTemplate': - switch ($param) { - case 'type': - return 'SmsTemplateType'; - case 'locale': - return 'SmsTemplateLocale'; - } - break; case 'createPlatform': switch ($param) { case 'type': diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 04d2813e30..d747373b59 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -265,7 +265,6 @@ class Response extends SwooleResponse public const MODEL_VARIABLE = 'variable'; public const MODEL_VARIABLE_LIST = 'variableList'; public const MODEL_VCS = 'vcs'; - public const MODEL_SMS_TEMPLATE = 'smsTemplate'; public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; // Health diff --git a/src/Appwrite/Utopia/Response/Model/TemplateSMS.php b/src/Appwrite/Utopia/Response/Model/TemplateSMS.php deleted file mode 100644 index 2b19ef4878..0000000000 --- a/src/Appwrite/Utopia/Response/Model/TemplateSMS.php +++ /dev/null @@ -1,32 +0,0 @@ - Date: Wed, 15 Apr 2026 18:29:43 +0200 Subject: [PATCH 027/402] More cleanup of sms templates --- app/config/locale/templates.php | 6 ---- .../projects/delete-sms-template.md | 1 - docs/references/projects/get-sms-template.md | 1 - .../projects/update-sms-template.md | 1 - .../Utopia/Response/Model/Template.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 36 ------------------- 6 files changed, 1 insertion(+), 46 deletions(-) delete mode 100644 docs/references/projects/delete-sms-template.md delete mode 100644 docs/references/projects/get-sms-template.md delete mode 100644 docs/references/projects/update-sms-template.md diff --git a/app/config/locale/templates.php b/app/config/locale/templates.php index 6aa376678a..680034554b 100644 --- a/app/config/locale/templates.php +++ b/app/config/locale/templates.php @@ -9,11 +9,5 @@ return [ 'mfaChallenge', 'sessionAlert', 'otpSession' - ], - 'sms' => [ - 'verification', - 'login', - 'invitation', - 'mfaChallenge' ] ]; diff --git a/docs/references/projects/delete-sms-template.md b/docs/references/projects/delete-sms-template.md deleted file mode 100644 index c5a7e6cac9..0000000000 --- a/docs/references/projects/delete-sms-template.md +++ /dev/null @@ -1 +0,0 @@ -Reset a custom SMS template to its default value. This endpoint removes any custom message and restores the template to its original state. \ No newline at end of file diff --git a/docs/references/projects/get-sms-template.md b/docs/references/projects/get-sms-template.md deleted file mode 100644 index 6ef1d93029..0000000000 --- a/docs/references/projects/get-sms-template.md +++ /dev/null @@ -1 +0,0 @@ -Get a custom SMS template for the specified locale and type returning it's contents. \ No newline at end of file diff --git a/docs/references/projects/update-sms-template.md b/docs/references/projects/update-sms-template.md deleted file mode 100644 index 3e67f613b7..0000000000 --- a/docs/references/projects/update-sms-template.md +++ /dev/null @@ -1 +0,0 @@ -Update a custom SMS template for the specified locale and type. Use this endpoint to modify the content of your SMS templates. \ No newline at end of file diff --git a/src/Appwrite/Utopia/Response/Model/Template.php b/src/Appwrite/Utopia/Response/Model/Template.php index 3ce9cacdb3..b0e127e07f 100644 --- a/src/Appwrite/Utopia/Response/Model/Template.php +++ b/src/Appwrite/Utopia/Response/Model/Template.php @@ -19,7 +19,7 @@ abstract class Template extends Model 'type' => self::TYPE_STRING, 'description' => 'Template locale', 'default' => '', - 'example' => 'en_us', + 'example' => 'worldwide', ]) ->addRule('message', [ 'type' => self::TYPE_STRING, diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 7b9848e38f..597030413e 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1161,42 +1161,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('verification', $response['body']['type']); $this->assertEquals('en-us', $response['body']['locale']); $this->assertEquals('Please verify your email {{url}}', $response['body']['message']); - - // Temporary disabled until implemented - // /** Get Default SMS Template */ - // $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/sms/verification/en-us', array_merge([ - // 'content-type' => 'application/json', - // 'x-appwrite-project' => $this->getProject()['$id'], - // ], $this->getHeaders())); - - // $this->assertEquals(200, $response['headers']['status-code']); - // $this->assertEquals('verification', $response['body']['type']); - // $this->assertEquals('en-us', $response['body']['locale']); - // $this->assertEquals('{{token}}', $response['body']['message']); - - // /** Update SMS template */ - // $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/sms/verification/en-us', array_merge([ - // 'content-type' => 'application/json', - // 'x-appwrite-project' => $this->getProject()['$id'], - // ], $this->getHeaders()), [ - // 'message' => 'Please verify your email {{token}}', - // ]); - - // $this->assertEquals(200, $response['headers']['status-code']); - // $this->assertEquals('verification', $response['body']['type']); - // $this->assertEquals('en-us', $response['body']['locale']); - // $this->assertEquals('Please verify your email {{token}}', $response['body']['message']); - - // /** Get Updated SMS Template */ - // $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/sms/verification/en-us', array_merge([ - // 'content-type' => 'application/json', - // 'x-appwrite-project' => $this->getProject()['$id'], - // ], $this->getHeaders())); - - // $this->assertEquals(200, $response['headers']['status-code']); - // $this->assertEquals('verification', $response['body']['type']); - // $this->assertEquals('en-us', $response['body']['locale']); - // $this->assertEquals('Please verify your email {{token}}', $response['body']['message']); } public function testUpdateProjectAuthDuration(): void From 2b42487198f122dfae7917753efc6006a4822932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:30:06 +0200 Subject: [PATCH 028/402] Linter fix --- app/controllers/api/account.php | 2 +- app/controllers/api/projects.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index a913a3bd90..4ddc9f8e92 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2265,7 +2265,7 @@ Http::post('/v1/account/tokens/magic-url') $subject = $locale->getText("emails.magicSession.subject"); $preview = $locale->getText("emails.magicSession.preview"); - + $customTemplate = $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? $project->getAttribute('templates', [])['email.magicSession-worldwide'] ?? []; diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 0cd5a27c3a..6422ebb409 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -857,7 +857,7 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') ->inject('dbForPlatform') ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { $locale = $locale ?? 'worldwide'; - + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -1017,7 +1017,7 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') ->inject('dbForPlatform') ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { $locale = $locale ?? 'worldwide'; - + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { From 90e14338787ad1e61f8325651abb1803a71890df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:38:08 +0200 Subject: [PATCH 029/402] Fix agent mistake --- app/controllers/api/projects.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 6422ebb409..411897d170 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -865,8 +865,7 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') } $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] - ?? ($locale !== 'worldwide' ? ($templates['email.' . $type . '-worldwide'] ?? null) : null); + $template = $templates['email.' . $type . '-' . $locale] ?? null; $localeObj = new Locale($locale); $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); From 590f0636946e9502910caa905a1cd482b6c95f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:40:29 +0200 Subject: [PATCH 030/402] Remove remaining sms leftover --- app/controllers/api/account.php | 14 -------------- .../Account/Http/Account/MFA/Challenges/Create.php | 7 ------- .../Modules/Teams/Http/Memberships/Create.php | 7 ------- 3 files changed, 28 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 4ddc9f8e92..03526bd49f 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2973,13 +2973,6 @@ Http::post('/v1/account/tokens/phone') if ($sendSMS) { $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl'); - $customTemplate = - $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? - $project->getAttribute('templates', [])['sms.login-worldwide'] ?? []; - if (!empty($customTemplate)) { - $message = $customTemplate['message'] ?? $message; - } - $projectName = $project->getAttribute('name'); if ($project->getId() === 'console') { $projectName = $platform['platformName']; @@ -4344,13 +4337,6 @@ Http::post('/v1/account/verifications/phone') if ($sendSMS) { $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl'); - $customTemplate = - $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? - $project->getAttribute('templates', [])['sms.verification-worldwide'] ?? []; - if (!empty($customTemplate)) { - $message = $customTemplate['message'] ?? $message; - } - $messageContent = Template::fromString($locale->getText("sms.verification.body")); $messageContent ->setParam('{{project}}', $project->getAttribute('name')) diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 6dc4f024a7..319e080f25 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -170,13 +170,6 @@ class Create extends Action $message = Template::fromFile($templatesPath . '/sms-base.tpl'); - $customTemplate = - $project->getAttribute('templates', [])['sms.mfaChallenge-' . $locale->default] ?? - $project->getAttribute('templates', [])['sms.mfaChallenge-worldwide'] ?? []; - if (!empty($customTemplate)) { - $message = $customTemplate['message'] ?? $message; - } - $messageContent = Template::fromString($locale->getText("sms.verification.body")); $messageContent ->setParam('{{project}}', $projectName) diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 3a8d4460cd..161e817aed 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -409,13 +409,6 @@ class Create extends Action $message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/sms-base.tpl'); - $customTemplate = - $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? - $project->getAttribute('templates', [])['sms.invitation-worldwide'] ?? []; - if (! empty($customTemplate)) { - $message = $customTemplate['message']; - } - $message = $message->setParam('{{token}}', $url); $message = $message->render(); From 8fd1c5d620c458539b9359ab30b6dab2a6d91c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:54:18 +0200 Subject: [PATCH 031/402] Remove worldwide to not be user-facing --- app/controllers/api/projects.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 411897d170..d4692a7774 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -928,7 +928,7 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') } $template['type'] = $type; - $template['locale'] = $locale; + $template['locale'] = $locale === 'worldwide' ? null : $locale; $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); }); @@ -982,7 +982,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document([ 'type' => $type, - 'locale' => $locale, + 'locale' => $locale === 'worldwide' ? null : $locale, 'senderName' => $senderName, 'senderEmail' => $senderEmail, 'subject' => $subject, @@ -1036,7 +1036,7 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document([ 'type' => $type, - 'locale' => $locale, + 'locale' => $locale === 'worldwide' ? null : $locale, 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], From b510194f007c09d8e609c0f5c647a68a77b28958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:57:37 +0200 Subject: [PATCH 032/402] Expose "worldwide" locale --- app/controllers/api/projects.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index d4692a7774..dbf608886f 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -852,12 +852,13 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) + ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ + ...$localeCodes, + 'worldwide' + ])), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { - $locale = $locale ?? 'worldwide'; - $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -928,7 +929,7 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') } $template['type'] = $type; - $template['locale'] = $locale === 'worldwide' ? null : $locale; + $template['locale'] = $locale; $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); }); @@ -961,8 +962,6 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->inject('response') ->inject('dbForPlatform') ->action(function (string $projectId, string $type, ?string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { - $locale = $locale ?? 'worldwide'; - $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -982,7 +981,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document([ 'type' => $type, - 'locale' => $locale === 'worldwide' ? null : $locale, + 'locale' => $locale, 'senderName' => $senderName, 'senderEmail' => $senderEmail, 'subject' => $subject, @@ -1011,12 +1010,13 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) + ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ + ...$localeCodes, + 'worldwide' + ])), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { - $locale = $locale ?? 'worldwide'; - + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -1036,7 +1036,7 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document([ 'type' => $type, - 'locale' => $locale === 'worldwide' ? null : $locale, + 'locale' => $locale, 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], From 6d2876ab268fd04d7d0001c85b2667d921c2781c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 19:01:35 +0200 Subject: [PATCH 033/402] New E2E tests --- .../Projects/ProjectsConsoleClientTest.php | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 597030413e..8fec74b6e3 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1163,6 +1163,97 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('Please verify your email {{url}}', $response['body']['message']); } + #[Group('smtpAndTemplates')] + public function testWorldwideTemplates(): void + { + $data = $this->setupProjectData(); + $id = $data['projectId']; + + /** Get default template without locale (should default to worldwide) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('worldwide', $response['body']['locale']); + + /** Get default template with explicit worldwide locale */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('worldwide', $response['body']['locale']); + + /** Set a worldwide email template */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'subject' => 'Worldwide verify subject', + 'message' => 'Worldwide verify message {{url}}', + 'senderName' => 'Worldwide Sender', + 'senderEmail' => 'worldwide@appwrite.io', + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Worldwide verify subject', $response['body']['subject']); + $this->assertEquals('Worldwide verify message {{url}}', $response['body']['message']); + $this->assertEquals('Worldwide Sender', $response['body']['senderName']); + $this->assertEquals('worldwide@appwrite.io', $response['body']['senderEmail']); + $this->assertEquals('verification', $response['body']['type']); + + /** Get the worldwide template back */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Worldwide verify subject', $response['body']['subject']); + $this->assertEquals('Worldwide verify message {{url}}', $response['body']['message']); + $this->assertEquals('Worldwide Sender', $response['body']['senderName']); + $this->assertEquals('worldwide@appwrite.io', $response['body']['senderEmail']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('worldwide', $response['body']['locale']); + + /** Locale-specific template should still return default (not worldwide custom) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('en-us', $response['body']['locale']); + // en-us template was not customized, so it should return the default subject + $this->assertEquals('Account Verification for {{project}}', $response['body']['subject']); + + /** Delete the worldwide template */ + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + + /** After deletion, worldwide GET should return default template */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('worldwide', $response['body']['locale']); + // Should be back to default (no custom subject) + $this->assertNotEquals('Worldwide verify subject', $response['body']['subject']); + } + public function testUpdateProjectAuthDuration(): void { $data = $this->setupProjectData(); From 55001a7daaa5170ca7c497ae0965fc39fc1a782f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 19:27:26 +0200 Subject: [PATCH 034/402] New integration tests --- app/controllers/api/projects.php | 7 +- .../Projects/ProjectsConsoleClientTest.php | 132 ++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index dbf608886f..2163059963 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -953,7 +953,10 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) + ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ + ...$localeCodes, + 'worldwide' + ])), 'Template locale', true, ['localeCodes']) ->param('subject', '', new Text(255), 'Email Subject') ->param('message', '', new Text(0), 'Template message') ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) @@ -961,7 +964,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->param('replyTo', '', new Email(), 'Reply to email', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, ?string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 8fec74b6e3..a6f0c2815a 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1254,6 +1254,138 @@ class ProjectsConsoleClientTest extends Scope $this->assertNotEquals('Worldwide verify subject', $response['body']['subject']); } + #[Group('smtpAndTemplates')] + public function testWorldwideFallbackOnMagicURL(): void + { + $smtpHost = System::getEnv('_APP_SMTP_HOST', 'maildev'); + $smtpPort = intval(System::getEnv('_APP_SMTP_PORT', '1025')); + $smtpUsername = System::getEnv('_APP_SMTP_USERNAME', 'user'); + $smtpPassword = System::getEnv('_APP_SMTP_PASSWORD', 'password'); + + /** Create a dedicated project for this test */ + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Worldwide Fallback Test Team', + ]); + $this->assertEquals(201, $team['headers']['status-code']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Worldwide Fallback Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + $this->assertEquals(201, $project['headers']['status-code']); + $projectId = $project['body']['$id']; + + /** Enable SMTP on the project pointing to maildev */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/smtp', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => true, + 'senderEmail' => 'mailer@appwrite.io', + 'senderName' => 'Mailer', + 'host' => $smtpHost, + 'port' => $smtpPort, + 'username' => $smtpUsername, + 'password' => $smtpPassword, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + /** Set worldwide magicSession template */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email/magicSession/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'subject' => 'Worldwide Magic Login', + 'message' => 'Worldwide magic link: {{url}}', + 'senderName' => 'Worldwide Mailer', + 'senderEmail' => 'worldwide@appwrite.io', + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Worldwide Magic Login', $response['body']['subject']); + + /** Set German (de) magicSession template */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email/magicSession/de', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'subject' => 'German Magic Login', + 'message' => 'German magic link: {{url}}', + 'senderName' => 'German Mailer', + 'senderEmail' => 'german@appwrite.io', + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('German Magic Login', $response['body']['subject']); + + /** Trigger magic URL with English locale — should use worldwide fallback */ + $emailEn = 'magic-en-' . uniqid() . '@appwrite.io'; + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'en', + ], [ + 'userId' => ID::unique(), + 'email' => $emailEn, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** Trigger magic URL with German locale — should use German template */ + $emailDe = 'magic-de-' . uniqid() . '@appwrite.io'; + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'de', + ], [ + 'userId' => ID::unique(), + 'email' => $emailDe, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** Trigger magic URL with Polish locale — should use worldwide fallback */ + $emailPl = 'magic-pl-' . uniqid() . '@appwrite.io'; + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'pl', + ], [ + 'userId' => ID::unique(), + 'email' => $emailPl, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** Verify English email uses worldwide fallback template */ + $lastEmailEn = $this->getLastEmailByAddress($emailEn); + $this->assertEquals('Worldwide Magic Login', $lastEmailEn['subject']); + $this->assertEquals('worldwide@appwrite.io', $lastEmailEn['from'][0]['address']); + $this->assertEquals('Worldwide Mailer', $lastEmailEn['from'][0]['name']); + $this->assertStringContainsString('Worldwide magic link:', $lastEmailEn['html']); + + /** Verify German email uses the German-specific template */ + $lastEmailDe = $this->getLastEmailByAddress($emailDe); + $this->assertEquals('German Magic Login', $lastEmailDe['subject']); + $this->assertEquals('german@appwrite.io', $lastEmailDe['from'][0]['address']); + $this->assertEquals('German Mailer', $lastEmailDe['from'][0]['name']); + $this->assertStringContainsString('German magic link:', $lastEmailDe['html']); + + /** Verify Polish email uses worldwide fallback template */ + $lastEmailPl = $this->getLastEmailByAddress($emailPl); + $this->assertEquals('Worldwide Magic Login', $lastEmailPl['subject']); + $this->assertEquals('worldwide@appwrite.io', $lastEmailPl['from'][0]['address']); + $this->assertEquals('Worldwide Mailer', $lastEmailPl['from'][0]['name']); + $this->assertStringContainsString('Worldwide magic link:', $lastEmailPl['html']); + } + public function testUpdateProjectAuthDuration(): void { $data = $this->setupProjectData(); From 19d0eb66c019a507b39dc23b5258d983746b320a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 16 Apr 2026 10:09:38 +0200 Subject: [PATCH 035/402] Fix tests --- app/controllers/api/projects.php | 5 ++- .../Projects/ProjectsConsoleClientTest.php | 40 +++++++++++++------ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 2163059963..60b6f5d770 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -868,8 +868,9 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $type . '-' . $locale] ?? null; - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + $fallbackLocale = System::getEnv('_APP_LOCALE', 'en'); + $localeObj = new Locale($locale === 'worldwide' ? $fallbackLocale : $locale); + $localeObj->setFallback($fallbackLocale); if (is_null($template)) { /** diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index a6f0c2815a..78b7661ab2 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1169,16 +1169,6 @@ class ProjectsConsoleClientTest extends Scope $data = $this->setupProjectData(); $id = $data['projectId']; - /** Get default template without locale (should default to worldwide) */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('worldwide', $response['body']['locale']); - /** Get default template with explicit worldwide locale */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ 'content-type' => 'application/json', @@ -1221,7 +1211,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('verification', $response['body']['type']); $this->assertEquals('worldwide', $response['body']['locale']); - /** Locale-specific template should still return default (not worldwide custom) */ + /** Locale-specific template should not return the worldwide custom template */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -1230,8 +1220,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('verification', $response['body']['type']); $this->assertEquals('en-us', $response['body']['locale']); - // en-us template was not customized, so it should return the default subject - $this->assertEquals('Account Verification for {{project}}', $response['body']['subject']); + // en-us should NOT return the worldwide custom subject + $this->assertNotEquals('Worldwide verify subject', $response['body']['subject']); /** Delete the worldwide template */ $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ @@ -1325,6 +1315,30 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('German Magic Login', $response['body']['subject']); + /** Verify worldwide template is stored correctly */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/templates/email/magicSession/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Worldwide Magic Login', $response['body']['subject']); + + /** Verify German template is stored correctly */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/templates/email/magicSession/de', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('German Magic Login', $response['body']['subject']); + + /** Verify SMTP is enabled on the project */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['smtpEnabled']); + /** Trigger magic URL with English locale — should use worldwide fallback */ $emailEn = 'magic-en-' . uniqid() . '@appwrite.io'; $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ From 4cf375de6d35c9625730fe26b385b0217fc25ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 16 Apr 2026 10:17:08 +0200 Subject: [PATCH 036/402] Re-add removed test --- app/controllers/api/projects.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 60b6f5d770..46806f79ed 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -858,7 +858,7 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') ])), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 78b7661ab2..e4dfb8d85b 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1169,6 +1169,16 @@ class ProjectsConsoleClientTest extends Scope $data = $this->setupProjectData(); $id = $data['projectId']; + /** Get default template without locale (should default to worldwide) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('worldwide', $response['body']['locale']); + /** Get default template with explicit worldwide locale */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ 'content-type' => 'application/json', @@ -1247,10 +1257,10 @@ class ProjectsConsoleClientTest extends Scope #[Group('smtpAndTemplates')] public function testWorldwideFallbackOnMagicURL(): void { - $smtpHost = System::getEnv('_APP_SMTP_HOST', 'maildev'); - $smtpPort = intval(System::getEnv('_APP_SMTP_PORT', '1025')); - $smtpUsername = System::getEnv('_APP_SMTP_USERNAME', 'user'); - $smtpPassword = System::getEnv('_APP_SMTP_PASSWORD', 'password'); + $smtpHost = 'maildev'; + $smtpPort = 1025; + $smtpUsername = 'user'; + $smtpPassword = 'password'; /** Create a dedicated project for this test */ $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ From 7fe65eec5751116f86689f16efb62f5a8f501c21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 16 Apr 2026 10:23:31 +0200 Subject: [PATCH 037/402] Restruture endpoints --- .../Project/SMTP/{ => Credentials}/Update.php | 8 +- .../Project/SMTP/{Test => Tests}/Create.php | 2 +- .../Modules/Project/Services/Http.php | 6 +- tests/e2e/Services/Project/SMTPBase.php | 78 +++++++++---------- 4 files changed, 47 insertions(+), 47 deletions(-) rename src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/{ => Credentials}/Update.php (95%) rename src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/{Test => Tests}/Create.php (99%) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php similarity index 95% rename from src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php rename to src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php index 28c4929694..9117554622 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/smtp') + ->setHttpPath('/v1/project/smtp/credentials') ->httpAlias('/v1/projects/:projectId/smtp') ->desc('Update project SMTP details') ->groups(['api', 'project']) @@ -46,7 +46,7 @@ class Update extends Action ->label('sdk', new Method( namespace: 'project', group: 'smtp', - name: 'updateSMTP', + name: 'updateSMTPCredentials', description: <<addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); // SMTP - $this->addAction(UpdateSMTP::getName(), new UpdateSMTP()); + $this->addAction(UpdateSMTPCredentials::getName(), new UpdateSMTPCredentials()); $this->addAction(UpdateSMTPStatus::getName(), new UpdateSMTPStatus()); $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index da5be3869d..51e2e5e8a9 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -87,9 +87,9 @@ trait SMTPBase // Update SMTP tests - public function testUpdateSMTP(): void + public function testUpdateSMTPCredentials(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -110,7 +110,7 @@ trait SMTPBase public function testUpdateSMTPWithOptionalReplyTo(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Full Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -132,14 +132,14 @@ trait SMTPBase public function testUpdateSMTPOverwritesPreviousSettings(): void { - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'First Sender', senderEmail: 'first@example.com', host: 'maildev', port: 1025, ); - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Second Sender', senderEmail: 'second@example.com', host: 'maildev', @@ -159,7 +159,7 @@ trait SMTPBase // Ensure SMTP is disabled $this->updateSMTPStatus(false); - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -175,7 +175,7 @@ trait SMTPBase public function testUpdateSMTPResponseModel(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -201,7 +201,7 @@ trait SMTPBase public function testUpdateSMTPWithoutAuthentication(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -214,7 +214,7 @@ trait SMTPBase public function testUpdateSMTPInvalidSenderEmail(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'not-an-email', host: 'maildev', @@ -226,7 +226,7 @@ trait SMTPBase public function testUpdateSMTPEmptySenderName(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: '', senderEmail: 'sender@example.com', host: 'maildev', @@ -238,7 +238,7 @@ trait SMTPBase public function testUpdateSMTPEmptySenderEmail(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: '', host: 'maildev', @@ -250,7 +250,7 @@ trait SMTPBase public function testUpdateSMTPEmptyHost(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: '', @@ -262,7 +262,7 @@ trait SMTPBase public function testUpdateSMTPInvalidHost(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'not a valid host!@#', @@ -274,7 +274,7 @@ trait SMTPBase public function testUpdateSMTPInvalidReplyToEmail(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -287,7 +287,7 @@ trait SMTPBase public function testUpdateSMTPInvalidSecure(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -300,7 +300,7 @@ trait SMTPBase public function testUpdateSMTPSenderNameMinLength(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'A', senderEmail: 'sender@example.com', host: 'maildev', @@ -317,7 +317,7 @@ trait SMTPBase public function testUpdateSMTPSenderNameMaxLength(): void { $name = str_repeat('a', 256); - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: $name, senderEmail: 'sender@example.com', host: 'maildev', @@ -333,7 +333,7 @@ trait SMTPBase public function testUpdateSMTPSenderNameTooLong(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: str_repeat('a', 257), senderEmail: 'sender@example.com', host: 'maildev', @@ -345,7 +345,7 @@ trait SMTPBase public function testUpdateSMTPUsernameMinLength(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -363,7 +363,7 @@ trait SMTPBase public function testUpdateSMTPUsernameMaxLength(): void { $username = str_repeat('a', 256); - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -380,7 +380,7 @@ trait SMTPBase public function testUpdateSMTPUsernameTooLong(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -393,7 +393,7 @@ trait SMTPBase public function testUpdateSMTPUsernameEmpty(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -406,7 +406,7 @@ trait SMTPBase public function testUpdateSMTPPasswordMinLength(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -424,7 +424,7 @@ trait SMTPBase public function testUpdateSMTPPasswordMaxLength(): void { $password = str_repeat('a', 256); - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -441,7 +441,7 @@ trait SMTPBase public function testUpdateSMTPPasswordTooLong(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -454,7 +454,7 @@ trait SMTPBase public function testUpdateSMTPPasswordEmpty(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -467,7 +467,7 @@ trait SMTPBase public function testUpdateSMTPWithoutSecure(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -483,7 +483,7 @@ trait SMTPBase public function testUpdateSMTPInvalidConnectionRefused(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'localhost', @@ -500,7 +500,7 @@ trait SMTPBase $this->updateSMTPStatus(true); // Use the deprecated enabled=false parameter to disable - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -517,7 +517,7 @@ trait SMTPBase public function testCreateSMTPTest(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -536,7 +536,7 @@ trait SMTPBase public function testCreateSMTPTestMultipleRecipients(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -576,7 +576,7 @@ trait SMTPBase public function testCreateSMTPTestEmptyEmails(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -595,7 +595,7 @@ trait SMTPBase public function testCreateSMTPTestInvalidEmail(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -613,7 +613,7 @@ trait SMTPBase public function testCreateSMTPTestExceedsMaxEmails(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -636,7 +636,7 @@ trait SMTPBase public function testCreateSMTPTestMaxEmails(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -667,7 +667,7 @@ trait SMTPBase $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; // Configure SMTP with replyTo and auth credentials - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', @@ -709,7 +709,7 @@ trait SMTPBase $recipientEmail = 'magicurl-' . \uniqid() . '@appwrite.io'; // Configure custom SMTP with auth credentials - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', @@ -764,7 +764,7 @@ trait SMTPBase ]); } - protected function updateSMTP( + protected function updateSMTPCredentials( string $senderName = '', string $senderEmail = '', string $host = '', @@ -812,7 +812,7 @@ trait SMTPBase $params['enabled'] = $enabled; } - return $this->client->call(Client::METHOD_PATCH, '/project/smtp', $headers, $params); + return $this->client->call(Client::METHOD_PATCH, '/project/smtp/credentials', $headers, $params); } /** From 93b9500a95af55447e438c2b04fadfd2e2a172e2 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 16 Apr 2026 15:22:45 +0530 Subject: [PATCH 038/402] align it with cloud pattern --- .../Modules/Functions/Workers/Screenshots.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index 0cb42822b3..8fed46aa00 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -21,6 +21,7 @@ use Utopia\Queue\Message; use Utopia\Storage\Device; use Utopia\System\System; use Utopia\Telemetry\Adapter as Telemetry; +use Utopia\Telemetry\Counter; use function Swoole\Coroutine\batch; @@ -67,6 +68,7 @@ class Screenshots extends Action } $screenshotMessage = Screenshot::fromArray($payload); + $counter = $telemetry->createCounter('worker.screenshots.capture'); Console::log('Site screenshot started'); @@ -271,23 +273,21 @@ class Screenshots extends Action $date = \date('H:i:s'); $this->appendToLogs($dbForProject, $deployment->getId(), $queueForRealtime, "[$date] [appwrite] Screenshot capturing failed. Deployment will continue. \n"); - $this->recordTelemetry($telemetry, 'failure'); + $this->recordTelemetry($counter, 'failure'); throw $th; } - $this->recordTelemetry($telemetry, 'success'); + $this->recordTelemetry($counter, 'success'); } - protected function recordTelemetry(Telemetry $telemetry, string $result): void + protected function recordTelemetry(Counter $counter, string $result): void { try { - $telemetry - ->createCounter('worker.screenshots.capture') - ->add(1, [ - 'resourceType' => RESOURCE_TYPE_SITES, - 'result' => $result, - ]); + $counter->add(1, [ + 'resourceType' => RESOURCE_TYPE_SITES, + 'result' => $result, + ]); } catch (\Throwable) { // Telemetry should never affect screenshot processing. } From 807e8bec8be12af10e9e9af01d004d6cf68420df Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 16:29:42 +0530 Subject: [PATCH 039/402] feat(specs): add discriminator for provider repository list response union Add ProviderRepositoryFrameworkList and ProviderRepositoryRuntimeList model classes with conditions and type field so the listRepositories endpoint's oneOf response gets a discriminator on the type property. --- app/init/models.php | 6 ++-- .../Http/Installations/Repositories/XList.php | 1 + .../Model/ProviderRepositoryFrameworkList.php | 29 +++++++++++++++++++ .../Model/ProviderRepositoryRuntimeList.php | 29 +++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php create mode 100644 src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php diff --git a/app/init/models.php b/app/init/models.php index dd97b03652..c92295ae33 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -117,7 +117,9 @@ use Appwrite\Utopia\Response\Model\Project; use Appwrite\Utopia\Response\Model\Provider; use Appwrite\Utopia\Response\Model\ProviderRepository; use Appwrite\Utopia\Response\Model\ProviderRepositoryFramework; +use Appwrite\Utopia\Response\Model\ProviderRepositoryFrameworkList; use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntime; +use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntimeList; use Appwrite\Utopia\Response\Model\ResourceToken; use Appwrite\Utopia\Response\Model\Row; use Appwrite\Utopia\Response\Model\Rule; @@ -190,8 +192,8 @@ Response::setModel(new BaseList('Site Templates List', Response::MODEL_TEMPLATE_ Response::setModel(new BaseList('Functions List', Response::MODEL_FUNCTION_LIST, 'functions', Response::MODEL_FUNCTION)); Response::setModel(new BaseList('Function Templates List', Response::MODEL_TEMPLATE_FUNCTION_LIST, 'templates', Response::MODEL_TEMPLATE_FUNCTION)); Response::setModel(new BaseList('Installations List', Response::MODEL_INSTALLATION_LIST, 'installations', Response::MODEL_INSTALLATION)); -Response::setModel(new BaseList('Framework Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, 'frameworkProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK)); -Response::setModel(new BaseList('Runtime Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, 'runtimeProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME)); +Response::setModel(new ProviderRepositoryFrameworkList()); +Response::setModel(new ProviderRepositoryRuntimeList()); Response::setModel(new BaseList('Branches List', Response::MODEL_BRANCH_LIST, 'branches', Response::MODEL_BRANCH)); Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIST, 'frameworks', Response::MODEL_FRAMEWORK)); Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME)); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php index d5b2b48175..b4172fabdf 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php @@ -313,6 +313,7 @@ class XList extends Action }, $repos); $response->dynamic(new Document([ + 'type' => $type, $type === 'framework' ? 'frameworkProviderRepositories' : 'runtimeProviderRepositories' => $repos, 'total' => $total, ]), ($type === 'framework') ? Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST : Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST); diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php new file mode 100644 index 0000000000..d1982e2f84 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php @@ -0,0 +1,29 @@ + 'framework', + ]; + + public function __construct() + { + parent::__construct( + 'Framework Provider Repositories List', + Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, + 'frameworkProviderRepositories', + Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK + ); + + $this->addRule('type', [ + 'type' => self::TYPE_STRING, + 'description' => 'Provider repository list type.', + 'default' => 'framework', + 'example' => 'framework', + ]); + } +} diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php new file mode 100644 index 0000000000..f7ef1d7b5f --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php @@ -0,0 +1,29 @@ + 'runtime', + ]; + + public function __construct() + { + parent::__construct( + 'Runtime Provider Repositories List', + Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, + 'runtimeProviderRepositories', + Response::MODEL_PROVIDER_REPOSITORY_RUNTIME + ); + + $this->addRule('type', [ + 'type' => self::TYPE_STRING, + 'description' => 'Provider repository list type.', + 'default' => 'runtime', + 'example' => 'runtime', + ]); + } +} From 463e5acf5069bf2331f2b95321f2fad6e4d84c42 Mon Sep 17 00:00:00 2001 From: Atharva Deosthale Date: Thu, 16 Apr 2026 16:57:19 +0530 Subject: [PATCH 040/402] compose fixes --- app/views/install/compose.phtml | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index ef4d4a1fe4..1bf36b7f6d 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -120,7 +120,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_SMTP_HOST - _APP_SMTP_PORT - _APP_SMTP_SECURE @@ -256,7 +255,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_USAGE_STATS - _APP_LOGGING_CONFIG @@ -287,7 +285,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG appwrite-worker-webhooks: @@ -315,7 +312,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -356,7 +352,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_STORAGE_DEVICE - _APP_STORAGE_S3_ACCESS_KEY - _APP_STORAGE_S3_SECRET @@ -416,7 +411,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG appwrite-worker-builds: @@ -453,7 +447,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_VCS_GITHUB_APP_NAME - _APP_VCS_GITHUB_PRIVATE_KEY @@ -529,7 +522,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG appwrite-worker-executions: @@ -592,7 +584,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_FUNCTIONS_TIMEOUT - _APP_SITES_TIMEOUT - _APP_COMPUTE_BUILD_TIMEOUT @@ -630,7 +621,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -673,7 +663,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_SMS_FROM - _APP_SMS_PROVIDER @@ -734,7 +723,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_MIGRATIONS_FIREBASE_CLIENT_ID - _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET @@ -773,7 +761,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_MAINTENANCE_INTERVAL - _APP_MAINTENANCE_RETENTION_EXECUTION - _APP_MAINTENANCE_RETENTION_CACHE @@ -806,7 +793,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -839,7 +825,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -871,7 +856,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -907,7 +891,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER appwrite-task-scheduler-executions: image: /: @@ -936,7 +919,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER appwrite-task-scheduler-messages: image: /: @@ -965,7 +947,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER appwrite-assistant: @@ -1068,13 +1049,12 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); image: mongo:8.2.5 container_name: appwrite-mongodb <<: *x-logging + restart: unless-stopped networks: - appwrite volumes: - appwrite-mongodb:/data/db - appwrite-mongodb-keyfile:/data/keyfile - ports: - - "27017:27017" environment: - MONGO_INITDB_ROOT_USERNAME=root - MONGO_INITDB_ROOT_PASSWORD=${_APP_DB_ROOT_PASS} @@ -1205,7 +1185,6 @@ volumes: appwrite-mongodb: appwrite-mongodb-keyfile: - appwrite-mongodb-config: appwrite-redis: appwrite-cache: From 1e797b3f01b005d5a385ecf85e61646b0f7b2634 Mon Sep 17 00:00:00 2001 From: Aditya Oberai Date: Thu, 16 Apr 2026 17:00:28 +0000 Subject: [PATCH 041/402] Update React Admin template metadata --- app/config/templates/site.php | 12 ++++++------ ...dmin-dark.png => dashboard-react-admin-dark.png} | Bin ...in-light.png => dashboard-react-admin-light.png} | Bin 3 files changed, 6 insertions(+), 6 deletions(-) rename public/images/sites/templates/{crm-dashboard-react-admin-dark.png => dashboard-react-admin-dark.png} (100%) rename public/images/sites/templates/{crm-dashboard-react-admin-light.png => dashboard-react-admin-light.png} (100%) diff --git a/app/config/templates/site.php b/app/config/templates/site.php index 26f8e39817..b26d31f475 100644 --- a/app/config/templates/site.php +++ b/app/config/templates/site.php @@ -1487,13 +1487,13 @@ return [ ] ], [ - 'key' => 'crm-dashboard-react-admin', - 'name' => 'CRM dashboard with React Admin', - 'tagline' => 'A React-based admin dashboard template with CRM features.', + 'key' => 'dashboard-react-admin', + 'name' => 'E-commerce dashboard with React Admin', + 'tagline' => 'A React-based admin dashboard template with e-commerce features.', 'score' => 4, // 0 to 10 based on looks of screenshot (avoid 1,2,3,8,9,10 if possible) - 'useCases' => [SiteUseCases::DASHBOARD], - 'screenshotDark' => $url . '/images/sites/templates/crm-dashboard-react-admin-dark.png', - 'screenshotLight' => $url . '/images/sites/templates/crm-dashboard-react-admin-light.png', + 'useCases' => [SiteUseCases::DASHBOARD, SiteUseCases::ECOMMERCE], + 'screenshotDark' => $url . '/images/sites/templates/dashboard-react-admin-dark.png', + 'screenshotLight' => $url . '/images/sites/templates/dashboard-react-admin-light.png', 'frameworks' => [ getFramework('REACT', [ 'providerRootDirectory' => './react/react-admin', diff --git a/public/images/sites/templates/crm-dashboard-react-admin-dark.png b/public/images/sites/templates/dashboard-react-admin-dark.png similarity index 100% rename from public/images/sites/templates/crm-dashboard-react-admin-dark.png rename to public/images/sites/templates/dashboard-react-admin-dark.png diff --git a/public/images/sites/templates/crm-dashboard-react-admin-light.png b/public/images/sites/templates/dashboard-react-admin-light.png similarity index 100% rename from public/images/sites/templates/crm-dashboard-react-admin-light.png rename to public/images/sites/templates/dashboard-react-admin-light.png From 71b74e21a36773a71c3c8ea8f28525a6ca636574 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Apr 2026 13:36:48 +0530 Subject: [PATCH 042/402] added delay metric --- app/init/constants.php | 1 + app/realtime.php | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/app/init/constants.php b/app/init/constants.php index f2127cd666..12de293ff6 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -410,6 +410,7 @@ const METRIC_REALTIME_CONNECTIONS = 'realtime.connections'; const METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT = 'realtime.messages.sent'; const METRIC_REALTIME_INBOUND = 'realtime.inbound'; const METRIC_REALTIME_OUTBOUND = 'realtime.outbound'; +const METRIC_REALTIME_DELIVERY_DELAY = 'realtime.delivery.delay'; // Resource types const RESOURCE_TYPE_PROJECTS = 'projects'; diff --git a/app/realtime.php b/app/realtime.php index 955832e93a..bbff9b98ab 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -600,6 +600,24 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT => $total, ]; + $updatedAt = $event['data']['payload']['$updatedAt'] ?? null; + if (\is_string($updatedAt)) { + try { + $updatedAtDate = new \DateTimeImmutable($updatedAt); + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000; + $nowTimestampMs = (float) $now->format('U.u') * 1000; + $delayMs = (int) \max( + 0, + $nowTimestampMs - $updatedAtTimestampMs + ); + + $metrics[METRIC_REALTIME_DELIVERY_DELAY] = $delayMs; + } catch (\Throwable) { + // Ignore invalid timestamp payloads. + } + } + if ($outboundBytes > 0) { $metrics[METRIC_REALTIME_OUTBOUND] = $outboundBytes; } From b5ec92964c28b98bc554f87cf4b133fe82361f54 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Apr 2026 14:08:42 +0530 Subject: [PATCH 043/402] updated telemetry --- app/init/constants.php | 1 - app/realtime.php | 33 +++++++++++++++------------------ 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/app/init/constants.php b/app/init/constants.php index 12de293ff6..f2127cd666 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -410,7 +410,6 @@ const METRIC_REALTIME_CONNECTIONS = 'realtime.connections'; const METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT = 'realtime.messages.sent'; const METRIC_REALTIME_INBOUND = 'realtime.inbound'; const METRIC_REALTIME_OUTBOUND = 'realtime.outbound'; -const METRIC_REALTIME_DELIVERY_DELAY = 'realtime.delivery.delay'; // Resource types const RESOURCE_TYPE_PROJECTS = 'projects'; diff --git a/app/realtime.php b/app/realtime.php index bbff9b98ab..192672a2f3 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -398,6 +398,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); $register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent')); + $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram('realtime.server.delivery_delay', 'ms')); $attempts = 0; $start = time(); @@ -592,6 +593,20 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, if ($total > 0) { $register->get('telemetry.messageSentCounter')->add($total); $stats->incr($event['project'], 'messages', $total); + $updatedAt = $event['data']['payload']['$updatedAt'] ?? null; + if (\is_string($updatedAt)) { + try { + $updatedAtDate = new \DateTimeImmutable($updatedAt); + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000; + $nowTimestampMs = (float) $now->format('U.u') * 1000; + $delayMs = (int) \max(0, $nowTimestampMs - $updatedAtTimestampMs); + + $register->get('telemetry.deliveryDelayHistogram')->record($delayMs); + } catch (\Throwable) { + // Ignore invalid timestamp payloads. + } + } $projectId = $event['project'] ?? null; @@ -600,24 +615,6 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT => $total, ]; - $updatedAt = $event['data']['payload']['$updatedAt'] ?? null; - if (\is_string($updatedAt)) { - try { - $updatedAtDate = new \DateTimeImmutable($updatedAt); - $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); - $updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000; - $nowTimestampMs = (float) $now->format('U.u') * 1000; - $delayMs = (int) \max( - 0, - $nowTimestampMs - $updatedAtTimestampMs - ); - - $metrics[METRIC_REALTIME_DELIVERY_DELAY] = $delayMs; - } catch (\Throwable) { - // Ignore invalid timestamp payloads. - } - } - if ($outboundBytes > 0) { $metrics[METRIC_REALTIME_OUTBOUND] = $outboundBytes; } From 11f23fdcfa3aa90cd862609e869ca430fb14f209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 10:52:21 +0200 Subject: [PATCH 044/402] Rework email templates PR after discussions --- app/controllers/api/account.php | 8 +- app/controllers/api/projects.php | 35 ++- src/Appwrite/Bus/Listeners/Mails.php | 2 +- .../Http/Account/MFA/Challenges/Create.php | 2 +- .../Modules/Teams/Http/Memberships/Create.php | 2 +- .../Utopia/Response/Model/Template.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 247 ------------------ 7 files changed, 25 insertions(+), 273 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 03526bd49f..7511f7d31f 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2268,7 +2268,7 @@ Http::post('/v1/account/tokens/magic-url') $customTemplate = $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.magicSession-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.magicSession-' . $locale->fallback] ?? []; $detector = new Detector($request->getUserAgent('UNKNOWN')); $agentOs = $detector->getOS(); @@ -2580,7 +2580,7 @@ Http::post('/v1/account/tokens/email') $customTemplate = $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.otpSession-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.otpSession-' . $locale->fallback] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); @@ -3728,7 +3728,7 @@ Http::post('/v1/account/recovery') $preview = $locale->getText("emails.recovery.preview"); $customTemplate = $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.recovery-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.recovery-' . $locale->fallback] ?? []; $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl'); $message @@ -4038,7 +4038,7 @@ Http::post('/v1/account/verifications/email') $customTemplate = $project->getAttribute('templates', [])['email.verification-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.verification-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.verification-' . $locale->fallback] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 46806f79ed..23d3af075a 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -852,13 +852,13 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ - ...$localeCodes, - 'worldwide' - ])), 'Template locale', true, ['localeCodes']) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { + ->inject('locale') + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -868,9 +868,8 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $type . '-' . $locale] ?? null; - $fallbackLocale = System::getEnv('_APP_LOCALE', 'en'); - $localeObj = new Locale($locale === 'worldwide' ? $fallbackLocale : $locale); - $localeObj->setFallback($fallbackLocale); + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); if (is_null($template)) { /** @@ -954,10 +953,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ - ...$localeCodes, - 'worldwide' - ])), 'Template locale', true, ['localeCodes']) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) ->param('subject', '', new Text(255), 'Email Subject') ->param('message', '', new Text(0), 'Template message') ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) @@ -965,7 +961,10 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->param('replyTo', '', new Email(), 'Reply to email', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { + ->inject('locale') + ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform, Locale $localeObject) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -1014,13 +1013,13 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ - ...$localeCodes, - 'worldwide' - ])), 'Template locale', true, ['localeCodes']) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { + ->inject('locale') + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index 7b33baced5..e59bbe3536 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -73,7 +73,7 @@ class Mails extends Listener $customTemplate = $project->getAttribute('templates', [])["email.sessionAlert-$event->locale"] ?? - $project->getAttribute('templates', [])['email.sessionAlert-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->fallback] ?? []; $isBranded = $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE; $subject = $customTemplate['subject'] ?? $locale->getText('emails.sessionAlert.subject'); diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 319e080f25..14dc4e3237 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -220,7 +220,7 @@ class Create extends Action $customTemplate = $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.mfaChallenge-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->fallback] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 161e817aed..aa4ee2c66c 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -326,7 +326,7 @@ class Create extends Action $subject = $locale->getText('emails.invitation.subject'); $customTemplate = $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.invitation-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.invitation-' . $locale->fallback] ?? []; $message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-inner-base.tpl'); $message diff --git a/src/Appwrite/Utopia/Response/Model/Template.php b/src/Appwrite/Utopia/Response/Model/Template.php index b0e127e07f..3ce9cacdb3 100644 --- a/src/Appwrite/Utopia/Response/Model/Template.php +++ b/src/Appwrite/Utopia/Response/Model/Template.php @@ -19,7 +19,7 @@ abstract class Template extends Model 'type' => self::TYPE_STRING, 'description' => 'Template locale', 'default' => '', - 'example' => 'worldwide', + 'example' => 'en_us', ]) ->addRule('message', [ 'type' => self::TYPE_STRING, diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e4dfb8d85b..597030413e 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1163,253 +1163,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('Please verify your email {{url}}', $response['body']['message']); } - #[Group('smtpAndTemplates')] - public function testWorldwideTemplates(): void - { - $data = $this->setupProjectData(); - $id = $data['projectId']; - - /** Get default template without locale (should default to worldwide) */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('worldwide', $response['body']['locale']); - - /** Get default template with explicit worldwide locale */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('worldwide', $response['body']['locale']); - - /** Set a worldwide email template */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'subject' => 'Worldwide verify subject', - 'message' => 'Worldwide verify message {{url}}', - 'senderName' => 'Worldwide Sender', - 'senderEmail' => 'worldwide@appwrite.io', - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('Worldwide verify subject', $response['body']['subject']); - $this->assertEquals('Worldwide verify message {{url}}', $response['body']['message']); - $this->assertEquals('Worldwide Sender', $response['body']['senderName']); - $this->assertEquals('worldwide@appwrite.io', $response['body']['senderEmail']); - $this->assertEquals('verification', $response['body']['type']); - - /** Get the worldwide template back */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('Worldwide verify subject', $response['body']['subject']); - $this->assertEquals('Worldwide verify message {{url}}', $response['body']['message']); - $this->assertEquals('Worldwide Sender', $response['body']['senderName']); - $this->assertEquals('worldwide@appwrite.io', $response['body']['senderEmail']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('worldwide', $response['body']['locale']); - - /** Locale-specific template should not return the worldwide custom template */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('en-us', $response['body']['locale']); - // en-us should NOT return the worldwide custom subject - $this->assertNotEquals('Worldwide verify subject', $response['body']['subject']); - - /** Delete the worldwide template */ - $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - - /** After deletion, worldwide GET should return default template */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('worldwide', $response['body']['locale']); - // Should be back to default (no custom subject) - $this->assertNotEquals('Worldwide verify subject', $response['body']['subject']); - } - - #[Group('smtpAndTemplates')] - public function testWorldwideFallbackOnMagicURL(): void - { - $smtpHost = 'maildev'; - $smtpPort = 1025; - $smtpUsername = 'user'; - $smtpPassword = 'password'; - - /** Create a dedicated project for this test */ - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'teamId' => ID::unique(), - 'name' => 'Worldwide Fallback Test Team', - ]); - $this->assertEquals(201, $team['headers']['status-code']); - - $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'projectId' => ID::unique(), - 'name' => 'Worldwide Fallback Test', - 'teamId' => $team['body']['$id'], - 'region' => System::getEnv('_APP_REGION', 'default'), - ]); - $this->assertEquals(201, $project['headers']['status-code']); - $projectId = $project['body']['$id']; - - /** Enable SMTP on the project pointing to maildev */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/smtp', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'enabled' => true, - 'senderEmail' => 'mailer@appwrite.io', - 'senderName' => 'Mailer', - 'host' => $smtpHost, - 'port' => $smtpPort, - 'username' => $smtpUsername, - 'password' => $smtpPassword, - ]); - $this->assertEquals(200, $response['headers']['status-code']); - - /** Set worldwide magicSession template */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email/magicSession/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'subject' => 'Worldwide Magic Login', - 'message' => 'Worldwide magic link: {{url}}', - 'senderName' => 'Worldwide Mailer', - 'senderEmail' => 'worldwide@appwrite.io', - ]); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('Worldwide Magic Login', $response['body']['subject']); - - /** Set German (de) magicSession template */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email/magicSession/de', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'subject' => 'German Magic Login', - 'message' => 'German magic link: {{url}}', - 'senderName' => 'German Mailer', - 'senderEmail' => 'german@appwrite.io', - ]); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('German Magic Login', $response['body']['subject']); - - /** Verify worldwide template is stored correctly */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/templates/email/magicSession/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('Worldwide Magic Login', $response['body']['subject']); - - /** Verify German template is stored correctly */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/templates/email/magicSession/de', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('German Magic Login', $response['body']['subject']); - - /** Verify SMTP is enabled on the project */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertTrue($response['body']['smtpEnabled']); - - /** Trigger magic URL with English locale — should use worldwide fallback */ - $emailEn = 'magic-en-' . uniqid() . '@appwrite.io'; - $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-locale' => 'en', - ], [ - 'userId' => ID::unique(), - 'email' => $emailEn, - ]); - $this->assertEquals(201, $response['headers']['status-code']); - - /** Trigger magic URL with German locale — should use German template */ - $emailDe = 'magic-de-' . uniqid() . '@appwrite.io'; - $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-locale' => 'de', - ], [ - 'userId' => ID::unique(), - 'email' => $emailDe, - ]); - $this->assertEquals(201, $response['headers']['status-code']); - - /** Trigger magic URL with Polish locale — should use worldwide fallback */ - $emailPl = 'magic-pl-' . uniqid() . '@appwrite.io'; - $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-locale' => 'pl', - ], [ - 'userId' => ID::unique(), - 'email' => $emailPl, - ]); - $this->assertEquals(201, $response['headers']['status-code']); - - /** Verify English email uses worldwide fallback template */ - $lastEmailEn = $this->getLastEmailByAddress($emailEn); - $this->assertEquals('Worldwide Magic Login', $lastEmailEn['subject']); - $this->assertEquals('worldwide@appwrite.io', $lastEmailEn['from'][0]['address']); - $this->assertEquals('Worldwide Mailer', $lastEmailEn['from'][0]['name']); - $this->assertStringContainsString('Worldwide magic link:', $lastEmailEn['html']); - - /** Verify German email uses the German-specific template */ - $lastEmailDe = $this->getLastEmailByAddress($emailDe); - $this->assertEquals('German Magic Login', $lastEmailDe['subject']); - $this->assertEquals('german@appwrite.io', $lastEmailDe['from'][0]['address']); - $this->assertEquals('German Mailer', $lastEmailDe['from'][0]['name']); - $this->assertStringContainsString('German magic link:', $lastEmailDe['html']); - - /** Verify Polish email uses worldwide fallback template */ - $lastEmailPl = $this->getLastEmailByAddress($emailPl); - $this->assertEquals('Worldwide Magic Login', $lastEmailPl['subject']); - $this->assertEquals('worldwide@appwrite.io', $lastEmailPl['from'][0]['address']); - $this->assertEquals('Worldwide Mailer', $lastEmailPl['from'][0]['name']); - $this->assertStringContainsString('Worldwide magic link:', $lastEmailPl['html']); - } - public function testUpdateProjectAuthDuration(): void { $data = $this->setupProjectData(); From 1b826df8f97aae963f9857898f043f4df0be836e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 11:24:59 +0200 Subject: [PATCH 045/402] Non-URL locale to allow optional --- app/controllers/api/projects.php | 9 ++++++--- src/Appwrite/Bus/Listeners/Mails.php | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 23d3af075a..439692e1dd 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -833,7 +833,8 @@ Http::post('/v1/projects/:projectId/smtp/tests') $response->noContent(); }); -Http::get('/v1/projects/:projectId/templates/email/:type/:locale') +Http::get('/v1/projects/:projectId/templates/email') + ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Get custom email template') ->groups(['api', 'projects']) ->label('scope', 'projects.write') @@ -934,7 +935,8 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); }); -Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') +Http::patch('/v1/projects/:projectId/templates/email') + ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Update custom email templates') ->groups(['api', 'projects']) ->label('scope', 'projects.write') @@ -993,7 +995,8 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ]), Response::MODEL_EMAIL_TEMPLATE); }); -Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') +Http::delete('/v1/projects/:projectId/templates/email') + ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Delete custom email template') ->groups(['api', 'projects']) ->label('scope', 'projects.write') diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index e59bbe3536..3d31101d2b 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -72,7 +72,7 @@ class Mails extends Listener } $customTemplate = - $project->getAttribute('templates', [])["email.sessionAlert-$event->locale"] ?? + $project->getAttribute('templates', [])["email.sessionAlert-" . $locale->default] ?? $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->fallback] ?? []; $isBranded = $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE; From bf9bb22ac5dd140d866e2a42d8690e574fc23320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 11:30:24 +0200 Subject: [PATCH 046/402] New tests --- .../Projects/ProjectsConsoleClientTest.php | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 597030413e..f937317b8f 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1163,6 +1163,207 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('Please verify your email {{url}}', $response['body']['message']); } + #[Group('smtpAndTemplates')] + public function testSessionAlertLocaleFallback(): void + { + $smtpHost = 'maildev'; + $smtpPort = 1025; + $smtpUsername = 'user'; + $smtpPassword = 'password'; + + /** Create team */ + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Session Alert Locale Fallback Test Team', + ]); + $this->assertEquals(201, $team['headers']['status-code']); + + /** Create project */ + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Session Alert Locale Fallback Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + $this->assertEquals(201, $project['headers']['status-code']); + $projectId = $project['body']['$id']; + + /** Configure custom SMTP pointing to maildev */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/smtp', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => true, + 'senderEmail' => 'mailer@appwrite.io', + 'senderName' => 'Mailer', + 'host' => $smtpHost, + 'port' => $smtpPort, + 'username' => $smtpUsername, + 'password' => $smtpPassword, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + /** + * Set custom sessionAlert template with no explicit locale. + * When locale is omitted, the server stores it under the request's + * default locale (en), which is the same slot used as the system-wide + * fallback when a session's locale has no dedicated template. + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'type' => 'sessionAlert', + // Intentionally no locale + 'subject' => 'Fallback sign-in alert', + 'message' => 'Fallback sign-in alert body', + 'senderName' => 'Fallback Mailer', + 'senderEmail' => 'fallback@appwrite.io', + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Fallback sign-in alert', $response['body']['subject']); + $this->assertEquals('Fallback sign-in alert body', $response['body']['message']); + $this->assertEquals('Fallback Mailer', $response['body']['senderName']); + $this->assertEquals('fallback@appwrite.io', $response['body']['senderEmail']); + + /** Set custom sessionAlert template for Slovak locale */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'type' => 'sessionAlert', + 'locale' => 'sk', + 'subject' => 'Slovak sign-in alert', + 'message' => 'Slovak sign-in alert body', + 'senderName' => 'Slovak Mailer', + 'senderEmail' => 'sk@appwrite.io', + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Slovak sign-in alert', $response['body']['subject']); + $this->assertEquals('Slovak sign-in alert body', $response['body']['message']); + $this->assertEquals('Slovak Mailer', $response['body']['senderName']); + $this->assertEquals('sk@appwrite.io', $response['body']['senderEmail']); + + /** Enable session alerts */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/auth/session-alerts', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'alerts' => true, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + /** Verify alerts are enabled */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['authSessionAlerts']); + + /** Create user (email + password) in the project */ + $userEmail = 'session-alert-' . uniqid() . '@appwrite.io'; + $password = 'password'; + $response = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'userId' => ID::unique(), + 'email' => $userEmail, + 'password' => $password, + 'name' => 'Session Alert User', + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * Prime first session — the listener suppresses the alert on the very + * first session of a user, so this session is setup only. + */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $userEmail, + 'password' => $password, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** Create a new session with no locale — expect fallback (en) template */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $userEmail, + 'password' => $password, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $lastEmail = $this->getLastEmailByAddress($userEmail); + $this->assertEquals('Fallback sign-in alert', $lastEmail['subject']); + $this->assertEquals('fallback@appwrite.io', $lastEmail['from'][0]['address']); + $this->assertEquals('Fallback Mailer', $lastEmail['from'][0]['name']); + $this->assertStringContainsString('Fallback sign-in alert body', $lastEmail['html']); + + /** Create a new session with German locale — expect fallback (en) template */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'de', + ], [ + 'email' => $userEmail, + 'password' => $password, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $lastEmail = $this->getLastEmailByAddress($userEmail); + $this->assertEquals('Fallback sign-in alert', $lastEmail['subject']); + $this->assertEquals('fallback@appwrite.io', $lastEmail['from'][0]['address']); + $this->assertEquals('Fallback Mailer', $lastEmail['from'][0]['name']); + $this->assertStringContainsString('Fallback sign-in alert body', $lastEmail['html']); + + /** Create a new session with Slovak locale — expect Slovak template */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'sk', + ], [ + 'email' => $userEmail, + 'password' => $password, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $lastEmail = $this->getLastEmailByAddress($userEmail); + $this->assertEquals('Slovak sign-in alert', $lastEmail['subject']); + $this->assertEquals('sk@appwrite.io', $lastEmail['from'][0]['address']); + $this->assertEquals('Slovak Mailer', $lastEmail['from'][0]['name']); + $this->assertStringContainsString('Slovak sign-in alert body', $lastEmail['html']); + + /** Cleanup — delete the project */ + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(204, $response['headers']['status-code']); + + /** Cleanup — delete the team */ + $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(204, $response['headers']['status-code']); + } + public function testUpdateProjectAuthDuration(): void { $data = $this->setupProjectData(); From c97dd783353bf174edc9df7c5e7639a63bde1e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 11:40:05 +0200 Subject: [PATCH 047/402] Fix tests --- .../Projects/ProjectsConsoleClientTest.php | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index f937317b8f..59ff5e353c 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1180,6 +1180,7 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Session Alert Locale Fallback Test Team', ]); $this->assertEquals(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; /** Create project */ $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ @@ -1188,7 +1189,7 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders()), [ 'projectId' => ID::unique(), 'name' => 'Session Alert Locale Fallback Test', - 'teamId' => $team['body']['$id'], + 'teamId' => $teamId, 'region' => System::getEnv('_APP_REGION', 'default'), ]); $this->assertEquals(201, $project['headers']['status-code']); @@ -1307,9 +1308,17 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(201, $response['headers']['status-code']); - $lastEmail = $this->getLastEmailByAddress($userEmail); + /** + * Emails are delivered asynchronously via the mail queue, so maildev may + * still be catching up. The probe callback forces getLastEmailByAddress + * to keep polling until an email matching the expected `from` address + * appears — i.e. we await the new email rather than returning an older + * one already in the inbox from a previous session. + */ + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) { + $this->assertEquals('fallback@appwrite.io', $email['from'][0]['address']); + }); $this->assertEquals('Fallback sign-in alert', $lastEmail['subject']); - $this->assertEquals('fallback@appwrite.io', $lastEmail['from'][0]['address']); $this->assertEquals('Fallback Mailer', $lastEmail['from'][0]['name']); $this->assertStringContainsString('Fallback sign-in alert body', $lastEmail['html']); @@ -1325,9 +1334,11 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(201, $response['headers']['status-code']); - $lastEmail = $this->getLastEmailByAddress($userEmail); + /** Probe on `from` address ensures we await a fallback-shaped email */ + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) { + $this->assertEquals('fallback@appwrite.io', $email['from'][0]['address']); + }); $this->assertEquals('Fallback sign-in alert', $lastEmail['subject']); - $this->assertEquals('fallback@appwrite.io', $lastEmail['from'][0]['address']); $this->assertEquals('Fallback Mailer', $lastEmail['from'][0]['name']); $this->assertStringContainsString('Fallback sign-in alert body', $lastEmail['html']); @@ -1343,9 +1354,11 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(201, $response['headers']['status-code']); - $lastEmail = $this->getLastEmailByAddress($userEmail); + /** Probe on `from` address ensures we await the Slovak email specifically */ + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) { + $this->assertEquals('sk@appwrite.io', $email['from'][0]['address']); + }); $this->assertEquals('Slovak sign-in alert', $lastEmail['subject']); - $this->assertEquals('sk@appwrite.io', $lastEmail['from'][0]['address']); $this->assertEquals('Slovak Mailer', $lastEmail['from'][0]['name']); $this->assertStringContainsString('Slovak sign-in alert body', $lastEmail['html']); From 47f3ab930b7ab744eb69779e5ab2485e7f6fe0c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 13:14:34 +0200 Subject: [PATCH 048/402] Remove /status from project paths; Upgrade to platform 0.13 --- composer.json | 2 +- composer.lock | 14 +++++++------- .../Functions/Http/Deployments/Download/Get.php | 2 +- .../Http/Project/Protocols/{Status => }/Update.php | 13 +++++++------ .../Http/Project/Services/{Status => }/Update.php | 13 +++++++------ .../Platform/Modules/Project/Services/Http.php | 8 ++++---- src/Appwrite/Utopia/Request/Filters/V19.php | 7 +++++++ 7 files changed, 34 insertions(+), 25 deletions(-) rename src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/{Status => }/Update.php (89%) rename src/Appwrite/Platform/Modules/Project/Http/Project/Services/{Status => }/Update.php (89%) diff --git a/composer.json b/composer.json index 3aa6d157cf..6312243e32 100644 --- a/composer.json +++ b/composer.json @@ -74,7 +74,7 @@ "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.22.*", "utopia-php/migration": "1.9.*", - "utopia-php/platform": "0.12.*", + "utopia-php/platform": "0.13.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", "utopia-php/preloader": "0.2.*", diff --git a/composer.lock b/composer.lock index bc3d9d30bf..b1d559f87d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f6a87c1012b316e614258f8f57a28e48", + "content-hash": "c5ae97637fd0ec0a950044d1c33677ea", "packages": [ { "name": "adhocore/jwt", @@ -4642,16 +4642,16 @@ }, { "name": "utopia-php/platform", - "version": "0.12.1", + "version": "0.13.0", "source": { "type": "git", "url": "https://github.com/utopia-php/platform.git", - "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc" + "reference": "d23af5349a7ea9ee11f9920a13626226f985522e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/platform/zipball/2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc", - "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc", + "url": "https://api.github.com/repos/utopia-php/platform/zipball/d23af5349a7ea9ee11f9920a13626226f985522e", + "reference": "d23af5349a7ea9ee11f9920a13626226f985522e", "shasum": "" }, "require": { @@ -4687,9 +4687,9 @@ ], "support": { "issues": "https://github.com/utopia-php/platform/issues", - "source": "https://github.com/utopia-php/platform/tree/0.12.1" + "source": "https://github.com/utopia-php/platform/tree/0.13.0" }, - "time": "2026-04-08T04:11:31+00:00" + "time": "2026-04-17T09:57:18+00:00" }, { "name": "utopia-php/pools", diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php index 50c901e4c8..d3e7155dc6 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php @@ -31,7 +31,7 @@ class Get extends Action $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/functions/:functionId/deployments/:deploymentId/download') - ->httpAlias('/v1/functions/:functionId/deployments/:deploymentId/build/download', ['type' => 'output']) + ->httpAlias('/v1/functions/:functionId/deployments/:deploymentId/build/download') ->groups(['api', 'functions']) ->desc('Get deployment download') ->label('scope', 'functions.read') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php similarity index 89% rename from src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php rename to src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php index 71c20faca7..ad5691c1e0 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/protocols/:protocolId/status') + ->setHttpPath('/v1/project/protocols/:protocolId') + ->httpAlias('/v1/project/protocols/:protocolId/status') ->httpAlias('/v1/projects/:projectId/api') - ->desc('Update project protocol status') + ->desc('Update project protocol') ->groups(['api', 'project']) ->label('scope', 'project.write') ->label('event', 'protocols.[protocolId].update') @@ -40,9 +41,9 @@ class Update extends Action ->label('sdk', new Method( namespace: 'project', group: null, - name: 'updateProtocolStatus', + name: 'updateProtocol', description: <<setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/services/:serviceId/status') + ->setHttpPath('/v1/project/services/:serviceId') + ->httpAlias('/v1/project/services/:serviceId/status') ->httpAlias('/v1/projects/:projectId/service') - ->desc('Update project service status') + ->desc('Update project service') ->groups(['api', 'project']) ->label('scope', 'project.write') ->label('event', 'services.[serviceId].update') @@ -40,9 +41,9 @@ class Update extends Action ->label('sdk', new Method( namespace: 'project', group: null, - name: 'updateServiceStatus', + name: 'updateService', description: <<addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); - $this->addAction(UpdateProjectProtocolStatus::getName(), new UpdateProjectProtocolStatus()); - $this->addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); + $this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol()); + $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); diff --git a/src/Appwrite/Utopia/Request/Filters/V19.php b/src/Appwrite/Utopia/Request/Filters/V19.php index e7789ac0f7..4f2be12367 100644 --- a/src/Appwrite/Utopia/Request/Filters/V19.php +++ b/src/Appwrite/Utopia/Request/Filters/V19.php @@ -35,6 +35,13 @@ class V19 extends Filter case 'functions.updateVariable': $content['secret'] = false; break; + case 'functions.getDeploymentDownload': + // Pre-1.7.0 clients call the legacy alias + // `/v1/functions/:functionId/deployments/:deploymentId/build/download`, + // which always downloaded the build output. The merged 1.7.0 endpoint + // requires an explicit `type` param, so force it to `output` here. + $content['type'] = 'output'; + break; } return $content; } From c484c487a9a0a19bb0ed3c71cc1969c3d1aafb04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 13:19:20 +0200 Subject: [PATCH 049/402] Update tests --- tests/e2e/Services/Project/ProtocolsBase.php | 29 +++++++++++++++++++- tests/e2e/Services/Project/ServicesBase.php | 29 +++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Services/Project/ProtocolsBase.php b/tests/e2e/Services/Project/ProtocolsBase.php index 0187fc8463..f828994ea3 100644 --- a/tests/e2e/Services/Project/ProtocolsBase.php +++ b/tests/e2e/Services/Project/ProtocolsBase.php @@ -241,6 +241,33 @@ trait ProtocolsBase $this->assertSame(404, $response['headers']['status-code']); } + // Backwards compatibility + + public function testUpdateProtocolLegacyStatusPath(): void + { + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + + // Disable via the legacy `/status` alias + $response = $this->client->call(Client::METHOD_PATCH, '/project/protocols/rest/status', $headers, [ + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['protocolStatusForRest']); + + // Re-enable via the legacy `/status` alias + $response = $this->client->call(Client::METHOD_PATCH, '/project/protocols/rest/status', $headers, [ + 'enabled' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['protocolStatusForRest']); + } + // Helpers protected function updateProtocolStatus(string $protocolId, bool $enabled, bool $authenticated = true): mixed @@ -254,7 +281,7 @@ trait ProtocolsBase $headers = array_merge($headers, $this->getHeaders()); } - return $this->client->call(Client::METHOD_PATCH, '/project/protocols/' . $protocolId . '/status', $headers, [ + return $this->client->call(Client::METHOD_PATCH, '/project/protocols/' . $protocolId, $headers, [ 'enabled' => $enabled, ]); } diff --git a/tests/e2e/Services/Project/ServicesBase.php b/tests/e2e/Services/Project/ServicesBase.php index 1bc7ce5042..b5f94f8181 100644 --- a/tests/e2e/Services/Project/ServicesBase.php +++ b/tests/e2e/Services/Project/ServicesBase.php @@ -239,6 +239,33 @@ trait ServicesBase $this->assertSame(404, $response['headers']['status-code']); } + // Backwards compatibility + + public function testUpdateServiceLegacyStatusPath(): void + { + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + + // Disable via the legacy `/status` alias + $response = $this->client->call(Client::METHOD_PATCH, '/project/services/teams/status', $headers, [ + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['serviceStatusForTeams']); + + // Re-enable via the legacy `/status` alias + $response = $this->client->call(Client::METHOD_PATCH, '/project/services/teams/status', $headers, [ + 'enabled' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['serviceStatusForTeams']); + } + // Helpers protected function updateServiceStatus(string $serviceId, bool $enabled, bool $authenticated = true): mixed @@ -252,7 +279,7 @@ trait ServicesBase $headers = array_merge($headers, $this->getHeaders()); } - return $this->client->call(Client::METHOD_PATCH, '/project/services/' . $serviceId . '/status', $headers, [ + return $this->client->call(Client::METHOD_PATCH, '/project/services/' . $serviceId, $headers, [ 'enabled' => $enabled, ]); } From df0f7ba581ee5f2b7960914be5fdd7663a0a076b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Apr 2026 18:02:04 +0530 Subject: [PATCH 050/402] added bucket boundary --- app/realtime.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 192672a2f3..5631a7f860 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -398,7 +398,11 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); $register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent')); - $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram('realtime.server.delivery_delay', 'ms')); + $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram( + name: 'realtime.server.delivery_delay', + unit: 'ms', + advisory: ['ExplicitBucketBoundaries' => [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]], + )); $attempts = 0; $start = time(); From 27b0e48296d75c88ab4dbed69f69a3aab3da9017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 14:53:59 +0200 Subject: [PATCH 051/402] Remove Status suffix from project event names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project.updateServiceStatus → project.updateService - project.updateProtocolStatus → project.updateProtocol --- src/Appwrite/Utopia/Request/Filters/V22.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Utopia/Request/Filters/V22.php b/src/Appwrite/Utopia/Request/Filters/V22.php index 4f1e746775..7e4c5b8e41 100644 --- a/src/Appwrite/Utopia/Request/Filters/V22.php +++ b/src/Appwrite/Utopia/Request/Filters/V22.php @@ -73,10 +73,10 @@ class V22 extends Filter public function parse(array $content, string $model): array { switch ($model) { - case 'project.updateServiceStatus': + case 'project.updateService': $content = $this->parseUpdateServiceStatus($content); break; - case 'project.updateProtocolStatus': + case 'project.updateProtocol': $content = $this->parseUpdateProtocolStatus($content); break; case 'project.createKey': From 9765c7f0e313d00fafb5c5fabf751b912c4e2e3f Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:30:22 +0100 Subject: [PATCH 052/402] feat: use buildTimeout from message payload in build worker Co-Authored-By: Claude Sonnet 4.6 --- src/Appwrite/Platform/Modules/Functions/Workers/Builds.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 0071b03d2d..323abfd564 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -592,9 +592,9 @@ class Builds extends Action $cpus = $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT; $memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory); - $timeout = (int) System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900); + $timeout = (int) ($payload['buildTimeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)); - $jwtExpiry = (int) System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900); + $jwtExpiry = $timeout; $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); $apiKey = $jwtObj->encode([ From 4043153df313870ba5fe93c185bb272c36601fc4 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:47:41 +0100 Subject: [PATCH 053/402] fix: pass buildTimeout as parameter to buildDeployment to fix PHPStan error Co-Authored-By: Claude Sonnet 4.6 --- .../Platform/Modules/Functions/Workers/Builds.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 323abfd564..6b28de6601 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -144,7 +144,8 @@ class Builds extends Action $log, $executor, $plan, - $platform + $platform, + (int) ($payload['buildTimeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)) ); break; @@ -179,7 +180,8 @@ class Builds extends Action Log $log, Executor $executor, array $plan, - array $platform + array $platform, + int $buildTimeout = 900 ): void { Console::info('Deployment action started'); @@ -592,7 +594,7 @@ class Builds extends Action $cpus = $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT; $memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory); - $timeout = (int) ($payload['buildTimeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)); + $timeout = $buildTimeout; $jwtExpiry = $timeout; $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); From 8f39783d7a30033c22b7b92c837928a4a1160c8c Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:47:58 +0100 Subject: [PATCH 054/402] refactor: remove jwtExpiry alias, use timeout directly Co-Authored-By: Claude Sonnet 4.6 --- src/Appwrite/Platform/Modules/Functions/Workers/Builds.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 6b28de6601..d6184107bd 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -596,8 +596,7 @@ class Builds extends Action $memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory); $timeout = $buildTimeout; - $jwtExpiry = $timeout; - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $timeout, 0); $apiKey = $jwtObj->encode([ 'projectId' => $project->getId(), From 7df181420322fa617d022ed0d2d67cfeb393130e Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:22:53 +0100 Subject: [PATCH 055/402] refactor: rename buildTimeout to timeout in payload and buildDeployment param Co-Authored-By: Claude Sonnet 4.6 --- src/Appwrite/Platform/Modules/Functions/Workers/Builds.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index d6184107bd..87e936a965 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -145,7 +145,7 @@ class Builds extends Action $executor, $plan, $platform, - (int) ($payload['buildTimeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)) + (int) ($payload['timeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)) ); break; @@ -181,7 +181,7 @@ class Builds extends Action Executor $executor, array $plan, array $platform, - int $buildTimeout = 900 + int $timeout ): void { Console::info('Deployment action started'); @@ -594,8 +594,6 @@ class Builds extends Action $cpus = $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT; $memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory); - $timeout = $buildTimeout; - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $timeout, 0); $apiKey = $jwtObj->encode([ From 1a46fc200613fba8c71693645de8b36a81468666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 16:43:17 +0200 Subject: [PATCH 056/402] Move template APIs under project API --- app/controllers/api/projects.php | 219 ------------------ .../Http/Project/Templates/Email/Delete.php | 101 ++++++++ .../Http/Project/Templates/Email/Get.php | 147 ++++++++++++ .../Http/Project/Templates/Email/Update.php | 121 ++++++++++ .../Modules/Project/Services/Http.php | 8 + 5 files changed, 377 insertions(+), 219 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 439692e1dd..b972103224 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -20,7 +20,6 @@ use Utopia\Database\Document; use Utopia\Database\Validator\UID; use Utopia\Emails\Validator\Email; use Utopia\Http\Http; -use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; @@ -833,224 +832,6 @@ Http::post('/v1/projects/:projectId/smtp/tests') $response->noContent(); }); -Http::get('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Get custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'getEmailTemplate', - description: '/docs/references/projects/get-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); - - if (is_null($template)) { - /** - * different templates, different placeholders. - */ - $templateConfigs = [ - 'magicSession' => [ - 'file' => 'email-magic-url.tpl', - 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] - ], - 'mfaChallenge' => [ - 'file' => 'email-mfa-challenge.tpl', - 'placeholders' => ['description', 'clientInfo'] - ], - 'otpSession' => [ - 'file' => 'email-otp.tpl', - 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] - ], - 'sessionAlert' => [ - 'file' => 'email-session-alert.tpl', - 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] - ], - ]; - - // fallback to the base template. - $config = $templateConfigs[$type] ?? [ - 'file' => 'email-inner-base.tpl', - 'placeholders' => ['buttonText', 'body', 'footer'] - ]; - - $templateString = file_get_contents(__DIR__ . '/../../config/locale/templates/' . $config['file']); - - // We use `fromString` due to the replace above - $message = Template::fromString($templateString); - - // Set type-specific parameters - foreach ($config['placeholders'] as $param) { - $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); - $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml); - } - - $message - // common placeholders on all the templates - ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello")) - ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); - - // `useContent: false` will strip new lines! - $message = $message->render(useContent: true); - - $template = [ - 'message' => $message, - 'subject' => $localeObj->getText('emails.' . $type . '.subject'), - 'senderEmail' => '', - 'senderName' => '' - ]; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::patch('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Update custom email templates') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'updateEmailTemplate', - description: '/docs/references/projects/update-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->param('subject', '', new Text(255), 'Email Subject') - ->param('message', '', new Text(0), 'Template message') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $templates['email.' . $type . '-' . $locale] = [ - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::delete('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Delete custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteEmailTemplate', - description: '/docs/references/projects/delete-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ], - contentType: ContentType::JSON - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); - } - - unset($templates['email.' . $type . '-' . $locale]); - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $template['senderName'], - 'senderEmail' => $template['senderEmail'], - 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], - 'message' => $template['message'] - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - Http::patch('/v1/projects/:projectId/auth/session-invalidation') ->desc('Update invalidate session option of the project') ->groups(['api', 'projects']) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php new file mode 100644 index 0000000000..9133971c40 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php @@ -0,0 +1,101 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->desc('Delete project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.write') + ->label('event', 'templates.[templateType].delete') + ->label('audits.event', 'project.template.delete') + ->label('audits.resource', 'project.template/{response.type}') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'deleteEmailTemplate', + description: <<param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes']) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('locale') + ->callback($this->action(...)); + } + + public function action( + string $type, + string $locale, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + Locale $localeObject, + ) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + + $templates = $project->getAttribute('templates', []); + $template = $templates['email.' . $type . '-' . $locale] ?? null; + + if (is_null($template)) { + throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); + } + + unset($templates['email.' . $type . '-' . $locale]); + + $updates = new Document([ + 'templates' => $templates, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('templateType', $type); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php new file mode 100644 index 0000000000..3c5888f8b8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -0,0 +1,147 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->desc('Get project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'getEmailTemplate', + description: <<param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes']) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('locale') + ->callback($this->action(...)); + } + + public function action( + string $type, + string $locale, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + Locale $localeObject, + ) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + + $templates = $project->getAttribute('templates', []); + $template = $templates['email.' . $type . '-' . $locale] ?? null; + + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + + if (is_null($template)) { + /** + * different templates, different placeholders. + */ + $templateConfigs = [ + 'magicSession' => [ + 'file' => 'email-magic-url.tpl', + 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] + ], + 'mfaChallenge' => [ + 'file' => 'email-mfa-challenge.tpl', + 'placeholders' => ['description', 'clientInfo'] + ], + 'otpSession' => [ + 'file' => 'email-otp.tpl', + 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] + ], + 'sessionAlert' => [ + 'file' => 'email-session-alert.tpl', + 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] + ], + ]; + + // fallback to the base template. + $config = $templateConfigs[$type] ?? [ + 'file' => 'email-inner-base.tpl', + 'placeholders' => ['buttonText', 'body', 'footer'] + ]; + + $templateString = file_get_contents(__DIR__ . '/../../config/locale/templates/' . $config['file']); + + // We use `fromString` due to the replace above + $message = Template::fromString($templateString); + + // Set type-specific parameters + foreach ($config['placeholders'] as $param) { + $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); + $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml); + } + + $message + // common placeholders on all the templates + ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello")) + ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) + ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); + + // `useContent: false` will strip new lines! + $message = $message->render(useContent: true); + + $template = [ + 'message' => $message, + 'subject' => $localeObj->getText('emails.' . $type . '.subject'), + 'senderEmail' => '', + 'senderName' => '', + 'custom' => false, + ]; + } else { + $template['custom'] = true; + } + + $template['type'] = $type; + $template['locale'] = $locale; + + $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php new file mode 100644 index 0000000000..ff739f9fe8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -0,0 +1,121 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->desc('Update project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.write') + ->label('event', 'templates.[templateType].update') + ->label('audits.event', 'project.template.update') + ->label('audits.resource', 'project.template/{response.type}') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'updateEmailTemplate', + description: <<param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes']) + ->param('subject', '', new Text(255), 'Subject of the email template. Can be up to 255 characters.') + ->param('message', '', new Text(10485760), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') + ->param('senderName', '', new Text(255, 0), 'Name of the email sender.', true) + ->param('senderEmail', '', new Email(), 'Email of the sender.', true) + ->param('replyTo', '', new Email(), 'Reply to email.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('locale') + ->callback($this->action(...)); + } + + public function action( + string $type, + string $locale, + string $subject, + string $message, + string $senderName, + string $senderEmail, + string $replyTo, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + Locale $localeObject, + ) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + + $template = [ + 'senderName' => $senderName, + 'senderEmail' => $senderEmail, + 'subject' => $subject, + 'replyTo' => $replyTo, + 'message' => $message + ]; + + $templates = $project->getAttribute('templates', []); + $templates['email.' . $type . '-' . $locale] = $template; + + $updates = new Document([ + 'templates' => $templates, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('templateType', $type); + + $response->dynamic(new Document([ + 'type' => $type, + 'locale' => $locale, + 'senderName' => $template['senderName'], + 'senderEmail' => $template['senderEmail'], + 'subject' => $template['subject'], + 'replyTo' => $template['replyTo'], + 'message' => $template['message'], + 'custom' => true, + ]), Response::MODEL_EMAIL_TEMPLATE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index bcab75a8c5..f6c3a2efc2 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -24,6 +24,9 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as U use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdateProjectProtocol; use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Delete as DeleteTemplate; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -45,6 +48,11 @@ class Http extends Service $this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol()); $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); + // Templates + $this->addAction(GetTemplate::getName(), new GetTemplate()); + $this->addAction(DeleteTemplate::getName(), new DeleteTemplate()); + $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); + // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); $this->addAction(ListVariables::getName(), new ListVariables()); From 489b2c4e211d442ae3ee41f8ba47f06537a10b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 16:45:04 +0200 Subject: [PATCH 057/402] Add new scopes --- app/config/roles.php | 2 ++ app/config/scopes/project.php | 8 ++++++++ src/Appwrite/Platform/Workers/Migrations.php | 2 ++ tests/e2e/Scopes/ProjectCustom.php | 2 ++ 4 files changed, 14 insertions(+) diff --git a/app/config/roles.php b/app/config/roles.php index 116e8ac932..0903e79b13 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -55,6 +55,8 @@ $admins = [ 'tables.write', 'platforms.read', 'platforms.write', + 'templates.read', + 'templates.write', 'projects.write', 'keys.read', 'keys.write', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 6c7f75c08e..861e292407 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -204,4 +204,12 @@ return [ // List of publicly visible scopes "description" => "Access to create, update, and delete project\'s platforms", ], + "templates.read" => [ + "description" => + "Access to read project\'s templates", + ], + "templates.write" => [ + "description" => + "Access to create, update, and delete project\'s templates", + ], ]; diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 118ff7acf9..e69541820b 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -376,6 +376,8 @@ class Migrations extends Action 'keys.write', 'platforms.read', 'platforms.write', + 'templates.read', + 'templates.write', ] ]); diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index a62a1e8ba3..7d9f5eb126 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -169,6 +169,8 @@ trait ProjectCustom 'keys.write', 'platforms.read', 'platforms.write', + 'templates.read', + 'templates.write', ], ]); From b01ec03723f828e329b1e9cca8b80fb607ab115c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:01:27 +0200 Subject: [PATCH 058/402] Fix analyze bug --- .../Modules/Project/Http/Project/Templates/Email/Get.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 3c5888f8b8..1a44bf24a5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -6,6 +6,7 @@ use Appwrite\Event\Event as QueueEvent; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Template\Template; use Appwrite\Utopia\Response; use Utopia\Config\Config; use Utopia\Database\Database; From e388d2f6a3b950758674f13a1adc9132ca77f898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:22:55 +0200 Subject: [PATCH 059/402] List tempaltes endpoint --- app/init/models.php | 1 + .../Http/Project/Templates/Email/Get.php | 3 +- .../Http/Project/Templates/Email/XList.php | 113 ++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + .../Utopia/Response/Model/TemplateEmail.php | 6 + 6 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php diff --git a/app/init/models.php b/app/init/models.php index f654c10121..924df52bdd 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -210,6 +210,7 @@ Response::setModel(new BaseList('Currencies List', Response::MODEL_CURRENCY_LIST Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phones', Response::MODEL_PHONE)); Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false)); Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE)); +Response::setModel(new BaseList('Email Templates List', Response::MODEL_EMAIL_TEMPLATE_LIST, 'templates', Response::MODEL_EMAIL_TEMPLATE)); Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS)); Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE)); Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE)); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 1a44bf24a5..1855f13d17 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -30,8 +30,7 @@ class Get extends Action public function __construct() { $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/project/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email') + ->setHttpPath('/v1/project/templates/email/:type') ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Get project email template') ->groups(['api', 'project']) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php new file mode 100644 index 0000000000..5cd5184177 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -0,0 +1,113 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->desc('List project email templates') + ->groups(['api', 'project']) + ->label('scope', 'templates.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'listEmailTemplates', + description: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('project') + ->inject('response') + ->inject('localeCodes') + ->callback($this->action(...)); + } + + /** + * @param array $queries + * @param array $localeCodes + */ + public function action( + array $queries, + bool $includeTotal, + Document $project, + Response $response, + array $localeCodes, + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $grouped = Query::groupByType($queries); + $limit = $grouped['limit'] ?? APP_LIMIT_COUNT; + $offset = $grouped['offset'] ?? 0; + + $types = Config::getParam('locale-templates')['email'] ?? []; + $projectTemplates = $project->getAttribute('templates', []); + + $templates = []; + foreach ($types as $type) { + foreach ($localeCodes as $locale) { + $key = 'email.' . $type . '-' . $locale; + $stored = $projectTemplates[$key] ?? null; + + $templates[] = new Document([ + 'type' => $type, + 'locale' => $locale, + 'message' => $stored['message'] ?? '', + 'subject' => $stored['subject'] ?? '', + 'senderName' => $stored['senderName'] ?? '', + 'senderEmail' => $stored['senderEmail'] ?? '', + 'replyTo' => $stored['replyTo'] ?? '', + 'custom' => !\is_null($stored), + ]); + } + } + + $total = $includeTotal ? \count($templates) : 0; + $templates = \array_slice($templates, $offset, $limit); + + $response->dynamic(new Document([ + 'templates' => $templates, + 'total' => $total, + ]), Response::MODEL_EMAIL_TEMPLATE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index f6c3a2efc2..b91541928c 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -27,6 +27,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProj use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Delete as DeleteTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\XList as ListTemplates; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -49,6 +50,7 @@ class Http extends Service $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); // Templates + $this->addAction(ListTemplates::getName(), new ListTemplates()); $this->addAction(GetTemplate::getName(), new GetTemplate()); $this->addAction(DeleteTemplate::getName(), new DeleteTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index d747373b59..4aecc62fd8 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -266,6 +266,7 @@ class Response extends SwooleResponse public const MODEL_VARIABLE_LIST = 'variableList'; public const MODEL_VCS = 'vcs'; public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; + public const MODEL_EMAIL_TEMPLATE_LIST = 'emailTemplateList'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index ecdf89e774..95cd57a584 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -34,6 +34,12 @@ class TemplateEmail extends Template 'default' => '', 'example' => 'Please verify your email address', ]) + ->addRule('custom', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether the template has been customized for the project. Non-custom templates render from defaults.', + 'default' => false, + 'example' => false, + ]) ; } From dc704fdb5131d7262ee61253419249e31a572f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:27:19 +0200 Subject: [PATCH 060/402] Improved xlist endpoint --- .../Http/Project/Templates/Email/XList.php | 119 +++++++++++++++++- 1 file changed, 117 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php index 5cd5184177..3a04cf4490 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -8,12 +8,15 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Config\Config; +use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; use Utopia\Database\Validator\Queries; +use Utopia\Database\Validator\Query\Filter; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; +use Utopia\Database\Validator\Query\Order; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; @@ -22,6 +25,17 @@ class XList extends Action { use HTTP; + private const ALLOWED_ATTRIBUTES = [ + 'type' => Database::VAR_STRING, + 'locale' => Database::VAR_STRING, + 'subject' => Database::VAR_STRING, + 'message' => Database::VAR_STRING, + 'senderName' => Database::VAR_STRING, + 'senderEmail' => Database::VAR_STRING, + 'replyTo' => Database::VAR_STRING, + 'custom' => Database::VAR_BOOLEAN, + ]; + public static function getName() { return 'listProjectEmailTemplates'; @@ -29,10 +43,25 @@ class XList extends Action public function __construct() { + $attributes = []; + foreach (self::ALLOWED_ATTRIBUTES as $key => $type) { + $attributes[] = new Document([ + 'key' => $key, + 'type' => $type, + 'array' => false, + ]); + } + + $queriesValidator = new Queries([ + new Limit(), + new Offset(), + new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES), + new Order($attributes), + ]); + $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/project/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email') ->desc('List project email templates') ->groups(['api', 'project']) ->label('scope', 'templates.read') @@ -51,7 +80,7 @@ class XList extends Action ) ] )) - ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset.', true) + ->param('queries', [], $queriesValidator, 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(self::ALLOWED_ATTRIBUTES)), true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('project') ->inject('response') @@ -80,6 +109,13 @@ class XList extends Action $limit = $grouped['limit'] ?? APP_LIMIT_COUNT; $offset = $grouped['offset'] ?? 0; + /** @var array $filters */ + $filters = $grouped['filters'] ?? []; + /** @var array $orderAttributes */ + $orderAttributes = $grouped['orderAttributes'] ?? []; + /** @var array $orderTypes */ + $orderTypes = $grouped['orderTypes'] ?? []; + $types = Config::getParam('locale-templates')['email'] ?? []; $projectTemplates = $project->getAttribute('templates', []); @@ -102,6 +138,9 @@ class XList extends Action } } + $templates = $this->applyFilters($templates, $filters); + $templates = $this->applyOrder($templates, $orderAttributes, $orderTypes); + $total = $includeTotal ? \count($templates) : 0; $templates = \array_slice($templates, $offset, $limit); @@ -110,4 +149,80 @@ class XList extends Action 'total' => $total, ]), Response::MODEL_EMAIL_TEMPLATE_LIST); } + + /** + * @param array $templates + * @param array $filters + * @return array + */ + private function applyFilters(array $templates, array $filters): array + { + if (empty($filters)) { + return $templates; + } + + return \array_values(\array_filter($templates, function (Document $template) use ($filters) { + foreach ($filters as $filter) { + if (!$this->matches($template, $filter)) { + return false; + } + } + return true; + })); + } + + private function matches(Document $template, Query $filter): bool + { + $attribute = $filter->getAttribute(); + $values = $filter->getValues(); + $actual = $template->getAttribute($attribute); + $needle = (string) ($values[0] ?? ''); + + return match ($filter->getMethod()) { + Query::TYPE_EQUAL => \in_array($actual, $values, false), + Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false), + Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle), + Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle), + Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle), + Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle), + Query::TYPE_CONTAINS => \is_string($actual) && \str_contains($actual, $needle), + Query::TYPE_NOT_CONTAINS => \is_string($actual) && !\str_contains($actual, $needle), + Query::TYPE_SEARCH => \is_string($actual) && \stripos($actual, $needle) !== false, + Query::TYPE_NOT_SEARCH => \is_string($actual) && \stripos($actual, $needle) === false, + Query::TYPE_IS_NULL => $actual === null || $actual === '', + Query::TYPE_IS_NOT_NULL => $actual !== null && $actual !== '', + default => throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Query method not supported for email templates: ' . $filter->getMethod()), + }; + } + + /** + * @param array $templates + * @param array $orderAttributes + * @param array $orderTypes + * @return array + */ + private function applyOrder(array $templates, array $orderAttributes, array $orderTypes): array + { + if (empty($orderAttributes)) { + return $templates; + } + + \usort($templates, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) { + foreach ($orderAttributes as $index => $attribute) { + $direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC); + $valueA = $a->getAttribute($attribute); + $valueB = $b->getAttribute($attribute); + + $cmp = $valueA <=> $valueB; + if ($cmp === 0) { + continue; + } + + return $direction === Database::ORDER_DESC ? -$cmp : $cmp; + } + return 0; + }); + + return $templates; + } } From 956285d522b820dc869140c26383ad8667d3ca45 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:35:26 +0100 Subject: [PATCH 061/402] fix: do not cache error responses for storage preview, bump utopia-php/image to 0.8.5 Cache write hook now checks HTTP status code before writing to prevent failed AVIF (or any other) conversions from poisoning the cache. Bumps utopia-php/image to 0.8.5 which fixes AVIF/HEIC output by using native Imagick instead of the deprecated magick convert shell command. Co-Authored-By: Claude Sonnet 4.6 --- app/controllers/shared/api.php | 3 +- composer.lock | 22 ++++---- tests/e2e/Services/Storage/StorageBase.php | 65 ++++++++++++++++++++++ 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 5567281e67..bba00bede1 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -971,7 +971,8 @@ Http::shutdown() if ($useCache) { $resource = $resourceType = null; $data = $response->getPayload(); - if (! empty($data['payload'])) { + $statusCode = $response->getStatusCode(); + if (! empty($data['payload']) && $statusCode >= 200 && $statusCode < 300) { $pattern = $route->getLabel('cache.resource', null); if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); diff --git a/composer.lock b/composer.lock index b1d559f87d..56b838a0fe 100644 --- a/composer.lock +++ b/composer.lock @@ -4325,16 +4325,16 @@ }, { "name": "utopia-php/image", - "version": "0.8.4", + "version": "0.8.5", "source": { "type": "git", "url": "https://github.com/utopia-php/image.git", - "reference": "ce788ff0121a79286fdbe3ef3eba566de646df65" + "reference": "9af2fcff028a42550465e2ccad88e3b31c3584f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/image/zipball/ce788ff0121a79286fdbe3ef3eba566de646df65", - "reference": "ce788ff0121a79286fdbe3ef3eba566de646df65", + "url": "https://api.github.com/repos/utopia-php/image/zipball/9af2fcff028a42550465e2ccad88e3b31c3584f3", + "reference": "9af2fcff028a42550465e2ccad88e3b31c3584f3", "shasum": "" }, "require": { @@ -4343,10 +4343,12 @@ "php": ">=8.1" }, "require-dev": { - "laravel/pint": "1.2.*", - "phpstan/phpstan": "^1.10.0", - "phpunit/phpunit": "^9.3", - "vimeo/psalm": "4.13.1" + "laravel/pint": "1.24.*", + "phpstan/phpstan": "2.1.*", + "phpunit/phpunit": "10.5.*" + }, + "suggest": { + "ext-imagick": "Imagick extension is required for Imagick adapter" }, "type": "library", "autoload": { @@ -4368,9 +4370,9 @@ ], "support": { "issues": "https://github.com/utopia-php/image/issues", - "source": "https://github.com/utopia-php/image/tree/0.8.4" + "source": "https://github.com/utopia-php/image/tree/0.8.5" }, - "time": "2025-06-03T08:32:20+00:00" + "time": "2026-04-17T15:02:49+00:00" }, { "name": "utopia-php/locale", diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index d1cb548016..60a4aefc85 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1050,6 +1050,28 @@ trait StorageBase $this->assertEquals(404, $file['headers']['status-code']); } + public function testFilePreviewAvifPublic(): void + { + $data = $this->setupBucketFile(); + $bucketId = $data['bucketId']; + $fileId = $data['fileId']; + $projectId = $this->getProject()['$id']; + + // Matches the customer's URL pattern: no headers, project + output in query string only + $preview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', [ + 'content-type' => 'application/json', + ], [ + 'project' => $projectId, + 'width' => 1080, + 'quality' => 40, + 'output' => 'avif', + ]); + + $this->assertEquals(200, $preview['headers']['status-code']); + $this->assertEquals('image/avif', $preview['headers']['content-type']); + $this->assertNotEmpty($preview['body']); + } + public function testFilePreview(): void { $data = $this->setupBucketFile(); @@ -1069,6 +1091,49 @@ trait StorageBase $this->assertEquals(200, $preview['headers']['status-code']); $this->assertEquals('image/webp', $preview['headers']['content-type']); $this->assertNotEmpty($preview['body']); + + // Preview PNG as avif + $avifPreview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'width' => 1080, + 'quality' => 40, + 'output' => 'avif', + ]); + + $this->assertEquals(200, $avifPreview['headers']['status-code']); + $this->assertEquals('image/avif', $avifPreview['headers']['content-type']); + $this->assertNotEmpty($avifPreview['body']); + + // Preview JPEG as avif + $jpegFile = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/disk-a/kitten-1.jpg'), 'image/jpeg', 'kitten-1.jpg'), + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $jpegFile['headers']['status-code']); + + $avifFromJpeg = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $jpegFile['body']['$id'] . '/preview', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'width' => 1080, + 'quality' => 40, + 'output' => 'avif', + ]); + + $this->assertEquals(200, $avifFromJpeg['headers']['status-code']); + $this->assertEquals('image/avif', $avifFromJpeg['headers']['content-type']); + $this->assertNotEmpty($avifFromJpeg['body']); } public function testDeletePartiallyUploadedFile(): void From bb38bf42487b0c59cfc7b09f3a72509d94f57709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:42:46 +0200 Subject: [PATCH 062/402] Improve code quality --- .../Http/Project/Templates/Email/XList.php | 128 +------------- .../Utopia/Database/InMemoryQuery.php | 159 ++++++++++++++++++ .../Validator/Queries/BaseInMemory.php | 41 +++++ .../Validator/Queries/ProjectTemplates.php | 24 +++ 4 files changed, 230 insertions(+), 122 deletions(-) create mode 100644 src/Appwrite/Utopia/Database/InMemoryQuery.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php index 3a04cf4490..9fdd576af1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -6,17 +6,13 @@ use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\InMemoryQuery; +use Appwrite\Utopia\Database\Validator\Queries\ProjectTemplates; use Appwrite\Utopia\Response; use Utopia\Config\Config; -use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; -use Utopia\Database\Validator\Queries; -use Utopia\Database\Validator\Query\Filter; -use Utopia\Database\Validator\Query\Limit; -use Utopia\Database\Validator\Query\Offset; -use Utopia\Database\Validator\Query\Order; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; @@ -25,17 +21,6 @@ class XList extends Action { use HTTP; - private const ALLOWED_ATTRIBUTES = [ - 'type' => Database::VAR_STRING, - 'locale' => Database::VAR_STRING, - 'subject' => Database::VAR_STRING, - 'message' => Database::VAR_STRING, - 'senderName' => Database::VAR_STRING, - 'senderEmail' => Database::VAR_STRING, - 'replyTo' => Database::VAR_STRING, - 'custom' => Database::VAR_BOOLEAN, - ]; - public static function getName() { return 'listProjectEmailTemplates'; @@ -43,22 +28,6 @@ class XList extends Action public function __construct() { - $attributes = []; - foreach (self::ALLOWED_ATTRIBUTES as $key => $type) { - $attributes[] = new Document([ - 'key' => $key, - 'type' => $type, - 'array' => false, - ]); - } - - $queriesValidator = new Queries([ - new Limit(), - new Offset(), - new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES), - new Order($attributes), - ]); - $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/project/templates/email') @@ -80,7 +49,7 @@ class XList extends Action ) ] )) - ->param('queries', [], $queriesValidator, 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(self::ALLOWED_ATTRIBUTES)), true) + ->param('queries', [], new ProjectTemplates(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(ProjectTemplates::ALLOWED_ATTRIBUTES)), true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('project') ->inject('response') @@ -106,15 +75,6 @@ class XList extends Action } $grouped = Query::groupByType($queries); - $limit = $grouped['limit'] ?? APP_LIMIT_COUNT; - $offset = $grouped['offset'] ?? 0; - - /** @var array $filters */ - $filters = $grouped['filters'] ?? []; - /** @var array $orderAttributes */ - $orderAttributes = $grouped['orderAttributes'] ?? []; - /** @var array $orderTypes */ - $orderTypes = $grouped['orderTypes'] ?? []; $types = Config::getParam('locale-templates')['email'] ?? []; $projectTemplates = $project->getAttribute('templates', []); @@ -138,91 +98,15 @@ class XList extends Action } } - $templates = $this->applyFilters($templates, $filters); - $templates = $this->applyOrder($templates, $orderAttributes, $orderTypes); + $templates = InMemoryQuery::filter($templates, $grouped['filters']); + $templates = InMemoryQuery::order($templates, $grouped['orderAttributes'], $grouped['orderTypes']); $total = $includeTotal ? \count($templates) : 0; - $templates = \array_slice($templates, $offset, $limit); + $templates = InMemoryQuery::paginate($templates, $grouped['limit'] ?? APP_LIMIT_COUNT, $grouped['offset']); $response->dynamic(new Document([ 'templates' => $templates, 'total' => $total, ]), Response::MODEL_EMAIL_TEMPLATE_LIST); } - - /** - * @param array $templates - * @param array $filters - * @return array - */ - private function applyFilters(array $templates, array $filters): array - { - if (empty($filters)) { - return $templates; - } - - return \array_values(\array_filter($templates, function (Document $template) use ($filters) { - foreach ($filters as $filter) { - if (!$this->matches($template, $filter)) { - return false; - } - } - return true; - })); - } - - private function matches(Document $template, Query $filter): bool - { - $attribute = $filter->getAttribute(); - $values = $filter->getValues(); - $actual = $template->getAttribute($attribute); - $needle = (string) ($values[0] ?? ''); - - return match ($filter->getMethod()) { - Query::TYPE_EQUAL => \in_array($actual, $values, false), - Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false), - Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle), - Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle), - Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle), - Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle), - Query::TYPE_CONTAINS => \is_string($actual) && \str_contains($actual, $needle), - Query::TYPE_NOT_CONTAINS => \is_string($actual) && !\str_contains($actual, $needle), - Query::TYPE_SEARCH => \is_string($actual) && \stripos($actual, $needle) !== false, - Query::TYPE_NOT_SEARCH => \is_string($actual) && \stripos($actual, $needle) === false, - Query::TYPE_IS_NULL => $actual === null || $actual === '', - Query::TYPE_IS_NOT_NULL => $actual !== null && $actual !== '', - default => throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Query method not supported for email templates: ' . $filter->getMethod()), - }; - } - - /** - * @param array $templates - * @param array $orderAttributes - * @param array $orderTypes - * @return array - */ - private function applyOrder(array $templates, array $orderAttributes, array $orderTypes): array - { - if (empty($orderAttributes)) { - return $templates; - } - - \usort($templates, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) { - foreach ($orderAttributes as $index => $attribute) { - $direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC); - $valueA = $a->getAttribute($attribute); - $valueB = $b->getAttribute($attribute); - - $cmp = $valueA <=> $valueB; - if ($cmp === 0) { - continue; - } - - return $direction === Database::ORDER_DESC ? -$cmp : $cmp; - } - return 0; - }); - - return $templates; - } } diff --git a/src/Appwrite/Utopia/Database/InMemoryQuery.php b/src/Appwrite/Utopia/Database/InMemoryQuery.php new file mode 100644 index 0000000000..c9f930369a --- /dev/null +++ b/src/Appwrite/Utopia/Database/InMemoryQuery.php @@ -0,0 +1,159 @@ + $documents + * @param array $filters + * @return array + */ + public static function filter(array $documents, array $filters): array + { + if (empty($filters)) { + return \array_values($documents); + } + + return \array_values(\array_filter($documents, function (Document $document) use ($filters) { + foreach ($filters as $filter) { + if (!self::matches($document, $filter)) { + return false; + } + } + return true; + })); + } + + /** + * Evaluate a single filter query against a document. + */ + public static function matches(Document $document, Query $filter): bool + { + $attribute = $filter->getAttribute(); + $values = $filter->getValues(); + $actual = $document->getAttribute($attribute); + $needle = (string) ($values[0] ?? ''); + + return match ($filter->getMethod()) { + Query::TYPE_EQUAL => \in_array($actual, $values, false), + Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false), + Query::TYPE_LESSER => self::compareScalar($actual, $values[0] ?? null) < 0, + Query::TYPE_LESSER_EQUAL => self::compareScalar($actual, $values[0] ?? null) <= 0, + Query::TYPE_GREATER => self::compareScalar($actual, $values[0] ?? null) > 0, + Query::TYPE_GREATER_EQUAL => self::compareScalar($actual, $values[0] ?? null) >= 0, + Query::TYPE_BETWEEN => self::compareScalar($actual, $values[0] ?? null) >= 0 && self::compareScalar($actual, $values[1] ?? null) <= 0, + Query::TYPE_NOT_BETWEEN => self::compareScalar($actual, $values[0] ?? null) < 0 || self::compareScalar($actual, $values[1] ?? null) > 0, + Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle), + Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle), + Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle), + Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle), + Query::TYPE_CONTAINS => self::containsValue($actual, $values), + Query::TYPE_NOT_CONTAINS => !self::containsValue($actual, $values), + Query::TYPE_SEARCH => \is_string($actual) && $needle !== '' && \stripos($actual, $needle) !== false, + Query::TYPE_NOT_SEARCH => \is_string($actual) && ($needle === '' || \stripos($actual, $needle) === false), + Query::TYPE_IS_NULL => $actual === null, + Query::TYPE_IS_NOT_NULL => $actual !== null, + default => throw new \InvalidArgumentException('Unsupported query method: ' . $filter->getMethod()), + }; + } + + /** + * Sort documents by one or more attributes. + * + * @param array $documents + * @param array $orderAttributes + * @param array $orderTypes + * @return array + */ + public static function order(array $documents, array $orderAttributes, array $orderTypes): array + { + if (empty($orderAttributes)) { + return \array_values($documents); + } + + $documents = \array_values($documents); + + \usort($documents, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) { + foreach ($orderAttributes as $index => $attribute) { + $direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC); + $cmp = self::compareScalar($a->getAttribute($attribute), $b->getAttribute($attribute)); + if ($cmp !== 0) { + return $direction === Database::ORDER_DESC ? -$cmp : $cmp; + } + } + return 0; + }); + + return $documents; + } + + /** + * Apply limit and offset. + * + * @param array $documents + * @return array + */ + public static function paginate(array $documents, ?int $limit, ?int $offset): array + { + return \array_slice(\array_values($documents), $offset ?? 0, $limit); + } + + /** + * Compare two scalars in a way that handles null consistently (null sorts before any value). + */ + private static function compareScalar(mixed $a, mixed $b): int + { + if ($a === null && $b === null) { + return 0; + } + if ($a === null) { + return -1; + } + if ($b === null) { + return 1; + } + return $a <=> $b; + } + + /** + * Check if an attribute contains any of the given values. Handles both array attributes + * (checks membership) and string attributes (checks substring). + * + * @param array $values + */ + private static function containsValue(mixed $actual, array $values): bool + { + if (\is_array($actual)) { + foreach ($values as $value) { + if (\in_array($value, $actual, false)) { + return true; + } + } + return false; + } + + if (\is_string($actual)) { + foreach ($values as $value) { + if (\str_contains($actual, (string) $value)) { + return true; + } + } + return false; + } + + return false; + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php b/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php new file mode 100644 index 0000000000..c2df415abd --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php @@ -0,0 +1,41 @@ + $allowedAttributes Map of attribute key to Database::VAR_* type + */ + public function __construct(array $allowedAttributes) + { + $attributes = []; + foreach ($allowedAttributes as $key => $type) { + $attributes[] = new Document([ + 'key' => $key, + 'type' => $type, + 'array' => false, + ]); + } + + parent::__construct([ + new Limit(), + new Offset(), + new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES), + new Order($attributes), + ]); + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php b/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php new file mode 100644 index 0000000000..ea2b7c4cad --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php @@ -0,0 +1,24 @@ + Database::VAR_STRING, + 'locale' => Database::VAR_STRING, + 'subject' => Database::VAR_STRING, + 'message' => Database::VAR_STRING, + 'senderName' => Database::VAR_STRING, + 'senderEmail' => Database::VAR_STRING, + 'replyTo' => Database::VAR_STRING, + 'custom' => Database::VAR_BOOLEAN, + ]; + + public function __construct() + { + parent::__construct(self::ALLOWED_ATTRIBUTES); + } +} From 64d182ac6a109d529b80fecfaef0b1d16ea5a7b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:49:20 +0200 Subject: [PATCH 063/402] Add tests for templates --- tests/e2e/Services/Project/TemplatesBase.php | 575 ++++++++++++++++++ .../Project/TemplatesConsoleClientTest.php | 14 + .../Project/TemplatesCustomServerTest.php | 14 + 3 files changed, 603 insertions(+) create mode 100644 tests/e2e/Services/Project/TemplatesBase.php create mode 100644 tests/e2e/Services/Project/TemplatesConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/TemplatesCustomServerTest.php diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php new file mode 100644 index 0000000000..34fe6ee491 --- /dev/null +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -0,0 +1,575 @@ +getEmailTemplate('verification', 'en'); + + $this->assertSame(200, $template['headers']['status-code']); + $this->assertSame('verification', $template['body']['type']); + $this->assertSame('en', $template['body']['locale']); + $this->assertFalse($template['body']['custom']); + $this->assertNotEmpty($template['body']['subject']); + $this->assertNotEmpty($template['body']['message']); + } + + public function testGetEmailTemplateDefaultLocale(): void + { + $template = $this->getEmailTemplate('verification'); + + $this->assertSame(200, $template['headers']['status-code']); + $this->assertSame('verification', $template['body']['type']); + $this->assertSame('en', $template['body']['locale']); + $this->assertFalse($template['body']['custom']); + } + + public function testGetEmailTemplateCustom(): void + { + $update = $this->updateEmailTemplate('magicSession', 'en', 'Magic Subject', 'Magic Body'); + $this->assertSame(200, $update['headers']['status-code']); + + $get = $this->getEmailTemplate('magicSession', 'en'); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('magicSession', $get['body']['type']); + $this->assertSame('en', $get['body']['locale']); + $this->assertTrue($get['body']['custom']); + $this->assertSame('Magic Subject', $get['body']['subject']); + $this->assertSame('Magic Body', $get['body']['message']); + + // Cleanup + $this->deleteEmailTemplate('magicSession', 'en'); + } + + public function testGetEmailTemplateInvalidType(): void + { + $template = $this->getEmailTemplate('notATemplate', 'en'); + + $this->assertSame(400, $template['headers']['status-code']); + } + + public function testGetEmailTemplateInvalidLocale(): void + { + $template = $this->getEmailTemplate('verification', 'not-a-locale'); + + $this->assertSame(400, $template['headers']['status-code']); + } + + public function testGetEmailTemplateWithoutAuthentication(): void + { + $template = $this->getEmailTemplate('verification', 'en', false); + + $this->assertSame(401, $template['headers']['status-code']); + } + + // ========================================================================= + // List email templates tests + // ========================================================================= + + public function testListEmailTemplates(): void + { + $list = $this->listEmailTemplates(null, true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertIsArray($list['body']['templates']); + $this->assertGreaterThan(0, $list['body']['total']); + $this->assertGreaterThan(0, \count($list['body']['templates'])); + + foreach ($list['body']['templates'] as $template) { + $this->assertArrayHasKey('type', $template); + $this->assertArrayHasKey('locale', $template); + $this->assertArrayHasKey('custom', $template); + $this->assertArrayHasKey('subject', $template); + $this->assertArrayHasKey('message', $template); + } + } + + public function testListEmailTemplatesWithLimit(): void + { + $list = $this->listEmailTemplates([ + Query::limit(5)->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertCount(5, $list['body']['templates']); + $this->assertGreaterThanOrEqual(5, $list['body']['total']); + } + + public function testListEmailTemplatesWithOffset(): void + { + $first = $this->listEmailTemplates([ + Query::limit(2)->toString(), + ], true); + $this->assertSame(200, $first['headers']['status-code']); + + $second = $this->listEmailTemplates([ + Query::limit(2)->toString(), + Query::offset(2)->toString(), + ], true); + $this->assertSame(200, $second['headers']['status-code']); + + $firstIds = \array_map( + fn ($t) => $t['type'] . '-' . $t['locale'], + $first['body']['templates'] + ); + $secondIds = \array_map( + fn ($t) => $t['type'] . '-' . $t['locale'], + $second['body']['templates'] + ); + + $this->assertEmpty(\array_intersect($firstIds, $secondIds)); + } + + public function testListEmailTemplatesWithoutTotal(): void + { + $list = $this->listEmailTemplates(null, false); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(0, $list['body']['total']); + $this->assertGreaterThan(0, \count($list['body']['templates'])); + } + + public function testListEmailTemplatesFilterByType(): void + { + $list = $this->listEmailTemplates([ + Query::equal('type', ['verification'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThan(0, $list['body']['total']); + + foreach ($list['body']['templates'] as $template) { + $this->assertSame('verification', $template['type']); + } + } + + public function testListEmailTemplatesFilterByLocale(): void + { + $list = $this->listEmailTemplates([ + Query::equal('locale', ['en'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThan(0, $list['body']['total']); + + foreach ($list['body']['templates'] as $template) { + $this->assertSame('en', $template['locale']); + } + } + + public function testListEmailTemplatesFilterByCustom(): void + { + $update = $this->updateEmailTemplate('recovery', 'en', 'Recovery Subject', 'Recovery Body'); + $this->assertSame(200, $update['headers']['status-code']); + + $list = $this->listEmailTemplates([ + Query::equal('custom', [true])->toString(), + Query::limit(100)->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + $found = false; + foreach ($list['body']['templates'] as $template) { + $this->assertTrue($template['custom']); + if ($template['type'] === 'recovery' && $template['locale'] === 'en') { + $found = true; + } + } + $this->assertTrue($found, 'Customized template should appear in custom=true filter'); + + // Cleanup + $this->deleteEmailTemplate('recovery', 'en'); + } + + public function testListEmailTemplatesCombinedFilters(): void + { + $list = $this->listEmailTemplates([ + Query::equal('type', ['verification'])->toString(), + Query::equal('locale', ['en'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(1, $list['body']['total']); + $this->assertCount(1, $list['body']['templates']); + $this->assertSame('verification', $list['body']['templates'][0]['type']); + $this->assertSame('en', $list['body']['templates'][0]['locale']); + } + + public function testListEmailTemplatesOrderByType(): void + { + $asc = $this->listEmailTemplates([ + Query::orderAsc('type')->toString(), + Query::limit(100)->toString(), + ], true); + $this->assertSame(200, $asc['headers']['status-code']); + + $ascTypes = \array_map(fn ($t) => $t['type'], $asc['body']['templates']); + $sorted = $ascTypes; + \sort($sorted); + $this->assertSame($sorted, $ascTypes); + + $desc = $this->listEmailTemplates([ + Query::orderDesc('type')->toString(), + Query::limit(100)->toString(), + ], true); + $this->assertSame(200, $desc['headers']['status-code']); + + $descTypes = \array_map(fn ($t) => $t['type'], $desc['body']['templates']); + $sorted = $descTypes; + \rsort($sorted); + $this->assertSame($sorted, $descTypes); + } + + public function testListEmailTemplatesInvalidQuery(): void + { + $list = $this->listEmailTemplates([ + Query::equal('notAnAttribute', ['foo'])->toString(), + ], true); + + $this->assertSame(400, $list['headers']['status-code']); + } + + public function testListEmailTemplatesWithoutAuthentication(): void + { + $list = $this->listEmailTemplates(null, true, false); + + $this->assertSame(401, $list['headers']['status-code']); + } + + // ========================================================================= + // Update email template tests + // ========================================================================= + + public function testUpdateEmailTemplate(): void + { + $update = $this->updateEmailTemplate( + 'verification', + 'en', + 'Please verify your email', + 'Click here to verify: {{url}}', + ); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertSame('verification', $update['body']['type']); + $this->assertSame('en', $update['body']['locale']); + $this->assertSame('Please verify your email', $update['body']['subject']); + $this->assertSame('Click here to verify: {{url}}', $update['body']['message']); + $this->assertTrue($update['body']['custom']); + + // Verify persisted via GET + $get = $this->getEmailTemplate('verification', 'en'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Please verify your email', $get['body']['subject']); + $this->assertSame('Click here to verify: {{url}}', $get['body']['message']); + $this->assertTrue($get['body']['custom']); + + // Cleanup + $this->deleteEmailTemplate('verification', 'en'); + } + + public function testUpdateEmailTemplateWithOptionalFields(): void + { + $update = $this->updateEmailTemplate( + 'invitation', + 'en', + 'Team invitation', + 'You have been invited', + 'Appwrite Team', + 'team@appwrite.io', + 'reply@appwrite.io', + ); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertSame('Team invitation', $update['body']['subject']); + $this->assertSame('You have been invited', $update['body']['message']); + $this->assertSame('Appwrite Team', $update['body']['senderName']); + $this->assertSame('team@appwrite.io', $update['body']['senderEmail']); + $this->assertSame('reply@appwrite.io', $update['body']['replyTo']); + + // Cleanup + $this->deleteEmailTemplate('invitation', 'en'); + } + + public function testUpdateEmailTemplateDefaultLocale(): void + { + $update = $this->updateEmailTemplate( + 'sessionAlert', + null, + 'Session alert', + 'Someone signed in', + ); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertSame('sessionAlert', $update['body']['type']); + $this->assertSame('en', $update['body']['locale']); + + // Cleanup + $this->deleteEmailTemplate('sessionAlert', 'en'); + } + + public function testUpdateEmailTemplateOverwrite(): void + { + $this->updateEmailTemplate('otpSession', 'en', 'First', 'First body'); + + $second = $this->updateEmailTemplate('otpSession', 'en', 'Second', 'Second body'); + + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame('Second', $second['body']['subject']); + $this->assertSame('Second body', $second['body']['message']); + + $get = $this->getEmailTemplate('otpSession', 'en'); + $this->assertSame('Second', $get['body']['subject']); + + // Cleanup + $this->deleteEmailTemplate('otpSession', 'en'); + } + + public function testUpdateEmailTemplateInvalidType(): void + { + $update = $this->updateEmailTemplate('notATemplate', 'en', 'Subject', 'Message'); + + $this->assertSame(400, $update['headers']['status-code']); + } + + public function testUpdateEmailTemplateMissingSubject(): void + { + $update = $this->updateEmailTemplate('verification', 'en', null, 'Message only'); + + $this->assertSame(400, $update['headers']['status-code']); + } + + public function testUpdateEmailTemplateMissingMessage(): void + { + $update = $this->updateEmailTemplate('verification', 'en', 'Subject only', null); + + $this->assertSame(400, $update['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidSenderEmail(): void + { + $update = $this->updateEmailTemplate( + 'verification', + 'en', + 'Subject', + 'Message', + 'Sender', + 'not-an-email', + ); + + $this->assertSame(400, $update['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidReplyTo(): void + { + $update = $this->updateEmailTemplate( + 'verification', + 'en', + 'Subject', + 'Message', + null, + null, + 'not-an-email', + ); + + $this->assertSame(400, $update['headers']['status-code']); + } + + public function testUpdateEmailTemplateWithoutAuthentication(): void + { + $update = $this->updateEmailTemplate( + 'verification', + 'en', + 'Subject', + 'Message', + null, + null, + null, + false, + ); + + $this->assertSame(401, $update['headers']['status-code']); + } + + // ========================================================================= + // Delete email template tests + // ========================================================================= + + public function testDeleteEmailTemplate(): void + { + $update = $this->updateEmailTemplate('mfaChallenge', 'en', 'MFA', 'Enter code'); + $this->assertSame(200, $update['headers']['status-code']); + + $customBefore = $this->getEmailTemplate('mfaChallenge', 'en'); + $this->assertTrue($customBefore['body']['custom']); + + $delete = $this->deleteEmailTemplate('mfaChallenge', 'en'); + $this->assertSame(204, $delete['headers']['status-code']); + $this->assertEmpty($delete['body']); + + // Verify reset back to default + $after = $this->getEmailTemplate('mfaChallenge', 'en'); + $this->assertSame(200, $after['headers']['status-code']); + $this->assertFalse($after['body']['custom']); + $this->assertNotSame('MFA', $after['body']['subject']); + } + + public function testDeleteEmailTemplateDefault(): void + { + // Attempt to delete a template that was never customized + $delete = $this->deleteEmailTemplate('verification', 'fr'); + + $this->assertSame(401, $delete['headers']['status-code']); + $this->assertSame('project_template_default_deletion', $delete['body']['type']); + } + + public function testDeleteEmailTemplateInvalidType(): void + { + $delete = $this->deleteEmailTemplate('notATemplate', 'en'); + + $this->assertSame(400, $delete['headers']['status-code']); + } + + public function testDeleteEmailTemplateWithoutAuthentication(): void + { + $update = $this->updateEmailTemplate('recovery', 'en', 'Recovery', 'Reset password'); + $this->assertSame(200, $update['headers']['status-code']); + + $delete = $this->deleteEmailTemplate('recovery', 'en', false); + + $this->assertSame(401, $delete['headers']['status-code']); + + // Verify still customized + $get = $this->getEmailTemplate('recovery', 'en'); + $this->assertTrue($get['body']['custom']); + + // Cleanup + $this->deleteEmailTemplate('recovery', 'en'); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + protected function getEmailTemplate(string $type, ?string $locale = null, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($locale !== null) { + $params['locale'] = $locale; + } + + return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $type, $headers, $params); + } + + /** + * @param array|null $queries + */ + protected function listEmailTemplates(?array $queries, ?bool $total, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($queries !== null) { + $params['queries'] = $queries; + } + if ($total !== null) { + $params['total'] = $total; + } + + return $this->client->call(Client::METHOD_GET, '/project/templates/email', $headers, $params); + } + + protected function updateEmailTemplate( + string $type, + ?string $locale, + ?string $subject, + ?string $message, + ?string $senderName = null, + ?string $senderEmail = null, + ?string $replyTo = null, + bool $authenticated = true, + ): mixed { + $params = [ + 'type' => $type, + ]; + + if ($locale !== null) { + $params['locale'] = $locale; + } + if ($subject !== null) { + $params['subject'] = $subject; + } + if ($message !== null) { + $params['message'] = $message; + } + if ($senderName !== null) { + $params['senderName'] = $senderName; + } + if ($senderEmail !== null) { + $params['senderEmail'] = $senderEmail; + } + if ($replyTo !== null) { + $params['replyTo'] = $replyTo; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PATCH, '/project/templates/email', $headers, $params); + } + + protected function deleteEmailTemplate(string $type, ?string $locale = null, bool $authenticated = true): mixed + { + $params = [ + 'type' => $type, + ]; + + if ($locale !== null) { + $params['locale'] = $locale; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_DELETE, '/project/templates/email', $headers, $params); + } +} diff --git a/tests/e2e/Services/Project/TemplatesConsoleClientTest.php b/tests/e2e/Services/Project/TemplatesConsoleClientTest.php new file mode 100644 index 0000000000..d5431074e3 --- /dev/null +++ b/tests/e2e/Services/Project/TemplatesConsoleClientTest.php @@ -0,0 +1,14 @@ + Date: Fri, 17 Apr 2026 18:15:49 +0200 Subject: [PATCH 064/402] Fix bug --- .../Project/Http/Project/Templates/Email/Get.php | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 1855f13d17..a5a4d8ce8f 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -2,16 +2,13 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Templates\Email; -use Appwrite\Event\Event as QueueEvent; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; use Appwrite\Utopia\Response; use Utopia\Config\Config; -use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Validator\Authorization; use Utopia\Locale\Locale; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; @@ -53,9 +50,6 @@ class Get extends Action ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes']) ->inject('response') - ->inject('queueForEvents') - ->inject('dbForPlatform') - ->inject('authorization') ->inject('project') ->inject('locale') ->callback($this->action(...)); @@ -65,9 +59,6 @@ class Get extends Action string $type, string $locale, Response $response, - QueueEvent $queueForEvents, - Database $dbForPlatform, - Authorization $authorization, Document $project, Locale $localeObject, ) { @@ -108,7 +99,7 @@ class Get extends Action 'placeholders' => ['buttonText', 'body', 'footer'] ]; - $templateString = file_get_contents(__DIR__ . '/../../config/locale/templates/' . $config['file']); + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/../../config/locale/templates/' . $config['file']); // We use `fromString` due to the replace above $message = Template::fromString($templateString); From ad3bdee6c1b7bfac0eee1bfd982f9fff72098da7 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:34:13 +0100 Subject: [PATCH 065/402] fix: include project ID in storage preview cache key Cache key never included the project ID, so two projects with the same bucketId, fileId, and transform params would share a cache key. On a cache hit, Appwrite re-validates the bucket from the cached resourceType (another project's bucket), which doesn't exist in the requesting project's DB, throwing storage_bucket_not_found. Fix: add 'project' to cache.params on the preview route (covers query param case) and fall back to the X-Appwrite-Project header in cacheIdentifier() for authenticated requests. Co-Authored-By: Claude Sonnet 4.6 --- .../Modules/Storage/Http/Buckets/Files/Preview/Get.php | 2 +- src/Appwrite/Utopia/Request.php | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index f0ee045214..f6b6eb25da 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -54,7 +54,7 @@ class Get extends Action ->label('cache', true) ->label('cache.resourceType', 'bucket/{request.bucketId}') ->label('cache.resource', 'file/{request.fileId}') - ->label('cache.params', ['width', 'height', 'gravity', 'quality', 'borderWidth', 'borderColor', 'borderRadius', 'opacity', 'rotation', 'background', 'output']) + ->label('cache.params', ['width', 'height', 'gravity', 'quality', 'borderWidth', 'borderColor', 'borderRadius', 'opacity', 'rotation', 'background', 'output', 'project']) ->label('sdk', new Method( namespace: 'storage', group: 'files', diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 3f1ea794ab..bd0a870f7a 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -239,6 +239,9 @@ class Request extends UtopiaRequest $params = array_intersect_key($params, array_flip($allowedParams)); } ksort($params); + if (!isset($params['project'])) { + $params['project'] = $this->getHeader('x-appwrite-project', ''); + } return md5($this->getURI() . '*' . serialize($params) . '*' . APP_CACHE_BUSTER); } From 08b43dce504a3a8b8c6e85ccf6667f6f6f2275b9 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:45:00 +0100 Subject: [PATCH 066/402] fix: ksort after project injection to keep cache key order stable Co-Authored-By: Claude Sonnet 4.6 --- src/Appwrite/Utopia/Request.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index bd0a870f7a..32f0fa89a9 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -238,10 +238,10 @@ class Request extends UtopiaRequest if ($allowedParams !== null) { $params = array_intersect_key($params, array_flip($allowedParams)); } - ksort($params); if (!isset($params['project'])) { $params['project'] = $this->getHeader('x-appwrite-project', ''); } + ksort($params); return md5($this->getURI() . '*' . serialize($params) . '*' . APP_CACHE_BUSTER); } From be5eeb1abac7902053282dcb407325a15a99351e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 19:50:34 +0200 Subject: [PATCH 067/402] Fix failing tests --- .../Modules/Project/Http/Project/Templates/Email/Get.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index a5a4d8ce8f..9725215613 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -99,7 +99,7 @@ class Get extends Action 'placeholders' => ['buttonText', 'body', 'footer'] ]; - $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/../../config/locale/templates/' . $config['file']); + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); // We use `fromString` due to the replace above $message = Template::fromString($templateString); From fcc7a56f4af2354ebaebfa3758a508983e756d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 18 Apr 2026 11:01:26 +0200 Subject: [PATCH 068/402] Fix list endpoint --- .../Http/Project/Templates/Email/XList.php | 81 ++++++++++++++++--- 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php index 9fdd576af1..0128a18e25 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -6,6 +6,7 @@ use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Template\Template; use Appwrite\Utopia\Database\InMemoryQuery; use Appwrite\Utopia\Database\Validator\Queries\ProjectTemplates; use Appwrite\Utopia\Response; @@ -13,8 +14,10 @@ use Utopia\Config\Config; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; +use Utopia\Locale\Locale; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Utopia\System\System; use Utopia\Validator\Boolean; class XList extends Action @@ -85,16 +88,74 @@ class XList extends Action $key = 'email.' . $type . '-' . $locale; $stored = $projectTemplates[$key] ?? null; - $templates[] = new Document([ - 'type' => $type, - 'locale' => $locale, - 'message' => $stored['message'] ?? '', - 'subject' => $stored['subject'] ?? '', - 'senderName' => $stored['senderName'] ?? '', - 'senderEmail' => $stored['senderEmail'] ?? '', - 'replyTo' => $stored['replyTo'] ?? '', - 'custom' => !\is_null($stored), - ]); + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + + if (is_null($stored)) { + /** + * different templates, different placeholders. + */ + $templateConfigs = [ + 'magicSession' => [ + 'file' => 'email-magic-url.tpl', + 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] + ], + 'mfaChallenge' => [ + 'file' => 'email-mfa-challenge.tpl', + 'placeholders' => ['description', 'clientInfo'] + ], + 'otpSession' => [ + 'file' => 'email-otp.tpl', + 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] + ], + 'sessionAlert' => [ + 'file' => 'email-session-alert.tpl', + 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] + ], + ]; + + // fallback to the base template. + $config = $templateConfigs[$type] ?? [ + 'file' => 'email-inner-base.tpl', + 'placeholders' => ['buttonText', 'body', 'footer'] + ]; + + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); + + // We use `fromString` due to the replace above + $message = Template::fromString($templateString); + + // Set type-specific parameters + foreach ($config['placeholders'] as $param) { + $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); + $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml); + } + + $message + // common placeholders on all the templates + ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello")) + ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) + ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); + + // `useContent: false` will strip new lines! + $message = $message->render(useContent: true); + + $template = [ + 'message' => $message, + 'subject' => $localeObj->getText('emails.' . $type . '.subject'), + 'senderEmail' => '', + 'senderName' => '', + 'custom' => false, + ]; + } else { + $template = $stored; + $template['custom'] = true; + } + + $template['type'] = $type; + $template['locale'] = $locale; + + $templates[] = new Document($template); } } From 0a71efc24494ec15189a426472cd717e79615f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 18 Apr 2026 11:23:53 +0200 Subject: [PATCH 069/402] improve test coverage --- tests/e2e/Services/Project/TemplatesBase.php | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 34fe6ee491..27601eddbe 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -207,6 +207,32 @@ trait TemplatesBase $this->assertSame('en', $list['body']['templates'][0]['locale']); } + public function testListEmailTemplatesDefaultMatchesGet(): void + { + $get = $this->getEmailTemplate('verification', 'en'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertFalse($get['body']['custom']); + + $list = $this->listEmailTemplates([ + Query::equal('type', ['verification'])->toString(), + Query::equal('locale', ['en'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertCount(1, $list['body']['templates']); + + $listed = $list['body']['templates'][0]; + + $this->assertSame($get['body']['type'], $listed['type']); + $this->assertSame($get['body']['locale'], $listed['locale']); + $this->assertSame($get['body']['custom'], $listed['custom']); + $this->assertSame($get['body']['subject'], $listed['subject']); + $this->assertSame($get['body']['message'], $listed['message']); + $this->assertSame($get['body']['senderName'], $listed['senderName']); + $this->assertSame($get['body']['senderEmail'], $listed['senderEmail']); + $this->assertSame($get['body']['replyTo'], $listed['replyTo']); + } + public function testListEmailTemplatesOrderByType(): void { $asc = $this->listEmailTemplates([ From 13f48797d43e01532956a1058286762d5998371c Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Sat, 18 Apr 2026 23:01:41 +0530 Subject: [PATCH 070/402] added project region --- src/Appwrite/Platform/Workers/Migrations.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 118ff7acf9..3656ea712b 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -195,9 +195,16 @@ class Migrations extends Action $migrationOptions = $migration->getAttribute('options'); /** @var Database|null $projectDB */ $projectDB = null; + $useAppwriteApiSource = false; if ($credentials['projectId']) { $this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']); - $projectDB = call_user_func($this->getProjectDB, $this->sourceProject); + $sourceRegion = $this->sourceProject->getAttribute('region', 'default'); + $destinationRegion = $this->project->getAttribute('region', 'default'); + $useAppwriteApiSource = $source === SourceAppwrite::getName() + && $sourceRegion !== $destinationRegion; + if (! $useAppwriteApiSource) { + $projectDB = call_user_func($this->getProjectDB, $this->sourceProject); + } } $getDatabasesDB = fn (Document $database): Database => $this->getDatabasesDBForProject($database); @@ -233,7 +240,7 @@ class Migrations extends Action $credentials['endpoint'], $credentials['apiKey'], $getDatabasesDB, - SourceAppwrite::SOURCE_DATABASE, + $useAppwriteApiSource ? SourceAppwrite::SOURCE_API : SourceAppwrite::SOURCE_DATABASE, $projectDB, $queries ), From 2793bcac3893a0c9d5c9da27aa51d97a52dd1353 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Sat, 18 Apr 2026 23:11:04 +0530 Subject: [PATCH 071/402] updated --- src/Appwrite/Platform/Workers/Migrations.php | 21 +++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 3656ea712b..7a16b02642 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -198,12 +198,18 @@ class Migrations extends Action $useAppwriteApiSource = false; if ($credentials['projectId']) { $this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']); - $sourceRegion = $this->sourceProject->getAttribute('region', 'default'); - $destinationRegion = $this->project->getAttribute('region', 'default'); - $useAppwriteApiSource = $source === SourceAppwrite::getName() - && $sourceRegion !== $destinationRegion; - if (! $useAppwriteApiSource) { - $projectDB = call_user_func($this->getProjectDB, $this->sourceProject); + if ($this->sourceProject->isEmpty()) { + if ($source === SourceAppwrite::getName()) { + $useAppwriteApiSource = true; + } + } else { + $sourceRegion = $this->sourceProject->getAttribute('region', 'default'); + $destinationRegion = $this->project->getAttribute('region', 'default'); + $useAppwriteApiSource = $source === SourceAppwrite::getName() + && $sourceRegion !== $destinationRegion; + if (! $useAppwriteApiSource) { + $projectDB = call_user_func($this->getProjectDB, $this->sourceProject); + } } } $getDatabasesDB = fn (Document $database): Database => @@ -585,9 +591,10 @@ class Migrations extends Action protected function getDatabasesDBForProject(Document $database) { - if ($this->sourceProject) { + if (isset($this->sourceProject) && ! $this->sourceProject->isEmpty()) { return ($this->getDatabasesDB)($database, $this->sourceProject); } + return ($this->getDatabasesDB)($database); } From 8a1f8c71b24439433537aaa329a3a6b14b0f15cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 10:15:48 +0200 Subject: [PATCH 072/402] Temporary removal of listEmailTemplates Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Http/Project/Templates/Email/XList.php | 173 ------------- .../Modules/Project/Services/Http.php | 2 - .../Utopia/Database/InMemoryQuery.php | 159 ------------ .../Validator/Queries/BaseInMemory.php | 41 ---- .../Validator/Queries/ProjectTemplates.php | 24 -- tests/e2e/Services/Project/TemplatesBase.php | 228 ------------------ 6 files changed, 627 deletions(-) delete mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php delete mode 100644 src/Appwrite/Utopia/Database/InMemoryQuery.php delete mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php delete mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php deleted file mode 100644 index 0128a18e25..0000000000 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ /dev/null @@ -1,173 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/project/templates/email') - ->desc('List project email templates') - ->groups(['api', 'project']) - ->label('scope', 'templates.read') - ->label('sdk', new Method( - namespace: 'project', - group: 'templates', - name: 'listEmailTemplates', - description: <<param('queries', [], new ProjectTemplates(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(ProjectTemplates::ALLOWED_ATTRIBUTES)), true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->inject('project') - ->inject('response') - ->inject('localeCodes') - ->callback($this->action(...)); - } - - /** - * @param array $queries - * @param array $localeCodes - */ - public function action( - array $queries, - bool $includeTotal, - Document $project, - Response $response, - array $localeCodes, - ) { - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - $grouped = Query::groupByType($queries); - - $types = Config::getParam('locale-templates')['email'] ?? []; - $projectTemplates = $project->getAttribute('templates', []); - - $templates = []; - foreach ($types as $type) { - foreach ($localeCodes as $locale) { - $key = 'email.' . $type . '-' . $locale; - $stored = $projectTemplates[$key] ?? null; - - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); - - if (is_null($stored)) { - /** - * different templates, different placeholders. - */ - $templateConfigs = [ - 'magicSession' => [ - 'file' => 'email-magic-url.tpl', - 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] - ], - 'mfaChallenge' => [ - 'file' => 'email-mfa-challenge.tpl', - 'placeholders' => ['description', 'clientInfo'] - ], - 'otpSession' => [ - 'file' => 'email-otp.tpl', - 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] - ], - 'sessionAlert' => [ - 'file' => 'email-session-alert.tpl', - 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] - ], - ]; - - // fallback to the base template. - $config = $templateConfigs[$type] ?? [ - 'file' => 'email-inner-base.tpl', - 'placeholders' => ['buttonText', 'body', 'footer'] - ]; - - $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); - - // We use `fromString` due to the replace above - $message = Template::fromString($templateString); - - // Set type-specific parameters - foreach ($config['placeholders'] as $param) { - $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); - $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml); - } - - $message - // common placeholders on all the templates - ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello")) - ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); - - // `useContent: false` will strip new lines! - $message = $message->render(useContent: true); - - $template = [ - 'message' => $message, - 'subject' => $localeObj->getText('emails.' . $type . '.subject'), - 'senderEmail' => '', - 'senderName' => '', - 'custom' => false, - ]; - } else { - $template = $stored; - $template['custom'] = true; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $templates[] = new Document($template); - } - } - - $templates = InMemoryQuery::filter($templates, $grouped['filters']); - $templates = InMemoryQuery::order($templates, $grouped['orderAttributes'], $grouped['orderTypes']); - - $total = $includeTotal ? \count($templates) : 0; - $templates = InMemoryQuery::paginate($templates, $grouped['limit'] ?? APP_LIMIT_COUNT, $grouped['offset']); - - $response->dynamic(new Document([ - 'templates' => $templates, - 'total' => $total, - ]), Response::MODEL_EMAIL_TEMPLATE_LIST); - } -} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index b91541928c..f6c3a2efc2 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -27,7 +27,6 @@ use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProj use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Delete as DeleteTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate; -use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\XList as ListTemplates; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -50,7 +49,6 @@ class Http extends Service $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); // Templates - $this->addAction(ListTemplates::getName(), new ListTemplates()); $this->addAction(GetTemplate::getName(), new GetTemplate()); $this->addAction(DeleteTemplate::getName(), new DeleteTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); diff --git a/src/Appwrite/Utopia/Database/InMemoryQuery.php b/src/Appwrite/Utopia/Database/InMemoryQuery.php deleted file mode 100644 index c9f930369a..0000000000 --- a/src/Appwrite/Utopia/Database/InMemoryQuery.php +++ /dev/null @@ -1,159 +0,0 @@ - $documents - * @param array $filters - * @return array - */ - public static function filter(array $documents, array $filters): array - { - if (empty($filters)) { - return \array_values($documents); - } - - return \array_values(\array_filter($documents, function (Document $document) use ($filters) { - foreach ($filters as $filter) { - if (!self::matches($document, $filter)) { - return false; - } - } - return true; - })); - } - - /** - * Evaluate a single filter query against a document. - */ - public static function matches(Document $document, Query $filter): bool - { - $attribute = $filter->getAttribute(); - $values = $filter->getValues(); - $actual = $document->getAttribute($attribute); - $needle = (string) ($values[0] ?? ''); - - return match ($filter->getMethod()) { - Query::TYPE_EQUAL => \in_array($actual, $values, false), - Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false), - Query::TYPE_LESSER => self::compareScalar($actual, $values[0] ?? null) < 0, - Query::TYPE_LESSER_EQUAL => self::compareScalar($actual, $values[0] ?? null) <= 0, - Query::TYPE_GREATER => self::compareScalar($actual, $values[0] ?? null) > 0, - Query::TYPE_GREATER_EQUAL => self::compareScalar($actual, $values[0] ?? null) >= 0, - Query::TYPE_BETWEEN => self::compareScalar($actual, $values[0] ?? null) >= 0 && self::compareScalar($actual, $values[1] ?? null) <= 0, - Query::TYPE_NOT_BETWEEN => self::compareScalar($actual, $values[0] ?? null) < 0 || self::compareScalar($actual, $values[1] ?? null) > 0, - Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle), - Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle), - Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle), - Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle), - Query::TYPE_CONTAINS => self::containsValue($actual, $values), - Query::TYPE_NOT_CONTAINS => !self::containsValue($actual, $values), - Query::TYPE_SEARCH => \is_string($actual) && $needle !== '' && \stripos($actual, $needle) !== false, - Query::TYPE_NOT_SEARCH => \is_string($actual) && ($needle === '' || \stripos($actual, $needle) === false), - Query::TYPE_IS_NULL => $actual === null, - Query::TYPE_IS_NOT_NULL => $actual !== null, - default => throw new \InvalidArgumentException('Unsupported query method: ' . $filter->getMethod()), - }; - } - - /** - * Sort documents by one or more attributes. - * - * @param array $documents - * @param array $orderAttributes - * @param array $orderTypes - * @return array - */ - public static function order(array $documents, array $orderAttributes, array $orderTypes): array - { - if (empty($orderAttributes)) { - return \array_values($documents); - } - - $documents = \array_values($documents); - - \usort($documents, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) { - foreach ($orderAttributes as $index => $attribute) { - $direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC); - $cmp = self::compareScalar($a->getAttribute($attribute), $b->getAttribute($attribute)); - if ($cmp !== 0) { - return $direction === Database::ORDER_DESC ? -$cmp : $cmp; - } - } - return 0; - }); - - return $documents; - } - - /** - * Apply limit and offset. - * - * @param array $documents - * @return array - */ - public static function paginate(array $documents, ?int $limit, ?int $offset): array - { - return \array_slice(\array_values($documents), $offset ?? 0, $limit); - } - - /** - * Compare two scalars in a way that handles null consistently (null sorts before any value). - */ - private static function compareScalar(mixed $a, mixed $b): int - { - if ($a === null && $b === null) { - return 0; - } - if ($a === null) { - return -1; - } - if ($b === null) { - return 1; - } - return $a <=> $b; - } - - /** - * Check if an attribute contains any of the given values. Handles both array attributes - * (checks membership) and string attributes (checks substring). - * - * @param array $values - */ - private static function containsValue(mixed $actual, array $values): bool - { - if (\is_array($actual)) { - foreach ($values as $value) { - if (\in_array($value, $actual, false)) { - return true; - } - } - return false; - } - - if (\is_string($actual)) { - foreach ($values as $value) { - if (\str_contains($actual, (string) $value)) { - return true; - } - } - return false; - } - - return false; - } -} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php b/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php deleted file mode 100644 index c2df415abd..0000000000 --- a/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php +++ /dev/null @@ -1,41 +0,0 @@ - $allowedAttributes Map of attribute key to Database::VAR_* type - */ - public function __construct(array $allowedAttributes) - { - $attributes = []; - foreach ($allowedAttributes as $key => $type) { - $attributes[] = new Document([ - 'key' => $key, - 'type' => $type, - 'array' => false, - ]); - } - - parent::__construct([ - new Limit(), - new Offset(), - new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES), - new Order($attributes), - ]); - } -} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php b/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php deleted file mode 100644 index ea2b7c4cad..0000000000 --- a/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php +++ /dev/null @@ -1,24 +0,0 @@ - Database::VAR_STRING, - 'locale' => Database::VAR_STRING, - 'subject' => Database::VAR_STRING, - 'message' => Database::VAR_STRING, - 'senderName' => Database::VAR_STRING, - 'senderEmail' => Database::VAR_STRING, - 'replyTo' => Database::VAR_STRING, - 'custom' => Database::VAR_BOOLEAN, - ]; - - public function __construct() - { - parent::__construct(self::ALLOWED_ATTRIBUTES); - } -} diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 27601eddbe..45e09779b9 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -3,7 +3,6 @@ namespace Tests\E2E\Services\Project; use Tests\E2E\Client; -use Utopia\Database\Query; trait TemplatesBase { @@ -72,208 +71,6 @@ trait TemplatesBase $this->assertSame(401, $template['headers']['status-code']); } - // ========================================================================= - // List email templates tests - // ========================================================================= - - public function testListEmailTemplates(): void - { - $list = $this->listEmailTemplates(null, true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertIsArray($list['body']['templates']); - $this->assertGreaterThan(0, $list['body']['total']); - $this->assertGreaterThan(0, \count($list['body']['templates'])); - - foreach ($list['body']['templates'] as $template) { - $this->assertArrayHasKey('type', $template); - $this->assertArrayHasKey('locale', $template); - $this->assertArrayHasKey('custom', $template); - $this->assertArrayHasKey('subject', $template); - $this->assertArrayHasKey('message', $template); - } - } - - public function testListEmailTemplatesWithLimit(): void - { - $list = $this->listEmailTemplates([ - Query::limit(5)->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertCount(5, $list['body']['templates']); - $this->assertGreaterThanOrEqual(5, $list['body']['total']); - } - - public function testListEmailTemplatesWithOffset(): void - { - $first = $this->listEmailTemplates([ - Query::limit(2)->toString(), - ], true); - $this->assertSame(200, $first['headers']['status-code']); - - $second = $this->listEmailTemplates([ - Query::limit(2)->toString(), - Query::offset(2)->toString(), - ], true); - $this->assertSame(200, $second['headers']['status-code']); - - $firstIds = \array_map( - fn ($t) => $t['type'] . '-' . $t['locale'], - $first['body']['templates'] - ); - $secondIds = \array_map( - fn ($t) => $t['type'] . '-' . $t['locale'], - $second['body']['templates'] - ); - - $this->assertEmpty(\array_intersect($firstIds, $secondIds)); - } - - public function testListEmailTemplatesWithoutTotal(): void - { - $list = $this->listEmailTemplates(null, false); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertSame(0, $list['body']['total']); - $this->assertGreaterThan(0, \count($list['body']['templates'])); - } - - public function testListEmailTemplatesFilterByType(): void - { - $list = $this->listEmailTemplates([ - Query::equal('type', ['verification'])->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertGreaterThan(0, $list['body']['total']); - - foreach ($list['body']['templates'] as $template) { - $this->assertSame('verification', $template['type']); - } - } - - public function testListEmailTemplatesFilterByLocale(): void - { - $list = $this->listEmailTemplates([ - Query::equal('locale', ['en'])->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertGreaterThan(0, $list['body']['total']); - - foreach ($list['body']['templates'] as $template) { - $this->assertSame('en', $template['locale']); - } - } - - public function testListEmailTemplatesFilterByCustom(): void - { - $update = $this->updateEmailTemplate('recovery', 'en', 'Recovery Subject', 'Recovery Body'); - $this->assertSame(200, $update['headers']['status-code']); - - $list = $this->listEmailTemplates([ - Query::equal('custom', [true])->toString(), - Query::limit(100)->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertGreaterThanOrEqual(1, $list['body']['total']); - - $found = false; - foreach ($list['body']['templates'] as $template) { - $this->assertTrue($template['custom']); - if ($template['type'] === 'recovery' && $template['locale'] === 'en') { - $found = true; - } - } - $this->assertTrue($found, 'Customized template should appear in custom=true filter'); - - // Cleanup - $this->deleteEmailTemplate('recovery', 'en'); - } - - public function testListEmailTemplatesCombinedFilters(): void - { - $list = $this->listEmailTemplates([ - Query::equal('type', ['verification'])->toString(), - Query::equal('locale', ['en'])->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertSame(1, $list['body']['total']); - $this->assertCount(1, $list['body']['templates']); - $this->assertSame('verification', $list['body']['templates'][0]['type']); - $this->assertSame('en', $list['body']['templates'][0]['locale']); - } - - public function testListEmailTemplatesDefaultMatchesGet(): void - { - $get = $this->getEmailTemplate('verification', 'en'); - $this->assertSame(200, $get['headers']['status-code']); - $this->assertFalse($get['body']['custom']); - - $list = $this->listEmailTemplates([ - Query::equal('type', ['verification'])->toString(), - Query::equal('locale', ['en'])->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertCount(1, $list['body']['templates']); - - $listed = $list['body']['templates'][0]; - - $this->assertSame($get['body']['type'], $listed['type']); - $this->assertSame($get['body']['locale'], $listed['locale']); - $this->assertSame($get['body']['custom'], $listed['custom']); - $this->assertSame($get['body']['subject'], $listed['subject']); - $this->assertSame($get['body']['message'], $listed['message']); - $this->assertSame($get['body']['senderName'], $listed['senderName']); - $this->assertSame($get['body']['senderEmail'], $listed['senderEmail']); - $this->assertSame($get['body']['replyTo'], $listed['replyTo']); - } - - public function testListEmailTemplatesOrderByType(): void - { - $asc = $this->listEmailTemplates([ - Query::orderAsc('type')->toString(), - Query::limit(100)->toString(), - ], true); - $this->assertSame(200, $asc['headers']['status-code']); - - $ascTypes = \array_map(fn ($t) => $t['type'], $asc['body']['templates']); - $sorted = $ascTypes; - \sort($sorted); - $this->assertSame($sorted, $ascTypes); - - $desc = $this->listEmailTemplates([ - Query::orderDesc('type')->toString(), - Query::limit(100)->toString(), - ], true); - $this->assertSame(200, $desc['headers']['status-code']); - - $descTypes = \array_map(fn ($t) => $t['type'], $desc['body']['templates']); - $sorted = $descTypes; - \rsort($sorted); - $this->assertSame($sorted, $descTypes); - } - - public function testListEmailTemplatesInvalidQuery(): void - { - $list = $this->listEmailTemplates([ - Query::equal('notAnAttribute', ['foo'])->toString(), - ], true); - - $this->assertSame(400, $list['headers']['status-code']); - } - - public function testListEmailTemplatesWithoutAuthentication(): void - { - $list = $this->listEmailTemplates(null, true, false); - - $this->assertSame(401, $list['headers']['status-code']); - } - // ========================================================================= // Update email template tests // ========================================================================= @@ -507,31 +304,6 @@ trait TemplatesBase return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $type, $headers, $params); } - /** - * @param array|null $queries - */ - protected function listEmailTemplates(?array $queries, ?bool $total, bool $authenticated = true): mixed - { - $headers = [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ]; - - if ($authenticated) { - $headers = \array_merge($headers, $this->getHeaders()); - } - - $params = []; - if ($queries !== null) { - $params['queries'] = $queries; - } - if ($total !== null) { - $params['total'] = $total; - } - - return $this->client->call(Client::METHOD_GET, '/project/templates/email', $headers, $params); - } - protected function updateEmailTemplate( string $type, ?string $locale, From 2a95cfd5a3a6d973970e03ae03216eb579b313a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 10:35:57 +0200 Subject: [PATCH 073/402] Final template API rework --- .github/workflows/ci.yml | 3 +- CHANGES.md | 8 ++ README-CN.md | 6 +- README.md | 6 +- app/controllers/general.php | 8 ++ app/init/constants.php | 4 +- src/Appwrite/Migration/Migration.php | 1 + .../Http/Project/Templates/Email/Delete.php | 18 +-- .../Http/Project/Templates/Email/Get.php | 28 ++--- .../Http/Project/Templates/Email/Update.php | 18 +-- src/Appwrite/Utopia/Request/Filters/V23.php | 31 +++++ src/Appwrite/Utopia/Response/Filters/V23.php | 28 +++++ .../Utopia/Response/Model/Template.php | 32 ----- .../Utopia/Response/Model/TemplateEmail.php | 22 +++- tests/e2e/Services/Project/TemplatesBase.php | 113 ++++++++++++++++-- 15 files changed, 243 insertions(+), 83 deletions(-) create mode 100644 src/Appwrite/Utopia/Request/Filters/V23.php create mode 100644 src/Appwrite/Utopia/Response/Filters/V23.php delete mode 100644 src/Appwrite/Utopia/Response/Model/Template.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8256ddc7a..48c8ea901b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -701,9 +701,8 @@ jobs: - name: Installing latest version run: | - rm docker-compose.yml + # TODO: Minify docker-compose; remove development tooling rm .env - curl https://appwrite.io/install/compose -o docker-compose.yml curl https://appwrite.io/install/env -o .env sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env docker compose up -d diff --git a/CHANGES.md b/CHANGES.md index 548c0d72b0..d717de4668 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,11 @@ +# Version 1.9.2 + +### Notable changes + +### Fixes + +### Miscellaneous + # Version 1.9.0 ## What's Changed diff --git a/README-CN.md b/README-CN.md index 2c7402f1ef..7f758bc247 100644 --- a/README-CN.md +++ b/README-CN.md @@ -72,7 +72,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.2 ``` ### Windows @@ -84,7 +84,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.2 ``` #### PowerShell @@ -94,7 +94,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.2 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index 31076ffa31..b0926bbeeb 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.2 ``` ### Windows @@ -88,7 +88,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.2 ``` #### PowerShell @@ -99,7 +99,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.2 ``` Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation. diff --git a/app/controllers/general.php b/app/controllers/general.php index b4f4a5c1d1..596fbd0926 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -26,6 +26,7 @@ use Appwrite\Utopia\Request\Filters\V19 as RequestV19; use Appwrite\Utopia\Request\Filters\V20 as RequestV20; use Appwrite\Utopia\Request\Filters\V21 as RequestV21; use Appwrite\Utopia\Request\Filters\V22 as RequestV22; +use Appwrite\Utopia\Request\Filters\V23 as RequestV23; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filters\V16 as ResponseV16; use Appwrite\Utopia\Response\Filters\V17 as ResponseV17; @@ -34,6 +35,7 @@ use Appwrite\Utopia\Response\Filters\V19 as ResponseV19; use Appwrite\Utopia\Response\Filters\V20 as ResponseV20; use Appwrite\Utopia\Response\Filters\V21 as ResponseV21; use Appwrite\Utopia\Response\Filters\V22 as ResponseV22; +use Appwrite\Utopia\Response\Filters\V23 as ResponseV23; use Appwrite\Utopia\View; use Executor\Executor; use MaxMind\Db\Reader; @@ -897,6 +899,9 @@ Http::init() if (version_compare($requestFormat, '1.9.1', '<')) { $request->addFilter(new RequestV22()); } + if (version_compare($requestFormat, '1.9.2', '<')) { + $request->addFilter(new RequestV23()); + } } $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); @@ -921,6 +926,9 @@ Http::init() */ $responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); if ($responseFormat) { + if (version_compare($responseFormat, '1.9.2', '<')) { + $response->addFilter(new ResponseV23()); + } if (version_compare($responseFormat, '1.9.1', '<')) { $response->addFilter(new ResponseV22()); } diff --git a/app/init/constants.php b/app/init/constants.php index f2127cd666..54a94652fb 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -46,8 +46,8 @@ const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours -const APP_CACHE_BUSTER = 4322; -const APP_VERSION_STABLE = '1.9.1'; +const APP_CACHE_BUSTER = 4323; +const APP_VERSION_STABLE = '1.9.2'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; const APP_DATABASE_ATTRIBUTE_IP = 'ip'; diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index a01031de9b..ef0dd9f8b5 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -94,6 +94,7 @@ abstract class Migration '1.8.1' => 'V23', '1.9.0' => 'V24', '1.9.1' => 'V24', + '1.9.2' => 'V24', ]; /** diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php index 9133971c40..cf02704d47 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php @@ -33,13 +33,13 @@ class Delete extends Action $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) ->setHttpPath('/v1/project/templates/email') ->httpAlias('/v1/projects/:projectId/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale') ->desc('Delete project email template') ->groups(['api', 'project']) ->label('scope', 'templates.write') ->label('event', 'templates.[templateType].delete') ->label('audits.event', 'project.template.delete') - ->label('audits.resource', 'project.template/{response.type}') + ->label('audits.resource', 'project.template/{response.templateId}') ->label('sdk', new Method( namespace: 'project', group: 'templates', @@ -56,8 +56,8 @@ class Delete extends Action ], contentType: ContentType::NONE )) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes']) + ->param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) ->inject('response') ->inject('queueForEvents') ->inject('dbForPlatform') @@ -68,7 +68,7 @@ class Delete extends Action } public function action( - string $type, + string $templateId, string $locale, Response $response, QueueEvent $queueForEvents, @@ -77,16 +77,16 @@ class Delete extends Action Document $project, Locale $localeObject, ) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; + $template = $templates['email.' . $templateId . '-' . $locale] ?? null; if (is_null($template)) { throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); } - unset($templates['email.' . $type . '-' . $locale]); + unset($templates['email.' . $templateId . '-' . $locale]); $updates = new Document([ 'templates' => $templates, @@ -94,7 +94,7 @@ class Delete extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - $queueForEvents->setParam('templateType', $type); + $queueForEvents->setParam('templateType', $templateId); $response->noContent(); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 9725215613..115b10f7dd 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -27,8 +27,8 @@ class Get extends Action public function __construct() { $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/project/templates/email/:type') - ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->setHttpPath('/v1/project/templates/email/:templateId') + ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale') ->desc('Get project email template') ->groups(['api', 'project']) ->label('scope', 'templates.read') @@ -47,8 +47,8 @@ class Get extends Action ) ] )) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes']) + ->param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) ->inject('response') ->inject('project') ->inject('locale') @@ -56,16 +56,16 @@ class Get extends Action } public function action( - string $type, + string $templateId, string $locale, Response $response, Document $project, Locale $localeObject, ) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; + $template = $templates['email.' . $templateId . '-' . $locale] ?? null; $localeObj = new Locale($locale); $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); @@ -94,7 +94,7 @@ class Get extends Action ]; // fallback to the base template. - $config = $templateConfigs[$type] ?? [ + $config = $templateConfigs[$templateId] ?? [ 'file' => 'email-inner-base.tpl', 'placeholders' => ['buttonText', 'body', 'footer'] ]; @@ -107,21 +107,21 @@ class Get extends Action // Set type-specific parameters foreach ($config['placeholders'] as $param) { $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); - $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml); + $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml); } $message // common placeholders on all the templates - ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello")) - ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); + ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello")) + ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks")) + ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature")); // `useContent: false` will strip new lines! $message = $message->render(useContent: true); $template = [ 'message' => $message, - 'subject' => $localeObj->getText('emails.' . $type . '.subject'), + 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'), 'senderEmail' => '', 'senderName' => '', 'custom' => false, @@ -130,7 +130,7 @@ class Get extends Action $template['custom'] = true; } - $template['type'] = $type; + $template['templateId'] = $templateId; $template['locale'] = $locale; $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index ff739f9fe8..f17be381f6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -33,13 +33,13 @@ class Update extends Action $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) ->setHttpPath('/v1/project/templates/email') ->httpAlias('/v1/projects/:projectId/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale') ->desc('Update project email template') ->groups(['api', 'project']) ->label('scope', 'templates.write') ->label('event', 'templates.[templateType].update') ->label('audits.event', 'project.template.update') - ->label('audits.resource', 'project.template/{response.type}') + ->label('audits.resource', 'project.template/{response.templateId}') ->label('sdk', new Method( namespace: 'project', group: 'templates', @@ -55,8 +55,8 @@ class Update extends Action ) ] )) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes']) + ->param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) ->param('subject', '', new Text(255), 'Subject of the email template. Can be up to 255 characters.') ->param('message', '', new Text(10485760), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') ->param('senderName', '', new Text(255, 0), 'Name of the email sender.', true) @@ -72,7 +72,7 @@ class Update extends Action } public function action( - string $type, + string $templateId, string $locale, string $subject, string $message, @@ -86,7 +86,7 @@ class Update extends Action Document $project, Locale $localeObject, ) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); $template = [ 'senderName' => $senderName, @@ -97,7 +97,7 @@ class Update extends Action ]; $templates = $project->getAttribute('templates', []); - $templates['email.' . $type . '-' . $locale] = $template; + $templates['email.' . $templateId . '-' . $locale] = $template; $updates = new Document([ 'templates' => $templates, @@ -105,10 +105,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - $queueForEvents->setParam('templateType', $type); + $queueForEvents->setParam('templateType', $templateId); $response->dynamic(new Document([ - 'type' => $type, + 'templateId' => $templateId, 'locale' => $locale, 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php new file mode 100644 index 0000000000..adb5d69aea --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -0,0 +1,31 @@ +parseEmailTemplate($content); + break; + } + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Filters/V23.php b/src/Appwrite/Utopia/Response/Filters/V23.php new file mode 100644 index 0000000000..54fcf8459f --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V23.php @@ -0,0 +1,28 @@ + $this->parseEmailTemplate($content), + default => $content, + }; + } + + private function parseEmailTemplate(array $content): array + { + if (isset($content['templateId'])) { + $content['type'] = $content['templateId']; + unset($content['templateId']); + } + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Template.php b/src/Appwrite/Utopia/Response/Model/Template.php deleted file mode 100644 index 3ce9cacdb3..0000000000 --- a/src/Appwrite/Utopia/Response/Model/Template.php +++ /dev/null @@ -1,32 +0,0 @@ -addRule('type', [ - 'type' => self::TYPE_STRING, - 'description' => 'Template type', - 'default' => '', - 'example' => 'verification', - ]) - ->addRule('locale', [ - 'type' => self::TYPE_STRING, - 'description' => 'Template locale', - 'default' => '', - 'example' => 'en_us', - ]) - ->addRule('message', [ - 'type' => self::TYPE_STRING, - 'description' => 'Template message', - 'default' => '', - 'example' => 'Click on the link to verify your account.', - ]) - ; - } -} diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index 95cd57a584..9b77617b8c 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -3,13 +3,31 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; +use Appwrite\Utopia\Response\Model; -class TemplateEmail extends Template +class TemplateEmail extends Model { public function __construct() { - parent::__construct(); $this + ->addRule('templateId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Template type', + 'default' => '', + 'example' => 'verification', + ]) + ->addRule('locale', [ + 'type' => self::TYPE_STRING, + 'description' => 'Template locale', + 'default' => '', + 'example' => 'en_us', + ]) + ->addRule('message', [ + 'type' => self::TYPE_STRING, + 'description' => 'Template message', + 'default' => '', + 'example' => 'Click on the link to verify your account.', + ]) ->addRule('senderName', [ 'type' => self::TYPE_STRING, 'description' => 'Name of the sender', diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 45e09779b9..b61f85aeef 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -15,7 +15,7 @@ trait TemplatesBase $template = $this->getEmailTemplate('verification', 'en'); $this->assertSame(200, $template['headers']['status-code']); - $this->assertSame('verification', $template['body']['type']); + $this->assertSame('verification', $template['body']['templateId']); $this->assertSame('en', $template['body']['locale']); $this->assertFalse($template['body']['custom']); $this->assertNotEmpty($template['body']['subject']); @@ -27,7 +27,7 @@ trait TemplatesBase $template = $this->getEmailTemplate('verification'); $this->assertSame(200, $template['headers']['status-code']); - $this->assertSame('verification', $template['body']['type']); + $this->assertSame('verification', $template['body']['templateId']); $this->assertSame('en', $template['body']['locale']); $this->assertFalse($template['body']['custom']); } @@ -40,7 +40,7 @@ trait TemplatesBase $get = $this->getEmailTemplate('magicSession', 'en'); $this->assertSame(200, $get['headers']['status-code']); - $this->assertSame('magicSession', $get['body']['type']); + $this->assertSame('magicSession', $get['body']['templateId']); $this->assertSame('en', $get['body']['locale']); $this->assertTrue($get['body']['custom']); $this->assertSame('Magic Subject', $get['body']['subject']); @@ -85,7 +85,7 @@ trait TemplatesBase ); $this->assertSame(200, $update['headers']['status-code']); - $this->assertSame('verification', $update['body']['type']); + $this->assertSame('verification', $update['body']['templateId']); $this->assertSame('en', $update['body']['locale']); $this->assertSame('Please verify your email', $update['body']['subject']); $this->assertSame('Click here to verify: {{url}}', $update['body']['message']); @@ -135,7 +135,7 @@ trait TemplatesBase ); $this->assertSame(200, $update['headers']['status-code']); - $this->assertSame('sessionAlert', $update['body']['type']); + $this->assertSame('sessionAlert', $update['body']['templateId']); $this->assertSame('en', $update['body']['locale']); // Cleanup @@ -281,6 +281,105 @@ trait TemplatesBase $this->deleteEmailTemplate('recovery', 'en'); } + // ========================================================================= + // Legacy response format tests (request + response filters) + // ========================================================================= + + public function testGetEmailTemplateLegacyResponseFormat(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $template = $this->client->call( + Client::METHOD_GET, + '/project/templates/email/verification', + $headers, + ); + + $this->assertSame(200, $template['headers']['status-code']); + // Response filter should rename templateId -> type for < 1.9.2 clients. + $this->assertArrayHasKey('type', $template['body']); + $this->assertArrayNotHasKey('templateId', $template['body']); + $this->assertSame('verification', $template['body']['type']); + $this->assertSame('en', $template['body']['locale']); + } + + public function testUpdateEmailTemplateLegacyResponseFormat(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + // Request filter should accept legacy `type` and map it to `templateId`. + $update = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + $headers, + [ + 'type' => 'magicSession', + 'locale' => 'en', + 'subject' => 'Legacy Subject', + 'message' => 'Legacy Body', + ], + ); + + $this->assertSame(200, $update['headers']['status-code']); + // Response filter should rename templateId -> type for < 1.9.2 clients. + $this->assertArrayHasKey('type', $update['body']); + $this->assertArrayNotHasKey('templateId', $update['body']); + $this->assertSame('magicSession', $update['body']['type']); + $this->assertSame('Legacy Subject', $update['body']['subject']); + $this->assertSame('Legacy Body', $update['body']['message']); + $this->assertTrue($update['body']['custom']); + + // Verify persisted, then cleanup via legacy DELETE with `type`. + $get = $this->getEmailTemplate('magicSession', 'en'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['custom']); + + $delete = $this->client->call( + Client::METHOD_DELETE, + '/project/templates/email', + $headers, + [ + 'type' => 'magicSession', + 'locale' => 'en', + ], + ); + $this->assertSame(204, $delete['headers']['status-code']); + + $after = $this->getEmailTemplate('magicSession', 'en'); + $this->assertFalse($after['body']['custom']); + } + + public function testUpdateEmailTemplateLegacyInvalidType(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $update = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + $headers, + [ + 'type' => 'notATemplate', + 'locale' => 'en', + 'subject' => 'Subject', + 'message' => 'Message', + ], + ); + + $this->assertSame(400, $update['headers']['status-code']); + } + // ========================================================================= // Helpers // ========================================================================= @@ -315,7 +414,7 @@ trait TemplatesBase bool $authenticated = true, ): mixed { $params = [ - 'type' => $type, + 'templateId' => $type, ]; if ($locale !== null) { @@ -352,7 +451,7 @@ trait TemplatesBase protected function deleteEmailTemplate(string $type, ?string $locale = null, bool $authenticated = true): mixed { $params = [ - 'type' => $type, + 'templateId' => $type, ]; if ($locale !== null) { From afab349a7792b1d970a5fcdbb8cc57a2f9101be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 10:43:57 +0200 Subject: [PATCH 074/402] self review fixes --- CHANGES.md | 8 --- README-CN.md | 6 +-- README.md | 6 +-- tests/e2e/Services/Project/TemplatesBase.php | 54 ++++++++++++++++++++ 4 files changed, 60 insertions(+), 14 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d717de4668..548c0d72b0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,11 +1,3 @@ -# Version 1.9.2 - -### Notable changes - -### Fixes - -### Miscellaneous - # Version 1.9.0 ## What's Changed diff --git a/README-CN.md b/README-CN.md index 7f758bc247..212b5bb08d 100644 --- a/README-CN.md +++ b/README-CN.md @@ -72,7 +72,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.2 + appwrite/appwrite:1.9.0 ``` ### Windows @@ -84,7 +84,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.2 + appwrite/appwrite:1.9.0 ``` #### PowerShell @@ -94,7 +94,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.2 + appwrite/appwrite:1.9.0 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index b0926bbeeb..88d527f060 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.2 + appwrite/appwrite:1.9.0 ``` ### Windows @@ -88,7 +88,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.2 + appwrite/appwrite:1.9.0 ``` #### PowerShell @@ -99,7 +99,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.2 + appwrite/appwrite:1.9.0 ``` Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation. diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index b61f85aeef..4f78992079 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -357,6 +357,60 @@ trait TemplatesBase $this->assertFalse($after['body']['custom']); } + public function testDeleteEmailTemplateLegacyResponseFormat(): void + { + // Seed a custom template using the current API. + $update = $this->updateEmailTemplate('otpSession', 'en', 'Legacy OTP', 'Legacy OTP body'); + $this->assertSame(200, $update['headers']['status-code']); + + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + // Request filter should accept legacy `type` and map it to `templateId`. + $delete = $this->client->call( + Client::METHOD_DELETE, + '/project/templates/email', + $headers, + [ + 'type' => 'otpSession', + 'locale' => 'en', + ], + ); + + $this->assertSame(204, $delete['headers']['status-code']); + $this->assertEmpty($delete['body']); + + // Verify reset back to default. + $after = $this->getEmailTemplate('otpSession', 'en'); + $this->assertSame(200, $after['headers']['status-code']); + $this->assertFalse($after['body']['custom']); + $this->assertNotSame('Legacy OTP', $after['body']['subject']); + } + + public function testDeleteEmailTemplateLegacyInvalidType(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $delete = $this->client->call( + Client::METHOD_DELETE, + '/project/templates/email', + $headers, + [ + 'type' => 'notATemplate', + 'locale' => 'en', + ], + ); + + $this->assertSame(400, $delete['headers']['status-code']); + } + public function testUpdateEmailTemplateLegacyInvalidType(): void { $headers = \array_merge([ From 447375dcbfec003feac0bea27ae7ba4e66cf01a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 10:53:11 +0200 Subject: [PATCH 075/402] Fix tests --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 59ff5e353c..4400c337ac 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1121,6 +1121,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); @@ -1133,6 +1134,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'subject' => 'Please verify your email', 'message' => 'Please verify your email {{url}}', @@ -1152,6 +1154,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); @@ -1219,6 +1222,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'type' => 'sessionAlert', // Intentionally no locale @@ -1237,6 +1241,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'type' => 'sessionAlert', 'locale' => 'sk', From 69d53cb2d4553ad074f2feb7c04bfb38a351cc18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 11:03:36 +0200 Subject: [PATCH 076/402] Remove unused email template list response model Co-Authored-By: Claude Opus 4.7 (1M context) --- app/init/models.php | 1 - src/Appwrite/Utopia/Response.php | 1 - 2 files changed, 2 deletions(-) diff --git a/app/init/models.php b/app/init/models.php index 924df52bdd..f654c10121 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -210,7 +210,6 @@ Response::setModel(new BaseList('Currencies List', Response::MODEL_CURRENCY_LIST Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phones', Response::MODEL_PHONE)); Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false)); Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE)); -Response::setModel(new BaseList('Email Templates List', Response::MODEL_EMAIL_TEMPLATE_LIST, 'templates', Response::MODEL_EMAIL_TEMPLATE)); Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS)); Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE)); Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE)); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 4aecc62fd8..d747373b59 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -266,7 +266,6 @@ class Response extends SwooleResponse public const MODEL_VARIABLE_LIST = 'variableList'; public const MODEL_VCS = 'vcs'; public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; - public const MODEL_EMAIL_TEMPLATE_LIST = 'emailTemplateList'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; From d2230f8fe79d48a1302da70f17a0fe27b51af3b6 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 19 Apr 2026 17:31:20 +0530 Subject: [PATCH 077/402] chore: bump PHPStan to level 4 and fix all new errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raises `phpstan.neon` level from 3 to 4 and fixes the 549 new errors that level 4 surfaces across 157 files. Fixes are root-cause — no `@phpstan-ignore`, no `@var` casts, no baseline entries, no widened types. A handful of latent bugs were fixed along the way: - `app/controllers/general.php`: path-traversal guard was negating `\substr(...)` before the strict comparison (`!\substr(...) === $base` was always `false === $base`). Rewritten as `\substr(...) !== $base`. - `src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php` and `.../TablesDB/Logs/XList.php`: were importing the raw Matomo `DeviceDetector` (whose `getDevice()` returns `?int`) but treating the result as an array with `deviceName/deviceBrand/deviceModel` keys. Swapped to `Appwrite\Detector\Detector`, matching the wrapper already used a few lines below for `$os`/`$client`. - `src/Appwrite/Platform/Modules/Functions/Workers/Builds.php`: a match key was checking `$resourceKey === 'functions'` when `$resourceKey` is `'functionId'|'siteId'` — always false. Switched to the intended `$resource->getCollection() === 'functions'` check. - `src/Appwrite/OpenSSL/OpenSSL.php`: `encrypt()` return type tightened to `string|false` to match `openssl_encrypt`; this lets callers' `=== false` error handling remain meaningful. - `app/controllers/api/messaging.php`: removed a dead `array_key_exists('from', [])` branch in the Msg91 provider (empty array literal; branch was unreachable). Large cleanup categories across the 549 fixes: - Removed redundant `?? default` on array offsets and expressions that PHPStan now knows are non-nullable. - Removed unreachable statements (mostly `return;` after `throw` or `markTestSkipped()`). - Removed redundant `is_array`/`is_string`/`is_bool`/`instanceof` checks on already-narrowed types. - Added `default =>` arms (or throwing arms) to non-exhaustive matches on `string`/`mixed` input. - Removed dead `$document === false` branches where method return types were tightened to non-nullable `Document`. - Removed unused properties (`$version` on Etsy/Zoom OAuth2, `$paths` on Installer State, `$source` on MigrationsWorker, `$account2` on two GraphQL auth tests), unused traits (`ApiVectorsDB`, `DatabaseFixture`), and an unused `cleanupStaleExecutions` task method. - Replaced `assertTrue(true)` and redundant `assertIsArray`/`assertIsString`/ `assertNotNull` assertions with `addToAssertionCount(1)` or `assertNotEmpty` where the runtime type was already known. --- app/controllers/api/account.php | 55 ++-- app/controllers/api/messaging.php | 15 +- app/controllers/api/migrations.php | 3 +- app/controllers/api/project.php | 3 +- app/controllers/api/projects.php | 1 - app/controllers/api/users.php | 9 +- app/controllers/general.php | 77 +++--- app/controllers/mock.php | 2 +- app/controllers/shared/api.php | 18 +- app/init/realtime/connection.php | 4 +- app/init/registers.php | 13 +- app/init/resources.php | 2 +- app/init/resources/request.php | 12 +- app/init/worker/message.php | 2 +- app/worker.php | 2 +- phpstan.neon | 2 +- src/Appwrite/Auth/OAuth2/Etsy.php | 5 - src/Appwrite/Auth/OAuth2/Podio.php | 2 +- src/Appwrite/Auth/OAuth2/Zoom.php | 5 - src/Appwrite/Auth/Validator/PersonalData.php | 2 +- src/Appwrite/Docker/Compose/Service.php | 2 +- src/Appwrite/Docker/Env.php | 2 +- src/Appwrite/Event/Event.php | 2 +- src/Appwrite/Event/Validator/Event.php | 5 +- src/Appwrite/Event/Webhook.php | 2 +- src/Appwrite/GraphQL/Types/Mapper.php | 26 +- src/Appwrite/Messaging/Adapter/Realtime.php | 9 +- src/Appwrite/OpenSSL/OpenSSL.php | 2 +- .../Installer/Http/Installer/Install.php | 14 +- .../Installer/Http/Installer/Status.php | 4 +- .../Platform/Installer/Runtime/Config.php | 2 +- .../Platform/Installer/Runtime/State.php | 6 +- src/Appwrite/Platform/Installer/Server.php | 2 +- .../Installer/Validator/AppDomain.php | 2 +- .../Avatars/Http/Cards/Cloud/Front/Get.php | 4 +- .../Avatars/Http/Cards/Cloud/OG/Get.php | 4 +- .../Modules/Avatars/Http/Favicon/Get.php | 4 +- .../Platform/Modules/Avatars/Http/QR/Get.php | 1 - .../Modules/Avatars/Http/Screenshots/Get.php | 2 +- .../Platform/Modules/Compute/Base.php | 4 +- .../Http/Databases/Collections/Action.php | 2 +- .../Collections/Attributes/Action.php | 4 +- .../Collections/Documents/Action.php | 6 +- .../Collections/Documents/Create.php | 10 - .../Databases/Collections/Documents/Get.php | 2 +- .../Collections/Documents/Upsert.php | 7 +- .../Databases/Collections/Documents/XList.php | 2 +- .../Databases/Collections/Indexes/Action.php | 2 +- .../Http/Databases/Collections/Usage/Get.php | 1 + .../Databases/Http/Databases/Logs/XList.php | 8 +- .../Http/Databases/Transactions/Action.php | 4 +- .../Databases/Http/Databases/Usage/Get.php | 1 + .../Databases/Http/Databases/Usage/XList.php | 1 + .../Databases/Http/TablesDB/Logs/XList.php | 8 +- .../Http/VectorsDB/Embeddings/Text/Create.php | 2 +- .../Modules/Databases/Workers/Databases.php | 2 +- .../Functions/Http/Deployments/XList.php | 2 +- .../Functions/Http/Executions/Create.php | 46 +--- .../Functions/Http/Functions/Update.php | 4 - .../Modules/Functions/Http/Usage/Get.php | 1 + .../Modules/Functions/Http/Usage/XList.php | 2 + .../Functions/Http/Variables/Delete.php | 6 +- .../Modules/Functions/Http/Variables/Get.php | 5 - .../Functions/Http/Variables/Update.php | 2 +- .../Modules/Functions/Workers/Builds.php | 43 +--- .../Modules/Functions/Workers/Screenshots.php | 4 +- .../Health/Http/Health/Certificate/Get.php | 2 +- .../Health/Http/Health/Queue/Failed/Get.php | 2 + .../Modules/Projects/Http/DevKeys/Delete.php | 2 +- .../Modules/Projects/Http/DevKeys/Get.php | 2 +- .../Modules/Projects/Http/DevKeys/Update.php | 2 +- .../Modules/Projects/Http/Projects/XList.php | 2 +- .../Platform/Modules/Proxy/Action.php | 4 +- .../Proxy/Http/Rules/Redirect/Create.php | 3 +- .../Modules/Sites/Http/Deployments/XList.php | 2 +- .../Modules/Sites/Http/Sites/Update.php | 4 - .../Platform/Modules/Sites/Http/Usage/Get.php | 1 + .../Modules/Sites/Http/Usage/XList.php | 2 + .../Modules/Sites/Http/Variables/Delete.php | 6 +- .../Modules/Sites/Http/Variables/Get.php | 5 - .../Modules/Sites/Http/Variables/Update.php | 2 +- .../Storage/Http/Buckets/Files/Create.php | 22 +- .../Http/Buckets/Files/Preview/Get.php | 4 +- .../Storage/Http/Buckets/Files/Update.php | 2 +- .../Modules/Storage/Http/Buckets/XList.php | 9 +- .../Modules/Storage/Http/Usage/Get.php | 1 + .../Modules/Storage/Http/Usage/XList.php | 2 + .../Modules/VCS/Http/GitHub/Callback/Get.php | 8 +- .../Modules/VCS/Http/GitHub/Deployment.php | 2 +- .../Modules/VCS/Http/Installations/Get.php | 2 +- .../Repositories/Branches/XList.php | 6 +- .../Repositories/Contents/Get.php | 2 +- .../Installations/Repositories/Create.php | 2 +- .../Repositories/Detections/Create.php | 2 +- .../Http/Installations/Repositories/Get.php | 6 +- src/Appwrite/Platform/Tasks/Install.php | 24 +- src/Appwrite/Platform/Tasks/Interval.php | 47 ---- src/Appwrite/Platform/Tasks/SDKs.php | 4 +- src/Appwrite/Platform/Tasks/ScheduleBase.php | 2 +- .../Platform/Tasks/ScheduleFunctions.php | 10 +- src/Appwrite/Platform/Tasks/Screenshot.php | 3 - src/Appwrite/Platform/Tasks/Upgrade.php | 3 - src/Appwrite/Platform/Workers/Audits.php | 2 +- .../Platform/Workers/Certificates.php | 2 +- src/Appwrite/Platform/Workers/Deletes.php | 5 +- src/Appwrite/Platform/Workers/Executions.php | 2 +- src/Appwrite/Platform/Workers/Functions.php | 16 +- src/Appwrite/Platform/Workers/Mails.php | 2 +- src/Appwrite/Platform/Workers/Messaging.php | 13 +- src/Appwrite/Platform/Workers/Migrations.php | 5 +- .../Platform/Workers/StatsResources.php | 2 +- src/Appwrite/Platform/Workers/StatsUsage.php | 2 +- src/Appwrite/Platform/Workers/Webhooks.php | 2 +- src/Appwrite/SDK/Specification/Format.php | 23 +- .../SDK/Specification/Format/OpenAPI3.php | 51 ++-- .../SDK/Specification/Format/Swagger2.php | 41 ++- .../Utopia/Database/Validator/Attributes.php | 6 +- .../Database/Validator/Queries/Webhooks.php | 16 +- src/Appwrite/Utopia/Fetch/BodyMultipart.php | 4 +- src/Appwrite/Utopia/Request/Filter.php | 8 +- src/Appwrite/Utopia/Request/Filters/V20.php | 2 +- src/Appwrite/Utopia/Response/Filters/V16.php | 2 +- src/Appwrite/Vcs/Comment.php | 4 +- src/Executor/Executor.php | 6 +- tests/e2e/Client.php | 2 +- tests/e2e/General/UsageTest.php | 2 - tests/e2e/Scopes/ApiVectorsDB.php | 110 -------- tests/e2e/Services/Account/AccountBase.php | 2 +- .../Account/AccountConsoleClientTest.php | 2 +- .../Account/AccountCustomClientTest.php | 4 +- .../e2e/Services/Databases/DatabasesBase.php | 10 - .../Databases/Transactions/ACIDBase.php | 1 - .../Services/GraphQL/FunctionsClientTest.php | 6 +- .../Services/GraphQL/FunctionsServerTest.php | 6 +- .../e2e/Services/GraphQL/Legacy/AuthTest.php | 1 - .../Services/GraphQL/StorageClientTest.php | 2 +- .../Services/GraphQL/StorageServerTest.php | 2 +- .../Services/GraphQL/TablesDB/AuthTest.php | 1 - .../e2e/Services/GraphQL/TeamsServerTest.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 44 ---- ...altimeCustomClientQueryTestWithMessage.php | 2 +- .../Realtime/RealtimeCustomClientTest.php | 6 +- .../Services/Realtime/RealtimeQueryBase.php | 52 ++-- .../Services/Sites/SitesCustomServerTest.php | 18 -- .../Tokens/TokensConsoleClientTest.php | 4 - tests/e2e/Traits/DatabaseFixture.php | 239 ------------------ tests/extensions/Async/Eventually.php | 2 +- tests/extensions/RetrySubscriber.php | 8 - .../unit/Messaging/MessagingChannelsTest.php | 2 - tests/unit/Network/Validators/DNSTest.php | 5 +- .../Platform/Modules/Installer/ModuleTest.php | 10 +- .../Modules/Installer/Runtime/StateTest.php | 14 +- .../Installer/Validator/AppDomainTest.php | 1 - tests/unit/URL/URLTest.php | 11 - .../Database/Query/RuntimeQueryTest.php | 4 +- tests/unit/Utopia/RequestTest.php | 1 - tests/unit/Utopia/ResponseTest.php | 1 - 157 files changed, 378 insertions(+), 1129 deletions(-) delete mode 100644 tests/e2e/Scopes/ApiVectorsDB.php delete mode 100644 tests/e2e/Traits/DatabaseFixture.php diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index ffe2b54c5b..4b2d7a31b8 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -133,9 +133,6 @@ $createSession = function (string $userId, string $secret, Request $request, Res }); $provider = match ($verifiedToken->getAttribute('type')) { - TOKEN_TYPE_VERIFICATION, - TOKEN_TYPE_RECOVERY, - TOKEN_TYPE_INVITE => SESSION_PROVIDER_EMAIL, TOKEN_TYPE_MAGIC_URL => SESSION_PROVIDER_MAGIC_URL, TOKEN_TYPE_PHONE => SESSION_PROVIDER_PHONE, TOKEN_TYPE_OAUTH2 => $oauthProvider, @@ -335,15 +332,15 @@ Http::post('/v1/account') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -837,7 +834,7 @@ Http::patch('/v1/account/sessions/:sessionId') throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); } - if (!empty($provider) && $className !== null && \class_exists($className)) { + if (!empty($provider) && \class_exists($className)) { $appId = $project->getAttribute('oAuthProviders', [])[$provider . 'Appid'] ?? ''; $appSecret = $project->getAttribute('oAuthProviders', [])[$provider . 'Secret'] ?? '{}'; @@ -1604,7 +1601,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } } - if ($user === false || $user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email + if ($user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email if (empty($email)) { $failureRedirect(Exception::USER_UNAUTHORIZED, 'OAuth provider failed to return email.'); } @@ -1621,7 +1618,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } // If user is not found, check if there is a user with the same email - if ($user === false || $user->isEmpty()) { + if ($user->isEmpty()) { $userWithEmail = $dbForProject->findOne('users', [ Query::equal('email', [$email]), ]); @@ -1634,7 +1631,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } // If user is not found, check if there is an identity with the same email - if ($user === false || $user->isEmpty()) { + if ($user->isEmpty()) { $identityWithMatchingEmail = $dbForProject->findOne('identities', [ Query::equal('providerEmail', [$email]), ]); @@ -1646,7 +1643,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } } - if ($user === false || $user->isEmpty()) { // Last option -> create the user + if ($user->isEmpty()) { // Last option -> create the user $limit = $project->getAttribute('auths', [])['limit'] ?? 0; if ($limit !== 0) { @@ -1679,15 +1676,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $failureRedirect(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { $failureRedirect(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { $failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { $failureRedirect(Exception::USER_EMAIL_FREE); } @@ -1820,15 +1817,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $failureRedirect(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { $failureRedirect(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { $failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { $failureRedirect(Exception::USER_EMAIL_FREE); } @@ -1954,7 +1951,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->addCookie($store->getKey(), $encoded, (new \DateTime($expire))->getTimestamp(), '/', $cookieDomain, ('https' == $protocol), true, Config::getParam('cookieSamesite')); } - if (isset($sessionUpgrade) && $sessionUpgrade && isset($session)) { + if (isset($sessionUpgrade) && isset($session)) { foreach ($user->getAttribute('targets', []) as $target) { if ($target->getAttribute('providerType') !== MESSAGE_TYPE_PUSH) { continue; @@ -2178,15 +2175,15 @@ Http::post('/v1/account/tokens/magic-url') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -2488,15 +2485,15 @@ Http::post('/v1/account/tokens/email') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -3397,15 +3394,15 @@ Http::patch('/v1/account/email') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -3442,7 +3439,7 @@ Http::patch('/v1/account/email') */ $oldTarget = $user->find('identifier', $oldEmail, 'targets'); - if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { + if (!$oldTarget->isEmpty()) { $authorization->skip(fn () => $dbForProject->updateDocument('targets', $oldTarget->getId(), $oldTarget->setAttribute('identifier', $email))); } $dbForProject->purgeCachedDocument('users', $user->getId()); @@ -3531,7 +3528,7 @@ Http::patch('/v1/account/phone') */ $oldTarget = $user->find('identifier', $oldPhone, 'targets'); - if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { + if (!$oldTarget->isEmpty()) { $authorization->skip(fn () => $dbForProject->updateDocument('targets', $oldTarget->getId(), $oldTarget->setAttribute('identifier', $phone))); } $dbForProject->purgeCachedDocument('users', $user->getId()); diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php index 2a0012bd30..58c6a2c29e 100644 --- a/app/controllers/api/messaging.php +++ b/app/controllers/api/messaging.php @@ -482,7 +482,6 @@ Http::post('/v1/messaging/providers/msg91') $enabled === true && \array_key_exists('senderId', $credentials) && \array_key_exists('authKey', $credentials) - && \array_key_exists('from', $options) ) { $enabled = true; } else { @@ -3207,10 +3206,6 @@ Http::post('/v1/messaging/messages/email') throw new Exception(Exception::MESSAGE_MISSING_TARGET); } - if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) { - throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE); - } - $mergedTargets = \array_merge($targets, $cc, $bcc); if (!empty($mergedTargets)) { @@ -3386,10 +3381,6 @@ Http::post('/v1/messaging/messages/sms') throw new Exception(Exception::MESSAGE_MISSING_TARGET); } - if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) { - throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE); - } - if (!empty($targets)) { $foundTargets = $dbForProject->find('targets', [ Query::equal('$id', $targets), @@ -3527,10 +3518,6 @@ Http::post('/v1/messaging/messages/push') throw new Exception(Exception::MESSAGE_MISSING_TARGET); } - if ($status === MessageStatus::SCHEDULED && \is_null($scheduledAt)) { - throw new Exception(Exception::MESSAGE_MISSING_SCHEDULE); - } - if (!empty($targets)) { $foundTargets = $dbForProject->find('targets', [ Query::equal('$id', $targets), @@ -4660,7 +4647,7 @@ Http::delete('/v1/messaging/messages/:messageId') if (!empty($scheduleId)) { try { $dbForPlatform->deleteDocument('schedules', $scheduleId); - } catch (Exception) { + } catch (\Throwable) { // Ignore } } diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php index 4c541d2817..7338197511 100644 --- a/app/controllers/api/migrations.php +++ b/app/controllers/api/migrations.php @@ -51,7 +51,8 @@ function getDatabaseTransferResourceServices(string $databaseType) DATABASE_TYPE_LEGACY, DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, - DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, + default => throw new \LogicException('Unknown database type: ' . $databaseType), }; } diff --git a/app/controllers/api/project.php b/app/controllers/api/project.php index 054a7c8f0d..544beade77 100644 --- a/app/controllers/api/project.php +++ b/app/controllers/api/project.php @@ -113,11 +113,12 @@ Http::get('/v1/project/usage') $factor = match ($period) { '1h' => 3600, '1d' => 86400, + default => throw new \LogicException('Unsupported period: ' . $period), }; $limit = match ($period) { '1h' => (new DateTime($startDate))->diff(new DateTime($endDate))->days * 24, - '1d' => (new DateTime($startDate))->diff(new DateTime($endDate))->days + '1d' => (new DateTime($startDate))->diff(new DateTime($endDate))->days, }; $format = match ($period) { diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 439692e1dd..36fd176a15 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -321,7 +321,6 @@ Http::patch('/v1/projects/:projectId/auth/:method') $project = $dbForPlatform->getDocument('projects', $projectId); $auth = Config::getParam('auth')[$method] ?? []; $authKey = $auth['key'] ?? ''; - $status = ($status === '1' || $status === 'true' || $status === 1 || $status === true); if ($project->isEmpty()) { throw new Exception(Exception::PROJECT_NOT_FOUND); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index a8875fc442..57c5854422 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1535,7 +1535,7 @@ Http::patch('/v1/users/:userId/email') Query::equal('identifier', [$email]), ]); - if ($target instanceof Document && !$target->isEmpty()) { + if (!$target->isEmpty()) { throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS); } } @@ -1600,7 +1600,7 @@ Http::patch('/v1/users/:userId/email') */ $oldTarget = $user->find('identifier', $oldEmail, 'targets'); - if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { + if (!$oldTarget->isEmpty()) { if (\strlen($email) !== 0) { $dbForProject->updateDocument('targets', $oldTarget->getId(), new Document(['identifier' => $email])); $oldTarget->setAttribute('identifier', $email); @@ -1681,7 +1681,7 @@ Http::patch('/v1/users/:userId/phone') Query::equal('identifier', [$number]), ]); - if ($target instanceof Document && !$target->isEmpty()) { + if (!$target->isEmpty()) { throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS); } } @@ -1696,7 +1696,7 @@ Http::patch('/v1/users/:userId/phone') */ $oldTarget = $user->find('identifier', $oldPhone, 'targets'); - if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { + if (!$oldTarget->isEmpty()) { if ($number !== '') { $dbForProject->updateDocument('targets', $oldTarget->getId(), new Document(['identifier' => $number])); $oldTarget->setAttribute('identifier', $number); @@ -2842,6 +2842,7 @@ Http::get('/v1/users/usage') $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new \LogicException('Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/app/controllers/general.php b/app/controllers/general.php index b4f4a5c1d1..06ed676a76 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -67,7 +67,7 @@ Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE); function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, Bus $bus, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { - $host = $request->getHostname() ?? ''; + $host = $request->getHostname(); if (!empty($previewHostname)) { $host = $previewHostname; } @@ -200,12 +200,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S $deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $activeDeploymentId)); } - if ($deployment->getAttribute('resourceType', '') === 'functions') { - $type = 'function'; - } elseif ($deployment->getAttribute('resourceType', '') === 'sites') { - $type = 'site'; - } - if ($deployment->isEmpty()) { $resourceType = $rule->getAttribute('deploymentResourceType', ''); $resourceId = $rule->getAttribute('deploymentResourceId', ''); @@ -215,6 +209,14 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S throw $exception; } + if ($deployment->getAttribute('resourceType', '') === 'functions') { + $type = 'function'; + } elseif ($deployment->getAttribute('resourceType', '') === 'sites') { + $type = 'site'; + } else { + throw new AppwriteException(AppwriteException::GENERAL_SERVER_ERROR, 'Unknown deployment resource type', view: $errorView); + } + $resource = $type === 'function' ? $authorization->skip(fn () => $dbForProject->getDocument('functions', $deployment->getAttribute('resourceId', ''))) : $authorization->skip(fn () => $dbForProject->getDocument('sites', $deployment->getAttribute('resourceId', ''))); @@ -302,13 +304,13 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S } } - $body = $swooleRequest->getContent() ?? ''; + $body = $swooleRequest->getContent() ?: ''; $method = $swooleRequest->server['request_method']; $requestHeaders = $request->getHeaders(); if ($resource->isEmpty() || !$resource->getAttribute('enabled')) { - if ($type === 'functions') { + if ($type === 'function') { throw new AppwriteException(AppwriteException::FUNCTION_NOT_FOUND, view: $errorView); } else { throw new AppwriteException(AppwriteException::SITE_NOT_FOUND, view: $errorView); @@ -330,7 +332,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S $runtime = match ($type) { 'function' => $runtimes[$resource->getAttribute('runtime')] ?? null, 'site' => $runtimes[$resource->getAttribute('buildRuntime')] ?? null, - default => null }; // Static site enforced runtime @@ -459,10 +460,10 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S // V2 vars if ($version === 'v2') { $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', + 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'], 'APPWRITE_FUNCTION_DATA' => $body, - 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', - 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' + 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'], + 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ]); } @@ -678,9 +679,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S if (\is_string($logs) && \strlen($logs) > $maxLogLength) { $warningMessage = "[WARNING] Logs truncated. The output exceeded {$maxLogLength} characters.\n"; - $warningLength = \strlen($warningMessage); - $maxContentLength = max(0, $maxLogLength - $warningLength); - $logs = $warningMessage . ($maxContentLength > 0 ? \substr($logs, -$maxContentLength) : ''); + $maxContentLength = $maxLogLength - \strlen($warningMessage); + $logs = $warningMessage . \substr($logs, -$maxContentLength); } // Truncate errors if they exceed the limit @@ -689,9 +689,8 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S if (\is_string($errors) && \strlen($errors) > $maxErrorLength) { $warningMessage = "[WARNING] Errors truncated. The output exceeded {$maxErrorLength} characters.\n"; - $warningLength = \strlen($warningMessage); - $maxContentLength = max(0, $maxErrorLength - $warningLength); - $errors = $warningMessage . ($maxContentLength > 0 ? \substr($errors, -$maxContentLength) : ''); + $maxContentLength = $maxErrorLength - \strlen($warningMessage); + $errors = $warningMessage . \substr($errors, -$maxContentLength); } /** Update execution status */ $status = $executionResponse['statusCode'] >= 500 ? 'failed' : 'completed'; @@ -719,14 +718,12 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S throw $th; } } finally { - if ($type === 'function' || $type === 'site') { - $bus->dispatch(new ExecutionCompleted( - execution: $execution->getArrayCopy(), - project: $project->getArrayCopy(), - spec: $spec, - resource: $resource->getArrayCopy(), - )); - } + $bus->dispatch(new ExecutionCompleted( + execution: $execution->getArrayCopy(), + project: $project->getArrayCopy(), + spec: $spec, + resource: $resource->getArrayCopy(), + )); } $execution->setAttribute('logs', ''); @@ -774,20 +771,6 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S deployment: $deployment->getArrayCopy(), )); - /* cleanup */ - if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { - $resourceType = $type === 'function' - ? RESOURCE_TYPE_FUNCTIONS - : RESOURCE_TYPE_SITES; - - $queueForDeletes - ->setProject($project) - ->setResourceType($resourceType) - ->setResource($resource->getSequence()) - ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) - ->trigger(); - } - return true; } elseif ($type === 'api') { return false; @@ -852,7 +835,7 @@ Http::init() /* * Appwrite Router */ - $hostname = $request->getHostname() ?? ''; + $hostname = $request->getHostname(); $platformHostnames = $platform['hostnames'] ?? []; // Only run Router when external domain if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) { @@ -1499,9 +1482,9 @@ Http::error() ->setParam('development', Http::isDevelopment()) ->setParam('projectName', $project->getAttribute('name')) ->setParam('projectURL', $project->getAttribute('url')) - ->setParam('message', $output['message'] ?? '') - ->setParam('type', $output['type'] ?? '') - ->setParam('code', $output['code'] ?? '') + ->setParam('message', $output['message']) + ->setParam('type', $output['type']) + ->setParam('code', $output['code']) ->setParam('trace', $output['trace'] ?? []) ->setParam('exception', $error); @@ -1616,7 +1599,7 @@ Http::get('/.well-known/acme-challenge/*') throw new AppwriteException(AppwriteException::GENERAL_ROUTE_NOT_FOUND, 'Unknown path'); } - if (!\substr($absolute, 0, \strlen($base)) === $base) { + if (\substr($absolute, 0, \strlen($base)) !== $base) { throw new AppwriteException(AppwriteException::GENERAL_UNAUTHORIZED_SCOPE, 'Invalid path'); } @@ -1695,7 +1678,7 @@ Http::get('/_appwrite/authorize') ->inject('previewHostname') ->action(function (Request $request, Response $response, string $previewHostname) { - $host = $request->getHostname() ?? ''; + $host = $request->getHostname(); if (!empty($previewHostname)) { $host = $previewHostname; } diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 99713af430..4e92b3482d 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -251,7 +251,7 @@ Http::get('/v1/mock/github/callback') $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); $projectInternalId = $project->getSequence(); diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index bba00bede1..fe3fc1d8fe 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -46,7 +46,7 @@ use Utopia\Validator\WhiteList; $parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user) { preg_match_all('/{(.*?)}/', $label, $matches); - foreach ($matches[1] ?? [] as $pos => $match) { + foreach ($matches[1] as $pos => $match) { $find = $matches[0][$pos]; $parts = explode('.', $match); @@ -54,8 +54,8 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar throw new Exception(Exception::GENERAL_SERVER_ERROR, "The server encountered an error while parsing the label: $label. Please create an issue on GitHub to allow us to investigate further https://github.com/appwrite/appwrite/issues/new/choose"); } - $namespace = $parts[0] ?? ''; - $replace = $parts[1] ?? ''; + $namespace = $parts[0]; + $replace = $parts[1]; $params = match ($namespace) { 'user' => (array) $user, @@ -263,8 +263,7 @@ Http::init() $userClone->setAttribute('type', match ($apiKey->getType()) { API_KEY_STANDARD => ACTIVITY_TYPE_KEY_PROJECT, API_KEY_ACCOUNT => ACTIVITY_TYPE_KEY_ACCOUNT, - API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION, - default => ACTIVITY_TYPE_KEY_PROJECT, + default => ACTIVITY_TYPE_KEY_ORGANIZATION, }); $auditContext->user = $userClone; } @@ -385,7 +384,7 @@ Http::init() } // Step 6: Update project and user last activity - if (! $project->isEmpty() && $project->getId() !== 'console') { + if ($project->getId() !== 'console') { $accessedAt = $project->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) { $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ @@ -415,9 +414,6 @@ Http::init() } // Steps 7-9: Access Control - Method, Namespace and Scope Validation - /** - * @var ?Method $method - */ $method = $route->getLabel('sdk', false); // Take the first method if there's more than one, @@ -646,7 +642,7 @@ Http::init() if (! empty($data) && ! $cacheLog->isEmpty()) { $parts = explode('/', $cacheLog->getAttribute('resourceType', '')); - $type = $parts[0] ?? null; + $type = $parts[0]; if ($type === 'bucket' && (! $isImageTransformation || ! $isDisabled)) { $bucketId = $parts[1] ?? null; @@ -937,7 +933,7 @@ Http::shutdown() } $auditUser = $auditContext->user; - if (! empty($auditContext->resource) && ! \is_null($auditUser) && ! $auditUser->isEmpty()) { + if (! empty($auditContext->resource) && ! $auditUser->isEmpty()) { /** * audits.payload is switched to default true * in order to auto audit payload for all endpoints diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index 0c1dbad923..c0219fa816 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -85,7 +85,7 @@ return function (Container $container): void { return $dbForPlatform->findOne('rules', [ Query::equal('domain', [$domain]), - ]) ?? new Document(); + ]); }); $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); @@ -139,7 +139,7 @@ return function (Container $container): void { $sdkValidator = new WhiteList($servers, true); $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN')); - if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) { + if ($sdk !== 'unknown' && $sdkValidator->isValid($sdk)) { $sdks = $key->getAttribute('sdks', []); if (!\in_array($sdk, $sdks, true)) { diff --git a/app/init/registers.php b/app/init/registers.php index c07bc9da8b..54c0053a33 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -71,7 +71,7 @@ $register->set('logger', function () { $providerConfig = match ($providerName) { 'sentry' => [ 'key' => $configChunks[0], 'projectId' => $configChunks[1] ?? '', 'host' => '',], - 'logowl' => ['ticket' => $configChunks[0] ?? '', 'host' => ''], + 'logowl' => ['ticket' => $configChunks[0], 'host' => ''], default => ['key' => $providerConfig], }; } @@ -249,11 +249,11 @@ $register->set('pools', function () { $poolSize = max(1, (int)($instanceConnections / $workerCount)); foreach ($connections as $key => $connection) { - $type = $connection['type'] ?? ''; - $multiple = $connection['multiple'] ?? false; - $schemes = $connection['schemes'] ?? []; + $type = $connection['type']; + $multiple = $connection['multiple']; + $schemes = $connection['schemes']; $config = []; - $dsns = explode(',', $connection['dsns'] ?? ''); + $dsns = explode(',', $connection['dsns']); foreach ($dsns as &$dsn) { $dsn = explode('=', $dsn); $name = ($multiple) ? $key . '_' . $dsn[0] : $key; @@ -318,7 +318,7 @@ $register->set('pools', function () { )); }); }, - 'redis' => function () use ($dsnHost, $dsnPort, $dsnPass) { + default => function () use ($dsnHost, $dsnPort, $dsnPass) { $redis = new \Redis(); @$redis->pconnect($dsnHost, (int)$dsnPort); if ($dsnPass) { @@ -328,7 +328,6 @@ $register->set('pools', function () { return $redis; }, - default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Invalid scheme'), }; $poolAdapter = System::getEnv('_APP_POOL_ADAPTER', default: 'stack') === 'swoole' ? new SwoolePool() : new StackPool(); diff --git a/app/init/resources.php b/app/init/resources.php index d1bb7584bf..29506bfc9c 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -266,7 +266,7 @@ function getDevice(string $root, string $connection = ''): Device return new Local($root); } } else { - switch (strtolower(System::getEnv('_APP_STORAGE_DEVICE', Storage::DEVICE_LOCAL) ?? '')) { + switch (strtolower(System::getEnv('_APP_STORAGE_DEVICE', Storage::DEVICE_LOCAL))) { case Storage::DEVICE_LOCAL: default: return new Local($root); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 3f6196c460..7d1731b80d 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -375,7 +375,7 @@ return function (Container $container): void { return $dbForPlatform->findOne('rules', [ Query::equal('domain', [$domain]), - ]) ?? new Document(); + ]); }); $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); @@ -478,14 +478,10 @@ return function (Container $container): void { } // Get fallback session from old clients (no SameSite support) or clients who block 3rd-party cookies - if ($response) { // if in http context - add debug header - $response->addHeader('X-Debug-Fallback', 'false'); - } + $response->addHeader('X-Debug-Fallback', 'false'); if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) { - if ($response) { - $response->addHeader('X-Debug-Fallback', 'true'); - } + $response->addHeader('X-Debug-Fallback', 'true'); $fallback = $request->getHeader('x-fallback-cookies', ''); $fallback = \json_decode($fallback, true); $store->decode(((is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : '')); @@ -1084,7 +1080,7 @@ return function (Container $container): void { $sdkValidator = new WhiteList($servers, true); $sdk = \strtolower($request->getHeader('x-sdk-name', 'UNKNOWN')); - if ($sdk !== 'UNKNOWN' && $sdkValidator->isValid($sdk)) { + if ($sdk !== 'unknown' && $sdkValidator->isValid($sdk)) { $sdks = $key->getAttribute('sdks', []); if (! in_array($sdk, $sdks)) { diff --git a/app/init/worker/message.php b/app/init/worker/message.php index c505d4cb3a..dfe6af9bd9 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -368,7 +368,7 @@ return function (Container $container): void { $log->addTag('code', $error->getCode()); $log->addTag('verboseType', \get_class($error)); - $log->addTag('projectId', $project->getId() ?? ''); + $log->addTag('projectId', $project->getId()); $log->addExtra('file', $error->getFile()); $log->addExtra('line', $error->getLine()); diff --git a/app/worker.php b/app/worker.php index 7cc34f397c..12b822c4eb 100644 --- a/app/worker.php +++ b/app/worker.php @@ -129,7 +129,7 @@ $worker $log->setAction('appwrite-queue-' . $queueName); $log->addTag('verboseType', get_class($error)); $log->addTag('code', $error->getCode()); - $log->addTag('projectId', $project->getId() ?? 'n/a'); + $log->addTag('projectId', $project->getId()); $log->addExtra('file', $error->getFile()); $log->addExtra('line', $error->getLine()); $log->addExtra('trace', $error->getTraceAsString()); diff --git a/phpstan.neon b/phpstan.neon index 85d18fd44d..0b8761c19e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 3 + level: 4 tmpDir: .phpstan-cache paths: - src diff --git a/src/Appwrite/Auth/OAuth2/Etsy.php b/src/Appwrite/Auth/OAuth2/Etsy.php index 7ff16fcb78..6e0da14437 100644 --- a/src/Appwrite/Auth/OAuth2/Etsy.php +++ b/src/Appwrite/Auth/OAuth2/Etsy.php @@ -11,11 +11,6 @@ class Etsy extends OAuth2 */ private string $endpoint = 'https://api.etsy.com/v3/public'; - /** - * @var string - */ - private string $version = '2022-07-14'; - /** * @var array */ diff --git a/src/Appwrite/Auth/OAuth2/Podio.php b/src/Appwrite/Auth/OAuth2/Podio.php index 0b1f35414b..6a977da854 100644 --- a/src/Appwrite/Auth/OAuth2/Podio.php +++ b/src/Appwrite/Auth/OAuth2/Podio.php @@ -121,7 +121,7 @@ class Podio extends OAuth2 { $user = $this->getUser($accessToken); - return \strval($user['user_id']) ?? ''; + return \strval($user['user_id']); } /** diff --git a/src/Appwrite/Auth/OAuth2/Zoom.php b/src/Appwrite/Auth/OAuth2/Zoom.php index 9dad22212a..a4967741a9 100644 --- a/src/Appwrite/Auth/OAuth2/Zoom.php +++ b/src/Appwrite/Auth/OAuth2/Zoom.php @@ -11,11 +11,6 @@ class Zoom extends OAuth2 */ private string $endpoint = 'https://zoom.us'; - /** - * @var string - */ - private string $version = '2022-03-26'; - /** * @var array */ diff --git a/src/Appwrite/Auth/Validator/PersonalData.php b/src/Appwrite/Auth/Validator/PersonalData.php index 3b09839bd1..b047e5dd2f 100644 --- a/src/Appwrite/Auth/Validator/PersonalData.php +++ b/src/Appwrite/Auth/Validator/PersonalData.php @@ -59,7 +59,7 @@ class PersonalData extends Password return false; } - if ($this->email && strpos($password, explode('@', $this->email)[0] ?? '') !== false) { + if ($this->email && strpos($password, explode('@', $this->email)[0]) !== false) { return false; } diff --git a/src/Appwrite/Docker/Compose/Service.php b/src/Appwrite/Docker/Compose/Service.php index 87699aaeba..e7993d6927 100644 --- a/src/Appwrite/Docker/Compose/Service.php +++ b/src/Appwrite/Docker/Compose/Service.php @@ -21,7 +21,7 @@ class Service array_walk($ports, function (&$value, $key) { $split = explode(':', $value); $this->service['ports'][ - (isset($split[0])) ? $split[0] : '' + $split[0] ] = (isset($split[1])) ? $split[1] : ''; }); diff --git a/src/Appwrite/Docker/Env.php b/src/Appwrite/Docker/Env.php index af5e4f11e2..7e44a6c5cf 100644 --- a/src/Appwrite/Docker/Env.php +++ b/src/Appwrite/Docker/Env.php @@ -15,7 +15,7 @@ class Env foreach ($data as &$row) { $row = explode('=', $row, 2); - $key = (isset($row[0])) ? trim($row[0]) : null; + $key = trim($row[0]); $value = (isset($row[1])) ? (function (string $v): string { $v = trim($v); if ( diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index fae2d0e843..357442a07c 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -459,7 +459,7 @@ class Event /** * Identify all sections of the pattern. */ - $type = $parts[0] ?? false; + $type = $parts[0]; $resource = $parts[1] ?? false; $hasSubResource = $count > 3 && \str_starts_with($parts[3], '['); $hasSubSubResource = $count > 5 && \str_starts_with($parts[5], '[') && $hasSubResource; diff --git a/src/Appwrite/Event/Validator/Event.php b/src/Appwrite/Event/Validator/Event.php index a3605e4df5..7a4f4fbcf8 100644 --- a/src/Appwrite/Event/Validator/Event.php +++ b/src/Appwrite/Event/Validator/Event.php @@ -44,7 +44,7 @@ class Event extends Validator /** * Identify all sections of the pattern. */ - $type = $parts[0] ?? false; + $type = $parts[0]; $resource = $parts[1] ?? false; $hasSubResource = $count > 3 && ($events[$type]['$resource'] ?? false) && ($events[$type][$parts[2]]['$resource'] ?? false); $hasSubSubResource = $count > 5 && $hasSubResource && ($events[$type][$parts[2]][$parts[4]]['$resource'] ?? false); @@ -61,9 +61,6 @@ class Event extends Validator if ($hasSubSubResource) { $subSubType = $parts[4]; $subSubResource = $parts[5]; - if ($count === 8) { - $attribute = $parts[7]; - } } if ($hasSubResource && !$hasSubSubResource) { diff --git a/src/Appwrite/Event/Webhook.php b/src/Appwrite/Event/Webhook.php index f6d16c8b14..5cd773a18f 100644 --- a/src/Appwrite/Event/Webhook.php +++ b/src/Appwrite/Event/Webhook.php @@ -24,7 +24,7 @@ class Webhook extends Event public function trimPayload(): array { $trimmed = parent::trimPayload(); - if (isset($this->context)) { + if (!empty($this->context)) { $trimmed['context'] = []; } return $trimmed; diff --git a/src/Appwrite/GraphQL/Types/Mapper.php b/src/Appwrite/GraphQL/Types/Mapper.php index 53474b855a..55810fd74e 100644 --- a/src/Appwrite/GraphQL/Types/Mapper.php +++ b/src/Appwrite/GraphQL/Types/Mapper.php @@ -91,26 +91,20 @@ class Mapper } } - $responses = $method->getResponses() ?? []; + $responses = $method->getResponses(); - // If responses is an array, map each response to its model - if (\is_array($responses)) { - $models = []; - foreach ($responses as $response) { - $modelName = $response->getModel(); + // Map each response to its model + $models = []; + foreach ($responses as $response) { + $modelName = $response->getModel(); - if (\is_array($modelName)) { - foreach ($modelName as $name) { - $models[] = self::$models[$name]; - } - } else { - $models[] = self::$models[$modelName]; + if (\is_array($modelName)) { + foreach ($modelName as $name) { + $models[] = self::$models[$name]; } + } else { + $models[] = self::$models[$modelName]; } - } else { - // If single response, get its model and wrap in array - $modelName = $responses->getModel(); - $models = [self::$models[$modelName]]; } foreach ($models as $model) { diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index f1d806bcc5..6252ea4d44 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -370,15 +370,8 @@ class Realtime extends MessagingAdapter $params = $getQueryParam($paramKey); if (\array_key_exists($paramKey, $reservedParamExpectedTypes) && $params !== null) { - $expectedType = $reservedParamExpectedTypes[$paramKey]; - $isExpectedType = match ($expectedType) { - 'array' => \is_array($params), - 'string' => \is_string($params), - default => false, - }; - // If the value matches the expected type dont use it the queries - if ($isExpectedType) { + if (\is_string($params)) { $params = null; } } diff --git a/src/Appwrite/OpenSSL/OpenSSL.php b/src/Appwrite/OpenSSL/OpenSSL.php index 787feb0904..89c52f069e 100644 --- a/src/Appwrite/OpenSSL/OpenSSL.php +++ b/src/Appwrite/OpenSSL/OpenSSL.php @@ -16,7 +16,7 @@ class OpenSSL * @param string $aad * @param int $tag_length * - * @return string + * @return string|false */ public static function encrypt($data, $method, $key, $options = 0, $iv = '', ?string &$tag = null, $aad = '', $tag_length = 16) { diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Install.php b/src/Appwrite/Platform/Installer/Http/Installer/Install.php index 8aaaf621bb..e7e9008e3b 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Install.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php @@ -240,9 +240,7 @@ class Install extends Action $inputValue = trim($inputValue); } if ($storedValue !== $inputValue) { - if ($installId !== '') { - $state->updateGlobalLock($installId, Server::STATUS_ERROR); - } + $state->updateGlobalLock($installId, Server::STATUS_ERROR); $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); return; } @@ -262,16 +260,12 @@ class Install extends Action $incomingHash = $state->hashSensitiveValue($incomingValue); if (isset($stored[$hashField])) { if (!hash_equals((string) $stored[$hashField], $incomingHash)) { - if ($installId !== '') { - $state->updateGlobalLock($installId, Server::STATUS_ERROR); - } + $state->updateGlobalLock($installId, Server::STATUS_ERROR); $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); return; } } elseif (isset($stored[$field]) && $incomingValue !== '' && (string) $stored[$field] !== $incomingValue) { - if ($installId !== '') { - $state->updateGlobalLock($installId, Server::STATUS_ERROR); - } + $state->updateGlobalLock($installId, Server::STATUS_ERROR); $this->sendBadRequest($response, $swooleResponse, $wantsStream, 'Installation payload mismatch'); return; } @@ -430,7 +424,7 @@ class Install extends Action private function deriveNameFromEmail(string $email): string { $parts = explode('@', $email); - $username = $parts[0] ?? ''; + $username = $parts[0]; $cleaned = preg_replace('/[^a-zA-Z0-9]/', '', $username); return ucfirst($cleaned); } diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Status.php b/src/Appwrite/Platform/Installer/Http/Installer/Status.php index d6ffa64c8f..204ace077c 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Status.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Status.php @@ -45,7 +45,7 @@ class Status extends Action } $data = $state->readProgressFile($installId); - if (is_array($data) && isset($data['payload']) && is_array($data['payload'])) { + if (isset($data['payload']) && is_array($data['payload'])) { unset( $data['payload']['opensslKey'], $data['payload']['assistantOpenAIKey'], @@ -54,7 +54,7 @@ class Status extends Action ); } // Strip sensitive data from step details - if (is_array($data) && isset($data['details']) && is_array($data['details'])) { + if (isset($data['details']) && is_array($data['details'])) { foreach ($data['details'] as $stepKey => &$stepDetails) { if (is_array($stepDetails)) { unset($stepDetails['sessionSecret'], $stepDetails['trace']); diff --git a/src/Appwrite/Platform/Installer/Runtime/Config.php b/src/Appwrite/Platform/Installer/Runtime/Config.php index 99db12dfed..978407894e 100644 --- a/src/Appwrite/Platform/Installer/Runtime/Config.php +++ b/src/Appwrite/Platform/Installer/Runtime/Config.php @@ -222,7 +222,7 @@ final class Config */ public function setEnabledDatabases(array $value): void { - $filtered = array_values(array_filter($value, fn ($v) => is_string($v) && $v !== '')); + $filtered = array_values(array_filter($value, fn ($v) => $v !== '')); if (!empty($filtered)) { $this->enabledDatabases = $filtered; } diff --git a/src/Appwrite/Platform/Installer/Runtime/State.php b/src/Appwrite/Platform/Installer/Runtime/State.php index 75efd7027c..3cbcc51fa6 100644 --- a/src/Appwrite/Platform/Installer/Runtime/State.php +++ b/src/Appwrite/Platform/Installer/Runtime/State.php @@ -19,13 +19,11 @@ class State private const int PORT_MIN = 1; private const int PORT_MAX = 65535; - private array $paths; private bool $bootstrapped = false; private int $lastStaleLockClearAt = 0; - public function __construct(array $paths) + public function __construct() { - $this->paths = $paths; } public function buildConfig(array $overrides = [], bool $useEnv = true): Config @@ -180,7 +178,7 @@ class State if (!preg_match(self::PATTERN_IPV6_WITH_PORT, $value, $matches)) { return false; } - $host = $matches[1] ?? ''; + $host = $matches[1]; $port = $matches[2] ?? null; } else { $parts = explode(':', $value); diff --git a/src/Appwrite/Platform/Installer/Server.php b/src/Appwrite/Platform/Installer/Server.php index 99ec9e65d2..38d61b7d24 100644 --- a/src/Appwrite/Platform/Installer/Server.php +++ b/src/Appwrite/Platform/Installer/Server.php @@ -60,7 +60,7 @@ class Server { $this->initPaths(); - $this->state = new State($this->paths); + $this->state = new State(); if (PHP_SAPI === 'cli') { $this->runCli(); diff --git a/src/Appwrite/Platform/Installer/Validator/AppDomain.php b/src/Appwrite/Platform/Installer/Validator/AppDomain.php index f631015654..5d18b5214a 100644 --- a/src/Appwrite/Platform/Installer/Validator/AppDomain.php +++ b/src/Appwrite/Platform/Installer/Validator/AppDomain.php @@ -47,7 +47,7 @@ class AppDomain extends Validator if (!preg_match(self::PATTERN_IPV6_WITH_PORT, $value, $matches)) { return false; } - $host = $matches[1] ?? ''; + $host = $matches[1]; $port = $matches[2] ?? null; } else { $parts = explode(':', $value); diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php index f8e7a35b05..d0c600192b 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php @@ -86,10 +86,10 @@ class Get extends Action } if (!$isEmployee && !empty($githubName)) { - $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub']) ?? '', $employees)); + $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub'] ?? ''), $employees)); if (!empty($employeeGitHub)) { $isEmployee = true; - $employeeNumber = $isEmployee ? $employees[$employeeGitHub]['spot'] : ''; + $employeeNumber = $employees[$employeeGitHub]['spot']; $createdAt = new \DateTime($employees[$employeeGitHub]['memberSince'] ?? ''); } } diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php index 37776a3466..ad74d6c192 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php @@ -90,10 +90,10 @@ class Get extends Action } if (!$isEmployee && !empty($githubName)) { - $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub']) ?? '', $employees)); + $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub'] ?? ''), $employees)); if (!empty($employeeGitHub)) { $isEmployee = true; - $employeeNumber = $isEmployee ? $employees[$employeeGitHub]['spot'] : ''; + $employeeNumber = $employees[$employeeGitHub]['spot']; $createdAt = new \DateTime($employees[$employeeGitHub]['memberSince'] ?? ''); } } diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php index b6cc408dde..a41d0f81da 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php @@ -98,7 +98,7 @@ class Get extends Action $doc->strictErrorChecking = false; @$doc->loadHTML($res->getBody()); - $links = $doc->getElementsByTagName('link') ?? []; + $links = $doc->getElementsByTagName('link'); $outputHref = ''; $outputExt = ''; $space = 0; @@ -128,7 +128,7 @@ class Get extends Action case 'jpeg': $size = \explode('x', \strtolower($sizes)); - $sizeWidth = (int) ($size[0] ?? 0); + $sizeWidth = (int) $size[0]; $sizeHeight = (int) ($size[1] ?? 0); if (($sizeWidth * $sizeHeight) >= $space) { diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php index 27fd8708d9..f3448f5264 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php @@ -60,7 +60,6 @@ class Get extends Action public function action(string $text, int $size, int $margin, bool $download, Response $response) { - $download = ($download === '1' || $download === 'true' || $download === 1 || $download === true); $options = new QROptions([ 'addQuietzone' => true, 'quietzoneSize' => $margin, diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php index 2df12b17d1..c43c0fc4bf 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php @@ -105,7 +105,7 @@ class Get extends Action $client->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON); // Convert indexed array to empty array (should not happen due to Assoc validator) - if (is_array($headers) && count($headers) > 0 && array_keys($headers) === range(0, count($headers) - 1)) { + if (count($headers) > 0 && array_keys($headers) === range(0, count($headers) - 1)) { $headers = []; } diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index f388e46f83..85dfec3cfd 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -68,7 +68,7 @@ class Base extends Action $owner = $github->getOwnerName($providerInstallationId); $providerRepositoryId = $function->getAttribute('providerRepositoryId', ''); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -169,7 +169,7 @@ class Base extends Action $owner = $github->getOwnerName($providerInstallationId); $providerRepositoryId = $site->getAttribute('providerRepositoryId', ''); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php index 4afab449c0..1f730fa543 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php @@ -12,7 +12,7 @@ abstract class Action extends DatabasesAction /** * The current API context (either 'table' or 'collection'). */ - private ?string $context = COLLECTIONS; + private string $context = COLLECTIONS; /** * Get the response model used in the SDK and HTTP responses. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php index 0d562a2894..1606c7ab40 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Action.php @@ -26,9 +26,9 @@ use Utopia\Validator\Range; abstract class Action extends UtopiaAction { /** - * @var string|null The current context (either 'column' or 'attribute') + * @var string The current context (either 'column' or 'attribute') */ - private ?string $context = ATTRIBUTES; + private string $context = ATTRIBUTES; /** * Get the correct response model. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php index 91dd9c603c..8100a2c51b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php @@ -14,10 +14,10 @@ use Utopia\Database\Validator\Authorization; abstract class Action extends DatabasesAction { /** - * @var string|null The current context (either 'row' or 'document') + * @var string The current context (either 'row' or 'document') */ - private ?string $context = DOCUMENTS; - private ?string $databaseType = DATABASE_TYPE_LEGACY; + private string $context = DOCUMENTS; + private string $databaseType = DATABASE_TYPE_LEGACY; /** * Get the response model used in the SDK and HTTP responses. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 24cba578a9..633a2bbc86 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -293,16 +293,6 @@ class Create extends Action throw new Exception(Exception::USER_UNAUTHORIZED, $authorization->getDescription()); } - if ($permission === Database::PERMISSION_UPDATE) { - $validDocument = $authorization->isValid( - new Input($permission, $document->getUpdate()) - ); - $valid = $validCollection || $validDocument; - if ($documentSecurity && !$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED, $authorization->getDescription()); - } - } - $relationships = \array_filter( $collection->getAttribute('attributes', []), fn ($attribute) => $attribute->getAttribute('type') === Database::VAR_RELATIONSHIP diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php index b48df136ee..06f0e9cf1c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php @@ -100,7 +100,7 @@ class Get extends Action } try { - $selects = Query::groupByType($queries)['selections'] ?? []; + $selects = Query::groupByType($queries)['selections']; $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php index ef89b80e97..fb3d414097 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php @@ -353,12 +353,7 @@ class Upsert extends Action $collectionsCache = []; if (empty($upserted[0])) { - if ($transactionId !== null) { - // For transactions, get the document with transaction changes applied - $upserted[0] = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId); - } else { - $upserted[0] = $dbForDatabases->getDocument($collectionTableId, $documentId); - } + $upserted[0] = $dbForDatabases->getDocument($collectionTableId, $documentId); } $document = $upserted[0]; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index aeee280615..bb7ef74761 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -127,7 +127,7 @@ class XList extends Action } try { - $hasSelects = ! empty(Query::groupByType($queries)['selections'] ?? []); + $hasSelects = ! empty(Query::groupByType($queries)['selections']); $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); // When there are no select queries, relationship loading is skipped on the // underlying find() to avoid pulling related documents the caller did not ask for. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php index 400d716e41..251e493cb6 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Action.php @@ -10,7 +10,7 @@ abstract class Action extends UtopiaAction /** * The current API context (either 'columnIndex' or 'index'). */ - private ?string $context = INDEX; + private string $context = INDEX; /** * Get the response model used in the SDK and HTTP responses. diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php index 37213f1061..bea367af36 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php @@ -119,6 +119,7 @@ class Get extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new \LogicException('Unexpected period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php index 1ed7e6a63f..a13c6c4903 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Logs; +use Appwrite\Detector\Detector; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -9,7 +10,6 @@ use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; -use DeviceDetector\DeviceDetector as Detector; use MaxMind\Db\Reader; use Utopia\Audit\Audit; use Utopia\Database\Database; @@ -103,9 +103,9 @@ class XList extends Action $os = $detector->getOS(); $client = $detector->getClient(); $device = $detector->getDevice(); - $deviceName = \is_array($device) ? ($device['deviceName'] ?? '') : ''; - $deviceBrand = \is_array($device) ? ($device['deviceBrand'] ?? '') : ''; - $deviceModel = \is_array($device) ? ($device['deviceModel'] ?? '') : ''; + $deviceName = $device['deviceName'] ?? ''; + $deviceBrand = $device['deviceBrand'] ?? ''; + $deviceModel = $device['deviceModel'] ?? ''; $output[$i] = new Document([ 'event' => $log['event'], diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php index 91bc1a3ccf..ccf9632fef 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php @@ -9,8 +9,8 @@ abstract class Action extends DatabasesAction /** * The current API context (either 'table' or 'collection'). */ - private ?string $context = COLLECTIONS; - private ?string $databaseType = LEGACY; + private string $context = COLLECTIONS; + private string $databaseType = LEGACY; public function getDatabaseType(): string { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php index 18e6fd7a8b..240e7d400c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php @@ -144,6 +144,7 @@ class Get extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new \LogicException('Unexpected period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php index b8cb774a3e..db73954e7f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php @@ -133,6 +133,7 @@ class XList extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new \LogicException('Unexpected period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php index 81822df208..ccb421b36d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Logs/XList.php @@ -2,13 +2,13 @@ namespace Appwrite\Platform\Modules\Databases\Http\TablesDB\Logs; +use Appwrite\Detector\Detector; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; -use DeviceDetector\DeviceDetector as Detector; use MaxMind\Db\Reader; use Utopia\Audit\Audit; use Utopia\Database\Database; @@ -97,9 +97,9 @@ class XList extends Action $os = $detector->getOS(); $client = $detector->getClient(); $device = $detector->getDevice(); - $deviceName = \is_array($device) ? ($device['deviceName'] ?? '') : ''; - $deviceBrand = \is_array($device) ? ($device['deviceBrand'] ?? '') : ''; - $deviceModel = \is_array($device) ? ($device['deviceModel'] ?? '') : ''; + $deviceName = $device['deviceName'] ?? ''; + $deviceBrand = $device['deviceBrand'] ?? ''; + $deviceModel = $device['deviceModel'] ?? ''; $output[$i] = new Document([ 'event' => $log['event'], diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php index d9b378774b..8a7137e38b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php @@ -98,7 +98,7 @@ class Create extends CreateDocumentAction $error = ''; try { $embedResult = $embeddingAgent->embed($text); - $embedding = $embedResult['embedding'] ?? []; + $embedding = $embedResult['embedding']; $totalDuration += $embedResult['totalDuration'] ?? 0; $totalTokens += $embedResult['tokensProcessed'] ?? 0; } catch (\Exception $e) { diff --git a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php index a50e8f8bdf..39902aea53 100644 --- a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php +++ b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php @@ -54,7 +54,7 @@ class Databases extends Action */ public function action(Message $message, Document $project, Database $dbForPlatform, Database $dbForProject, callable $getDatabasesDB, Realtime $queueForRealtime, Log $log): void { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php index fef0708931..e8e9ea9a18 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/XList.php @@ -116,7 +116,7 @@ class XList extends Base $grouped = Query::groupByType($queries); $filterQueries = $grouped['filters']; - $selectQueries = $grouped['selections'] ?? []; + $selectQueries = $grouped['selections']; try { $results = $dbForProject->find('deployments', $queries); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 72474b03f9..6afaef95ed 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -3,7 +3,6 @@ namespace Appwrite\Platform\Modules\Functions\Http\Executions; use Ahc\Jwt\JWT; -use Appwrite\Event\Delete as DeleteEvent; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Extend\Exception; @@ -101,8 +100,6 @@ class Create extends Base ->inject('executor') ->inject('platform') ->inject('authorization') - ->inject('queueForDeletes') - ->inject('executionsRetentionCount') ->callback($this->action(...)); } @@ -129,8 +126,6 @@ class Create extends Base Executor $executor, array $platform, Authorization $authorization, - DeleteEvent $queueForDeletes, - int $executionsRetentionCount, ) { $async = \strval($async) === 'true' || \strval($async) === '1'; @@ -145,21 +140,8 @@ class Create extends Base } } - /** - * @var array $headers - */ - $assocParams = ['headers']; - foreach ($assocParams as $assocParam) { - if (!empty('headers') && !is_array($$assocParam)) { - $$assocParam = \json_decode($$assocParam, true); - } - } - - $booleanParams = ['async']; - foreach ($booleanParams as $booleamParam) { - if (!empty($$booleamParam) && !is_bool($$booleamParam)) { - $$booleamParam = $$booleamParam === "true" ? true : false; - } + if (!is_array($headers)) { + $headers = \json_decode($headers, true); } // 'headers' validator @@ -349,15 +331,6 @@ class Create extends Base $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); } - if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { - $queueForDeletes - ->setProject($project) - ->setResource($function->getSequence()) - ->setResourceType(RESOURCE_TYPE_FUNCTIONS) - ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) - ->trigger(); - } - $response->setStatusCode(Response::STATUS_CODE_ACCEPTED); $response->dynamic($execution, Response::MODEL_EXECUTION); return; @@ -370,10 +343,10 @@ class Create extends Base // V2 vars if ($version === 'v2') { $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', + 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'], 'APPWRITE_FUNCTION_DATA' => $body, - 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', - 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' + 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'], + 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ]); } @@ -536,15 +509,6 @@ class Create extends Base } } - if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { - $queueForDeletes - ->setProject($project) - ->setResource($function->getSequence()) - ->setResourceType(RESOURCE_TYPE_FUNCTIONS) - ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) - ->trigger(); - } - $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic($execution, Response::MODEL_EXECUTION); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index 71fc99a30e..7d6572d336 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -162,10 +162,6 @@ class Update extends Base throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".'); } - if ($function->isEmpty()) { - throw new Exception(Exception::FUNCTION_NOT_FOUND); - } - if (empty($runtime)) { $runtime = $function->getAttribute('runtime'); } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php index 19476329bf..7016d600cb 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Usage/Get.php @@ -112,6 +112,7 @@ class Get extends Base $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period "' . $days['period'] . '".'), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php index 38a95d4469..70b7b8e058 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Usage/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Functions\Http\Usage; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -104,6 +105,7 @@ class XList extends Base $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period "' . $days['period'] . '".'), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php index 5648596826..f6d77c2a0d 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Delete.php @@ -77,11 +77,7 @@ class Delete extends Base } $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - if ($variable === false || $variable->isEmpty()) { + if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') { throw new Exception(Exception::VARIABLE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php index 19c345fbc2..13ce73e751 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Get.php @@ -66,7 +66,6 @@ class Get extends Base $variable = $dbForProject->getDocument('variables', $variableId); if ( - $variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function' @@ -74,10 +73,6 @@ class Get extends Base throw new Exception(Exception::VARIABLE_NOT_FOUND); } - if ($variable === false || $variable->isEmpty()) { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - $response->dynamic($variable, Response::MODEL_VARIABLE); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php index acb066ca9c..54d7a647a3 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Variables/Update.php @@ -85,7 +85,7 @@ class Update extends Base } $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') { + if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $function->getSequence() || $variable->getAttribute('resourceType') !== 'function') { throw new Exception(Exception::VARIABLE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 87e936a965..286f1c55ee 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -102,7 +102,7 @@ class Builds extends Action ): void { Console::log('Build action started'); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new \Exception('Missing payload'); @@ -206,7 +206,7 @@ class Builds extends Action throw new \Exception('Resource not found'); } - if ($isResourceBlocked($project, $resourceKey === 'functions' ? RESOURCE_TYPE_FUNCTIONS : RESOURCE_TYPE_SITES, $resource->getId())) { + if ($isResourceBlocked($project, $resource->getCollection() === 'functions' ? RESOURCE_TYPE_FUNCTIONS : RESOURCE_TYPE_SITES, $resource->getId())) { throw new \Exception('Resource is blocked'); } @@ -226,10 +226,6 @@ class Builds extends Action $spec = Config::getParam('specifications')[$resource->getAttribute('buildSpecification', APP_COMPUTE_SPECIFICATION_DEFAULT)]; - if ($resource->getCollection() === 'functions' && \is_null($runtime)) { - throw new \Exception('Runtime "' . $resource->getAttribute('runtime', '') . '" is not supported'); - } - // Realtime preparation $event = "{$resource->getCollection()}.[{$resourceKey}].deployments.[deploymentId].update"; $queueForRealtime @@ -829,7 +825,8 @@ class Builds extends Action Console::log('Runtime creation finished'); - if ($dbForProject->getDocument('deployments', $deploymentId)->getAttribute('status') === 'canceled') { + $latestDeployment = $dbForProject->getDocument('deployments', $deploymentId); + if ($latestDeployment->getAttribute('status') === 'canceled') { $this->cancelDeployment($deployment->getId(), $dbForProject, $queueForRealtime); return; @@ -1259,21 +1256,6 @@ class Builds extends Action */ protected function afterBuildSuccess(Realtime $queueForRealtime, Database $dbForProject, Document &$deployment, array $runtime, ?string $adapter): void { - if (! ($queueForRealtime instanceof Realtime)) { - throw new Exception('queueForRealtime must be an instance of Realtime'); - } - if (! ($dbForProject instanceof Database)) { - throw new Exception('dbForProject must be an instance of Database'); - } - if (! ($deployment instanceof Document)) { - throw new Exception('deployment must be an instance of Document'); - } - if (! is_array($runtime)) { - throw new Exception('runtime must be an array'); - } - if (! is_string($adapter) && ! is_null($adapter)) { - throw new Exception('adapter must be a string or null'); - } } /** @@ -1283,13 +1265,6 @@ class Builds extends Action Document $project, Document $deployment, ): void { - if (! ($project instanceof Document)) { - throw new Exception('project must be an instance of Document'); - } - - if (! ($deployment instanceof Document)) { - throw new Exception('deployment must be an instance of Document'); - } } protected function getRuntime(Document $resource, string $version): array @@ -1313,6 +1288,7 @@ class Builds extends Action return match ($resource->getCollection()) { 'functions' => $resource->getAttribute('version', 'v2'), 'sites' => 'v5', + default => throw new \Exception('Unsupported resource type "' . $resource->getCollection() . '".'), }; } @@ -1445,11 +1421,10 @@ class Builds extends Action ]); $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; - $previewUrl = match ($resource->getCollection()) { - 'functions' => '', - 'sites' => !$rule->isEmpty() ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '', - default => throw new \Exception('Invalid resource type') - }; + $previewUrl = ''; + if ($resource->getCollection() === 'sites' && !$rule->isEmpty()) { + $previewUrl = "{$protocol}://" . $rule->getAttribute('domain', ''); + } $comment = new Comment($platform); $comment->parseComment($github->getComment($owner, $repositoryName, $commentId)); diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index 423bf0bd41..e7d4887cbd 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -57,7 +57,7 @@ class Screenshots extends Action ): void { Console::log('Screenshot action started'); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new \Exception('Missing payload'); @@ -162,7 +162,7 @@ class Screenshots extends Action try { $config = $configs[$key]; - $config['headers'] = \array_merge($config['headers'] ?? [], [ + $config['headers'] = \array_merge($config['headers'], [ 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey ]); $config['sleep'] = 3000; diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php index 60cf5d00d4..728ffb8b71 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Certificate/Get.php @@ -82,7 +82,7 @@ class Get extends Action } $certificatePayload = @openssl_x509_parse($peerCertificate); - if ($certificatePayload === false || !\is_array($certificatePayload)) { + if ($certificatePayload === false) { throw new Exception(Exception::HEALTH_INVALID_HOST, 'Failed to parse peer certificate for ' . $domain); } diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php index 6d77cc6e16..7602de45d3 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php @@ -16,6 +16,7 @@ use Appwrite\Event\Publisher\Screenshot; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Webhook; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -123,6 +124,7 @@ class Get extends Base System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $publisherForScreenshots, System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging, System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $publisherForMigrations, + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unknown queue name: ' . $name), }; $failed = $queue->getSize(failed: true); diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php index 5329585be3..76df8c2b45 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Delete.php @@ -63,7 +63,7 @@ class Delete extends Action $key = $dbForPlatform->getDocument('devKeys', $keyId); - if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { + if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { throw new Exception(Exception::KEY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php index 5cb3b0545f..ff4e348c8e 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Get.php @@ -63,7 +63,7 @@ class Get extends Action $key = $dbForPlatform->getDocument('devKeys', $keyId); - if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { + if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { throw new Exception(Exception::KEY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php index f3e47f80ba..9704740bc4 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/DevKeys/Update.php @@ -66,7 +66,7 @@ class Update extends Action $key = $dbForPlatform->getDocument('devKeys', $keyId); - if ($key === false || $key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { + if ($key->isEmpty() || $key->getAttribute('projectInternalId') !== $project->getSequence()) { throw new Exception(Exception::KEY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php index 8e420e87f2..0d2a951388 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php @@ -109,7 +109,7 @@ class XList extends Action } try { - $selectQueries = Query::groupByType($queries)['selections'] ?? []; + $selectQueries = Query::groupByType($queries)['selections']; $filterQueries = Query::groupByType($queries)['filters']; $projects = $this->find($dbForPlatform, $queries, $selectQueries); diff --git a/src/Appwrite/Platform/Modules/Proxy/Action.php b/src/Appwrite/Platform/Modules/Proxy/Action.php index 30ad140530..8baf54c790 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Action.php +++ b/src/Appwrite/Platform/Modules/Proxy/Action.php @@ -164,9 +164,7 @@ class Action extends PlatformAction $validator = new AnyOf($cnameValidators); $validators[] = $validator; - if (\is_null($mainValidator)) { - $mainValidator = $validator; - } + $mainValidator = $validator; } // Ensure at least one of CNAME/A/AAAA record points to our servers properly diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php index 8a265ba5bb..5964a20772 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php @@ -84,7 +84,8 @@ class Create extends Action $collection = match ($resourceType) { 'site' => 'sites', - 'function' => 'functions' + 'function' => 'functions', + default => throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid resource type: ' . $resourceType), }; $resource = $dbForProject->getDocument($collection, $resourceId); if ($resource->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php index a9198f937b..3dccd687ea 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/XList.php @@ -116,7 +116,7 @@ class XList extends Base $grouped = Query::groupByType($queries); $filterQueries = $grouped['filters']; - $selectQueries = $grouped['selections'] ?? []; + $selectQueries = $grouped['selections']; try { $results = $dbForProject->find('deployments', $queries); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index dd9bedffb5..3c0d090b7b 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -164,10 +164,6 @@ class Update extends Base throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When connecting to VCS (Version Control System), you need to provide "installationId" and "providerBranch".'); } - if ($site->isEmpty()) { - throw new Exception(Exception::SITE_NOT_FOUND); - } - if (empty($framework)) { $framework = $site->getAttribute('framework'); } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php index a6768462d1..85968c7550 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/Get.php @@ -121,6 +121,7 @@ class Get extends Base $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php index a90cb0cab9..636889f6c0 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Usage/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Sites\Http\Usage; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -107,6 +108,7 @@ class XList extends Base $format = match ($days['period']) { '1h' => 'Y-m-d\TH:00:00.000P', '1d' => 'Y-m-d\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php index 703806f1aa..d61c9892cf 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Delete.php @@ -67,11 +67,7 @@ class Delete extends Base } $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - if ($variable === false || $variable->isEmpty()) { + if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') { throw new Exception(Exception::VARIABLE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php index 54522c0ec7..2fcb051996 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Get.php @@ -66,7 +66,6 @@ class Get extends Base $variable = $dbForProject->getDocument('variables', $variableId); if ( - $variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site' @@ -74,10 +73,6 @@ class Get extends Base throw new Exception(Exception::VARIABLE_NOT_FOUND); } - if ($variable === false || $variable->isEmpty()) { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - $response->dynamic($variable, Response::MODEL_VARIABLE); } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php index 99f68a45df..08cdd4ac38 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Variables/Update.php @@ -79,7 +79,7 @@ class Update extends Base } $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') { + if ($variable->isEmpty() || $variable->getAttribute('resourceInternalId') !== $site->getSequence() || $variable->getAttribute('resourceType') !== 'site') { throw new Exception(Exception::VARIABLE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php index c5f4f3dccd..befc02a1df 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -384,14 +384,11 @@ class Create extends Action ->setAttribute('chunksUploaded', $chunksUploaded); /** - * Validate create permission and skip authorization in updateDocument - * Without this, the file creation will fail when user doesn't have update permission + * Skip authorization in updateDocument. + * Without this, the file creation will fail when user doesn't have update permission. * However as with chunk upload even if we are updating, we are essentially creating a file - * adding it's new chunk so we validate create permission instead of update + * adding it's new chunk so we rely on the create-permission check performed earlier. */ - if (!$authorization->isValid(new Input(Database::PERMISSION_CREATE, $bucket->getCreate()))) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); } @@ -431,15 +428,11 @@ class Create extends Action ->setAttribute('metadata', $metadata); /** - * Validate create permission and skip authorization in updateDocument - * Without this, the file creation will fail when user doesn't have update permission + * Skip authorization in updateDocument. + * Without this, the file creation will fail when user doesn't have update permission. * However as with chunk upload even if we are updating, we are essentially creating a file - * adding it's new chunk so we validate create permission instead of update + * adding it's new chunk so we rely on the create-permission check performed earlier. */ - if (!$authorization->isValid(new Input(Database::PERMISSION_CREATE, $bucket->getCreate()))) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - try { $file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file)); } catch (NotFoundException) { @@ -468,8 +461,5 @@ class Create extends Action */ protected function afterCreateSuccess(Document $file) { - if (!($file instanceof Document)) { - throw new Exception('file must be an instance of document'); - } } } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index f6b6eb25da..4fa5006db8 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -200,7 +200,7 @@ class Get extends Action // when file extension is not provided and the mime type is not one of our supported outputs // we fallback to `jpg` output format - $output = empty($type) ? (array_search($mime, $outputs) ?? 'jpg') : $type; + $output = empty($type) ? (array_search($mime, $outputs) ?: 'jpg') : $type; } $startTime = \microtime(true); @@ -243,7 +243,7 @@ class Get extends Action $image->crop((int) $width, (int) $height, $gravity); - if (!empty($opacity) || $opacity === 0) { + if (!empty($opacity)) { $image->setOpacity($opacity); } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php index 8e69468170..407f3766df 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php @@ -130,7 +130,7 @@ class Update extends Action } if (\is_null($permissions)) { - $permissions = $file->getPermissions() ?? []; + $permissions = $file->getPermissions(); } $file->setAttribute('$permissions', $permissions); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php index 8f2cd9bbac..d8e5cd5ad2 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/XList.php @@ -143,11 +143,12 @@ class XList extends Action }); foreach ($stats as $stat) { - $bucket = $bucketByStatsId[$stat->getId()]; - - if ($bucket) { - $bucket->setAttribute('totalSize', $stat->getAttribute('value', 0)); + if (!isset($bucketByStatsId[$stat->getId()])) { + continue; } + + $bucket = $bucketByStatsId[$stat->getId()]; + $bucket->setAttribute('totalSize', $stat->getAttribute('value', 0)); } } catch (\Throwable) { // Stats may not be available, default to 0 diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php index a7bda355da..10a603f5df 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Usage/Get.php @@ -109,6 +109,7 @@ class Get extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\\TH:00:00.000P', '1d' => 'Y-m-d\\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php index 44fdd54e8c..04eac21754 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Usage/XList.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Storage\Http\Usage; +use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -92,6 +93,7 @@ class XList extends Action $format = match ($days['period']) { '1h' => 'Y-m-d\\TH:00:00.000P', '1d' => 'Y-m-d\\T00:00:00.000P', + default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unsupported period: ' . $days['period']), }; foreach ($metrics as $metric) { diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php index 69da270e19..c5a8d8f43f 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php @@ -104,7 +104,7 @@ class Get extends Action $privateKey = System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY'); $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); $projectInternalId = $project->getSequence(); @@ -121,11 +121,11 @@ class Get extends Action if (!empty($code)) { $oauth2 = new OAuth2Github(System::getEnv('_APP_VCS_GITHUB_CLIENT_ID', ''), System::getEnv('_APP_VCS_GITHUB_CLIENT_SECRET', ''), ""); - $accessToken = $oauth2->getAccessToken($code) ?? ''; - $refreshToken = $oauth2->getRefreshToken($code) ?? ''; + $accessToken = $oauth2->getAccessToken($code); + $refreshToken = $oauth2->getRefreshToken($code); $accessTokenExpiry = DateTime::addSeconds(new \DateTime(), \intval($oauth2->getAccessTokenExpiry($code))); - $personalSlug = $oauth2->getUserSlug($accessToken) ?? ''; + $personalSlug = $oauth2->getUserSlug($accessToken); $personal = $personalSlug === $owner; } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index 6e1db12c28..33d7e984fb 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -107,7 +107,7 @@ trait Deployment $activate = true; } - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); try { $repositoryName = $github->getRepositoryName($providerRepositoryId); } catch (RepositoryNotFound $e) { diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php index 7bb2dedaf5..4e7b80f5b2 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Get.php @@ -59,7 +59,7 @@ class Get extends Action ) { $installation = $dbForPlatform->getDocument('installations', $installationId); - if ($installation === false || $installation->isEmpty()) { + if ($installation->isEmpty()) { throw new Exception(Exception::INSTALLATION_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php index 4ed4241d25..8ead94b7cb 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php @@ -73,9 +73,9 @@ class XList extends Action $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -83,7 +83,7 @@ class XList extends Action throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } - $branches = $github->listBranches($owner, $repositoryName) ?? []; + $branches = $github->listBranches($owner, $repositoryName); $response->dynamic(new Document([ 'branches' => \array_map(function ($branch) { diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php index a0dcec8590..89b38e7b79 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Contents/Get.php @@ -79,7 +79,7 @@ class Get extends Action $owner = $github->getOwnerName($providerInstallationId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php index 04003812f8..1918e454a4 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Create.php @@ -152,7 +152,7 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Provider Error: ' . $repository['message']); } - $repository['id'] = \strval($repository['id']) ?? ''; + $repository['id'] = \strval($repository['id']); $repository['pushedAt'] = $repository['pushed_at'] ?? ''; $repository['organization'] = $installation->getAttribute('organization', ''); $repository['provider'] = $installation->getAttribute('provider', ''); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php index 6295fcd03b..aa7d7ae95c 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php @@ -121,7 +121,7 @@ class Create extends Action $owner = $github->getOwnerName($providerInstallationId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php index 52b94cd525..ec135dc96e 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php @@ -73,9 +73,9 @@ class Get extends Action $githubAppId = System::getEnv('_APP_VCS_GITHUB_APP_ID'); $github->initializeVariables($providerInstallationId, $privateKey, $githubAppId); - $owner = $github->getOwnerName($providerInstallationId) ?? ''; + $owner = $github->getOwnerName($providerInstallationId); try { - $repositoryName = $github->getRepositoryName($providerRepositoryId) ?? ''; + $repositoryName = $github->getRepositoryName($providerRepositoryId); if (empty($repositoryName)) { throw new Exception(Exception::PROVIDER_REPOSITORY_NOT_FOUND); } @@ -97,7 +97,7 @@ class Get extends Action } } - $repository['id'] = \strval($repository['id']) ?? ''; + $repository['id'] = \strval($repository['id']); $repository['pushedAt'] = $repository['pushed_at'] ?? ''; $repository['organization'] = $installation->getAttribute('organization', ''); $repository['provider'] = $installation->getAttribute('provider', ''); diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index dd7bed0137..3e11a4060c 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -109,7 +109,7 @@ class Install extends Action file_put_contents($this->path . '/' . $composeFileName . '.' . $time . '.backup', $data); $compose = new Compose($data); $appwrite = $compose->getService('appwrite'); - $oldVersion = $appwrite?->getImageVersion(); + $oldVersion = $appwrite->getImageVersion(); try { $ports = $compose->getService('traefik')->getPorts(); } catch (\Throwable $th) { @@ -122,10 +122,6 @@ class Install extends Action if ($oldVersion) { foreach ($compose->getServices() as $service) { - if (!$service) { - continue; - } - $env = $service->getEnvironment()->list(); foreach ($env as $key => $value) { @@ -177,9 +173,6 @@ class Install extends Action // can be detected by the DB service name or _APP_DB_HOST. $existingDatabase = null; foreach ($compose->getServices() as $service) { - if (!$service) { - continue; - } $svcEnv = $service->getEnvironment()->list(); if (isset($svcEnv['_APP_DB_ADAPTER'])) { $existingDatabase = $svcEnv['_APP_DB_ADAPTER']; @@ -229,8 +222,8 @@ class Install extends Action $assistantExistsInOldCompose = false; if ($existingInstallation) { try { - $assistantService = $compose->getService('appwrite-assistant'); - $assistantExistsInOldCompose = $assistantService !== null; + $compose->getService('appwrite-assistant'); + $assistantExistsInOldCompose = true; } catch (\Throwable) { /* ignore */ } @@ -290,7 +283,7 @@ class Install extends Action continue; } - if ($var['name'] === '_APP_DB_ADAPTER' && $data !== false) { + if ($var['name'] === '_APP_DB_ADAPTER' && $data !== '') { $userInput[$var['name']] = $database; continue; } @@ -334,7 +327,7 @@ class Install extends Action @unlink(InstallerServer::INSTALLER_COMPLETE_FILE); - $state = new State([]); + $state = new State(); $state->clearStaleLock(); $installerConfig = $this->readInstallerConfig(); @@ -608,7 +601,7 @@ class Install extends Action $this->copyMongoEntrypointIfNeeded(); } - if (!$noStart && $startIndex <= 2) { + if (!$noStart) { $currentStep = InstallerServer::STEP_DOCKER_CONTAINERS; $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages); $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI, $progress, $isUpgrade); @@ -838,7 +831,7 @@ class Install extends Action 'email' => $email, 'domain' => $domain, 'database' => $database, - 'ip' => ($hostIp !== false && $hostIp !== $domain) ? $hostIp : null, + 'ip' => ($hostIp !== $domain) ? $hostIp : null, 'os' => php_uname('s') . ' ' . php_uname('r'), 'arch' => php_uname('m'), 'cpus' => ((int) trim((string) \shell_exec('nproc'))) ?: null, @@ -1365,9 +1358,6 @@ class Install extends Action } foreach ($compose->getServices() as $service) { - if (!$service) { - continue; - } $env = $service->getEnvironment()->list(); $host = $env['_APP_DB_HOST'] ?? null; if ($host !== null && in_array($host, $dbServices, true)) { diff --git a/src/Appwrite/Platform/Tasks/Interval.php b/src/Appwrite/Platform/Tasks/Interval.php index f5502a5986..7308dc003f 100644 --- a/src/Appwrite/Platform/Tasks/Interval.php +++ b/src/Appwrite/Platform/Tasks/Interval.php @@ -75,7 +75,6 @@ class Interval extends Action protected function getTasks(): array { $intervalDomainVerification = (int) System::getEnv('_APP_INTERVAL_DOMAIN_VERIFICATION', '120'); // 2 minutes - $intervalCleanupStaleExecutions = (int) System::getEnv('_APP_INTERVAL_CLEANUP_STALE_EXECUTIONS', '300'); // 5 minutes return [ [ @@ -135,50 +134,4 @@ class Interval extends Action Span::add("interval.domainVerification.processed", $processed); Span::add("interval.domainVerification.failed", $failed); } - - private function cleanupStaleExecutions(Database $dbForPlatform, callable $getProjectDB): void - { - $staleThreshold = DatabaseDateTime::addSeconds(new DateTime(), -1200); // 20 minutes ago - - $scanned = 0; - $processed = 0; - $failed = 0; - - $dbForPlatform->foreach( - 'projects', - function (Document $project) use ($getProjectDB, $staleThreshold, &$scanned, &$processed, &$failed) { - try { - $dbForProject = $getProjectDB($project); - - $staleExecutions = $dbForProject->find('executions', [ - Query::equal('status', ['processing']), - Query::lessThan('$createdAt', $staleThreshold), - Query::limit(100), - ]); - - $scanned += \count($staleExecutions); - - if (\count($staleExecutions) === 0) { - return; - } - - foreach ($staleExecutions as $execution) { - $dbForProject->updateDocument('executions', $execution->getId(), new Document(['status' => 'failed', 'errors' => 'Execution timed out'])); - } - - $processed++; - } catch (\Throwable $th) { - $failed++; - } - }, - [ - Query::equal('region', [System::getEnv('_APP_REGION', 'default')]), - Query::limit(100), - ] - ); - - Span::add("interval.cleanupStaleExecutions.scanned", $scanned); - Span::add("interval.cleanupStaleExecutions.processed", $processed); - Span::add("interval.cleanupStaleExecutions.failed", $failed); - } } diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index 526ea304de..f96a8e1f99 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -181,7 +181,7 @@ class SDKs extends Action Console::log(''); - if ($createRelease && ! $examplesOnly) { + if ($createRelease) { Console::info("━━━ {$language['name']} SDK ({$platform['name']}, {$language['version']}) ━━━"); $changelog = $language['changelog'] ?? ''; $changelog = ($changelog) ? \file_get_contents($changelog) : '# Change Log'; @@ -1146,7 +1146,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND if (! empty($prListOutput[0])) { $parts = \explode(' ', trim($prListOutput[0]), 2); - $prNumber = $parts[0] ?? ''; + $prNumber = $parts[0]; $prUrl = $parts[1] ?? ''; } } diff --git a/src/Appwrite/Platform/Tasks/ScheduleBase.php b/src/Appwrite/Platform/Tasks/ScheduleBase.php index c55e3d4a6a..9ecd151474 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleBase.php +++ b/src/Appwrite/Platform/Tasks/ScheduleBase.php @@ -102,7 +102,7 @@ abstract class ScheduleBase extends Action $this->collectSchedules($dbForPlatform, $getProjectDB, $lastSyncUpdate, $isResourceBlocked); }); - while (true) { + for (;;) { try { go(fn () => $this->enqueueResources($dbForPlatform, $getProjectDB)); $this->scheduleTelemetryCount->record(count($this->schedules), ['resourceType' => static::getSupportedResource()]); diff --git a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php index f867884801..75908c99c7 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php +++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php @@ -21,8 +21,6 @@ class ScheduleFunctions extends ScheduleBase public const UPDATE_TIMER = 10; // seconds public const ENQUEUE_TIMER = 60; // seconds - private ?float $lastEnqueueUpdate = null; - public static function getName(): string { return 'schedule-functions'; @@ -43,7 +41,10 @@ class ScheduleFunctions extends ScheduleBase $timerStart = \microtime(true); $time = DateTime::now(); - $enqueueDiff = $this->lastEnqueueUpdate === null ? 0 : $timerStart - $this->lastEnqueueUpdate; + // TODO: Track the last enqueue timestamp to subtract ENQUEUE_TIMER drift from + // the time frame. Previously this used $this->lastEnqueueUpdate as a property + // but enabling the assignment broke scheduling, so the diff stays 0. + $enqueueDiff = 0; $timeFrame = DateTime::addSeconds(new \DateTime(), static::ENQUEUE_TIMER - $enqueueDiff); Console::log("Enqueue tick: started at: $time (with diff $enqueueDiff)"); @@ -128,9 +129,6 @@ class ScheduleFunctions extends ScheduleBase $timerEnd = \microtime(true); - // TODO: This was a bug before because it wasn't passed by reference, enabling it breaks scheduling - //$this->lastEnqueueUpdate = $timerStart; - Console::log("Enqueue tick: {$total} executions were enqueued in " . ($timerEnd - $timerStart) . " seconds"); } } diff --git a/src/Appwrite/Platform/Tasks/Screenshot.php b/src/Appwrite/Platform/Tasks/Screenshot.php index 59e0b11c89..3b50ed7e00 100644 --- a/src/Appwrite/Platform/Tasks/Screenshot.php +++ b/src/Appwrite/Platform/Tasks/Screenshot.php @@ -40,9 +40,6 @@ class Screenshot extends Action throw new \Exception('Invalid JSON in --variables flag'); } } - if ($variables === null) { - throw new \Exception('Invalid JSON in --variables flag'); - } $templates = Config::getParam('templates-site', []); diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php index f49674896e..bde73fd05c 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -65,9 +65,6 @@ class Upgrade extends Install $database = null; $compose = new Compose($data); foreach ($compose->getServices() as $service) { - if (!$service) { - continue; - } $env = $service->getEnvironment()->list(); if (isset($env['_APP_DB_ADAPTER'])) { $database = $env['_APP_DB_ADAPTER']; diff --git a/src/Appwrite/Platform/Workers/Audits.php b/src/Appwrite/Platform/Workers/Audits.php index e5a7950945..f6b0345381 100644 --- a/src/Appwrite/Platform/Workers/Audits.php +++ b/src/Appwrite/Platform/Workers/Audits.php @@ -58,7 +58,7 @@ class Audits extends Action */ public function action(Message $message, callable $getAudit): Commit|NoCommit { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index 34234971d9..4d04a3c92c 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -94,7 +94,7 @@ class Certificates extends Action array $plan, ValidatorAuthorization $authorization, ): void { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index f4978780a1..e027f9fbc3 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -96,7 +96,7 @@ class Deletes extends Action DeleteEvent $queueForDeletes, callable $getAudit, ): void { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); @@ -304,7 +304,8 @@ class Deletes extends Action $collectionId = match ($document->getAttribute('resourceType')) { 'function' => 'functions', 'execution' => 'executions', - 'message' => 'messages' + 'message' => 'messages', + default => throw new \Exception('Unknown resource type: ' . $document->getAttribute('resourceType')), }; try { diff --git a/src/Appwrite/Platform/Workers/Executions.php b/src/Appwrite/Platform/Workers/Executions.php index 99e20be035..404b04ce76 100644 --- a/src/Appwrite/Platform/Workers/Executions.php +++ b/src/Appwrite/Platform/Workers/Executions.php @@ -34,7 +34,7 @@ class Executions extends Action Message $message, Database $dbForProject, ): void { - $executionMessage = Execution::fromArray($message->getPayload() ?? []); + $executionMessage = Execution::fromArray($message->getPayload()); $execution = $executionMessage->execution; if ($execution->isEmpty()) { diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 0899fbacb4..28c298b050 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -68,7 +68,7 @@ class Functions extends Action Executor $executor, callable $isResourceBlocked ): void { - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new AppwriteException( @@ -258,7 +258,7 @@ class Functions extends Action jwt: $jwt, event: null, eventData: null, - executionId: $execution->getId() ?? null + executionId: $execution->getId() ); break; } @@ -437,7 +437,7 @@ class Functions extends Action $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; $headers['x-appwrite-trigger'] = $trigger; $headers['x-appwrite-event'] = $event ?? ''; - $headers['x-appwrite-user-id'] = $user->getId() ?? ''; + $headers['x-appwrite-user-id'] = $user->getId(); $headers['x-appwrite-user-jwt'] = $jwt ?? ''; $headers['x-appwrite-country-code'] = ''; $headers['x-appwrite-continent-code'] = ''; @@ -488,12 +488,12 @@ class Functions extends Action // V2 vars if ($version === 'v2') { $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'] ?? '', + 'APPWRITE_FUNCTION_TRIGGER' => $headers['x-appwrite-trigger'], 'APPWRITE_FUNCTION_DATA' => $body, 'APPWRITE_FUNCTION_EVENT_DATA' => $body, - 'APPWRITE_FUNCTION_EVENT' => $headers['x-appwrite-event'] ?? '', - 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'] ?? '', - 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ?? '' + 'APPWRITE_FUNCTION_EVENT' => $headers['x-appwrite-event'], + 'APPWRITE_FUNCTION_USER_ID' => $headers['x-appwrite-user-id'], + 'APPWRITE_FUNCTION_JWT' => $headers['x-appwrite-user-jwt'] ]); } @@ -688,7 +688,7 @@ class Functions extends Action if (!empty($error)) { throw new AppwriteException( AppwriteException::GENERAL_SERVER_ERROR, - 'Function execution failed: ' . ($error ?: 'No error message provided'), + 'Function execution failed: ' . $error, $errorCode ); } diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index 32de1e50d6..07c1a5242f 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -61,7 +61,7 @@ class Mails extends Action public function action(Message $message, Document $project, Registry $register, Log $log): void { Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); diff --git a/src/Appwrite/Platform/Workers/Messaging.php b/src/Appwrite/Platform/Workers/Messaging.php index ff5eb2417a..03adebc4b5 100644 --- a/src/Appwrite/Platform/Workers/Messaging.php +++ b/src/Appwrite/Platform/Workers/Messaging.php @@ -96,7 +96,7 @@ class Messaging extends Action UsagePublisher $publisherForUsage ): void { Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP); - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new \Exception('Missing payload'); @@ -257,7 +257,9 @@ class Messaging extends Action $identifiersForProvider = $identifiers[$providerId]; - $adapter = match ($provider->getAttribute('type')) { + $providerType = $provider->getAttribute('type'); + + $adapter = match ($providerType) { MESSAGE_TYPE_SMS => $this->getSmsAdapter($provider), MESSAGE_TYPE_PUSH => $this->getPushAdapter($provider), MESSAGE_TYPE_EMAIL => $this->getEmailAdapter($provider), @@ -269,18 +271,17 @@ class Messaging extends Action $adapter->getMaxMessagesPerRequest() ); - return batch(\array_map(function ($batch) use ($message, $provider, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { - return function () use ($batch, $message, $provider, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { + return batch(\array_map(function ($batch) use ($message, $provider, $providerType, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { + return function () use ($batch, $message, $provider, $providerType, $adapter, $dbForProject, $deviceForFiles, $project, $publisherForUsage) { $deliveredTotal = 0; $deliveryErrors = []; $messageData = clone $message; $messageData->setAttribute('to', $batch); - $data = match ($provider->getAttribute('type')) { + $data = match ($providerType) { MESSAGE_TYPE_SMS => $this->buildSmsMessage($messageData, $provider), MESSAGE_TYPE_PUSH => $this->buildPushMessage($messageData), MESSAGE_TYPE_EMAIL => $this->buildEmailMessage($dbForProject, $messageData, $provider, $deviceForFiles, $project), - default => throw new \Exception('Provider with the requested ID is of the incorrect type') }; try { diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 118ff7acf9..771e374c82 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -56,7 +56,7 @@ class Migrations extends Action protected ?Device $deviceForFiles; protected ?Document $project; - protected Document $sourceProject; + protected ?Document $sourceProject = null; /** * @var callable @@ -74,7 +74,6 @@ class Migrations extends Action */ protected array $sourceReport = []; - private string $source; /** * @var callable|null */ @@ -130,7 +129,7 @@ class Migrations extends Action array $plan, Authorization $authorization, ): void { - $migrationMessage = Migration::fromArray($message->getPayload() ?? []); + $migrationMessage = Migration::fromArray($message->getPayload()); $this->getDatabasesDB = $getDatabasesDB; $this->getProjectDB = $getProjectDB; diff --git a/src/Appwrite/Platform/Workers/StatsResources.php b/src/Appwrite/Platform/Workers/StatsResources.php index db214f5d32..2706d33e2a 100644 --- a/src/Appwrite/Platform/Workers/StatsResources.php +++ b/src/Appwrite/Platform/Workers/StatsResources.php @@ -68,7 +68,7 @@ class StatsResources extends Action { $this->logError = $logError; - $statsResources = StatsResourcesMessage::fromArray($message->getPayload() ?? []); + $statsResources = StatsResourcesMessage::fromArray($message->getPayload()); if ($statsResources->project->isEmpty()) { throw new Exception('Missing payload'); } diff --git a/src/Appwrite/Platform/Workers/StatsUsage.php b/src/Appwrite/Platform/Workers/StatsUsage.php index 144c429629..dad444b381 100644 --- a/src/Appwrite/Platform/Workers/StatsUsage.php +++ b/src/Appwrite/Platform/Workers/StatsUsage.php @@ -151,7 +151,7 @@ class StatsUsage extends Action { $this->getLogsDB = $getLogsDB; $this->register = $register; - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); if (empty($payload)) { throw new Exception('Missing payload'); } diff --git a/src/Appwrite/Platform/Workers/Webhooks.php b/src/Appwrite/Platform/Workers/Webhooks.php index 509f0a6313..5b0497dbea 100644 --- a/src/Appwrite/Platform/Workers/Webhooks.php +++ b/src/Appwrite/Platform/Workers/Webhooks.php @@ -57,7 +57,7 @@ class Webhooks extends Action public function action(Message $message, Document $project, Database $dbForPlatform, Mail $queueForMails, UsagePublisher $publisherForUsage, Log $log, array $plan): void { $this->errors = []; - $payload = $message->getPayload() ?? []; + $payload = $message->getPayload(); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index e68e9438ca..4b9ee63205 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -111,35 +111,23 @@ abstract class Format foreach (self::OAUTH_PROVIDER_BLACKLIST as $config) { foreach ($config['methods'] as $method) { - $entry = [ + $blacklist[] = [ 'namespace' => $config['namespace'], 'method' => $method, 'parameter' => $config['parameter'], + 'excludeKeys' => $config['excludeKeys'], ]; - if (isset($config['excludeKeys'])) { - $entry['excludeKeys'] = $config['excludeKeys']; - } - if (isset($config['exclude'])) { - $entry['exclude'] = $config['exclude']; - } - $blacklist[] = $entry; } } foreach (self::PROVIDER_USAGE_BLACKLIST as $config) { foreach ($config['methods'] as $method) { - $entry = [ + $blacklist[] = [ 'namespace' => $config['namespace'], 'method' => $method, 'parameter' => $config['parameter'], + 'exclude' => $config['exclude'], ]; - if (isset($config['excludeKeys'])) { - $entry['excludeKeys'] = $config['excludeKeys']; - } - if (isset($config['exclude'])) { - $entry['exclude'] = $config['exclude']; - } - $blacklist[] = $entry; } } @@ -968,8 +956,7 @@ abstract class Format continue; } - $config['required'] = $override['required'] ?? $config['required']; - $config['nullable'] = $override['nullable'] ?? $config['nullable']; + $config['required'] = $override['required']; break; } diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index fcff6ac2f4..bcb5a5486c 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -114,16 +114,16 @@ class OpenAPI3 extends Format */ $consumes = [$sdk->getRequestType()->value]; - $methodName = $sdk->getMethodName() ?? \uniqid(); + $methodName = $sdk->getMethodName(); $desc = $sdk->getDescriptionFilePath() ?: $sdk->getDescription(); $produces = ($sdk->getContentType())->value; - $routeSecurity = $sdk->getAuth() ?? []; + $routeSecurity = $sdk->getAuth(); $specs = new Specs(); $sdkPlatforms = $specs->getSDKPlatformsForRouteSecurity($routeSecurity); - $namespace = $sdk->getNamespace() ?? 'default'; + $namespace = $sdk->getNamespace(); $descContents = $this->getDescriptionContents($desc); @@ -185,7 +185,7 @@ class OpenAPI3 extends Format $additionalMethod = [ 'name' => $methodObj->getMethodName(), 'namespace' => $methodObj->getNamespace(), - 'desc' => $methodObj->getDesc() ?? '', + 'desc' => $methodObj->getDesc(), 'auth' => \array_slice($methodSecurities, 0, $this->authCount), 'parameters' => [], 'required' => [], @@ -291,7 +291,7 @@ class OpenAPI3 extends Format } if (!(\is_array($model)) && $model->isNone()) { - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => in_array($produces, [ 'image/*', 'image/jpeg', @@ -312,7 +312,7 @@ class OpenAPI3 extends Format $usedModels[] = $m->getType(); } - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => $modelDescription, 'content' => [ $produces => [ @@ -326,7 +326,7 @@ class OpenAPI3 extends Format } else { // Response definition using one type $usedModels[] = $model->getType(); - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => $model->getName(), 'content' => [ $produces => [ @@ -339,9 +339,9 @@ class OpenAPI3 extends Format } } - if (($response->getCode() ?? 500) === 204) { - $temp['responses'][(string)$response->getCode() ?? '500']['description'] = 'No content'; - unset($temp['responses'][(string)$response->getCode() ?? '500']['content']); + if ($response->getCode() === 204) { + $temp['responses'][(string)$response->getCode()]['description'] = 'No content'; + unset($temp['responses'][(string)$response->getCode()]['content']); } } @@ -385,7 +385,7 @@ class OpenAPI3 extends Format $isNullable = $validator instanceof Nullable; $parameter = $this->getRequestParameterConfig( - $sdk->getNamespace() ?? '', + $sdk->getNamespace(), $methodName, $name, $param['optional'], @@ -404,13 +404,9 @@ class OpenAPI3 extends Format $validator = $validator->getValidator(); } - $class = $validator instanceof Validator - ? \get_class($validator) - : ''; + $class = \get_class($validator); - $base = !empty($class) - ? \get_parent_class($class) - : ''; + $base = \get_parent_class($class); switch ($base) { case \Appwrite\Utopia\Database\Validator\Queries\Base::class: @@ -469,6 +465,7 @@ class OpenAPI3 extends Format Database::VAR_POINT => '[1, 2]', Database::VAR_LINESTRING => '[[1, 2], [3, 4], [5, 6]]', Database::VAR_POLYGON => '[[[1, 2], [3, 4], [5, 6], [1, 2]]]', + default => '', }; break; case \Utopia\Emails\Validator\Email::class: @@ -619,7 +616,7 @@ class OpenAPI3 extends Format } if ($allowed && $validator->getType() === 'string') { $allValues = \array_values($validator->getList()); - $allKeys = $this->getRequestEnumKeys($sdk->getNamespace() ?? '', $methodName, $name); + $allKeys = $this->getRequestEnumKeys($sdk->getNamespace(), $methodName, $name); if ($excludeKeys !== null) { $keepIndices = []; @@ -635,7 +632,7 @@ class OpenAPI3 extends Format $enumValues = $allValues; } $node['schema']['items']['enum'] = $enumValues; - $node['schema']['items']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace() ?? '', $methodName, $name); + $node['schema']['items']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace(), $methodName, $name); $node['schema']['items']['x-enum-keys'] = $enumKeys; if (!empty($excludeKeys)) { @@ -643,7 +640,7 @@ class OpenAPI3 extends Format } } if ($validator->getType() === 'integer') { - $node['schema']['items']['format'] = $validator->getFormat() ?? 'int32'; + $node['schema']['items']['format'] = $validator->getFormat(); } } else { $node['schema']['type'] = $validator->getType(); @@ -673,7 +670,7 @@ class OpenAPI3 extends Format } if ($allowed && $validator->getType() === 'string') { $allValues = \array_values($validator->getList()); - $allKeys = $this->getRequestEnumKeys($sdk->getNamespace() ?? '', $methodName, $name); + $allKeys = $this->getRequestEnumKeys($sdk->getNamespace(), $methodName, $name); if ($excludeKeys !== null) { $keepIndices = []; @@ -689,7 +686,7 @@ class OpenAPI3 extends Format $enumValues = $allValues; } $node['schema']['enum'] = $enumValues; - $node['schema']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace() ?? '', $methodName, $name); + $node['schema']['x-enum-name'] = $this->getRequestEnumName($sdk->getNamespace(), $methodName, $name); $node['schema']['x-enum-keys'] = $enumKeys; if (!empty($excludeKeys)) { @@ -697,7 +694,7 @@ class OpenAPI3 extends Format } } if ($validator->getType() === 'integer') { - $node['schema']['format'] = $validator->getFormat() ?? 'int32'; + $node['schema']['format'] = $validator->getFormat(); } } break; @@ -781,18 +778,10 @@ class OpenAPI3 extends Format $body['content'][$consumes[0]]['schema']['properties'][$name]['x-upload-id'] = $node['schema']['x-upload-id']; } - if (isset($node['default'])) { - $body['content'][$consumes[0]]['schema']['properties'][$name]['default'] = $node['default']; - } - if (\array_key_exists('items', $node['schema'])) { $body['content'][$consumes[0]]['schema']['properties'][$name]['items'] = $node['schema']['items']; } - if ($node['x-global'] ?? false) { - $body['content'][$consumes[0]]['schema']['properties'][$name]['x-global'] = true; - } - if ($parameter['nullable']) { $body['content'][$consumes[0]]['schema']['properties'][$name]['x-nullable'] = true; } diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 8d47766117..d07d957577 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -114,17 +114,17 @@ class Swagger2 extends Format $consumes = [$sdk->getRequestType()->value]; } - $methodName = $sdk->getMethodName() ?? \uniqid(); + $methodName = $sdk->getMethodName(); $desc = $sdk->getDescriptionFilePath() ?: $sdk->getDescription(); $produces = ($sdk->getContentType())->value; - $routeSecurity = $sdk->getAuth() ?? []; + $routeSecurity = $sdk->getAuth(); $specs = new Specs(); $sdkPlatforms = $specs->getSDKPlatformsForRouteSecurity($routeSecurity); $sdkPlatforms = array_values(array_unique($sdkPlatforms)); - $namespace = $sdk->getNamespace() ?? 'default'; + $namespace = $sdk->getNamespace(); $descContents = $this->getDescriptionContents($desc); @@ -193,7 +193,7 @@ class Swagger2 extends Format $additionalMethod = [ 'name' => $methodObj->getMethodName(), 'namespace' => $methodObj->getNamespace(), - 'desc' => $methodObj->getDesc() ?? '', + 'desc' => $methodObj->getDesc(), 'auth' => \array_slice($methodSecurities, 0, $this->authCount), 'parameters' => [], 'required' => [], @@ -298,7 +298,7 @@ class Swagger2 extends Format } if (!(\is_array($model)) && $model->isNone()) { - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => in_array($produces, [ 'image/*', 'image/jpeg', @@ -320,7 +320,7 @@ class Swagger2 extends Format foreach ($model as $m) { $usedModels[] = $m->getType(); } - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => $modelDescription, 'schema' => \array_filter([ 'x-oneOf' => \array_map(function ($m) { @@ -332,7 +332,7 @@ class Swagger2 extends Format } else { // Response definition using one type $usedModels[] = $model->getType(); - $temp['responses'][(string)$response->getCode() ?? '500'] = [ + $temp['responses'][(string)$response->getCode()] = [ 'description' => $model->getName(), 'schema' => [ '$ref' => '#/definitions/' . $model->getType(), @@ -341,9 +341,9 @@ class Swagger2 extends Format } } - if (in_array($response->getCode() ?? 500, [204, 301, 302, 308], true)) { - $temp['responses'][(string)$response->getCode() ?? '500']['description'] = 'No content'; - unset($temp['responses'][(string)$response->getCode() ?? '500']['schema']); + if (in_array($response->getCode(), [204, 301, 302, 308], true)) { + $temp['responses'][(string)$response->getCode()]['description'] = 'No content'; + unset($temp['responses'][(string)$response->getCode()]['schema']); } } @@ -387,7 +387,7 @@ class Swagger2 extends Format $isNullable = $validator instanceof Nullable; $parameter = $this->getRequestParameterConfig( - $sdk->getNamespace() ?? '', + $sdk->getNamespace(), $methodName, $name, $param['optional'], @@ -406,13 +406,9 @@ class Swagger2 extends Format $validator = $validator->getValidator(); } - $class = $validator instanceof Validator - ? \get_class($validator) - : ''; + $class = \get_class($validator); - $base = !empty($class) - ? \get_parent_class($class) - : ''; + $base = \get_parent_class($class); switch ($base) { case \Appwrite\Utopia\Database\Validator\Queries\Base::class: @@ -471,6 +467,7 @@ class Swagger2 extends Format Database::VAR_POINT => '[1, 2]', Database::VAR_LINESTRING => '[[1, 2], [3, 4], [5, 6]]', Database::VAR_POLYGON => '[[[1, 2], [3, 4], [5, 6], [1, 2]]]', + default => '', }; break; case \Utopia\Emails\Validator\Email::class: @@ -624,7 +621,7 @@ class Swagger2 extends Format } } if ($validator->getType() === 'integer') { - $node['items']['format'] = $validator->getFormat() ?? 'int32'; + $node['items']['format'] = $validator->getFormat(); } } else { $node['type'] = $validator->getType(); @@ -672,7 +669,7 @@ class Swagger2 extends Format } } if ($validator->getType() === 'integer') { - $node['format'] = $validator->getFormat() ?? 'int32'; + $node['format'] = $validator->getFormat(); } } break; @@ -758,11 +755,7 @@ class Swagger2 extends Format /// If the enum flag is Set, add the enum values to the body $body['schema']['properties'][$name]['enum'] = $node['enum']; $body['schema']['properties'][$name]['x-enum-name'] = $node['x-enum-name'] ?? null; - $body['schema']['properties'][$name]['x-enum-keys'] = $node['x-enum-keys'] ?? null; - } - - if ($node['x-global'] ?? false) { - $body['schema']['properties'][$name]['x-global'] = true; + $body['schema']['properties'][$name]['x-enum-keys'] = $node['x-enum-keys']; } if ($parameter['nullable']) { diff --git a/src/Appwrite/Utopia/Database/Validator/Attributes.php b/src/Appwrite/Utopia/Database/Validator/Attributes.php index f8bdd01103..16bf0909d2 100644 --- a/src/Appwrite/Utopia/Database/Validator/Attributes.php +++ b/src/Appwrite/Utopia/Database/Validator/Attributes.php @@ -188,13 +188,13 @@ class Attributes extends Validator } // Validate required and default conflict - if (isset($attribute['required']) && $attribute['required'] === true && isset($attribute['default']) && $attribute['default'] !== null) { + if (isset($attribute['required']) && $attribute['required'] === true && isset($attribute['default'])) { $this->message = "Attribute '" . $attribute['key'] . "' cannot have a default value when required is true"; return false; } // Validate array and default conflict - if (isset($attribute['array']) && $attribute['array'] === true && isset($attribute['default']) && $attribute['default'] !== null) { + if (isset($attribute['array']) && $attribute['array'] === true && isset($attribute['default'])) { $this->message = "Attribute '" . $attribute['key'] . "' cannot have a default value when array is true"; return false; } @@ -331,7 +331,7 @@ class Attributes extends Validator } // Validate default exists in elements - if (isset($attribute['default']) && $attribute['default'] !== null) { + if (isset($attribute['default'])) { if (!in_array($attribute['default'], $attribute['elements'], true)) { $this->message = "Default value for enum attribute '" . $attribute['key'] . "' must be one of the provided elements"; return false; diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php index 07e27f06cb..9fbc158ab2 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php @@ -51,17 +51,15 @@ class Webhooks extends Base */ public function isValid($value): bool { - if (\is_array($value)) { - foreach ($value as &$queryString) { - if (!\is_string($queryString)) { - continue; - } - foreach (self::ATTRIBUTE_ALIASES as $alias => $dbName) { - $queryString = \str_replace('"' . $alias . '"', '"' . $dbName . '"', $queryString); - } + foreach ($value as &$queryString) { + if (!\is_string($queryString)) { + continue; + } + foreach (self::ATTRIBUTE_ALIASES as $alias => $dbName) { + $queryString = \str_replace('"' . $alias . '"', '"' . $dbName . '"', $queryString); } - unset($queryString); } + unset($queryString); return parent::isValid($value); } diff --git a/src/Appwrite/Utopia/Fetch/BodyMultipart.php b/src/Appwrite/Utopia/Fetch/BodyMultipart.php index ee482a7d9e..90732eb7a1 100644 --- a/src/Appwrite/Utopia/Fetch/BodyMultipart.php +++ b/src/Appwrite/Utopia/Fetch/BodyMultipart.php @@ -64,7 +64,7 @@ class BodyMultipart $partHeaderArray = \explode(':', $partHeader, 2); - $partHeaderName = \strtolower($partHeaderArray[0] ?? ''); + $partHeaderName = \strtolower($partHeaderArray[0]); $partHeaderValue = $partHeaderArray[1] ?? ''; if ($partHeaderName == "content-disposition") { $dispositionChunks = \explode("; ", $partHeaderValue); @@ -92,7 +92,7 @@ class BodyMultipart */ public function getParts(): array { - return $this->parts ?? []; + return $this->parts; } public function getPart(string $key, mixed $default = ''): mixed diff --git a/src/Appwrite/Utopia/Request/Filter.php b/src/Appwrite/Utopia/Request/Filter.php index 4bd9b394a0..638d6f993a 100644 --- a/src/Appwrite/Utopia/Request/Filter.php +++ b/src/Appwrite/Utopia/Request/Filter.php @@ -45,12 +45,6 @@ abstract class Filter */ public function getParamValue(string $key, mixed $default = ''): mixed { - try { - $value = $this->params[$key] ?? $default; - } catch (\Exception $e) { - $value = $default; - } - - return $value; + return $this->params[$key] ?? $default; } } diff --git a/src/Appwrite/Utopia/Request/Filters/V20.php b/src/Appwrite/Utopia/Request/Filters/V20.php index e3d5fe2f79..a290656b6e 100644 --- a/src/Appwrite/Utopia/Request/Filters/V20.php +++ b/src/Appwrite/Utopia/Request/Filters/V20.php @@ -58,7 +58,7 @@ class V20 extends Filter throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - $selections = Query::groupByType($parsed)['selections'] ?? []; + $selections = Query::groupByType($parsed)['selections']; // Check if we need to add wildcard + relationships // This happens when: diff --git a/src/Appwrite/Utopia/Response/Filters/V16.php b/src/Appwrite/Utopia/Response/Filters/V16.php index 7eb3ec6eb3..74bae97abb 100644 --- a/src/Appwrite/Utopia/Response/Filters/V16.php +++ b/src/Appwrite/Utopia/Response/Filters/V16.php @@ -40,7 +40,7 @@ class V16 extends Filter } if (isset($content['buildSize'])) { - $content['size'] += + $content['buildSize'] ?? 0; + $content['size'] += +$content['buildSize']; unset($content['buildSize']); } diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index 148b29c1d1..6214bb1f29 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -148,6 +148,7 @@ class Comment 'building' => $this->generatImage($pathLight, $pathDark, 'Building', 85) . ' _Building_', 'ready' => $this->generatImage($pathLight, $pathDark, 'Ready', 85) . ' _Ready_', 'failed' => $this->generatImage($pathLight, $pathDark, 'Failed', 85) . ' _Failed_', + default => '', }; if ($site['action']['type'] === 'logs') { @@ -195,6 +196,7 @@ class Comment 'building' => $this->generatImage($pathLight, $pathDark, 'Building', 85) . ' _Building_', 'ready' => $this->generatImage($pathLight, $pathDark, 'Ready', 85) . ' _Ready_', 'failed' => $this->generatImage($pathLight, $pathDark, 'Failed', 85) . ' _Failed_', + default => '', }; if ($function['action']['type'] === 'logs') { @@ -245,7 +247,7 @@ class Comment public function parseComment(string $comment): self { - $state = \explode("\n", $comment)[0] ?? ''; + $state = \explode("\n", $comment)[0]; $state = substr($state, strlen($this->statePrefix)); $json = \base64_decode($state); diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index f899f06bad..a4f1ae44cd 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -297,10 +297,10 @@ class Executor * @param array $params * @param array $headers * @param bool $decode - * @return array|string + * @return array * @throws Exception */ - private function call(string $endpoint, string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, int $timeout = 15, ?callable $callback = null) + private function call(string $endpoint, string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, int $timeout = 15, ?callable $callback = null): array { $headers = array_merge($this->headers, $headers); $ch = curl_init($endpoint . $path . (($method == self::METHOD_GET && !empty($params)) ? '?' . http_build_query($params) : '')); @@ -392,7 +392,7 @@ class Executor $strpos = \is_bool($strpos) ? \strlen($responseType) : $strpos; switch (substr($responseType, 0, $strpos)) { case 'multipart/form-data': - $boundary = \explode('boundary=', $responseHeaders['content-type'] ?? '')[1] ?? ''; + $boundary = \explode('boundary=', $responseHeaders['content-type'])[1] ?? ''; $multipartResponse = new BodyMultipart($boundary); $multipartResponse->load(\is_bool($responseBody) ? '' : $responseBody); diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index d170d56fe4..4358058fe3 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -264,7 +264,7 @@ class Client $strpos = \is_bool($strpos) ? \strlen($responseType) : $strpos; switch (substr($responseType, 0, $strpos)) { case 'multipart/form-data': - $boundary = \explode('boundary=', $responseHeaders['content-type'] ?? '')[1] ?? ''; + $boundary = \explode('boundary=', $responseHeaders['content-type'])[1] ?? ''; $multipartResponse = new BodyMultipart($boundary); $multipartResponse->load(\is_bool($responseBody) ? '' : $responseBody); diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php index f6eb963967..4f557e8959 100644 --- a/tests/e2e/General/UsageTest.php +++ b/tests/e2e/General/UsageTest.php @@ -1605,8 +1605,6 @@ class UsageTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeploymentSite($siteId, [ 'siteId' => $siteId, 'code' => $this->packageSite('static'), diff --git a/tests/e2e/Scopes/ApiVectorsDB.php b/tests/e2e/Scopes/ApiVectorsDB.php deleted file mode 100644 index 09494d3c10..0000000000 --- a/tests/e2e/Scopes/ApiVectorsDB.php +++ /dev/null @@ -1,110 +0,0 @@ -assertNotEmpty($code); $this->assertStringContainsStringIgnoringCase('Use OTP ' . $code . ' to sign in to '. $this->getProject()['name'] . '. Expires in 15 minutes.', $lastEmail['text']); diff --git a/tests/e2e/Services/Account/AccountConsoleClientTest.php b/tests/e2e/Services/Account/AccountConsoleClientTest.php index 9f825c3c89..cd2c43381c 100644 --- a/tests/e2e/Services/Account/AccountConsoleClientTest.php +++ b/tests/e2e/Services/Account/AccountConsoleClientTest.php @@ -203,7 +203,7 @@ class AccountConsoleClientTest extends Scope // Find 6 concurrent digits in email text - OTP preg_match_all("/\b\d{6}\b/", $lastEmail['text'], $matches); - $code = ($matches[0] ?? [])[0] ?? ''; + $code = $matches[0][0] ?? ''; $this->assertNotEmpty($code); diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 49f0c4c245..888611f4ea 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -2135,7 +2135,7 @@ class AccountCustomClientTest extends Scope // Find 6 concurrent digits in email text - OTP preg_match_all("/\b\d{6}\b/", $lastEmail['text'], $matches); - $code = ($matches[0] ?? [])[0] ?? ''; + $code = $matches[0][0] ?? ''; $this->assertNotEmpty($code); @@ -3363,7 +3363,7 @@ class AccountCustomClientTest extends Scope { $data = $this->setupPhoneAccount(); $id = $data['id']; - $token = explode(" ", $data['token'])[0] ?? ''; + $token = explode(" ", $data['token'])[0]; $number = $data['number']; /** diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index f5f1d1864c..236bf79d87 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -936,7 +936,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } // Use dedicated collections for this test to avoid conflicts with setupAttributes() $data = $this->setupDatabase(); @@ -1189,7 +1188,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupAttributes(); $databaseId = $data['databaseId']; @@ -1221,7 +1219,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDatabase(); $databaseId = $data['databaseId']; @@ -1290,7 +1287,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $database = $this->client->call(Client::METHOD_POST, $this->getApiBasePath(), [ 'content-type' => 'application/json', @@ -1351,7 +1347,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupAttributes(); $databaseId = $data['databaseId']; @@ -3324,7 +3319,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -3458,7 +3452,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -3531,7 +3524,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -3578,7 +3570,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; @@ -4929,7 +4920,6 @@ trait DatabasesBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('Attributes are not supported by this database adapter'); - return; } $database = $this->client->call(Client::METHOD_POST, $this->getApiBasePath(), array_merge([ 'content-type' => 'application/json', diff --git a/tests/e2e/Services/Databases/Transactions/ACIDBase.php b/tests/e2e/Services/Databases/Transactions/ACIDBase.php index 1a6ee83b33..11b6de3b70 100644 --- a/tests/e2e/Services/Databases/Transactions/ACIDBase.php +++ b/tests/e2e/Services/Databases/Transactions/ACIDBase.php @@ -178,7 +178,6 @@ trait ACIDBase { if (!$this->getSupportForAttributes()) { $this->markTestSkipped('This adapter does not support attributes; schema constraint consistency cannot be tested.'); - return; } // Create database diff --git a/tests/e2e/Services/GraphQL/FunctionsClientTest.php b/tests/e2e/Services/GraphQL/FunctionsClientTest.php index 8dc2fe337f..ed436ad075 100644 --- a/tests/e2e/Services/GraphQL/FunctionsClientTest.php +++ b/tests/e2e/Services/GraphQL/FunctionsClientTest.php @@ -184,7 +184,7 @@ class FunctionsClientTest extends Scope public function testCreateFunction(): void { $function = $this->setupFunction(); - $this->assertIsArray($function); + $this->assertNotEmpty($function); } /** @@ -194,7 +194,7 @@ class FunctionsClientTest extends Scope public function testCreateDeployment(): void { $deployment = $this->setupDeployment(); - $this->assertIsArray($deployment); + $this->assertNotEmpty($deployment); } /** @@ -204,7 +204,7 @@ class FunctionsClientTest extends Scope public function testCreateExecution(): void { $execution = $this->setupExecution(); - $this->assertIsArray($execution); + $this->assertNotEmpty($execution); } /** diff --git a/tests/e2e/Services/GraphQL/FunctionsServerTest.php b/tests/e2e/Services/GraphQL/FunctionsServerTest.php index 8e1c7ac7e7..572fde49bf 100644 --- a/tests/e2e/Services/GraphQL/FunctionsServerTest.php +++ b/tests/e2e/Services/GraphQL/FunctionsServerTest.php @@ -186,7 +186,7 @@ class FunctionsServerTest extends Scope public function testCreateFunction(): void { $function = $this->setupFunction(); - $this->assertIsArray($function); + $this->assertNotEmpty($function); } /** @@ -196,7 +196,7 @@ class FunctionsServerTest extends Scope public function testCreateDeployment(): void { $deployment = $this->setupDeployment(); - $this->assertIsArray($deployment); + $this->assertNotEmpty($deployment); } /** @@ -206,7 +206,7 @@ class FunctionsServerTest extends Scope public function testCreateExecution(): void { $execution = $this->setupExecution(); - $this->assertIsArray($execution); + $this->assertNotEmpty($execution); } /** diff --git a/tests/e2e/Services/GraphQL/Legacy/AuthTest.php b/tests/e2e/Services/GraphQL/Legacy/AuthTest.php index 4a3e49cc60..d3c6d01ffa 100644 --- a/tests/e2e/Services/GraphQL/Legacy/AuthTest.php +++ b/tests/e2e/Services/GraphQL/Legacy/AuthTest.php @@ -18,7 +18,6 @@ class AuthTest extends Scope use Base; private array $account1; - private array $account2; private string $token1; private string $token2; diff --git a/tests/e2e/Services/GraphQL/StorageClientTest.php b/tests/e2e/Services/GraphQL/StorageClientTest.php index 25041e843b..dd89819c34 100644 --- a/tests/e2e/Services/GraphQL/StorageClientTest.php +++ b/tests/e2e/Services/GraphQL/StorageClientTest.php @@ -112,7 +112,7 @@ class StorageClientTest extends Scope public function testCreateFile(): void { $file = $this->setupFile(); - $this->assertIsArray($file); + $this->assertNotEmpty($file); } /** diff --git a/tests/e2e/Services/GraphQL/StorageServerTest.php b/tests/e2e/Services/GraphQL/StorageServerTest.php index cc4c8ecec3..1377ef9207 100644 --- a/tests/e2e/Services/GraphQL/StorageServerTest.php +++ b/tests/e2e/Services/GraphQL/StorageServerTest.php @@ -111,7 +111,7 @@ class StorageServerTest extends Scope public function testCreateFile(): void { $file = $this->setupFile(); - $this->assertIsArray($file); + $this->assertNotEmpty($file); } public function testGetBuckets(): array diff --git a/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php b/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php index 9c6910fb30..13f083f0eb 100644 --- a/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php +++ b/tests/e2e/Services/GraphQL/TablesDB/AuthTest.php @@ -18,7 +18,6 @@ class AuthTest extends Scope use Base; private array $account1; - private array $account2; private string $token1; private string $token2; diff --git a/tests/e2e/Services/GraphQL/TeamsServerTest.php b/tests/e2e/Services/GraphQL/TeamsServerTest.php index ff6e8e3c6f..dd546119e2 100644 --- a/tests/e2e/Services/GraphQL/TeamsServerTest.php +++ b/tests/e2e/Services/GraphQL/TeamsServerTest.php @@ -199,7 +199,7 @@ class TeamsServerTest extends Scope public function testUpdateTeamPrefs() { $team = $this->setupTeamWithPrefs(); - $this->assertIsArray($team); + $this->assertNotEmpty($team); } public function testGetTeamPreferences() diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 59ff5e353c..967dd16fb3 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -6,7 +6,6 @@ use Appwrite\Extend\Exception; use Appwrite\Tests\Async; use PHPUnit\Framework\Attributes\Group; use Tests\E2E\Client; -use Tests\E2E\General\UsageTest; use Tests\E2E\Scopes\ProjectConsole; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideClient; @@ -831,49 +830,6 @@ class ProjectsConsoleClientTest extends Scope $this->markTestIncomplete( 'This test is failing right now due to functions collection.' ); - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_GET, '/project/usage', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'startDate' => UsageTest::getToday(), - 'endDate' => UsageTest::getTomorrow(), - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(8, count($response['body'])); - $this->assertNotEmpty($response['body']); - $this->assertIsArray($response['body']['requests']); - $this->assertIsArray($response['body']['network']); - $this->assertIsNumeric($response['body']['executionsTotal']); - $this->assertIsNumeric($response['body']['rowsTotal']); - $this->assertIsNumeric($response['body']['databasesTotal']); - $this->assertIsNumeric($response['body']['bucketsTotal']); - $this->assertIsNumeric($response['body']['usersTotal']); - $this->assertIsNumeric($response['body']['filesStorageTotal']); - $this->assertIsNumeric($response['body']['deploymentStorageTotal']); - $this->assertIsNumeric($response['body']['authPhoneTotal']); - $this->assertIsNumeric($response['body']['authPhoneEstimate']); - - - /** - * Test for FAILURE - */ - $response = $this->client->call(Client::METHOD_GET, '/projects/empty', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(404, $response['headers']['status-code']); - - $response = $this->client->call(Client::METHOD_GET, '/projects/id-is-really-long-id-is-really-long-id-is-really-long-id-is-really-long', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(400, $response['headers']['status-code']); } public function testUpdateProject(): void diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php index edce428e0f..102f41933b 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php @@ -513,7 +513,7 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $client->receive(); $this->fail('Expected TimeoutException - event should be filtered by updated query'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index ca07d45f46..ef1c5fce7a 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -3828,7 +3828,7 @@ class RealtimeCustomClientTest extends Scope $this->fail('Should not receive any event after rollback'); } catch (TimeoutException $e) { // Expected - no event should be triggered - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -5655,7 +5655,7 @@ class RealtimeCustomClientTest extends Scope $client->receive(); $this->fail('Should not receive duplicate event'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Test Document Decrement @@ -5686,7 +5686,7 @@ class RealtimeCustomClientTest extends Scope $client->receive(); $this->fail('Should not receive duplicate event'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php index 04ed56dae6..04b8400b57 100644 --- a/tests/e2e/Services/Realtime/RealtimeQueryBase.php +++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php @@ -101,7 +101,7 @@ trait RealtimeQueryBase $data = $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -206,7 +206,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -304,7 +304,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -398,7 +398,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -492,7 +492,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -604,7 +604,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -716,7 +716,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -810,7 +810,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -903,7 +903,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1019,7 +1019,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create document with priority > 5 but status != 'active' - should NOT receive event @@ -1041,7 +1041,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1157,7 +1157,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1296,7 +1296,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create document with score >= 80 but category != 'premium' or 'vip' - should NOT receive event @@ -1318,7 +1318,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1511,7 +1511,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered for scoped channel query'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1583,7 +1583,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1692,7 +1692,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered (neither query matches)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create document with matching ID but wrong status - should NOT receive event (only one query matches) @@ -1713,7 +1713,7 @@ trait RealtimeQueryBase $client->receive(); $this->fail('Expected TimeoutException - event should be filtered (ID matches but status does not)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $client->close(); @@ -1870,7 +1870,7 @@ trait RealtimeQueryBase $clientQ2->receive(); $this->fail('Expected TimeoutException - event should be filtered for clientQ2 (active document)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // clientComplex: should receive event, subscriptions should not be empty (query matched) @@ -1912,7 +1912,7 @@ trait RealtimeQueryBase $clientQ1->receive(); $this->fail('Expected TimeoutException - event should be filtered for clientQ1 (pending document)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // clientQ2: should receive event, subscriptions should not be empty (query matched) @@ -1929,7 +1929,7 @@ trait RealtimeQueryBase $clientComplex->receive(); $this->fail('Expected TimeoutException - event should be filtered for complex subscription (pending document)'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $clientAll->close(); @@ -2043,7 +2043,7 @@ trait RealtimeQueryBase $clientQ2->receive(); $this->fail('Expected TimeoutException - clientQ2 should not receive active document'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // 2) pending document -> only queryStatusPending subscription should see it @@ -2073,7 +2073,7 @@ trait RealtimeQueryBase $clientQ1->receive(); $this->fail('Expected TimeoutException - clientQ1 should not receive pending document'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $clientQ1->close(); @@ -2252,7 +2252,7 @@ trait RealtimeQueryBase $data = $client->receive(); $this->fail('Expected TimeoutException - document does not match query after permission change'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create a NEW document with a different ID - should NOT receive event @@ -2279,7 +2279,7 @@ trait RealtimeQueryBase $data = $client->receive(); $this->fail('Expected TimeoutException - new document does not match original query after permission change'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Create a document with the ORIGINAL matching ID - should receive event @@ -2439,7 +2439,7 @@ trait RealtimeQueryBase $clientWithNonMatchingQuery->receive(); $this->fail('Expected TimeoutException - client with non-matching query should not receive event'); } catch (TimeoutException $e) { - $this->assertTrue(true); + $this->addToAssertionCount(1); } $clientNoQuery->close(); diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 69dbd7fdf0..59727b8d22 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -801,8 +801,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - /** * Test for SUCCESS */ @@ -881,8 +879,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'siteId' => $siteId, 'code' => $this->packageSite('static-single-file'), @@ -943,8 +939,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -995,8 +989,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -1040,8 +1032,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -1243,8 +1233,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -1294,8 +1282,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - /** * Test for SUCCESS */ @@ -1383,8 +1369,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $deployment = $this->createDeployment($siteId, [ 'code' => $this->packageSite('static-single-file'), 'activate' => 'false' @@ -1427,8 +1411,6 @@ class SitesCustomServerTest extends Scope 'siteId' => ID::unique() ]); - $this->assertNotNull($siteId); - $site = $this->deleteSite($siteId); $this->assertEquals(204, $site['headers']['status-code']); diff --git a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php index 601bf1d2d0..80e406eac9 100644 --- a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php +++ b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php @@ -147,7 +147,6 @@ class TokensConsoleClientTest extends Scope $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 86400 * 365 * 10, 10); // 10 years maxAge try { $payload = $jwt->decode($token['body']['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); $this->assertArrayHasKey('tokenId', $payload, 'JWT payload should contain tokenId'); $this->assertArrayHasKey('resourceId', $payload, 'JWT payload should contain resourceId'); $this->assertArrayHasKey('resourceType', $payload, 'JWT payload should contain resourceType'); @@ -204,7 +203,6 @@ class TokensConsoleClientTest extends Scope $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 86400 * 365 * 10, 10); // 10 years maxAge try { $payload = $jwt->decode($token['body']['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); $this->assertArrayHasKey('exp', $payload, 'JWT payload should contain exp field'); $expectedExp = (new \DateTime($expiry))->getTimestamp(); @@ -226,7 +224,6 @@ class TokensConsoleClientTest extends Scope // Verify JWT does not contain exp for infinite expiry using native JWT decode try { $payload = $jwt->decode($token['body']['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); $this->assertArrayNotHasKey('exp', $payload, 'JWT payload should not contain exp field for infinite expiry'); } catch (JWTException $e) { $this->fail('Failed to decode JWT: ' . $e->getMessage()); @@ -265,7 +262,6 @@ class TokensConsoleClientTest extends Scope // Verify the JWT token is valid and contains correct information try { $payload = $jwt->decode($token['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); $this->assertArrayHasKey('tokenId', $payload, 'JWT payload should contain tokenId'); $this->assertArrayHasKey('resourceId', $payload, 'JWT payload should contain resourceId'); $this->assertArrayHasKey('resourceType', $payload, 'JWT payload should contain resourceType'); diff --git a/tests/e2e/Traits/DatabaseFixture.php b/tests/e2e/Traits/DatabaseFixture.php deleted file mode 100644 index f3ba10e765..0000000000 --- a/tests/e2e/Traits/DatabaseFixture.php +++ /dev/null @@ -1,239 +0,0 @@ -ensureFixturesCreated(); - return self::$fixtureDatabaseId; - } - - protected function getFixtureMoviesId(): string - { - $this->ensureFixturesCreated(); - return self::$fixtureMoviesId; - } - - protected function getFixtureActorsId(): string - { - $this->ensureFixturesCreated(); - return self::$fixtureActorsId; - } - - protected function getFixtureDocumentIds(): array - { - $this->ensureFixturesCreated(); - return self::$fixtureDocumentIds; - } - - protected function ensureFixturesCreated(): void - { - if (self::$fixturesInitialized) { - return; - } - - $this->createDatabaseFixtures(); - self::$fixturesInitialized = true; - } - - protected function createDatabaseFixtures(): void - { - $config = $this->getSchemaApiConfig(); - $isTablesDB = $config['basePath'] === '/tablesdb'; - - // Create database - $database = $this->client->call(Client::METHOD_POST, $config['basePath'], [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'databaseId' => ID::unique(), - 'name' => 'Fixture Database' - ]); - - self::$fixtureDatabaseId = $database['body']['$id']; - $databaseId = self::$fixtureDatabaseId; - - $collectionEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath']; - $collectionKey = $isTablesDB ? 'tableId' : 'collectionId'; - $docKey = $isTablesDB ? 'rowId' : 'documentId'; - $docEndpoint = $isTablesDB ? 'rows' : 'documents'; - - // Create Movies collection - $movies = $this->client->call(Client::METHOD_POST, $collectionEndpoint, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - $collectionKey => ID::unique(), - 'name' => 'Movies', - ($isTablesDB ? 'rowSecurity' : 'documentSecurity') => true, - 'permissions' => [ - Permission::create(Role::users()), - Permission::read(Role::users()), - Permission::update(Role::users()), - Permission::delete(Role::users()), - ], - ]); - - self::$fixtureMoviesId = $movies['body']['$id']; - - // Create Actors collection - $actors = $this->client->call(Client::METHOD_POST, $collectionEndpoint, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - $collectionKey => ID::unique(), - 'name' => 'Actors', - ($isTablesDB ? 'rowSecurity' : 'documentSecurity') => true, - 'permissions' => [ - Permission::create(Role::users()), - Permission::read(Role::users()), - Permission::update(Role::users()), - Permission::delete(Role::users()), - ], - ]); - - self::$fixtureActorsId = $actors['body']['$id']; - - // Create attributes on Movies - $attrEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureMoviesId . '/' . $config['attributePath']; - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/string', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'title', - 'size' => 256, - 'required' => true, - ]); - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/string', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'description', - 'size' => 512, - 'required' => false, - 'default' => '', - ]); - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/integer', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'releaseYear', - 'required' => false, - 'default' => 0, - ]); - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/float', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'rating', - 'required' => false, - 'default' => 0.0, - ]); - - $this->client->call(Client::METHOD_POST, $attrEndpoint . '/boolean', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'active', - 'required' => false, - 'default' => true, - ]); - - // Create attributes on Actors - $actorAttrEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureActorsId . '/' . $config['attributePath']; - - $this->client->call(Client::METHOD_POST, $actorAttrEndpoint . '/string', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); - - $this->waitForAllAttributes($databaseId, self::$fixtureMoviesId); - $this->waitForAllAttributes($databaseId, self::$fixtureActorsId); - - // Create indexes - $indexEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureMoviesId . '/' . $config['indexPath']; - - $this->client->call(Client::METHOD_POST, $indexEndpoint, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'key' => 'title_index', - 'type' => 'key', - 'attributes' => ['title'], - ]); - - $this->waitForAllIndexes($databaseId, self::$fixtureMoviesId); - - // Create sample documents - $docsEndpoint = $config['basePath'] . '/' . $databaseId . '/' . $config['collectionPath'] . '/' . self::$fixtureMoviesId . '/' . $docEndpoint; - - $sampleMovies = [ - ['title' => 'Inception', 'description' => 'A mind-bending thriller', 'releaseYear' => 2010, 'rating' => 8.8, 'active' => true], - ['title' => 'The Matrix', 'description' => 'A sci-fi classic', 'releaseYear' => 1999, 'rating' => 8.7, 'active' => true], - ['title' => 'Interstellar', 'description' => 'Space exploration epic', 'releaseYear' => 2014, 'rating' => 8.6, 'active' => true], - ]; - - foreach ($sampleMovies as $movie) { - $doc = $this->client->call(Client::METHOD_POST, $docsEndpoint, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - $docKey => ID::unique(), - 'data' => $movie, - 'permissions' => [ - Permission::read(Role::users()), - Permission::update(Role::user($this->getUser()['$id'])), - Permission::delete(Role::user($this->getUser()['$id'])), - ], - ]); - - self::$fixtureDocumentIds[] = $doc['body']['$id']; - } - } - - public static function tearDownAfterClass(): void - { - self::$fixtureDatabaseId = null; - self::$fixtureMoviesId = null; - self::$fixtureActorsId = null; - self::$fixtureDocumentIds = []; - self::$fixturesInitialized = false; - - parent::tearDownAfterClass(); - } -} diff --git a/tests/extensions/Async/Eventually.php b/tests/extensions/Async/Eventually.php index 10f6b41eee..d8c9dc998d 100644 --- a/tests/extensions/Async/Eventually.php +++ b/tests/extensions/Async/Eventually.php @@ -11,7 +11,7 @@ final class Eventually extends Constraint { } - public function evaluate(mixed $probe, string $description = '', bool $returnResult = false): ?bool + public function evaluate(mixed $probe, string $description = '', bool $returnResult = false): bool { if (!is_callable($probe)) { throw new \Exception('Probe must be a callable'); diff --git a/tests/extensions/RetrySubscriber.php b/tests/extensions/RetrySubscriber.php index 08623dc261..ff09b187d4 100644 --- a/tests/extensions/RetrySubscriber.php +++ b/tests/extensions/RetrySubscriber.php @@ -16,13 +16,6 @@ class RetrySubscriber implements FailedSubscriber */ private static array $retryCounts = []; - /** - * Track tests that should be retried - * - * @var array - */ - private static array $pendingRetries = []; - public function notify(Failed $event): void { $this->handleTestFailure($event->test(), $event->throwable()->asString()); @@ -98,6 +91,5 @@ class RetrySubscriber implements FailedSubscriber public static function reset(): void { self::$retryCounts = []; - self::$pendingRetries = []; } } diff --git a/tests/unit/Messaging/MessagingChannelsTest.php b/tests/unit/Messaging/MessagingChannelsTest.php index fc2d839ca6..af6592ef92 100644 --- a/tests/unit/Messaging/MessagingChannelsTest.php +++ b/tests/unit/Messaging/MessagingChannelsTest.php @@ -203,7 +203,6 @@ class MessagingChannelsTest extends TestCase * Making sure the right clients receive the event. */ $this->assertStringEndsWith($index, $receiverId); - $this->assertIsArray($queryKeys); } } } @@ -240,7 +239,6 @@ class MessagingChannelsTest extends TestCase * Making sure the right clients receive the event. */ $this->assertStringEndsWith($index, $receiverId); - $this->assertIsArray($queryKeys); } } } diff --git a/tests/unit/Network/Validators/DNSTest.php b/tests/unit/Network/Validators/DNSTest.php index 6e4a78022f..845d01e723 100644 --- a/tests/unit/Network/Validators/DNSTest.php +++ b/tests/unit/Network/Validators/DNSTest.php @@ -33,10 +33,7 @@ class DNSTest extends TestCase $result = $validator->isValid('nonexistent-domain-' . \uniqid() . '.com'); $this->assertEquals(false, $result); - $this->assertIsInt($validator->count); - $this->assertIsString($validator->value); - $this->assertIsArray($validator->records); - $this->assertIsString($validator->getDescription()); + $this->assertNotEmpty($validator->getDescription()); } public function testCoreDNSFailure(): void diff --git a/tests/unit/Platform/Modules/Installer/ModuleTest.php b/tests/unit/Platform/Modules/Installer/ModuleTest.php index 507a4e25f6..87babcfb16 100644 --- a/tests/unit/Platform/Modules/Installer/ModuleTest.php +++ b/tests/unit/Platform/Modules/Installer/ModuleTest.php @@ -157,7 +157,7 @@ class ModuleTest extends TestCase $platform->init(Service::TYPE_HTTP); // If we get here without exceptions, route registration succeeded - $this->assertTrue(true); + $this->addToAssertionCount(1); } public function testModuleHasNoTaskServices(): void @@ -267,14 +267,6 @@ class ModuleTest extends TestCase } } - public function testValidateClassHasCsrfMethod(): void - { - $this->assertTrue( - method_exists(Validate::class, 'validateCsrf'), - 'Validate class should expose validateCsrf method' - ); - } - private function getAction(string $name): Action { $services = $this->module->getServicesByType(Service::TYPE_HTTP); diff --git a/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php b/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php index 6c36e6d732..c8cfd6d884 100644 --- a/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php +++ b/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php @@ -19,14 +19,7 @@ class StateTest extends TestCase $this->tempDir = sys_get_temp_dir() . '/appwrite-installer-test-' . uniqid(); mkdir($this->tempDir, 0755, true); - $root = dirname(__DIR__, 6); - $this->state = new State([ - 'public' => $root . '/public', - 'init' => $root . '/app/init.php', - 'views' => $root . '/app/views/install', - 'vendor' => $root . '/vendor/autoload.php', - 'installPhp' => $root . '/src/Appwrite/Platform/Tasks/Install.php', - ]); + $this->state = new State(); // Preserve env state $env = getenv('APPWRITE_INSTALLER_CONFIG'); @@ -273,7 +266,6 @@ class StateTest extends TestCase public function testReadProgressFileReturnsDefaultForMissing(): void { $data = $this->state->readProgressFile('nonexistent-id-' . uniqid()); - $this->assertIsArray($data); $this->assertArrayHasKey('installId', $data); $this->assertArrayHasKey('steps', $data); $this->assertEmpty($data['steps']); @@ -291,7 +283,6 @@ class StateTest extends TestCase ]); $data = $this->state->readProgressFile($installId); - $this->assertIsArray($data); $this->assertArrayHasKey('steps', $data); $this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']); $this->assertEquals(Server::STATUS_IN_PROGRESS, $data['steps'][Server::STEP_ENV_VARS]['status']); @@ -604,7 +595,6 @@ class StateTest extends TestCase file_put_contents($path, 'not valid json {{{'); $data = $this->state->readProgressFile($installId); - $this->assertIsArray($data); $this->assertArrayHasKey('installId', $data); $this->assertArrayHasKey('steps', $data); $this->assertEmpty($data['steps']); @@ -618,7 +608,6 @@ class StateTest extends TestCase file_put_contents($path, ''); $data = $this->state->readProgressFile($installId); - $this->assertIsArray($data); $this->assertArrayHasKey('installId', $data); $this->assertEmpty($data['steps']); } @@ -631,7 +620,6 @@ class StateTest extends TestCase file_put_contents($path, '"just a string"'); $data = $this->state->readProgressFile($installId); - $this->assertIsArray($data); $this->assertEmpty($data['steps']); } diff --git a/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php b/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php index c453dcade4..0a360783ac 100644 --- a/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php +++ b/tests/unit/Platform/Modules/Installer/Validator/AppDomainTest.php @@ -22,7 +22,6 @@ class AppDomainTest extends TestCase public function testDescription(): void { $this->assertNotEmpty($this->validator->getDescription()); - $this->assertIsString($this->validator->getDescription()); } public function testIsArray(): void diff --git a/tests/unit/URL/URLTest.php b/tests/unit/URL/URLTest.php index ceca1c6304..597d77f74c 100644 --- a/tests/unit/URL/URLTest.php +++ b/tests/unit/URL/URLTest.php @@ -11,7 +11,6 @@ class URLTest extends TestCase { $url = URL::parse('https://appwrite.io:8080/path?query=string¶m=value'); - $this->assertIsArray($url); $this->assertEquals('https', $url['scheme']); $this->assertEquals('appwrite.io', $url['host']); $this->assertEquals('8080', $url['port']); @@ -20,7 +19,6 @@ class URLTest extends TestCase $url = URL::parse('https://appwrite.io'); - $this->assertIsArray($url); $this->assertEquals('https', $url['scheme']); $this->assertEquals('appwrite.io', $url['host']); $this->assertEquals(null, $url['port']); @@ -29,7 +27,6 @@ class URLTest extends TestCase $url = URL::parse('appwrite-callback-project://'); - $this->assertIsArray($url); $this->assertEquals('appwrite-callback-project', $url['scheme']); $this->assertEquals('', $url['host']); $this->assertEquals(null, $url['port']); @@ -47,7 +44,6 @@ class URLTest extends TestCase 'query' => 'query=string¶m=value', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io:8080/path?query=string¶m=value', $url); $url = URL::unparse([ @@ -58,7 +54,6 @@ class URLTest extends TestCase 'query' => 'query=string¶m=value', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io/path?query=string¶m=value', $url); $url = URL::unparse([ @@ -69,7 +64,6 @@ class URLTest extends TestCase 'query' => '', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io/', $url); $url = URL::unparse([ @@ -80,7 +74,6 @@ class URLTest extends TestCase 'fragment' => 'bottom', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io/#bottom', $url); $url = URL::unparse([ @@ -93,7 +86,6 @@ class URLTest extends TestCase 'fragment' => 'bottom', ]); - $this->assertIsString($url); $this->assertEquals('https://eldad:fux@appwrite.io/#bottom', $url); $url = URL::unparse([ @@ -106,7 +98,6 @@ class URLTest extends TestCase 'fragment' => '', ]); - $this->assertIsString($url); $this->assertEquals('https://appwrite.io/#', $url); } @@ -114,7 +105,6 @@ class URLTest extends TestCase { $result = URL::parseQuery('param1=value1¶m2=value2'); - $this->assertIsArray($result); $this->assertEquals(['param1' => 'value1', 'param2' => 'value2'], $result); } @@ -122,7 +112,6 @@ class URLTest extends TestCase { $result = URL::unparseQuery(['param1' => 'value1', 'param2' => 'value2']); - $this->assertIsString($result); $this->assertEquals('param1=value1¶m2=value2', $result); } } diff --git a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php index f7d73eb287..d5507327be 100644 --- a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php +++ b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php @@ -659,7 +659,7 @@ class RuntimeQueryTest extends TestCase $query = Query::select(['*']); // Should not throw RuntimeQuery::validateSelectQuery($query); - $this->assertTrue(true); + $this->addToAssertionCount(1); } public function testValidateSelectQueryWithSpecificFields(): void @@ -694,7 +694,7 @@ class RuntimeQueryTest extends TestCase $query = Query::equal('name', ['John']); // Should not throw for non-select queries RuntimeQuery::validateSelectQuery($query); - $this->assertTrue(true); + $this->addToAssertionCount(1); } // Filter tests with select("*") diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php index d5cd5d800a..81e0ead4b3 100644 --- a/tests/unit/Utopia/RequestTest.php +++ b/tests/unit/Utopia/RequestTest.php @@ -23,7 +23,6 @@ class RequestTest extends TestCase public function testFilters(): void { $this->assertFalse($this->request->hasFilters()); - $this->assertIsArray($this->request->getFilters()); $this->assertEmpty($this->request->getFilters()); $this->request->addFilter(new First()); diff --git a/tests/unit/Utopia/ResponseTest.php b/tests/unit/Utopia/ResponseTest.php index be8cfdc216..f5a30a5500 100644 --- a/tests/unit/Utopia/ResponseTest.php +++ b/tests/unit/Utopia/ResponseTest.php @@ -26,7 +26,6 @@ class ResponseTest extends TestCase public function testFilters(): void { $this->assertFalse($this->response->hasFilters()); - $this->assertIsArray($this->response->getFilters()); $this->assertEmpty($this->response->getFilters()); $this->response->addFilter(new First()); From d86258a6f6fe308bf439692b899f179185666b8f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 19 Apr 2026 17:52:51 +0530 Subject: [PATCH 078/402] fix: restore runtime guards and widen types missed by PHPStan cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups from CI that the level-4 pass got wrong: 1. `account.php` / `users.php`: `Document::find()` returns `mixed` (specifically `Document|false` in practice), not `Document`. The earlier `@var Document $oldTarget` docblocks were lies, and the runtime `instanceof Document` guards were load-bearing — removing them caused `Call to a member function isEmpty() on false` 500s on the `PATCH /v1/users/:id/email` and `/phone` endpoints (and the analogous `/v1/account/email`, `/v1/account/phone` flows). Dropped the misleading `@var` docblocks and restored `$oldTarget instanceof Document && !$oldTarget->isEmpty()`. 2. `Installer/Runtime/Config::setEnabledDatabases()` is a boundary that actually takes arbitrary user/compose input — not a trusted `string[]`. The `is_string($v)` filter was covering for that, and `ConfigTest::testSetEnabledDatabasesFiltersInvalid` explicitly asserts it. Widened the PHPDoc to `array` and restored `is_string($v) && $v !== ''` in the filter. 3. `OAuth2/Apple::getAppSecret()` wrapped `json_decode` in a `try/catch (\Throwable)` — but `json_decode` without `JSON_THROW_ON_ERROR` returns `null` on failure, it doesn't throw. PHP 8.3's PHPStan flagged the catch as dead (PHP 8.5 didn't, which is why it slipped through locally). Replaced with `if (!\is_array($secret)) throw`, which preserves the original "invalid secret" guard. --- app/controllers/api/account.php | 10 ++-------- app/controllers/api/users.php | 10 ++-------- src/Appwrite/Auth/OAuth2/Apple.php | 6 +++--- src/Appwrite/Platform/Installer/Runtime/Config.php | 4 ++-- 4 files changed, 9 insertions(+), 21 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 4b2d7a31b8..daabe9e3a3 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -3434,12 +3434,9 @@ Http::patch('/v1/account/email') try { $user = $dbForProject->updateDocument('users', $user->getId(), $user); - /** - * @var Document $oldTarget - */ $oldTarget = $user->find('identifier', $oldEmail, 'targets'); - if (!$oldTarget->isEmpty()) { + if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { $authorization->skip(fn () => $dbForProject->updateDocument('targets', $oldTarget->getId(), $oldTarget->setAttribute('identifier', $email))); } $dbForProject->purgeCachedDocument('users', $user->getId()); @@ -3523,12 +3520,9 @@ Http::patch('/v1/account/phone') try { $user = $dbForProject->updateDocument('users', $user->getId(), $user); - /** - * @var Document $oldTarget - */ $oldTarget = $user->find('identifier', $oldPhone, 'targets'); - if (!$oldTarget->isEmpty()) { + if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { $authorization->skip(fn () => $dbForProject->updateDocument('targets', $oldTarget->getId(), $oldTarget->setAttribute('identifier', $phone))); } $dbForProject->purgeCachedDocument('users', $user->getId()); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 57c5854422..342ca0648f 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1595,12 +1595,9 @@ Http::patch('/v1/users/:userId/email') 'emailIsDisposable' => $user->getAttribute('emailIsDisposable'), 'emailIsFree' => $user->getAttribute('emailIsFree'), ])); - /** - * @var Document $oldTarget - */ $oldTarget = $user->find('identifier', $oldEmail, 'targets'); - if (!$oldTarget->isEmpty()) { + if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { if (\strlen($email) !== 0) { $dbForProject->updateDocument('targets', $oldTarget->getId(), new Document(['identifier' => $email])); $oldTarget->setAttribute('identifier', $email); @@ -1691,12 +1688,9 @@ Http::patch('/v1/users/:userId/phone') 'phone' => $phoneValue, 'phoneVerification' => $user->getAttribute('phoneVerification'), ])); - /** - * @var Document $oldTarget - */ $oldTarget = $user->find('identifier', $oldPhone, 'targets'); - if (!$oldTarget->isEmpty()) { + if ($oldTarget instanceof Document && !$oldTarget->isEmpty()) { if ($number !== '') { $dbForProject->updateDocument('targets', $oldTarget->getId(), new Document(['identifier' => $number])); $oldTarget->setAttribute('identifier', $number); diff --git a/src/Appwrite/Auth/OAuth2/Apple.php b/src/Appwrite/Auth/OAuth2/Apple.php index 0b4ec50881..bae3446fcb 100644 --- a/src/Appwrite/Auth/OAuth2/Apple.php +++ b/src/Appwrite/Auth/OAuth2/Apple.php @@ -165,9 +165,9 @@ class Apple extends OAuth2 protected function getAppSecret(): string { - try { - $secret = \json_decode($this->appSecret, true); - } catch (\Throwable $th) { + $secret = \json_decode($this->appSecret, true); + + if (!\is_array($secret)) { throw new Exception('Invalid secret'); } diff --git a/src/Appwrite/Platform/Installer/Runtime/Config.php b/src/Appwrite/Platform/Installer/Runtime/Config.php index 978407894e..6142e47152 100644 --- a/src/Appwrite/Platform/Installer/Runtime/Config.php +++ b/src/Appwrite/Platform/Installer/Runtime/Config.php @@ -218,11 +218,11 @@ final class Config } /** - * @param string[] $value + * @param array $value */ public function setEnabledDatabases(array $value): void { - $filtered = array_values(array_filter($value, fn ($v) => $v !== '')); + $filtered = array_values(array_filter($value, fn ($v) => is_string($v) && $v !== '')); if (!empty($filtered)) { $this->enabledDatabases = $filtered; } From 56165ee3d947b2ceaa9497162aeb4b542850d753 Mon Sep 17 00:00:00 2001 From: Atharva Deosthale Date: Sun, 19 Apr 2026 18:39:19 +0530 Subject: [PATCH 079/402] add claude plugin to static sdk --- app/config/sdks.php | 20 ++++++++++++++++++++ src/Appwrite/Platform/Tasks/SDKs.php | 4 ++++ 2 files changed, 24 insertions(+) diff --git a/app/config/sdks.php b/app/config/sdks.php index 47dc8845b6..e89265b05e 100644 --- a/app/config/sdks.php +++ b/app/config/sdks.php @@ -300,6 +300,26 @@ return [ 'repoBranch' => 'main', 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/cursor-plugin/CHANGELOG.md'), ], + [ + 'key' => 'claude-plugin', + 'name' => 'ClaudePlugin', + 'version' => '0.1.0', + 'url' => 'https://github.com/appwrite/claude-plugin.git', + 'enabled' => true, + 'beta' => false, + 'dev' => false, + 'hidden' => false, + 'spec' => 'static', + 'family' => APP_SDK_PLATFORM_STATIC, + 'prism' => 'claude-plugin', + 'source' => \realpath(__DIR__ . '/../sdks/static-claude-plugin'), + 'gitUrl' => 'git@github.com:appwrite/claude-plugin.git', + 'gitRepoName' => 'claude-plugin', + 'gitUserName' => 'appwrite', + 'gitBranch' => 'dev', + 'repoBranch' => 'main', + 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/claude-plugin/CHANGELOG.md'), + ], ], ], diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index 526ea304de..aac738915d 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -5,6 +5,7 @@ namespace Appwrite\Platform\Tasks; use Appwrite\SDK\Language\AgentSkills; use Appwrite\SDK\Language\Android; use Appwrite\SDK\Language\Apple; +use Appwrite\SDK\Language\ClaudePlugin; use Appwrite\SDK\Language\CLI; use Appwrite\SDK\Language\CursorPlugin; use Appwrite\SDK\Language\Dart; @@ -451,6 +452,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND case 'cursor-plugin': $config = new CursorPlugin(); break; + case 'claude-plugin': + $config = new ClaudePlugin(); + break; default: throw new \Exception('Language "' . $language['key'] . '" not supported'); } From adb4e4ef360282bd7f8af06ccbf3c84f442b23d6 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 19 Apr 2026 20:34:51 +0530 Subject: [PATCH 080/402] ci: fix benchmark by pulling compose from GitHub raw for the latest tag `https://appwrite.io/install/compose` now returns a 308 redirect to the HTML install docs (`/docs/advanced/self-hosting/installation`) instead of serving the compose file, so the Benchmark job's "Installing latest version" step was downloading 0 bytes and `docker compose up -d` died with "empty compose file". This has been failing the Benchmark job on every recent PR, not just this one. Resolve the latest release tag via the GitHub API, then fetch the compose file and `.env` from `raw.githubusercontent.com` at that tag. Switched both curl calls to `-fsSL` so they fail loudly on non-2xx responses or redirect loss instead of silently writing empty files. --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8256ddc7a..50acb7230f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -703,8 +703,10 @@ jobs: run: | rm docker-compose.yml rm .env - curl https://appwrite.io/install/compose -o docker-compose.yml - curl https://appwrite.io/install/env -o .env + LATEST_TAG=$(curl -fsSL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/appwrite/appwrite/releases/latest | jq -r .tag_name) + echo "Latest release tag: $LATEST_TAG" + curl -fsSL "https://raw.githubusercontent.com/appwrite/appwrite/${LATEST_TAG}/docker-compose.yml" -o docker-compose.yml + curl -fsSL "https://raw.githubusercontent.com/appwrite/appwrite/${LATEST_TAG}/.env" -o .env sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env docker compose up -d sleep 10 From 6b66923f1861d2f93e3f2802f07f653d44f593d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 19:36:24 +0200 Subject: [PATCH 081/402] Fix delete response placeholder audit label --- app/controllers/api/account.php | 4 ++-- app/controllers/api/users.php | 4 ++-- .../Account/Http/Account/MFA/Authenticators/Delete.php | 4 ++-- .../Modules/Project/Http/Project/Platforms/Delete.php | 2 +- .../Modules/Project/Http/Project/Templates/Email/Delete.php | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index ffe2b54c5b..b26f6bc5c6 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -453,7 +453,7 @@ Http::delete('/v1/account') ->groups(['api', 'account']) ->label('scope', 'account') ->label('audits.event', 'user.delete') - ->label('audits.resource', 'user/{response.$id}') + ->label('audits.resource', 'user/{user.$id}') ->label('sdk', new Method( namespace: 'account', group: 'account', @@ -4618,7 +4618,7 @@ Http::delete('/v1/account/targets/:targetId/push') ->groups(['api', 'account']) ->label('scope', 'targets.write') ->label('audits.event', 'target.delete') - ->label('audits.resource', 'target/response.$id') + ->label('audits.resource', 'target/{request.targetId}') ->label('event', 'users.[userId].targets.[targetId].delete') ->label('sdk', new Method( namespace: 'account', diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index a8875fc442..c922b2a1f6 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -2252,8 +2252,8 @@ Http::delete('/v1/users/:userId/mfa/authenticators/:type') ->label('event', 'users.[userId].delete.mfa') ->label('scope', 'users.write') ->label('audits.event', 'user.update') - ->label('audits.resource', 'user/{response.$id}') - ->label('audits.userId', '{response.$id}') + ->label('audits.resource', 'user/{request.userId}') + ->label('audits.userId', '{request.userId}') ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk', [ new Method( diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php index 754255be15..5765c5bf6e 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php @@ -37,8 +37,8 @@ class Delete extends Action ->label('event', 'users.[userId].delete.mfa') ->label('scope', 'account') ->label('audits.event', 'user.update') - ->label('audits.resource', 'user/{response.$id}') - ->label('audits.userId', '{response.$id}') + ->label('audits.resource', 'user/{user.$id}') + ->label('audits.userId', '{user.$id}') ->label('sdk', [ new Method( namespace: 'account', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php index 4b58766751..24669b02b2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php @@ -36,7 +36,7 @@ class Delete extends Action ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].delete') ->label('audits.event', 'project.platform.delete') - ->label('audits.resource', 'project.platform/{response.$id}') + ->label('audits.resource', 'project.platform/{request.platformId}') ->label('sdk', new Method( namespace: 'project', group: 'platforms', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php index cf02704d47..176e8d7d63 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php @@ -39,7 +39,7 @@ class Delete extends Action ->label('scope', 'templates.write') ->label('event', 'templates.[templateType].delete') ->label('audits.event', 'project.template.delete') - ->label('audits.resource', 'project.template/{response.templateId}') + ->label('audits.resource', 'project.template/{request.templateId}') ->label('sdk', new Method( namespace: 'project', group: 'templates', From 37a2b1cbd9a9dbc1b0bbc6ea45bab45063e60208 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 20 Apr 2026 08:54:31 +0530 Subject: [PATCH 082/402] fix: restore executions limit cleanup behind a runtime env flag Per review feedback on the PHPStan cleanup, the two `if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE)` blocks in `app/controllers/general.php` and `src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php` were load-bearing feature flags, not dead code. Removing them silently dropped the ability to turn the cleanup on later. Changes: - Convert `ENABLE_EXECUTIONS_LIMIT_ON_ROUTE` from `const ... = false;` to a `define()` backed by the new `_APP_EXECUTIONS_LIMIT_ON_ROUTE` env var (defaults to `disabled`). PHPStan can no longer fold the `&&` away since the value is now runtime-resolved, so the guarded blocks are live again. - Restore the `/* cleanup */` block in the `router()` helper in `app/controllers/general.php`. - Restore the two cleanup blocks in `Functions/Http/Executions/Create.php` (one on the async-scheduled return path, one on the sync-response path), and re-add the `DeleteEvent $queueForDeletes` / `int $executionsRetentionCount` injections plus the `Appwrite\Event\Delete` import. Runtime behavior is identical to main (flag off by default); operators can now flip it via env without a code change. --- app/controllers/general.php | 14 +++++++++++ app/init/constants.php | 3 ++- .../Functions/Http/Executions/Create.php | 23 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/controllers/general.php b/app/controllers/general.php index 06ed676a76..a17f0dff04 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -771,6 +771,20 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S deployment: $deployment->getArrayCopy(), )); + /* cleanup */ + if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { + $resourceType = $type === 'function' + ? RESOURCE_TYPE_FUNCTIONS + : RESOURCE_TYPE_SITES; + + $queueForDeletes + ->setProject($project) + ->setResourceType($resourceType) + ->setResource($resource->getSequence()) + ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) + ->trigger(); + } + return true; } elseif ($type === 'api') { return false; diff --git a/app/init/constants.php b/app/init/constants.php index f2127cd666..fa090a648c 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -1,6 +1,7 @@ inject('executor') ->inject('platform') ->inject('authorization') + ->inject('queueForDeletes') + ->inject('executionsRetentionCount') ->callback($this->action(...)); } @@ -126,6 +129,8 @@ class Create extends Base Executor $executor, array $platform, Authorization $authorization, + DeleteEvent $queueForDeletes, + int $executionsRetentionCount, ) { $async = \strval($async) === 'true' || \strval($async) === '1'; @@ -331,6 +336,15 @@ class Create extends Base $execution = $authorization->skip(fn () => $dbForProject->createDocument('executions', $execution)); } + if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { + $queueForDeletes + ->setProject($project) + ->setResource($function->getSequence()) + ->setResourceType(RESOURCE_TYPE_FUNCTIONS) + ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) + ->trigger(); + } + $response->setStatusCode(Response::STATUS_CODE_ACCEPTED); $response->dynamic($execution, Response::MODEL_EXECUTION); return; @@ -509,6 +523,15 @@ class Create extends Base } } + if ($executionsRetentionCount > 0 && ENABLE_EXECUTIONS_LIMIT_ON_ROUTE) { + $queueForDeletes + ->setProject($project) + ->setResource($function->getSequence()) + ->setResourceType(RESOURCE_TYPE_FUNCTIONS) + ->setType(DELETE_TYPE_EXECUTIONS_LIMIT) + ->trigger(); + } + $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic($execution, Response::MODEL_EXECUTION); From a5c0a920baac5cbb6d2dc42d84fb482defade718 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 20 Apr 2026 04:01:30 +0000 Subject: [PATCH 083/402] feat: add afterQuery hook to list-documents/rows action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap each database call (find, count, transaction list/count) with a measuring closure so the actual DB duration is known — cache hits report near-zero, cache misses report only the DB time, not cache save / response serialization. After the response is sent, invoke a protected afterQuery() hook with the measured duration, the database/collection documents, and both parsed + raw query arrays. CE impl is a no-op; downstreams (e.g., cloud) can override it to log slow queries without relying on HTTP shutdown hooks or route-path matching. Exceptions from afterQuery are swallowed so observability never breaks the response. --- .../Http/TablesDB/Tables/Rows/XList.php | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php index 91c62aea05..315e1c2e67 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php @@ -2,15 +2,27 @@ namespace Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Rows; +use Appwrite\Databases\TransactionState; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\XList as DocumentXList; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Usage\Context; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Exception\Order as OrderException; +use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Exception\Timeout; +use Utopia\Database\Query; +use Utopia\Database\Validator\Authorization; +use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; +use Utopia\Http\Http; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; @@ -65,6 +77,200 @@ class XList extends DocumentXList ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('utopia') ->callback($this->action(...)); } + + /** + * List rows with actual database duration measurement and a post-query + * observability hook. Mirrors the parent listDocuments action body but + * wraps each DB call with a timer so subclasses can observe just the DB + * portion of the request via afterQuery(). + * + * @param array $queries + */ + public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, ?Http $utopia = null): void + { + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); + + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]); + } + + $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); + if ($collection->isEmpty() || (!$collection->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception($this->getParentNotFoundException(), params: [$collectionId]); + } + + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $dbForDatabases = $getDatabasesDB($database); + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $documentId = $cursor->getValue(); + + $cursorDocument = $authorization->skip(fn () => $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId)); + + if ($cursorDocument->isEmpty()) { + $type = ucfirst($this->getContext()); + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "$type '{$documentId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $dbDurationMs = 0.0; + $measure = function (callable $fn) use (&$dbDurationMs) { + $start = \microtime(true); + try { + return $fn(); + } finally { + $dbDurationMs += (\microtime(true) - $start) * 1000; + } + }; + + try { + $hasSelects = ! empty(Query::groupByType($queries)['selections'] ?? []); + $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); + $find = $hasSelects + ? fn () => $measure(fn () => $dbForDatabases->find($collectionTableId, $queries)) + : fn () => $measure(fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries))); + + if ($transactionId !== null) { + $documents = $measure(fn () => $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries)); + $total = $includeTotal ? $measure(fn () => $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries)) : 0; + } elseif ((int)$ttl > 0) { + $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); + $roles = $dbForProject->getAuthorization()->getRoles(); + $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS); + + $documentsCacheHit = false; + try { + $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); + } catch (\Throwable) { + $cachedDocuments = null; + } + + if ($cachedDocuments !== null && + $cachedDocuments !== false && + \is_array($cachedDocuments)) { + $documents = \array_map(function ($doc) { + return new Document($doc); + }, $cachedDocuments); + $documentsCacheHit = true; + } else { + $documents = $find(); + + $documentsArray = \array_map(function ($doc) { + return $doc->getArrayCopy(); + }, $documents); + try { + $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); + } catch (\Throwable) { + } + } + + if ($includeTotal) { + $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL); + try { + $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); + } catch (\Throwable) { + $cachedTotal = null; + } + if ($cachedTotal !== null && $cachedTotal !== false) { + $total = $cachedTotal; + } else { + $total = $measure(fn () => $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT)); + try { + $dbForProject->getCache()->save($cacheKey, $total, $totalField); + } catch (\Throwable) { + } + } + } else { + $total = 0; + } + + $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); + } else { + $documents = $find(); + $total = $includeTotal ? $measure(fn () => $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT)) : 0; + } + } catch (OrderException $e) { + $documents = $this->isCollectionsAPI() ? 'documents' : 'rows'; + $attribute = $this->isCollectionsAPI() ? 'attribute' : 'column'; + $message = "The order $attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all $documents order $attribute values are non-null."; + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, $message); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } catch (Timeout) { + throw new Exception(Exception::DATABASE_TIMEOUT); + } + + $operations = 0; + $collectionsCache = []; + foreach ($documents as $document) { + $this->processDocument( + database: $database, + collection: $collection, + document: $document, + dbForProject: $dbForProject, + collectionsCache: $collectionsCache, + authorization: $authorization, + operations: $operations + ); + } + + $usage + ->addMetric($this->getDatabasesOperationReadMetric(), max($operations, 1)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), $operations); + + $response->dynamic(new Document([ + 'total' => $total, + $this->getSDKGroup() => $documents, + ]), $this->getResponseModel()); + + try { + $this->afterQuery($dbDurationMs, $database, $collection, $queries, $utopia); + } catch (\Throwable) { + // Observers must never break the response. + } + } + + /** + * Hook invoked after listRows completes the response. Under Swoole (the + * default transport) the client connection has already been closed by + * `$response->dynamic()`, so observers here do not delay the client. + * Under synchronous transports observers would run before the bytes + * reach the client — keep work here cheap regardless. + * + * Runs with the actual measured database duration (cache hits report + * near-zero). Intended to be overridden for observability (e.g., slow- + * query logging in downstream distributions). CE implementation is a + * no-op. + * + * The `$utopia` Http instance is passed so overrides can resolve + * additional resources (e.g., a downstream-specific logger) via + * `$utopia->getResource(...)` without needing to inject them here. + * + * @param array $queries parsed Query objects (pass directly to + * `Query::fingerprint()` if you need a + * shape hash) + */ + protected function afterQuery(float $dbDurationMs, Document $database, Document $collection, array $queries, ?Http $utopia): void + { + // no-op in CE + } } From 8de7b419298dfc57091e86c4b19bdd76a1842df5 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 11:06:07 +0530 Subject: [PATCH 084/402] updated --- src/Appwrite/Platform/Workers/Migrations.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 7a16b02642..ba88ee0c8e 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -198,14 +198,11 @@ class Migrations extends Action $useAppwriteApiSource = false; if ($credentials['projectId']) { $this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']); - if ($this->sourceProject->isEmpty()) { - if ($source === SourceAppwrite::getName()) { - $useAppwriteApiSource = true; - } - } else { + if (! $this->sourceProject->isEmpty()) { $sourceRegion = $this->sourceProject->getAttribute('region', 'default'); $destinationRegion = $this->project->getAttribute('region', 'default'); $useAppwriteApiSource = $source === SourceAppwrite::getName() + && $destination === DestinationAppwrite::getName() && $sourceRegion !== $destinationRegion; if (! $useAppwriteApiSource) { $projectDB = call_user_func($this->getProjectDB, $this->sourceProject); From 5ab42b8d3241ca7c7a45b6e11c0996f99cae3411 Mon Sep 17 00:00:00 2001 From: Atharva Deosthale Date: Mon, 20 Apr 2026 11:26:31 +0530 Subject: [PATCH 085/402] update composer lock --- composer.lock | 133 +++++++++++++++++++++++++------------------------- 1 file changed, 66 insertions(+), 67 deletions(-) diff --git a/composer.lock b/composer.lock index 56b838a0fe..d0d69bd0c5 100644 --- a/composer.lock +++ b/composer.lock @@ -2887,7 +2887,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.34.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -2948,7 +2948,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.34.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0" }, "funding": [ { @@ -2972,7 +2972,7 @@ }, { "name": "symfony/polyfill-php82", - "version": "v1.34.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", @@ -3028,7 +3028,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.34.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.36.0" }, "funding": [ { @@ -3052,7 +3052,7 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.34.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", @@ -3108,7 +3108,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.34.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.36.0" }, "funding": [ { @@ -3132,7 +3132,7 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.34.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", @@ -3188,7 +3188,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.34.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.36.0" }, "funding": [ { @@ -4271,16 +4271,16 @@ }, { "name": "utopia-php/http", - "version": "0.34.20", + "version": "0.34.21", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "d6b360d555022d16c16d40be51f86180364819f8" + "reference": "49a6bd3ea0d2966aa19cf707255d442675288a24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/d6b360d555022d16c16d40be51f86180364819f8", - "reference": "d6b360d555022d16c16d40be51f86180364819f8", + "url": "https://api.github.com/repos/utopia-php/http/zipball/49a6bd3ea0d2966aa19cf707255d442675288a24", + "reference": "49a6bd3ea0d2966aa19cf707255d442675288a24", "shasum": "" }, "require": { @@ -4319,22 +4319,22 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.34.20" + "source": "https://github.com/utopia-php/http/tree/0.34.21" }, - "time": "2026-04-12T14:25:22+00:00" + "time": "2026-04-19T19:44:04+00:00" }, { "name": "utopia-php/image", - "version": "0.8.5", + "version": "0.8.6", "source": { "type": "git", "url": "https://github.com/utopia-php/image.git", - "reference": "9af2fcff028a42550465e2ccad88e3b31c3584f3" + "reference": "85ab7027873e11bc901110d8f7830252247ba724" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/image/zipball/9af2fcff028a42550465e2ccad88e3b31c3584f3", - "reference": "9af2fcff028a42550465e2ccad88e3b31c3584f3", + "url": "https://api.github.com/repos/utopia-php/image/zipball/85ab7027873e11bc901110d8f7830252247ba724", + "reference": "85ab7027873e11bc901110d8f7830252247ba724", "shasum": "" }, "require": { @@ -4370,9 +4370,9 @@ ], "support": { "issues": "https://github.com/utopia-php/image/issues", - "source": "https://github.com/utopia-php/image/tree/0.8.5" + "source": "https://github.com/utopia-php/image/tree/0.8.6" }, - "time": "2026-04-17T15:02:49+00:00" + "time": "2026-04-19T12:52:59+00:00" }, { "name": "utopia-php/locale", @@ -5464,16 +5464,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.17.11", + "version": "1.20", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "c714ee52659ef5968b3372ff4da0e407140a6250" + "reference": "525f0630520c95100fcdfb63c9dac859c1d02588" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/c714ee52659ef5968b3372ff4da0e407140a6250", - "reference": "c714ee52659ef5968b3372ff4da0e407140a6250", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/525f0630520c95100fcdfb63c9dac859c1d02588", + "reference": "525f0630520c95100fcdfb63c9dac859c1d02588", "shasum": "" }, "require": { @@ -5509,9 +5509,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.17.11" + "source": "https://github.com/appwrite/sdk-generator/tree/1.20" }, - "time": "2026-04-11T02:42:32+00:00" + "time": "2026-04-20T05:45:00+00:00" }, { "name": "brianium/paratest", @@ -6220,11 +6220,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.46", + "version": "2.1.50", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", - "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", + "reference": "d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", "shasum": "" }, "require": { @@ -6269,20 +6269,20 @@ "type": "github" } ], - "time": "2026-04-01T09:25:14+00:00" + "time": "2026-04-17T13:10:32+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.3", + "version": "12.5.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" + "reference": "876099a072646c7745f673d7aeab5382c4439691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", + "reference": "876099a072646c7745f673d7aeab5382c4439691", "shasum": "" }, "require": { @@ -6291,7 +6291,6 @@ "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", "php": ">=8.3", - "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", "sebastian/environment": "^8.0.3", @@ -6338,7 +6337,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" }, "funding": [ { @@ -6358,7 +6357,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T06:01:44+00:00" + "time": "2026-04-15T08:23:17+00:00" }, { "name": "phpunit/php-file-iterator", @@ -6619,16 +6618,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.17", + "version": "12.5.23", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "85b62adab1a340982df64e66daa4a4435eb5723b" + "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/85b62adab1a340982df64e66daa4a4435eb5723b", - "reference": "85b62adab1a340982df64e66daa4a4435eb5723b", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", + "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", "shasum": "" }, "require": { @@ -6642,15 +6641,15 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-code-coverage": "^12.5.6", "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.4", + "sebastian/comparator": "^7.1.6", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.4", + "sebastian/environment": "^8.1.0", "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", @@ -6697,7 +6696,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.17" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.23" }, "funding": [ { @@ -6705,7 +6704,7 @@ "type": "other" } ], - "time": "2026-04-08T03:04:19+00:00" + "time": "2026-04-18T06:12:49+00:00" }, { "name": "sebastian/cli-parser", @@ -6778,16 +6777,16 @@ }, { "name": "sebastian/comparator", - "version": "7.1.5", + "version": "7.1.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63" + "reference": "c769009dee98f494e0edc3fd4f4087501688f11e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c284f55811f43d555e51e8e5c166ac40d3e33c63", - "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e", + "reference": "c769009dee98f494e0edc3fd4f4087501688f11e", "shasum": "" }, "require": { @@ -6846,7 +6845,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.5" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6" }, "funding": [ { @@ -6866,7 +6865,7 @@ "type": "tidelift" } ], - "time": "2026-04-08T04:43:00+00:00" + "time": "2026-04-14T08:23:15+00:00" }, { "name": "sebastian/complexity", @@ -6995,16 +6994,16 @@ }, { "name": "sebastian/environment", - "version": "8.0.4", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11" + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", - "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6", + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6", "shasum": "" }, "require": { @@ -7019,7 +7018,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -7047,7 +7046,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4" + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0" }, "funding": [ { @@ -7067,7 +7066,7 @@ "type": "tidelift" } ], - "time": "2026-03-15T07:05:40+00:00" + "time": "2026-04-15T12:13:01+00:00" }, { "name": "sebastian/exporter", @@ -7780,7 +7779,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.34.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -7839,7 +7838,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.34.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0" }, "funding": [ { @@ -7863,7 +7862,7 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.34.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", @@ -7921,7 +7920,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.34.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0" }, "funding": [ { @@ -7945,7 +7944,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.34.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -8006,7 +8005,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.34.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0" }, "funding": [ { @@ -8030,7 +8029,7 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.34.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -8086,7 +8085,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.34.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.36.0" }, "funding": [ { From a9fb17b0676be4cb2007dc8a5e2fdc03205c8eae Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 11:39:50 +0530 Subject: [PATCH 086/402] added telemetry for message arriaval delay --- app/realtime.php | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 5631a7f860..4f1a81b33a 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -394,6 +394,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, Console::success('Worker ' . $workerId . ' started successfully'); $telemetry = getTelemetry($workerId); + $realtimeDelayBuckets = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]; $register->set('telemetry', fn () => $telemetry); $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); @@ -401,7 +402,12 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram( name: 'realtime.server.delivery_delay', unit: 'ms', - advisory: ['ExplicitBucketBoundaries' => [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]], + advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets], + )); + $register->set('telemetry.arrivalDelayHistogram', fn () => $telemetry->createHistogram( + name: 'realtime.server.arrival_delay', + unit: 'ms', + advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets], )); $attempts = 0; @@ -519,6 +525,21 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $pubsub->subscribe(['realtime'], function (mixed $redis, string $channel, string $payload) use ($server, $workerId, $stats, $register, $realtime) { $event = json_decode($payload, true); + $eventTimestamp = $event['data']['timestamp'] ?? null; + if (\is_string($eventTimestamp)) { + try { + $eventDate = new \DateTimeImmutable($eventTimestamp); + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $eventTimestampMs = (float) $eventDate->format('U.u') * 1000; + $nowTimestampMs = (float) $now->format('U.u') * 1000; + $arrivalDelayMs = (int) \max(0, $nowTimestampMs - $eventTimestampMs); + + $register->get('telemetry.arrivalDelayHistogram')->record($arrivalDelayMs); + } catch (\Throwable) { + // Ignore invalid timestamp payloads. + } + } + if ($event['permissionsChanged'] && isset($event['userId'])) { $projectId = $event['project']; $userId = $event['userId']; From 2bff4192ee159365b6ede9fe5cc1f0b65542e0ac Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 11:45:05 +0530 Subject: [PATCH 087/402] added telemetry for the connected clients --- app/realtime.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/realtime.php b/app/realtime.php index 4f1a81b33a..96fb46b0b1 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -396,6 +396,8 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $telemetry = getTelemetry($workerId); $realtimeDelayBuckets = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]; $register->set('telemetry', fn () => $telemetry); + $register->set('telemetry.workerCounter', fn () => $telemetry->createUpDownCounter('realtime.server.active_workers')); + $register->set('telemetry.workerClientCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_clients')); $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); $register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent')); @@ -409,6 +411,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, unit: 'ms', advisory: ['ExplicitBucketBoundaries' => $realtimeDelayBuckets], )); + $register->get('telemetry.workerCounter')->add(1); $attempts = 0; $start = time(); @@ -661,6 +664,16 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, Console::error('Failed to restart pub/sub...'); }); +$server->onWorkerStop(function (int $workerId) use ($register) { + Console::warning('Worker ' . $workerId . ' stopping'); + + try { + $register->get('telemetry.workerCounter')->add(-1); + } catch (\Throwable $th) { + Console::error('Realtime onWorkerStop telemetry error: ' . $th->getMessage()); + } +}); + $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $registerConnectionResources) { global $container; $request = new Request($request); @@ -749,6 +762,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void { $register->get('telemetry.connectionCounter')->add(1); + $register->get('telemetry.workerClientCounter')->add(1); $register->get('telemetry.connectionCreatedCounter')->add(1); $stats->set($projectId, [ @@ -1161,6 +1175,7 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { if (array_key_exists($connection, $realtime->connections)) { $stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal'); $register->get('telemetry.connectionCounter')->add(-1); + $register->get('telemetry.workerClientCounter')->add(-1); $projectId = $realtime->connections[$connection]['projectId']; From 12f76d74b16876c05db57bbfb7fec9663f98db57 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 11:52:43 +0530 Subject: [PATCH 088/402] added subscriptions telemetry and worker labelling to connections and subscriptions --- app/realtime.php | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 96fb46b0b1..591294dfa9 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -395,9 +395,12 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $telemetry = getTelemetry($workerId); $realtimeDelayBuckets = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]; + $workerTelemetryAttributes = ['workerId' => (string) $workerId]; $register->set('telemetry', fn () => $telemetry); + $register->set('telemetry.workerAttributes', fn () => $workerTelemetryAttributes); $register->set('telemetry.workerCounter', fn () => $telemetry->createUpDownCounter('realtime.server.active_workers')); $register->set('telemetry.workerClientCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_clients')); + $register->set('telemetry.workerSubscriptionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.worker_subscriptions')); $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); $register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent')); @@ -549,6 +552,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, if ($realtime->hasSubscriber($projectId, 'user:' . $userId)) { $connection = array_key_first(reset($realtime->subscriptions[$projectId]['user:' . $userId])); + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); $consoleDatabase = getConsoleDB(); $project = $consoleDatabase->getAuthorization()->skip(fn () => $consoleDatabase->getDocument('projects', $projectId)); $database = getProjectDB($project); @@ -579,6 +583,12 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, if ($authorization !== null) { $realtime->connections[$connection]['authorization'] = $authorization; } + + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } } } @@ -762,7 +772,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void { $register->get('telemetry.connectionCounter')->add(1); - $register->get('telemetry.workerClientCounter')->add(1); + $register->get('telemetry.workerClientCounter')->add(1, $register->get('telemetry.workerAttributes')); $register->get('telemetry.connectionCreatedCounter')->add(1); $stats->set($projectId, [ @@ -794,6 +804,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ]); $realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId()); + $register->get('telemetry.workerSubscriptionCounter')->add(1, $register->get('telemetry.workerAttributes')); $realtime->connections[$connection]['authorization'] = $authorization; $server->send([$connection], $connectedPayloadJson); $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); @@ -827,6 +838,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $mapping[$index] = $subscriptionId; } + if (!empty($subscriptions)) { + $register->get('telemetry.workerSubscriptionCounter')->add(\count($subscriptions), $register->get('telemetry.workerAttributes')); + } $realtime->connections[$connection]['authorization'] = $authorization; @@ -881,7 +895,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, } }); -$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId) { +$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId, $register) { $project = null; $authorization = null; try { @@ -995,6 +1009,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $authorization = $realtime->connections[$connection]['authorization'] ?? null; $projectId = $realtime->connections[$connection]['projectId'] ?? null; + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); $meta = $realtime->getSubscriptionMetadata($connection); $realtime->unsubscribe($connection); @@ -1019,6 +1034,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $realtime->connections[$connection]['authorization'] = $authorization; } + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } + $user = $response->output($user, Response::MODEL_ACCOUNT); $authResponsePayloadJson = json_encode([ @@ -1063,6 +1084,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re // bulk validation + parsing before subscribing $parsedPayloads = []; + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); foreach ($message['data'] as $payload) { if (!\is_array($payload)) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each subscribe payload must be an object.'); @@ -1107,6 +1129,11 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re // subscribe() overwrites the connection entry; restore auth so later onMessage uses the same context. $realtime->connections[$connection]['authorization'] = $authorization; + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } $responsePayload = json_encode([ 'type' => 'response', @@ -1175,7 +1202,11 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { if (array_key_exists($connection, $realtime->connections)) { $stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal'); $register->get('telemetry.connectionCounter')->add(-1); - $register->get('telemetry.workerClientCounter')->add(-1); + $register->get('telemetry.workerClientCounter')->add(-1, $register->get('telemetry.workerAttributes')); + $subscriptionsBeforeClose = \count($realtime->getSubscriptionMetadata($connection)); + if ($subscriptionsBeforeClose > 0) { + $register->get('telemetry.workerSubscriptionCounter')->add(-$subscriptionsBeforeClose, $register->get('telemetry.workerAttributes')); + } $projectId = $realtime->connections[$connection]['projectId']; From 4b94d14f1eb9ad1b8b16dc8109830ccf33fcde45 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 12:13:53 +0530 Subject: [PATCH 089/402] updated time metric --- app/realtime.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 591294dfa9..4621c6543e 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -534,7 +534,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $eventTimestamp = $event['data']['timestamp'] ?? null; if (\is_string($eventTimestamp)) { try { - $eventDate = new \DateTimeImmutable($eventTimestamp); + $eventDate = new \DateTimeImmutable($eventTimestamp, new \DateTimeZone('UTC')); $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $eventTimestampMs = (float) $eventDate->format('U.u') * 1000; $nowTimestampMs = (float) $now->format('U.u') * 1000; @@ -634,7 +634,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $updatedAt = $event['data']['payload']['$updatedAt'] ?? null; if (\is_string($updatedAt)) { try { - $updatedAtDate = new \DateTimeImmutable($updatedAt); + $updatedAtDate = new \DateTimeImmutable($updatedAt, new \DateTimeZone('UTC')); $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000; $nowTimestampMs = (float) $now->format('U.u') * 1000; From 62f7f25cb58973478e12a06c2104f7a2f561c15f Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 12:18:38 +0530 Subject: [PATCH 090/402] updated --- src/Appwrite/Platform/Workers/Migrations.php | 26 ++++++++++++-------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index ba88ee0c8e..339084727d 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -196,17 +196,23 @@ class Migrations extends Action /** @var Database|null $projectDB */ $projectDB = null; $useAppwriteApiSource = false; - if ($credentials['projectId']) { + if ($source === SourceAppwrite::getName() && empty($credentials['projectId'])) { + throw new \Exception('Source projectId is required for Appwrite migrations'); + } + + if (! empty($credentials['projectId'])) { $this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']); - if (! $this->sourceProject->isEmpty()) { - $sourceRegion = $this->sourceProject->getAttribute('region', 'default'); - $destinationRegion = $this->project->getAttribute('region', 'default'); - $useAppwriteApiSource = $source === SourceAppwrite::getName() - && $destination === DestinationAppwrite::getName() - && $sourceRegion !== $destinationRegion; - if (! $useAppwriteApiSource) { - $projectDB = call_user_func($this->getProjectDB, $this->sourceProject); - } + if ($this->sourceProject->isEmpty()) { + throw new \Exception('Source project not found for provided projectId'); + } + + $sourceRegion = $this->sourceProject->getAttribute('region', 'default'); + $destinationRegion = $this->project->getAttribute('region', 'default'); + $useAppwriteApiSource = $source === SourceAppwrite::getName() + && $destination === DestinationAppwrite::getName() + && $sourceRegion !== $destinationRegion; + if (! $useAppwriteApiSource) { + $projectDB = call_user_func($this->getProjectDB, $this->sourceProject); } } $getDatabasesDB = fn (Document $database): Database => From eef443f07e0288ef710daba41fe525a8622b1635 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 20 Apr 2026 07:53:36 +0000 Subject: [PATCH 091/402] chore: bump utopia-php/database to 5.3.22 for Query::fingerprint --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 56b838a0fe..9538b6e874 100644 --- a/composer.lock +++ b/composer.lock @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.21", + "version": "5.3.22", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "ee2d7d4c87b3a3fae954089ad7494ceb454f619d" + "reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/ee2d7d4c87b3a3fae954089ad7494ceb454f619d", - "reference": "ee2d7d4c87b3a3fae954089ad7494ceb454f619d", + "url": "https://api.github.com/repos/utopia-php/database/zipball/d765945da6b3141852014b2f96ecf1fe7e3d6ba7", + "reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7", "shasum": "" }, "require": { @@ -3903,9 +3903,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.21" + "source": "https://github.com/utopia-php/database/tree/5.3.22" }, - "time": "2026-04-10T12:38:57+00:00" + "time": "2026-04-20T07:12:46+00:00" }, { "name": "utopia-php/detector", From c4d9c3dc4f984b1a2df06239bd4a17116a35caf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 11:28:21 +0200 Subject: [PATCH 092/402] Improve endpoint quality --- .../Http/Project/SMTP/Credentials/Update.php | 144 ---------------- .../Http/Project/SMTP/Status/Update.php | 74 --------- .../Project/Http/Project/SMTP/Update.php | 157 ++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 - .../Utopia/Response/Model/Project.php | 14 +- 5 files changed, 169 insertions(+), 222 deletions(-) delete mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php delete mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php deleted file mode 100644 index 9117554622..0000000000 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php +++ /dev/null @@ -1,144 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/smtp/credentials') - ->httpAlias('/v1/projects/:projectId/smtp') - ->desc('Update project SMTP details') - ->groups(['api', 'project']) - ->label('scope', 'project.write') - ->label('event', 'smtp.*.update') - ->label('audits.event', 'project.smtp.update') - ->label('audits.resource', 'project.smtp/{response.$id}') - ->label('sdk', new Method( - namespace: 'project', - group: 'smtp', - name: 'updateSMTPCredentials', - description: <<param('senderName', '', new Text(256), 'Name of the email sender') - ->param('senderEmail', '', new Email(), 'Email of the sender') - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->param('host', '', new Hostname(), 'SMTP server host name') - ->param('port', 587, new Integer(), 'SMTP server port') - ->param('username', '', new Text(256), 'SMTP server username', true) - ->param('password', '', new Text(256), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->param('enabled', null, new Nullable(new Boolean()), 'Enable custom SMTP service', optional: true, deprecated: true) // Backwards compatibility - ->inject('response') - ->inject('dbForPlatform') - ->inject('project') - ->inject('authorization') - ->callback($this->action(...)); - } - - - public function action( - string $senderName, - string $senderEmail, - string $replyTo, - string $host, - int $port, - string $username, - string $password, - string $secure, - ?bool $enabled, // Backwards compatibility - Response $response, - Database $dbForPlatform, - Document $project, - Authorization $authorization - ): void { - // Backwards compatibility - if (!\is_null($enabled) && $enabled === false) { - $smtp = $project->getAttribute('smtp', []); - - $smtp['enabled'] = $enabled; - - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); - - $response->dynamic($project, Response::MODEL_PROJECT); - - return; - } - - // Validate SMTP settings - $mail = new PHPMailer(true); - $mail->isSMTP(); - $mail->SMTPAuth = (!empty($username) && !empty($password)); - $mail->Username = $username; - $mail->Password = $password; - $mail->Host = $host; - $mail->Port = $port; - $mail->SMTPSecure = $secure; - $mail->SMTPAutoTLS = false; - $mail->Timeout = 5; - - try { - $valid = $mail->SmtpConnect(); - - if (!$valid) { - throw new \Exception('Connection is not valid.'); - } - } catch (Throwable $error) { - throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); - } - - $smtp = [ - 'enabled' => true, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'replyTo' => $replyTo, - 'host' => $host, - 'port' => $port, - 'username' => $username, - 'password' => $password, - 'secure' => $secure, - ]; - - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); - - $response->dynamic($project, Response::MODEL_PROJECT); - } -} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php deleted file mode 100644 index 0669f6344f..0000000000 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php +++ /dev/null @@ -1,74 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/smtp/status') - ->desc('Update project SMTP status') - ->groups(['api', 'project']) - ->label('scope', 'project.write') - ->label('event', 'smtp.*.update') - ->label('audits.event', 'project.smtp.update') - ->label('audits.resource', 'project.smtp/{response.$id}') - ->label('sdk', new Method( - namespace: 'project', - group: 'smtp', - name: 'updateSMTPStatus', - description: <<param('enabled', null, new Boolean(), 'SMTP status.') - ->inject('response') - ->inject('dbForPlatform') - ->inject('project') - ->inject('authorization') - ->callback($this->action(...)); - } - - public function action( - bool $enabled, - Response $response, - Database $dbForPlatform, - Document $project, - Authorization $authorization - ): void { - $smtp = $project->getAttribute('smtp', []); - - $smtp['enabled'] = $enabled; - - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); - - $response->dynamic($project, Response::MODEL_PROJECT); - } -} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php new file mode 100644 index 0000000000..3c25750959 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -0,0 +1,157 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/smtp') + ->httpAlias('/v1/projects/:projectId/smtp') + ->desc('Update project SMTP configuration') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'smtp.*.update') + ->label('audits.event', 'project.smtp.update') + ->label('audits.resource', 'project.smtp/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'smtp', + name: 'updateSMTP', + description: <<param('host', '', new Nullable(new Hostname()), 'SMTP server hostname (domain)', optional: true) + ->param('port', 587, new Nullable(new Integer()), 'SMTP server port', optional: true) + ->param('username', '', new Nullable(new Text(256)), 'SMTP server username. Leave empty for no authorization.', optional: true) + ->param('password', '', new Nullable(new Text(256)), 'SMTP server password. Leave empty for no authorization. This property is stored securely and cannot be read in future (write-only).', optional: true) + ->param('senderEmail', '', new Nullable(new Email()), 'Email address shown in inbox as the sender of the email.', optional: true) + ->param('senderName', '', new Nullable(new Text(256)), 'Name shown in inbox as the sender of the email.', optional: true) + ->param('replyToEmail', '', new Nullable(new Email()), 'Email used when user replies to the email.', optional: true) + ->param('replyToName', '', new Nullable(new Text(256)), 'Name used when user replies to the email.', optional: true) + ->param('secure', '', new Nullable(new WhiteList(['tls', 'ssl'], true)), 'Configures if communication with SMTP server is encrypted. Allowed values are: tls, ssl. Leave empty for no encryption.', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'Enable or disable custom SMTP. Custom SMTP is useful for branding purposes, but also allows use of custom email templates.', optional: true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + + public function action( + ?string $host, + ?int $port, + ?string $username, + ?string $password, + ?string $senderEmail, + ?string $senderName, + ?string $replyToEmail, + ?string $replyToName, + ?string $secure, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + // Fetch current configuration + $smtp = $project->getAttribute('smtp', []); + + // Apply changes + $keys = ['host', 'port', 'username', 'password', 'senderEmail', 'senderName', 'replyToEmail', 'replyToName', 'secure', 'enabled']; + foreach ($keys as $key) { + if (!\is_null(${$key})) { + $smtp[$key] = ${$key}; + } + } + + // Ensure required fields are set + $requiredKeys = ['host', 'port', 'senderEmail']; + foreach ($requiredKeys as $key) { + if (empty($smtp[$key])) { + throw new \Exception('"' . $key . '" is required. Please provide a value.'); + } + } + + // Validate SMTP credentials + if($smtp['enabled'] === true) { + $mail = new PHPMailer(true); + $mail->isSMTP(); + + $mail->Host = $smtp['host'] ?? ''; + $mail->Port = $smtp['port'] ?? ''; + $mail->SMTPSecure = $smtp['secure'] ?? ''; + $mail->setFrom($smtp['senderEmail'], $smtp['senderName'] ?? ''); + + if(!empty($smtp['username'] ?? '')) { + $mail->SMTPAuth = true; + $mail->Username = $smtp['username']; + $mail->Password = $smtp['password'] ?? ''; + } + + if(!empty($smtp['replyToEmail'] ?? '')) { + $mail->addReplyTo($smtp['replyToEmail'], $smtp['replyToName'] ?? ''); + } + + $mail->SMTPAutoTLS = false; + $mail->Timeout = 5; + + try { + $valid = $mail->SmtpConnect(); + + if (!$valid) { + throw new \Exception('Connection is not valid.'); + } + } catch (Throwable $error) { + throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); + } + } + + // Save configuration + $updates = new Document([ + 'smtp' => $smtp, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 4fea33220b..ac048c6022 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -25,7 +25,6 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatfo use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Credentials\Update as UpdateSMTPCredentials; -use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Status\Update as UpdateSMTPStatus; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; @@ -50,7 +49,6 @@ class Http extends Service // SMTP $this->addAction(UpdateSMTPCredentials::getName(), new UpdateSMTPCredentials()); - $this->addAction(UpdateSMTPStatus::getName(), new UpdateSMTPStatus()); $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); // Variables diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 4cb038fc37..e1909c4785 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -247,7 +247,13 @@ class Project extends Model 'default' => '', 'example' => 'john@appwrite.io', ]) - ->addRule('smtpReplyTo', [ + ->addRule('smtpReplyToName', [ + 'type' => self::TYPE_STRING, + 'description' => 'SMTP reply to name', + 'default' => '', + 'example' => 'Support Team', + ]) + ->addRule('smtpReplyToEmail', [ 'type' => self::TYPE_STRING, 'description' => 'SMTP reply to email', 'default' => '', @@ -271,12 +277,15 @@ class Project extends Model 'default' => '', 'example' => 'emailuser', ]) + /* + We intentionally do not expose SMTP password - it's write-only property. ->addRule('smtpPassword', [ 'type' => self::TYPE_STRING, 'description' => 'SMTP server password', 'default' => '', 'example' => 'securepassword', ]) + */ ->addRule('smtpSecure', [ 'type' => self::TYPE_STRING, 'description' => 'SMTP server secure protocol', @@ -409,7 +418,8 @@ class Project extends Model $document->setAttribute('smtpEnabled', $smtp['enabled'] ?? false); $document->setAttribute('smtpSenderEmail', $smtp['senderEmail'] ?? ''); $document->setAttribute('smtpSenderName', $smtp['senderName'] ?? ''); - $document->setAttribute('smtpReplyTo', $smtp['replyTo'] ?? ''); + $document->setAttribute('smtpReplyToEmail', $smtp['replyToEmail'] ?? ''); + $document->setAttribute('smtpReplyToName', $smtp['replyToName'] ?? ''); $document->setAttribute('smtpHost', $smtp['host'] ?? ''); $document->setAttribute('smtpPort', $smtp['port'] ?? ''); $document->setAttribute('smtpUsername', $smtp['username'] ?? ''); From 06a2d48debacc7fb4a6a928eeb48ee13ac6e5084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 11:29:13 +0200 Subject: [PATCH 093/402] Linter fix --- .../Project/Http/Project/SMTP/Update.php | 26 +++++++++---------- .../Modules/Project/Services/Http.php | 4 +-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 3c25750959..8e636fb2e2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -94,7 +94,7 @@ class Update extends Action ): void { // Fetch current configuration $smtp = $project->getAttribute('smtp', []); - + // Apply changes $keys = ['host', 'port', 'username', 'password', 'senderEmail', 'senderName', 'replyToEmail', 'replyToName', 'secure', 'enabled']; foreach ($keys as $key) { @@ -102,7 +102,7 @@ class Update extends Action $smtp[$key] = ${$key}; } } - + // Ensure required fields are set $requiredKeys = ['host', 'port', 'senderEmail']; foreach ($requiredKeys as $key) { @@ -110,33 +110,33 @@ class Update extends Action throw new \Exception('"' . $key . '" is required. Please provide a value.'); } } - + // Validate SMTP credentials - if($smtp['enabled'] === true) { + if ($smtp['enabled'] === true) { $mail = new PHPMailer(true); $mail->isSMTP(); - + $mail->Host = $smtp['host'] ?? ''; $mail->Port = $smtp['port'] ?? ''; $mail->SMTPSecure = $smtp['secure'] ?? ''; $mail->setFrom($smtp['senderEmail'], $smtp['senderName'] ?? ''); - - if(!empty($smtp['username'] ?? '')) { + + if (!empty($smtp['username'] ?? '')) { $mail->SMTPAuth = true; $mail->Username = $smtp['username']; $mail->Password = $smtp['password'] ?? ''; } - - if(!empty($smtp['replyToEmail'] ?? '')) { + + if (!empty($smtp['replyToEmail'] ?? '')) { $mail->addReplyTo($smtp['replyToEmail'], $smtp['replyToName'] ?? ''); } - + $mail->SMTPAutoTLS = false; $mail->Timeout = 5; - + try { $valid = $mail->SmtpConnect(); - + if (!$valid) { throw new \Exception('Connection is not valid.'); } @@ -149,7 +149,7 @@ class Update extends Action $updates = new Document([ 'smtp' => $smtp, ]); - + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); $response->dynamic($project, Response::MODEL_PROJECT); diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index ac048c6022..f768fb31be 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -24,7 +24,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as U use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; -use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Credentials\Update as UpdateSMTPCredentials; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; @@ -48,7 +48,7 @@ class Http extends Service $this->addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); // SMTP - $this->addAction(UpdateSMTPCredentials::getName(), new UpdateSMTPCredentials()); + $this->addAction(UpdateSMTP::getName(), new UpdateSMTP()); $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); // Variables From bc592903dbe80932ded01d1841b2afd5cbc84395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 11:47:06 +0200 Subject: [PATCH 094/402] Support reply to name --- app/controllers/api/account.php | 81 +++++++++++++------ app/controllers/api/projects.php | 18 +++-- src/Appwrite/Bus/Listeners/Mails.php | 3 +- src/Appwrite/Event/Mail.php | 36 +++++++-- .../Http/Account/MFA/Challenges/Create.php | 20 +++-- .../Http/Project/SMTP/Tests/Create.php | 12 ++- .../Modules/Teams/Http/Memberships/Create.php | 20 +++-- src/Appwrite/Platform/Workers/Mails.php | 4 +- .../Utopia/Response/Model/TemplateEmail.php | 8 +- tests/e2e/Services/Project/SMTPBase.php | 33 +++++--- 10 files changed, 166 insertions(+), 69 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 0035778523..3f2a5c369b 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2295,8 +2295,8 @@ Http::post('/v1/account/tokens/magic-url') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -2305,8 +2305,11 @@ Http::post('/v1/account/tokens/magic-url') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (!empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -2323,8 +2326,11 @@ Http::post('/v1/account/tokens/magic-url') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (!empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -2332,7 +2338,8 @@ Http::post('/v1/account/tokens/magic-url') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -2611,7 +2618,8 @@ Http::post('/v1/account/tokens/email') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -2620,8 +2628,11 @@ Http::post('/v1/account/tokens/email') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (!empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -2638,8 +2649,11 @@ Http::post('/v1/account/tokens/email') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (!empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -2647,7 +2661,8 @@ Http::post('/v1/account/tokens/email') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -3743,7 +3758,8 @@ Http::post('/v1/account/recovery') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -3752,8 +3768,11 @@ Http::post('/v1/account/recovery') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (!empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -3770,8 +3789,11 @@ Http::post('/v1/account/recovery') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (!empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -3779,7 +3801,8 @@ Http::post('/v1/account/recovery') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -4060,7 +4083,8 @@ Http::post('/v1/account/verifications/email') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -4069,8 +4093,11 @@ Http::post('/v1/account/verifications/email') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (!empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -4087,8 +4114,11 @@ Http::post('/v1/account/verifications/email') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (!empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -4096,7 +4126,8 @@ Http::post('/v1/account/verifications/email') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index e964933686..fae95b78a3 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -772,7 +772,9 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') 'message' => $message, 'subject' => $localeObj->getText('emails.' . $type . '.subject'), 'senderEmail' => '', - 'senderName' => '' + 'senderName' => '', + 'replyToEmail' => '', + 'replyToName' => '' ]; } @@ -873,10 +875,11 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->param('message', '', new Text(0), 'Template message') ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) + ->param('replyToEmail', '', new Email(), 'Reply to email', true) + ->param('replyToName', '', new Text(255, 0), 'Reply to name', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyToEmail, string $replyToName, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -889,7 +892,8 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') 'senderName' => $senderName, 'senderEmail' => $senderEmail, 'subject' => $subject, - 'replyTo' => $replyTo, + 'replyToEmail' => $replyToEmail, + 'replyToName' => $replyToName, 'message' => $message ]; @@ -901,7 +905,8 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') 'senderName' => $senderName, 'senderEmail' => $senderEmail, 'subject' => $subject, - 'replyTo' => $replyTo, + 'replyToEmail' => $replyToEmail, + 'replyToName' => $replyToName, 'message' => $message ]), Response::MODEL_EMAIL_TEMPLATE); }); @@ -1026,7 +1031,8 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], + 'replyToEmail' => $template['replyToEmail'] ?? '', + 'replyToName' => $template['replyToName'] ?? '', 'message' => $template['message'] ]), Response::MODEL_EMAIL_TEMPLATE); }); diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index 2ffcbc9aa4..54754f317c 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -131,7 +131,8 @@ class Mails extends Listener ->setSmtpUsername($smtp['username'] ?? '') ->setSmtpPassword($smtp['password'] ?? '') ->setSmtpSecure($smtp['secure'] ?? '') - ->setSmtpReplyTo($customTemplate['replyTo'] ?? $smtp['replyTo'] ?? '') + ->setSmtpReplyToEmail($customTemplate['replyToEmail'] ?? $smtp['replyToEmail'] ?? '') + ->setSmtpReplyToName($customTemplate['replyToName'] ?? $smtp['replyToName'] ?? '') ->setSmtpSenderEmail($customTemplate['senderEmail'] ?? $smtp['senderEmail'] ?? System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM)) ->setSmtpSenderName($customTemplate['senderName'] ?? $smtp['senderName'] ?? System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server')); } diff --git a/src/Appwrite/Event/Mail.php b/src/Appwrite/Event/Mail.php index d8f25489c6..0685586c60 100644 --- a/src/Appwrite/Event/Mail.php +++ b/src/Appwrite/Event/Mail.php @@ -251,14 +251,26 @@ class Mail extends Event } /** - * Set SMTP reply to + * Set SMTP reply-to email * - * @param string $replyTo + * @param string $email * @return self */ - public function setSmtpReplyTo(string $replyTo): self + public function setSmtpReplyToEmail(string $email): self { - $this->smtp['replyTo'] = $replyTo; + $this->smtp['replyToEmail'] = $email; + return $this; + } + + /** + * Set SMTP reply-to name + * + * @param string $name + * @return self + */ + public function setSmtpReplyToName(string $name): self + { + $this->smtp['replyToName'] = $name; return $this; } @@ -333,13 +345,23 @@ class Mail extends Event } /** - * Get SMTP reply to + * Get SMTP reply-to email * * @return string */ - public function getSmtpReplyTo(): string + public function getSmtpReplyToEmail(): string { - return $this->smtp['replyTo'] ?? ''; + return $this->smtp['replyToEmail'] ?? ''; + } + + /** + * Get SMTP reply-to name + * + * @return string + */ + public function getSmtpReplyToName(): string + { + return $this->smtp['replyToName'] ?? ''; } /** diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 20a6afed2e..1a0006de64 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -253,7 +253,8 @@ class Create extends Action $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -262,8 +263,11 @@ class Create extends Action if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (!empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -280,8 +284,11 @@ class Create extends Action if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (!empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -289,7 +296,8 @@ class Create extends Action } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php index ec6bc87717..d60cb08ffd 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -104,7 +104,8 @@ class Create extends Action $senderName = $paramSenderName ?: ($smtp['senderName'] ?? ''); $senderEmail = $paramSenderEmail ?: ($smtp['senderEmail'] ?? ''); - $replyTo = $paramReplyTo ?: ($smtp['replyTo'] ?? ''); + $replyToEmail = $paramReplyTo ?: ($smtp['replyToEmail'] ?? ''); + $replyToName = $smtp['replyToName'] ?? ''; $host = $paramHost ?: ($smtp['host'] ?? ''); $port = $paramPort ?? ($smtp['port'] ?? ''); $username = $paramUsername ?: ($smtp['username'] ?? ''); @@ -127,13 +128,15 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP port must be configured on the project to send a test email.'); } - $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail; + // Fallback to sender details when reply-to is not explicitly configured + $replyToEmailDisplay = !empty($replyToEmail) ? $replyToEmail : $senderEmail; + $replyToNameDisplay = !empty($replyToName) ? $replyToName : $senderName; $subject = 'Custom SMTP email sample'; $template = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-smtp-test.tpl'); $template ->setParam('{{from}}', "{$senderName} ({$senderEmail})") - ->setParam('{{replyTo}}', "{$senderName} ({$replyToEmail})") + ->setParam('{{replyTo}}', "{$replyToNameDisplay} ({$replyToEmailDisplay})") ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL) ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR) ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER) @@ -149,7 +152,8 @@ class Create extends Action ->setSmtpUsername($username) ->setSmtpPassword($password) ->setSmtpSecure($secure) - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName) ->setRecipient($email) diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 5edc69f445..4d13f125d8 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -341,7 +341,8 @@ class Create extends Action $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ''; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (! empty($smtp['senderEmail'])) { @@ -350,8 +351,11 @@ class Create extends Action if (! empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (! empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (! empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (! empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -368,8 +372,11 @@ class Create extends Action if (! empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (! empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (! empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (! empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -377,7 +384,8 @@ class Create extends Action } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index 32de1e50d6..3ddbcf482e 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -173,8 +173,8 @@ class Mails extends Action $replyTo = $customMailOptions['replyToEmail'] ?? $replyTo; $replyToName = $customMailOptions['replyToName'] ?? $replyToName; } elseif (!empty($smtp)) { - $replyTo = !empty($smtp['replyTo']) ? $smtp['replyTo'] : ($smtp['senderEmail'] ?? $replyTo); - $replyToName = $smtp['senderName'] ?? $replyToName; + $replyTo = !empty($smtp['replyToEmail']) ? $smtp['replyToEmail'] : ($smtp['senderEmail'] ?? $replyTo); + $replyToName = !empty($smtp['replyToName']) ? $smtp['replyToName'] : ($smtp['senderName'] ?? $replyToName); } $attachments = null; diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index ecdf89e774..626a9fa368 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -22,12 +22,18 @@ class TemplateEmail extends Template 'default' => '', 'example' => 'mail@appwrite.io', ]) - ->addRule('replyTo', [ + ->addRule('replyToEmail', [ 'type' => self::TYPE_STRING, 'description' => 'Reply to email address', 'default' => '', 'example' => 'emails@appwrite.io', ]) + ->addRule('replyToName', [ + 'type' => self::TYPE_STRING, + 'description' => 'Reply to name', + 'default' => '', + 'example' => 'Support Team', + ]) ->addRule('subject', [ 'type' => self::TYPE_STRING, 'description' => 'Email subject', diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 51e2e5e8a9..887ed864ba 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -67,7 +67,8 @@ trait SMTPBase $this->assertArrayHasKey('smtpEnabled', $response['body']); $this->assertArrayHasKey('smtpSenderName', $response['body']); $this->assertArrayHasKey('smtpSenderEmail', $response['body']); - $this->assertArrayHasKey('smtpReplyTo', $response['body']); + $this->assertArrayHasKey('smtpReplyToEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyToName', $response['body']); $this->assertArrayHasKey('smtpHost', $response['body']); $this->assertArrayHasKey('smtpPort', $response['body']); $this->assertArrayHasKey('smtpUsername', $response['body']); @@ -115,14 +116,16 @@ trait SMTPBase senderEmail: 'sender@example.com', host: 'maildev', port: 1025, - replyTo: 'reply@example.com', + replyToEmail: 'reply@example.com', + replyToName: 'Full Reply', ); $this->assertSame(200, $response['headers']['status-code']); $this->assertSame(true, $response['body']['smtpEnabled']); $this->assertSame('Full Sender', $response['body']['smtpSenderName']); $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); - $this->assertSame('reply@example.com', $response['body']['smtpReplyTo']); + $this->assertSame('reply@example.com', $response['body']['smtpReplyToEmail']); + $this->assertSame('Full Reply', $response['body']['smtpReplyToName']); $this->assertSame('maildev', $response['body']['smtpHost']); $this->assertSame(1025, $response['body']['smtpPort']); @@ -188,7 +191,8 @@ trait SMTPBase $this->assertArrayHasKey('smtpEnabled', $response['body']); $this->assertArrayHasKey('smtpSenderName', $response['body']); $this->assertArrayHasKey('smtpSenderEmail', $response['body']); - $this->assertArrayHasKey('smtpReplyTo', $response['body']); + $this->assertArrayHasKey('smtpReplyToEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyToName', $response['body']); $this->assertArrayHasKey('smtpHost', $response['body']); $this->assertArrayHasKey('smtpPort', $response['body']); $this->assertArrayHasKey('smtpUsername', $response['body']); @@ -279,7 +283,7 @@ trait SMTPBase senderEmail: 'sender@example.com', host: 'maildev', port: 1025, - replyTo: 'not-an-email', + replyToEmail: 'not-an-email', ); $this->assertSame(400, $response['headers']['status-code']); @@ -664,15 +668,17 @@ trait SMTPBase $senderName = 'SMTP Test Sender'; $senderEmail = 'smtptest@appwrite.io'; $replyToEmail = 'smtpreply@appwrite.io'; + $replyToName = 'SMTP Reply Team'; $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; - // Configure SMTP with replyTo and auth credentials + // Configure SMTP with reply-to and auth credentials $response = $this->updateSMTPCredentials( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', port: 1025, - replyTo: $replyToEmail, + replyToEmail: $replyToEmail, + replyToName: $replyToName, username: 'user', password: 'password', ); @@ -693,7 +699,7 @@ trait SMTPBase $this->assertSame($senderEmail, $email['from'][0]['address']); $this->assertSame($senderName, $email['from'][0]['name']); $this->assertSame($replyToEmail, $email['replyTo'][0]['address']); - $this->assertSame($senderName, $email['replyTo'][0]['name']); + $this->assertSame($replyToName, $email['replyTo'][0]['name']); $this->assertSame('Custom SMTP email sample', $email['subject']); $this->assertStringContainsStringIgnoringCase('working correctly', $email['text']); $this->assertStringContainsStringIgnoringCase('working correctly', $email['html']); @@ -769,7 +775,8 @@ trait SMTPBase string $senderEmail = '', string $host = '', int $port = 587, - ?string $replyTo = null, + ?string $replyToEmail = null, + ?string $replyToName = null, ?string $username = null, ?string $password = null, ?string $secure = null, @@ -792,8 +799,12 @@ trait SMTPBase 'port' => $port, ]; - if (!\is_null($replyTo)) { - $params['replyTo'] = $replyTo; + if (!\is_null($replyToEmail)) { + $params['replyToEmail'] = $replyToEmail; + } + + if (!\is_null($replyToName)) { + $params['replyToName'] = $replyToName; } if (!\is_null($username)) { From 56385ce167cdb03a1979668d1d141b02558f54ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 11:48:45 +0200 Subject: [PATCH 095/402] Backwards compatibility --- .../Platform/Modules/Project/Http/Project/SMTP/Update.php | 3 +++ src/Appwrite/Utopia/Response/Model/Project.php | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 8e636fb2e2..97295aa7b5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -102,6 +102,9 @@ class Update extends Action $smtp[$key] = ${$key}; } } + + // Backwards compatibility + $smtp['replyToEmail'] = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; // Ensure required fields are set $requiredKeys = ['host', 'port', 'senderEmail']; diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index e1909c4785..aef4927ad7 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -418,7 +418,7 @@ class Project extends Model $document->setAttribute('smtpEnabled', $smtp['enabled'] ?? false); $document->setAttribute('smtpSenderEmail', $smtp['senderEmail'] ?? ''); $document->setAttribute('smtpSenderName', $smtp['senderName'] ?? ''); - $document->setAttribute('smtpReplyToEmail', $smtp['replyToEmail'] ?? ''); + $document->setAttribute('smtpReplyToEmail', $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''); // Includes backwards compatibility $document->setAttribute('smtpReplyToName', $smtp['replyToName'] ?? ''); $document->setAttribute('smtpHost', $smtp['host'] ?? ''); $document->setAttribute('smtpPort', $smtp['port'] ?? ''); From f040a4dc319f90c49b689ea9ca96b9b8624b4be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 11:58:55 +0200 Subject: [PATCH 096/402] More backwards compatibility --- app/controllers/api/account.php | 78 +++++++++++-------- app/controllers/api/projects.php | 8 +- src/Appwrite/Bus/Listeners/Mails.php | 2 +- .../Http/Account/MFA/Challenges/Create.php | 12 ++- .../Http/Project/SMTP/Tests/Create.php | 2 +- .../Modules/Teams/Http/Memberships/Create.php | 12 ++- src/Appwrite/Platform/Workers/Mails.php | 4 +- 7 files changed, 75 insertions(+), 43 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 3f2a5c369b..fdb6137b9c 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2305,8 +2305,10 @@ Http::post('/v1/account/tokens/magic-url') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; } if (!empty($smtp['replyToName'])) { $replyToName = $smtp['replyToName']; @@ -2326,8 +2328,10 @@ Http::post('/v1/account/tokens/magic-url') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; } if (!empty($customTemplate['replyToName'])) { $replyToName = $customTemplate['replyToName']; @@ -2618,8 +2622,8 @@ Http::post('/v1/account/tokens/email') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyToEmail = ''; - $replyToName = ''; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -2628,9 +2632,11 @@ Http::post('/v1/account/tokens/email') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; - } + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } if (!empty($smtp['replyToName'])) { $replyToName = $smtp['replyToName']; } @@ -2649,9 +2655,11 @@ Http::post('/v1/account/tokens/email') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; - } + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } if (!empty($customTemplate['replyToName'])) { $replyToName = $customTemplate['replyToName']; } @@ -2661,7 +2669,7 @@ Http::post('/v1/account/tokens/email') } $queueForMails - ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToEmail($replyToEmail) ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); @@ -3758,8 +3766,8 @@ Http::post('/v1/account/recovery') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyToEmail = ''; - $replyToName = ''; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -3768,9 +3776,11 @@ Http::post('/v1/account/recovery') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; - } + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } if (!empty($smtp['replyToName'])) { $replyToName = $smtp['replyToName']; } @@ -3789,9 +3799,11 @@ Http::post('/v1/account/recovery') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; - } + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } if (!empty($customTemplate['replyToName'])) { $replyToName = $customTemplate['replyToName']; } @@ -3801,7 +3813,7 @@ Http::post('/v1/account/recovery') } $queueForMails - ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToEmail($replyToEmail) ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); @@ -4083,8 +4095,8 @@ Http::post('/v1/account/verifications/email') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyToEmail = ''; - $replyToName = ''; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -4093,9 +4105,11 @@ Http::post('/v1/account/verifications/email') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; - } + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } if (!empty($smtp['replyToName'])) { $replyToName = $smtp['replyToName']; } @@ -4114,9 +4128,11 @@ Http::post('/v1/account/verifications/email') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; - } + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } if (!empty($customTemplate['replyToName'])) { $replyToName = $customTemplate['replyToName']; } @@ -4126,7 +4142,7 @@ Http::post('/v1/account/verifications/email') } $queueForMails - ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToEmail($replyToEmail) ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index fae95b78a3..9f5cedac9f 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -716,6 +716,12 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $type . '-' . $locale] ?? null; + // Includes backwards compatibility: fall back to legacy `replyTo` key + if (!is_null($template)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + $template['replyToName'] = $template['replyToName'] ?? ''; + } + $localeObj = new Locale($locale); $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); @@ -1031,7 +1037,7 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], - 'replyToEmail' => $template['replyToEmail'] ?? '', + 'replyToEmail' => $template['replyToEmail'] ?? $template['replyTo'] ?? '', // Includes backwards compatibility 'replyToName' => $template['replyToName'] ?? '', 'message' => $template['message'] ]), Response::MODEL_EMAIL_TEMPLATE); diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index 54754f317c..68dbe1d89b 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -131,7 +131,7 @@ class Mails extends Listener ->setSmtpUsername($smtp['username'] ?? '') ->setSmtpPassword($smtp['password'] ?? '') ->setSmtpSecure($smtp['secure'] ?? '') - ->setSmtpReplyToEmail($customTemplate['replyToEmail'] ?? $smtp['replyToEmail'] ?? '') + ->setSmtpReplyToEmail($customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '') // Includes backwards compatibility ->setSmtpReplyToName($customTemplate['replyToName'] ?? $smtp['replyToName'] ?? '') ->setSmtpSenderEmail($customTemplate['senderEmail'] ?? $smtp['senderEmail'] ?? System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM)) ->setSmtpSenderName($customTemplate['senderName'] ?? $smtp['senderName'] ?? System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server')); diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 1a0006de64..b53c19daa6 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -263,8 +263,10 @@ class Create extends Action if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; } if (!empty($smtp['replyToName'])) { $replyToName = $smtp['replyToName']; @@ -284,8 +286,10 @@ class Create extends Action if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; } if (!empty($customTemplate['replyToName'])) { $replyToName = $customTemplate['replyToName']; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php index d60cb08ffd..b61147aef6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -104,7 +104,7 @@ class Create extends Action $senderName = $paramSenderName ?: ($smtp['senderName'] ?? ''); $senderEmail = $paramSenderEmail ?: ($smtp['senderEmail'] ?? ''); - $replyToEmail = $paramReplyTo ?: ($smtp['replyToEmail'] ?? ''); + $replyToEmail = $paramReplyTo ?: ($smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''); // Includes backwards compatibility $replyToName = $smtp['replyToName'] ?? ''; $host = $paramHost ?: ($smtp['host'] ?? ''); $port = $paramPort ?? ($smtp['port'] ?? ''); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 4d13f125d8..b2bec33221 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -351,8 +351,10 @@ class Create extends Action if (! empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (! empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (! empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; } if (! empty($smtp['replyToName'])) { $replyToName = $smtp['replyToName']; @@ -372,8 +374,10 @@ class Create extends Action if (! empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (! empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (! empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; } if (! empty($customTemplate['replyToName'])) { $replyToName = $customTemplate['replyToName']; diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index 3ddbcf482e..04c588b2c8 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -173,7 +173,9 @@ class Mails extends Action $replyTo = $customMailOptions['replyToEmail'] ?? $replyTo; $replyToName = $customMailOptions['replyToName'] ?? $replyToName; } elseif (!empty($smtp)) { - $replyTo = !empty($smtp['replyToEmail']) ? $smtp['replyToEmail'] : ($smtp['senderEmail'] ?? $replyTo); + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + $replyTo = !empty($smtpReplyToEmail) ? $smtpReplyToEmail : ($smtp['senderEmail'] ?? $replyTo); $replyToName = !empty($smtp['replyToName']) ? $smtp['replyToName'] : ($smtp['senderName'] ?? $replyToName); } From 78ef52cc9e45a37e84ca61e4e34b76d0adc99bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 13:11:11 +0200 Subject: [PATCH 097/402] Manual QA fixes --- docker-compose.yml | 2 +- .../Modules/Project/Http/Project/SMTP/Tests/Create.php | 3 --- .../Modules/Project/Http/Project/SMTP/Update.php | 10 ++++++++-- .../Platform/Modules/Project/Services/Http.php | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index aa2bfdd16a..14f00205ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -254,7 +254,7 @@ services: appwrite-console: <<: *x-logging container_name: appwrite-console - image: appwrite/console:7.8.26 + image: appwrite/console:7.8.45 restart: unless-stopped networks: - appwrite diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php index b61147aef6..a41292466c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -40,9 +40,6 @@ class Create extends Action ->desc('Create project SMTP test') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'smtp.*.update') - ->label('audits.event', 'project.smtp.update') - ->label('audits.resource', 'project.smtp/{response.$id}') ->label('sdk', new Method( namespace: 'project', group: 'smtp', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 97295aa7b5..58360058da 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -102,7 +102,7 @@ class Update extends Action $smtp[$key] = ${$key}; } } - + // Backwards compatibility $smtp['replyToEmail'] = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; @@ -110,7 +110,7 @@ class Update extends Action $requiredKeys = ['host', 'port', 'senderEmail']; foreach ($requiredKeys as $key) { if (empty($smtp[$key])) { - throw new \Exception('"' . $key . '" is required. Please provide a value.'); + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, '"' . $key . '" is required. Please provide a value.'); } } @@ -143,6 +143,12 @@ class Update extends Action if (!$valid) { throw new \Exception('Connection is not valid.'); } + + // Auto-enable if configuration is valid + // Dont do this if specifically request to mark disabled + if(\is_null($enabled)) { + $smtp['enabled'] = true; + } } catch (Throwable $error) { throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); } diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index f768fb31be..e9e30f7590 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -24,8 +24,8 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as U use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; -use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; From 2097b0a0b0d7a5f968e72baec6769c4be617d14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 14:04:53 +0200 Subject: [PATCH 098/402] Better support for post-smtp changes --- .../Http/Project/Templates/Email/Delete.php | 20 +++++++++++++------ .../Http/Project/Templates/Email/Get.php | 8 ++++++++ .../Http/Project/Templates/Email/Update.php | 12 +++++++---- .../Utopia/Response/Model/TemplateEmail.php | 8 +++++++- tests/e2e/Services/Project/TemplatesBase.php | 12 +++++++---- 5 files changed, 45 insertions(+), 15 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php index 176e8d7d63..7928486192 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php @@ -5,7 +5,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Templates\Email; use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; -use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; @@ -50,11 +49,10 @@ class Delete extends Action auth: [AuthType::ADMIN, AuthType::KEY], responses: [ new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, + code: Response::STATUS_CODE_OK, + model: Response::MODEL_EMAIL_TEMPLATE, ) - ], - contentType: ContentType::NONE + ] )) ->param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) @@ -96,6 +94,16 @@ class Delete extends Action $queueForEvents->setParam('templateType', $templateId); - $response->noContent(); + $response->dynamic(new Document([ + 'templateId' => $templateId, + 'locale' => $locale, + 'senderName' => $template['senderName'] ?? '', + 'senderEmail' => $template['senderEmail'] ?? '', + 'subject' => $template['subject'] ?? '', + 'replyToEmail' => $template['replyToEmail'] ?? $template['replyTo'] ?? '', // Includes backwards compatibility + 'replyToName' => $template['replyToName'] ?? '', + 'message' => $template['message'] ?? '', + 'custom' => true, + ]), Response::MODEL_EMAIL_TEMPLATE); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 115b10f7dd..6e2b2ef56d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -67,6 +67,12 @@ class Get extends Action $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $templateId . '-' . $locale] ?? null; + // Includes backwards compatibility: fall back to legacy `replyTo` key + if (!is_null($template)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + $template['replyToName'] = $template['replyToName'] ?? ''; + } + $localeObj = new Locale($locale); $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); @@ -124,6 +130,8 @@ class Get extends Action 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'), 'senderEmail' => '', 'senderName' => '', + 'replyToEmail' => '', + 'replyToName' => '', 'custom' => false, ]; } else { diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index f17be381f6..93cfa7a3fb 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -61,7 +61,8 @@ class Update extends Action ->param('message', '', new Text(10485760), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') ->param('senderName', '', new Text(255, 0), 'Name of the email sender.', true) ->param('senderEmail', '', new Email(), 'Email of the sender.', true) - ->param('replyTo', '', new Email(), 'Reply to email.', true) + ->param('replyToEmail', '', new Email(), 'Reply to email.', true) + ->param('replyToName', '', new Text(255, 0), 'Reply to name.', true) ->inject('response') ->inject('queueForEvents') ->inject('dbForPlatform') @@ -78,7 +79,8 @@ class Update extends Action string $message, string $senderName, string $senderEmail, - string $replyTo, + string $replyToEmail, + string $replyToName, Response $response, QueueEvent $queueForEvents, Database $dbForPlatform, @@ -92,7 +94,8 @@ class Update extends Action 'senderName' => $senderName, 'senderEmail' => $senderEmail, 'subject' => $subject, - 'replyTo' => $replyTo, + 'replyToEmail' => $replyToEmail, + 'replyToName' => $replyToName, 'message' => $message ]; @@ -113,7 +116,8 @@ class Update extends Action 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], + 'replyToEmail' => $template['replyToEmail'], + 'replyToName' => $template['replyToName'], 'message' => $template['message'], 'custom' => true, ]), Response::MODEL_EMAIL_TEMPLATE); diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index 9b77617b8c..48cd0ba556 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -40,12 +40,18 @@ class TemplateEmail extends Model 'default' => '', 'example' => 'mail@appwrite.io', ]) - ->addRule('replyTo', [ + ->addRule('replyToEmail', [ 'type' => self::TYPE_STRING, 'description' => 'Reply to email address', 'default' => '', 'example' => 'emails@appwrite.io', ]) + ->addRule('replyToName', [ + 'type' => self::TYPE_STRING, + 'description' => 'Reply to name', + 'default' => '', + 'example' => 'My User', + ]) ->addRule('subject', [ 'type' => self::TYPE_STRING, 'description' => 'Email subject', diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 4f78992079..422f890785 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -119,7 +119,7 @@ trait TemplatesBase $this->assertSame('You have been invited', $update['body']['message']); $this->assertSame('Appwrite Team', $update['body']['senderName']); $this->assertSame('team@appwrite.io', $update['body']['senderEmail']); - $this->assertSame('reply@appwrite.io', $update['body']['replyTo']); + $this->assertSame('reply@appwrite.io', $update['body']['replyToEmail']); // Cleanup $this->deleteEmailTemplate('invitation', 'en'); @@ -464,7 +464,8 @@ trait TemplatesBase ?string $message, ?string $senderName = null, ?string $senderEmail = null, - ?string $replyTo = null, + ?string $replyToEmail = null, + ?string $replyToName = null, bool $authenticated = true, ): mixed { $params = [ @@ -486,8 +487,11 @@ trait TemplatesBase if ($senderEmail !== null) { $params['senderEmail'] = $senderEmail; } - if ($replyTo !== null) { - $params['replyTo'] = $replyTo; + if ($replyToEmail !== null) { + $params['replyToEmail'] = $replyToEmail; + } + if ($replyToName !== null) { + $params['replyToName'] = $replyToName; } $headers = [ From e9ea39a822d0cc7cba91b130cadfd03342d47bd3 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 17:37:45 +0530 Subject: [PATCH 099/402] Enhance Realtime adapter: support union of channels/roles on subscription and add unsubscribeSubscription method --- src/Appwrite/Messaging/Adapter/Realtime.php | 101 ++++++++++++++++++-- 1 file changed, 95 insertions(+), 6 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index f1d806bcc5..3dd0cda328 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -114,14 +114,24 @@ class Realtime extends MessagingAdapter } } - // Keep userId from onOpen/authentication when provided. - // Fallback to existing stored value for subsequent subscribe upserts. - $this->connections[$identifier] = [ + // Union channels/roles across all subscriptions on the connection; overwriting would + // leave getSubscriptionMetadata and full unsubscribe operating on stale state. + $existing = $this->connections[$identifier] ?? []; + $existingChannels = $existing['channels'] ?? []; + $existingRoles = $existing['roles'] ?? []; + + $entry = [ 'projectId' => $projectId, - 'roles' => $roles, - 'userId' => $userId ?? ($this->connections[$identifier]['userId'] ?? ''), - 'channels' => $channels + 'roles' => \array_values(\array_unique(\array_merge($existingRoles, $roles))), + 'userId' => $userId ?? ($existing['userId'] ?? ''), + 'channels' => \array_values(\array_unique(\array_merge($existingChannels, $channels))), ]; + + if (\array_key_exists('authorization', $existing)) { + $entry['authorization'] = $existing['authorization']; + } + + $this->connections[$identifier] = $entry; } /** @@ -206,6 +216,85 @@ class Realtime extends MessagingAdapter } } + /** + * Removes a single subscription from a connection, keeping the connection alive so + * the client can resubscribe. Idempotent — returns true only when something was removed. + * + * @param mixed $connection + * @param string $subscriptionId + * @return bool + */ + public function unsubscribeSubscription(mixed $connection, string $subscriptionId): bool + { + $projectId = $this->connections[$connection]['projectId'] ?? ''; + if ($projectId === '' || !isset($this->subscriptions[$projectId])) { + return false; + } + + $removed = false; + + foreach ($this->subscriptions[$projectId] as $role => $byChannel) { + foreach ($byChannel as $channel => $byConnection) { + if (!isset($byConnection[$connection][$subscriptionId])) { + continue; + } + + unset($this->subscriptions[$projectId][$role][$channel][$connection][$subscriptionId]); + $removed = true; + + if (empty($this->subscriptions[$projectId][$role][$channel][$connection])) { + unset($this->subscriptions[$projectId][$role][$channel][$connection]); + } + if (empty($this->subscriptions[$projectId][$role][$channel])) { + unset($this->subscriptions[$projectId][$role][$channel]); + } + } + if (empty($this->subscriptions[$projectId][$role])) { + unset($this->subscriptions[$projectId][$role]); + } + } + + if (empty($this->subscriptions[$projectId])) { + unset($this->subscriptions[$projectId]); + } + + if ($removed) { + $this->recomputeConnectionState($connection); + } + + return $removed; + } + + /** + * Recomputes the cached channels/roles on the connection entry from the subscriptions tree. + * Called after per-subscription removal so stale entries do not linger for later reads. + * + * @param mixed $connection + * @return void + */ + private function recomputeConnectionState(mixed $connection): void + { + if (!isset($this->connections[$connection])) { + return; + } + + $projectId = $this->connections[$connection]['projectId'] ?? ''; + $channels = []; + $roles = []; + + foreach ($this->subscriptions[$projectId] ?? [] as $role => $byChannel) { + foreach ($byChannel as $channel => $byConnection) { + if (isset($byConnection[$connection])) { + $roles[$role] = true; + $channels[$channel] = true; + } + } + } + + $this->connections[$connection]['roles'] = \array_keys($roles); + $this->connections[$connection]['channels'] = \array_keys($channels); + } + /** * Checks if Channel has a subscriber. * @param string $projectId From 9f6517764978cafeb991d0d23caacb43aa3e64ec Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 17:37:54 +0530 Subject: [PATCH 100/402] Add unsubscribe functionality to Realtime adapter --- app/realtime.php | 50 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 5631a7f860..32c71fa9a3 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -1070,9 +1070,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); } - // subscribe() overwrites the connection entry; restore auth so later onMessage uses the same context. - $realtime->connections[$connection]['authorization'] = $authorization; - $responsePayload = json_encode([ 'type' => 'response', 'data' => [ @@ -1102,6 +1099,53 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re break; + case 'unsubscribe': + if (!\is_array($message['data']) || !\array_is_list($message['data'])) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); + } + + $unsubscribeResults = []; + foreach ($message['data'] as $payload) { + if ( + !\is_array($payload) + || !\array_key_exists('subscriptionId', $payload) + || !\is_string($payload['subscriptionId']) + || $payload['subscriptionId'] === '' + ) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each unsubscribe payload must include a non-empty subscriptionId.'); + } + + $subscriptionId = $payload['subscriptionId']; + $wasRemoved = $realtime->unsubscribeSubscription($connection, $subscriptionId); + $unsubscribeResults[] = [ + 'subscriptionId' => $subscriptionId, + 'removed' => $wasRemoved, + ]; + } + + $unsubscribeResponsePayload = json_encode([ + 'type' => 'response', + 'data' => [ + 'to' => 'unsubscribe', + 'success' => true, + 'subscriptions' => $unsubscribeResults, + ], + ]); + + $server->send([$connection], $unsubscribeResponsePayload); + + if ($project !== null && !$project->isEmpty()) { + $unsubscribeOutboundBytes = \strlen($unsubscribeResponsePayload); + + if ($unsubscribeOutboundBytes > 0) { + triggerStats([ + METRIC_REALTIME_OUTBOUND => $unsubscribeOutboundBytes, + ], $project->getId()); + } + } + + break; + default: throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } From 78eeac6d14753bafd8fb5bb92632611f1e31fef1 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 17:38:01 +0530 Subject: [PATCH 101/402] Add unsubscribe functionality and enhance subscription handling in Realtime tests --- ...altimeCustomClientQueryTestWithMessage.php | 270 ++++++++++++++++++ tests/unit/Messaging/MessagingTest.php | 187 ++++++++++++ 2 files changed, 457 insertions(+) diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php index edce428e0f..5dce7056e8 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php @@ -164,6 +164,20 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope return $response; } + /** + * @param array> $payloadEntries + * @return array + */ + private function sendUnsubscribeMessage(WebSocketClient $client, array $payloadEntries): array + { + $client->send(\json_encode([ + 'type' => 'unsubscribe', + 'data' => $payloadEntries, + ])); + + return \json_decode($client->receive(), true); + } + /** * subscriptionId: update with id from connected, create by omitting id, explicit new id, * duplicate id in one bulk (last wins), mixed bulk, idempotent repeat, empty queries → select-all. @@ -293,6 +307,262 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $client->close(); } + /** + * Update a subscription's queries/channels by reusing its subscriptionId. + * Verifies the update takes effect on live event filtering (not just the response echo), + * sibling subscriptions are untouched, unknown ids upsert as new, empty queries fall + * back to select-all, and a removed id can be recreated by subscribing again. + */ + public function testUpdateSubscriptionAndEdgeCases(): void + { + $user = $this->getUser(); + $userId = $user['$id'] ?? ''; + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + $queryString = \http_build_query(['project' => $projectId]); + $client = new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . $queryString, + [ + 'headers' => $headers, + 'timeout' => 10, + ] + ); + $connected = \json_decode($client->receive(), true); + $this->assertEquals('connected', $connected['type'] ?? null); + + $triggerAccountEvent = function () use ($projectId, $session): void { + $this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), ['name' => 'Update Sub Test ' . \uniqid()]); + }; + + // subA matches current user, subB never matches + $created = $this->sendSubscribeMessage($client, [ + [ + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ], + [ + 'channels' => ['account'], + 'queries' => [Query::equal('$id', ['no-match-initial'])->toString()], + ], + ]); + $subA = $created['data']['subscriptions'][0]['subscriptionId']; + $subB = $created['data']['subscriptions'][1]['subscriptionId']; + $this->assertNotSame($subA, $subB); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertSame([$subA], $event['data']['subscriptions']); + + // Swap: A -> non-matching, B -> matching. Same ids returned, server-side filter swaps. + $swap = $this->sendSubscribeMessage($client, [ + [ + 'subscriptionId' => $subA, + 'channels' => ['account'], + 'queries' => [Query::equal('$id', ['no-match-swapped'])->toString()], + ], + [ + 'subscriptionId' => $subB, + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ], + ]); + $this->assertSame($subA, $swap['data']['subscriptions'][0]['subscriptionId']); + $this->assertSame($subB, $swap['data']['subscriptions'][1]['subscriptionId']); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertSame([$subB], $event['data']['subscriptions']); + + // Sibling isolation: updating only subA must leave subB's matching filter intact. + $isolation = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $subA, + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ]]); + $this->assertSame($subA, $isolation['data']['subscriptions'][0]['subscriptionId']); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subA, $subB], $event['data']['subscriptions']); + + // Empty queries on update -> select-all; subA still matches every event on the channel. + $empty = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $subA, + 'channels' => ['account'], + 'queries' => [], + ]]); + $this->assertSame($subA, $empty['data']['subscriptions'][0]['subscriptionId']); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subA, $subB], $event['data']['subscriptions']); + + // Unknown subscriptionId upserts as a new subscription. + $ghostId = ID::unique(); + $ghost = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $ghostId, + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ]]); + $this->assertSame($ghostId, $ghost['data']['subscriptions'][0]['subscriptionId']); + $this->assertNotSame($subA, $ghostId); + $this->assertNotSame($subB, $ghostId); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subA, $subB, $ghostId], $event['data']['subscriptions']); + + // Update after unsubscribe: subscribing with the removed id recreates it. + $unsub = $this->sendUnsubscribeMessage($client, [['subscriptionId' => $subA]]); + $this->assertTrue($unsub['data']['subscriptions'][0]['removed']); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subB, $ghostId], $event['data']['subscriptions']); + + $recreated = $this->sendSubscribeMessage($client, [[ + 'subscriptionId' => $subA, + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ]]); + $this->assertSame($subA, $recreated['data']['subscriptions'][0]['subscriptionId']); + + $triggerAccountEvent(); + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subA, $subB, $ghostId], $event['data']['subscriptions']); + + $client->close(); + } + + public function testUnsubscribeRemovesOnlyMatchingSubscription(): void + { + $user = $this->getUser(); + $userId = $user['$id'] ?? ''; + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + $queryString = \http_build_query(['project' => $projectId]); + $client = new WebSocketClient( + 'ws://appwrite.test/v1/realtime?' . $queryString, + [ + 'headers' => $headers, + 'timeout' => 10, + ] + ); + + $connected = \json_decode($client->receive(), true); + $this->assertEquals('connected', $connected['type'] ?? null); + + // Two subscriptions on the `account` channel, both matching the current user + $r1 = $this->sendSubscribeMessage($client, [[ + 'channels' => ['account'], + 'queries' => [Query::equal('$id', [$userId])->toString()], + ]]); + $subA = $r1['data']['subscriptions'][0]['subscriptionId']; + + $r2 = $this->sendSubscribeMessage($client, [[ + 'channels' => ['account'], + 'queries' => [Query::select(['*'])->toString()], + ]]); + $subB = $r2['data']['subscriptions'][0]['subscriptionId']; + + $this->assertNotSame($subA, $subB); + + // Trigger an event -- both subscriptions should match + $name = 'Unsubscribe Test ' . \uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), ['name' => $name]); + + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEqualsCanonicalizing([$subA, $subB], $event['data']['subscriptions']); + + // Unsubscribe subA only + $unsubA = $this->sendUnsubscribeMessage($client, [['subscriptionId' => $subA]]); + $this->assertEquals('response', $unsubA['type']); + $this->assertEquals('unsubscribe', $unsubA['data']['to']); + $this->assertTrue($unsubA['data']['success']); + $this->assertCount(1, $unsubA['data']['subscriptions']); + $this->assertSame($subA, $unsubA['data']['subscriptions'][0]['subscriptionId']); + $this->assertTrue($unsubA['data']['subscriptions'][0]['removed']); + + // Trigger another event -- only subB should match now + $name = 'Unsubscribe Test ' . \uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), ['name' => $name]); + + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertSame([$subB], $event['data']['subscriptions']); + + // Idempotent: unsubscribing subA again reports removed=false + $unsubAgain = $this->sendUnsubscribeMessage($client, [['subscriptionId' => $subA]]); + $this->assertTrue($unsubAgain['data']['success']); + $this->assertFalse($unsubAgain['data']['subscriptions'][0]['removed']); + + // Connection is still alive -- ping still works + $client->send(\json_encode(['type' => 'ping'])); + $pong = \json_decode($client->receive(), true); + $this->assertEquals('pong', $pong['type']); + + // Invalid payloads are rejected + $errNonString = $this->sendUnsubscribeMessage($client, [['subscriptionId' => 123]]); + $this->assertEquals('error', $errNonString['type']); + $this->assertStringContainsString('subscriptionId', $errNonString['data']['message']); + + $errEmpty = $this->sendUnsubscribeMessage($client, [['subscriptionId' => '']]); + $this->assertEquals('error', $errEmpty['type']); + + $errMissing = $this->sendUnsubscribeMessage($client, [['channels' => ['foo']]]); + $this->assertEquals('error', $errMissing['type']); + + $errNonList = $this->sendUnsubscribeMessage($client, ['subscriptionId' => $subB]); + $this->assertEquals('error', $errNonList['type']); + + // Bulk unsubscribe: remaining subB plus a never-existed id -- response mirrors input order + $bulk = $this->sendUnsubscribeMessage($client, [ + ['subscriptionId' => $subB], + ['subscriptionId' => 'does-not-exist'], + ]); + $this->assertTrue($bulk['data']['success']); + $this->assertCount(2, $bulk['data']['subscriptions']); + $this->assertSame($subB, $bulk['data']['subscriptions'][0]['subscriptionId']); + $this->assertTrue($bulk['data']['subscriptions'][0]['removed']); + $this->assertSame('does-not-exist', $bulk['data']['subscriptions'][1]['subscriptionId']); + $this->assertFalse($bulk['data']['subscriptions'][1]['removed']); + + $client->close(); + } + public function testInvalidQueryShouldNotSubscribe(): void { $user = $this->getUser(); diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 4b2474c760..f48be46202 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -147,6 +147,193 @@ class MessagingTest extends TestCase $this->assertEmpty($realtime->subscriptions); } + public function testSubscribeUnionsChannelsAndRoles(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::user(ID::custom('123'))->toString()], + ['documents'], + ); + + $realtime->subscribe( + '1', + 1, + 'sub-b', + [Role::users()->toString()], + ['files'], + ); + + $connection = $realtime->connections[1]; + + $this->assertContains('documents', $connection['channels']); + $this->assertContains('files', $connection['channels']); + $this->assertContains(Role::user(ID::custom('123'))->toString(), $connection['roles']); + $this->assertContains(Role::users()->toString(), $connection['roles']); + $this->assertCount(2, $connection['channels']); + $this->assertCount(2, $connection['roles']); + } + + public function testUnsubscribeSubscriptionRemovesOnlyOneSubscription(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::user(ID::custom('123'))->toString()], + ['documents'], + ); + + $realtime->subscribe( + '1', + 1, + 'sub-b', + [Role::users()->toString()], + ['files'], + ); + + $removed = $realtime->unsubscribeSubscription(1, 'sub-a'); + + $this->assertTrue($removed); + $this->assertArrayHasKey(1, $realtime->connections); + + // sub-a is fully cleaned from the tree + $this->assertArrayNotHasKey( + Role::user(ID::custom('123'))->toString(), + $realtime->subscriptions['1'] + ); + + // sub-b still delivers + $event = [ + 'project' => '1', + 'roles' => [Role::users()->toString()], + 'data' => [ + 'channels' => ['files'], + ], + ]; + $receivers = array_keys($realtime->getSubscribers($event)); + $this->assertEquals([1], $receivers); + + // Channels recomputed: sub-a's channel is gone + $this->assertSame(['files'], $realtime->connections[1]['channels']); + + // Roles are connection-level auth context — union of both subscribe calls preserved + $this->assertContains(Role::user(ID::custom('123'))->toString(), $realtime->connections[1]['roles']); + $this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']); + } + + public function testUnsubscribeSubscriptionIsIdempotent(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::users()->toString()], + ['documents'], + ); + + $this->assertFalse($realtime->unsubscribeSubscription(1, 'does-not-exist')); + $this->assertFalse($realtime->unsubscribeSubscription(99, 'sub-a')); + + // Original sub is untouched + $event = [ + 'project' => '1', + 'roles' => [Role::users()->toString()], + 'data' => [ + 'channels' => ['documents'], + ], + ]; + $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); + } + + public function testUnsubscribeSubscriptionKeepsConnectionWhenLastSubRemoved(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::users()->toString()], + ['documents'], + ); + + $this->assertTrue($realtime->unsubscribeSubscription(1, 'sub-a')); + + $this->assertArrayHasKey(1, $realtime->connections); + $this->assertSame([], $realtime->connections[1]['channels']); + // Roles preserved so a later resubscribe on the same connection still has auth context + $this->assertSame([Role::users()->toString()], $realtime->connections[1]['roles']); + $this->assertArrayNotHasKey('1', $realtime->subscriptions); + } + + public function testResubscribeAfterUnsubscribingLastSubDelivers(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::users()->toString()], + ['documents'], + ); + + $this->assertTrue($realtime->unsubscribeSubscription(1, 'sub-a')); + + // Simulate the message-based subscribe path reading stored roles + $storedRoles = $realtime->connections[1]['roles']; + $this->assertNotEmpty($storedRoles, 'connection roles must survive per-subscription removal'); + + $realtime->subscribe('1', 1, 'sub-b', $storedRoles, ['files']); + + $event = [ + 'project' => '1', + 'roles' => [Role::users()->toString()], + 'data' => [ + 'channels' => ['files'], + ], + ]; + $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); + } + + public function testSubscribeAfterOnOpenEmptySentinelPreservesUnion(): void + { + $realtime = new Realtime(); + + // Mirrors the onOpen empty-channels path: subscribe with '' id, empty channels + $realtime->subscribe( + '1', + 1, + '', + [Role::users()->toString()], + [], + [], + 'user-123', + ); + + // Now a real subscription comes in via the subscribe message type + $realtime->subscribe( + '1', + 1, + 'sub-a', + [Role::user(ID::custom('user-123'))->toString()], + ['documents'], + ); + + $this->assertSame('user-123', $realtime->connections[1]['userId']); + $this->assertContains('documents', $realtime->connections[1]['channels']); + $this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']); + $this->assertContains(Role::user(ID::custom('user-123'))->toString(), $realtime->connections[1]['roles']); + } + public function testConvertChannelsGuest(): void { $user = new Document([ From afb8f7031633a431e7bf89194e7ab212c21b60a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 14:42:52 +0200 Subject: [PATCH 102/402] Fix tests --- tests/e2e/Services/Project/SMTPBase.php | 251 ++++++++++++------------ 1 file changed, 130 insertions(+), 121 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 887ed864ba..2cfe645290 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -11,21 +11,33 @@ trait SMTPBase public function testUpdateSMTPStatusEnable(): void { - $response = $this->updateSMTPStatus(true); + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); $this->assertSame(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertSame(true, $response['body']['smtpEnabled']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPStatusDisable(): void { - $this->updateSMTPStatus(true); + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); - $response = $this->updateSMTPStatus(false); + $response = $this->updateSMTP(enabled: false); $this->assertSame(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); @@ -34,32 +46,50 @@ trait SMTPBase public function testUpdateSMTPStatusEnableIdempotent(): void { - $first = $this->updateSMTPStatus(true); + $first = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); $this->assertSame(200, $first['headers']['status-code']); $this->assertSame(true, $first['body']['smtpEnabled']); - $second = $this->updateSMTPStatus(true); + $second = $this->updateSMTP(enabled: true); $this->assertSame(200, $second['headers']['status-code']); $this->assertSame(true, $second['body']['smtpEnabled']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPStatusDisableIdempotent(): void { - $first = $this->updateSMTPStatus(false); + $first = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); $this->assertSame(200, $first['headers']['status-code']); $this->assertSame(false, $first['body']['smtpEnabled']); - $second = $this->updateSMTPStatus(false); + $second = $this->updateSMTP(enabled: false); $this->assertSame(200, $second['headers']['status-code']); $this->assertSame(false, $second['body']['smtpEnabled']); } public function testUpdateSMTPStatusResponseModel(): void { - $response = $this->updateSMTPStatus(true); + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); $this->assertSame(200, $response['headers']['status-code']); $this->assertArrayHasKey('$id', $response['body']); @@ -76,12 +106,12 @@ trait SMTPBase $this->assertArrayHasKey('smtpSecure', $response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPStatusWithoutAuthentication(): void { - $response = $this->updateSMTPStatus(true, false); + $response = $this->updateSMTP(enabled: true, authenticated: false); $this->assertSame(401, $response['headers']['status-code']); } @@ -90,7 +120,7 @@ trait SMTPBase public function testUpdateSMTPCredentials(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -106,12 +136,12 @@ trait SMTPBase $this->assertSame(1025, $response['body']['smtpPort']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPWithOptionalReplyTo(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Full Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -130,19 +160,19 @@ trait SMTPBase $this->assertSame(1025, $response['body']['smtpPort']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPOverwritesPreviousSettings(): void { - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'First Sender', senderEmail: 'first@example.com', host: 'maildev', port: 1025, ); - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Second Sender', senderEmail: 'second@example.com', host: 'maildev', @@ -154,15 +184,21 @@ trait SMTPBase $this->assertSame('second@example.com', $response['body']['smtpSenderEmail']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPEnablesSMTP(): void { // Ensure SMTP is disabled - $this->updateSMTPStatus(false); + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -173,12 +209,12 @@ trait SMTPBase $this->assertSame(true, $response['body']['smtpEnabled']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPResponseModel(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -200,12 +236,12 @@ trait SMTPBase $this->assertArrayHasKey('smtpSecure', $response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPWithoutAuthentication(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -218,7 +254,7 @@ trait SMTPBase public function testUpdateSMTPInvalidSenderEmail(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'not-an-email', host: 'maildev', @@ -230,7 +266,7 @@ trait SMTPBase public function testUpdateSMTPEmptySenderName(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: '', senderEmail: 'sender@example.com', host: 'maildev', @@ -242,7 +278,7 @@ trait SMTPBase public function testUpdateSMTPEmptySenderEmail(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: '', host: 'maildev', @@ -254,7 +290,7 @@ trait SMTPBase public function testUpdateSMTPEmptyHost(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: '', @@ -266,7 +302,7 @@ trait SMTPBase public function testUpdateSMTPInvalidHost(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'not a valid host!@#', @@ -278,7 +314,7 @@ trait SMTPBase public function testUpdateSMTPInvalidReplyToEmail(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -291,7 +327,7 @@ trait SMTPBase public function testUpdateSMTPInvalidSecure(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -304,7 +340,7 @@ trait SMTPBase public function testUpdateSMTPSenderNameMinLength(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'A', senderEmail: 'sender@example.com', host: 'maildev', @@ -315,13 +351,13 @@ trait SMTPBase $this->assertSame('A', $response['body']['smtpSenderName']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPSenderNameMaxLength(): void { $name = str_repeat('a', 256); - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: $name, senderEmail: 'sender@example.com', host: 'maildev', @@ -332,12 +368,12 @@ trait SMTPBase $this->assertSame($name, $response['body']['smtpSenderName']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPSenderNameTooLong(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: str_repeat('a', 257), senderEmail: 'sender@example.com', host: 'maildev', @@ -349,7 +385,7 @@ trait SMTPBase public function testUpdateSMTPUsernameMinLength(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -361,13 +397,13 @@ trait SMTPBase $this->assertSame('u', $response['body']['smtpUsername']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPUsernameMaxLength(): void { $username = str_repeat('a', 256); - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -379,12 +415,12 @@ trait SMTPBase $this->assertSame($username, $response['body']['smtpUsername']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPUsernameTooLong(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -397,7 +433,7 @@ trait SMTPBase public function testUpdateSMTPUsernameEmpty(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -410,7 +446,7 @@ trait SMTPBase public function testUpdateSMTPPasswordMinLength(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -422,13 +458,13 @@ trait SMTPBase $this->assertSame('p', $response['body']['smtpPassword']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPPasswordMaxLength(): void { $password = str_repeat('a', 256); - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -440,12 +476,12 @@ trait SMTPBase $this->assertSame($password, $response['body']['smtpPassword']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPPasswordTooLong(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -458,7 +494,7 @@ trait SMTPBase public function testUpdateSMTPPasswordEmpty(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -471,7 +507,7 @@ trait SMTPBase public function testUpdateSMTPWithoutSecure(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -482,12 +518,12 @@ trait SMTPBase $this->assertSame('', $response['body']['smtpSecure']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPInvalidConnectionRefused(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'localhost', @@ -501,10 +537,16 @@ trait SMTPBase public function testUpdateSMTPBackwardsCompatibilityDisable(): void { // First enable SMTP - $this->updateSMTPStatus(true); + $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); // Use the deprecated enabled=false parameter to disable - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -521,7 +563,7 @@ trait SMTPBase public function testCreateSMTPTest(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -534,13 +576,13 @@ trait SMTPBase $this->assertEmpty($response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testCreateSMTPTestMultipleRecipients(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -557,13 +599,19 @@ trait SMTPBase $this->assertEmpty($response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testCreateSMTPTestWhenSMTPDisabled(): void { // Ensure SMTP is disabled - $this->updateSMTPStatus(false); + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); $response = $this->createSMTPTest(['recipient@example.com']); @@ -580,7 +628,7 @@ trait SMTPBase public function testCreateSMTPTestEmptyEmails(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -593,13 +641,13 @@ trait SMTPBase $this->assertEmpty($response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testCreateSMTPTestInvalidEmail(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -611,13 +659,13 @@ trait SMTPBase $this->assertSame(400, $response['headers']['status-code']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testCreateSMTPTestExceedsMaxEmails(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -634,13 +682,13 @@ trait SMTPBase $this->assertSame(400, $response['headers']['status-code']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testCreateSMTPTestMaxEmails(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -658,7 +706,7 @@ trait SMTPBase $this->assertEmpty($response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } // Integration tests @@ -672,7 +720,7 @@ trait SMTPBase $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; // Configure SMTP with reply-to and auth credentials - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', @@ -705,7 +753,7 @@ trait SMTPBase $this->assertStringContainsStringIgnoringCase('working correctly', $email['html']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testMagicURLLoginUsesCustomSMTP(): void @@ -715,7 +763,7 @@ trait SMTPBase $recipientEmail = 'magicurl-' . \uniqid() . '@appwrite.io'; // Configure custom SMTP with auth credentials - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', @@ -749,32 +797,16 @@ trait SMTPBase $this->assertSame($this->getProject()['name'] . ' Login', $email['subject']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } // Helpers - protected function updateSMTPStatus(bool $enabled, bool $authenticated = true): mixed - { - $headers = [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ]; - - if ($authenticated) { - $headers = array_merge($headers, $this->getHeaders()); - } - - return $this->client->call(Client::METHOD_PATCH, '/project/smtp/status', $headers, [ - 'enabled' => $enabled, - ]); - } - - protected function updateSMTPCredentials( - string $senderName = '', - string $senderEmail = '', - string $host = '', - int $port = 587, + protected function updateSMTP( + ?string $senderName = null, + ?string $senderEmail = null, + ?string $host = null, + ?int $port = null, ?string $replyToEmail = null, ?string $replyToName = null, ?string $username = null, @@ -792,38 +824,15 @@ trait SMTPBase $headers = array_merge($headers, $this->getHeaders()); } - $params = [ - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'host' => $host, - 'port' => $port, - ]; + $params = []; - if (!\is_null($replyToEmail)) { - $params['replyToEmail'] = $replyToEmail; + foreach (['senderName', 'senderEmail', 'host', 'port', 'replyToEmail', 'replyToName', 'username', 'password', 'secure', 'enabled'] as $key) { + if (!\is_null(${$key})) { + $params[$key] = ${$key}; + } } - if (!\is_null($replyToName)) { - $params['replyToName'] = $replyToName; - } - - if (!\is_null($username)) { - $params['username'] = $username; - } - - if (!\is_null($password)) { - $params['password'] = $password; - } - - if (!\is_null($secure)) { - $params['secure'] = $secure; - } - - if (!\is_null($enabled)) { - $params['enabled'] = $enabled; - } - - return $this->client->call(Client::METHOD_PATCH, '/project/smtp/credentials', $headers, $params); + return $this->client->call(Client::METHOD_PATCH, '/project/smtp', $headers, $params); } /** From 1c1ec4315075d1d6e696c5e8a14e0b4c5adeba0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 14:47:01 +0200 Subject: [PATCH 103/402] Removeal post-merge --- app/controllers/api/projects.php | 214 ------------------------------- 1 file changed, 214 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 439692e1dd..6fc52d2158 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -619,220 +619,6 @@ Http::post('/v1/projects/:projectId/jwts') ])]), Response::MODEL_JWT); }); -// CUSTOM SMTP and Templates -Http::patch('/v1/projects/:projectId/smtp') - ->desc('Update SMTP') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSmtp', - description: '/docs/references/projects/update-smtp.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateSMTP', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSMTP', - description: '/docs/references/projects/update-smtp.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(), 'Enable custom SMTP service') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->param('host', '', new HostName(), 'SMTP server host name', true) - ->param('port', 587, new Integer(), 'SMTP server port', true) - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - // Ensure required params for when enabling SMTP - if ($enabled) { - if (empty($senderName)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender name is required when enabling SMTP.'); - } elseif (empty($senderEmail)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender email is required when enabling SMTP.'); - } elseif (empty($host)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Host is required when enabling SMTP.'); - } elseif (empty($port)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Port is required when enabling SMTP.'); - } - } - - // validate SMTP settings - if ($enabled) { - $mail = new PHPMailer(true); - $mail->isSMTP(); - $mail->SMTPAuth = (!empty($username) && !empty($password)); - $mail->Username = $username; - $mail->Password = $password; - $mail->Host = $host; - $mail->Port = $port; - $mail->SMTPSecure = $secure; - $mail->SMTPAutoTLS = false; - $mail->Timeout = 5; - - try { - $valid = $mail->SmtpConnect(); - - if (!$valid) { - throw new Exception('Connection is not valid.'); - } - } catch (Throwable $error) { - throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); - } - } - - // Save SMTP settings - if ($enabled) { - $smtp = [ - 'enabled' => $enabled, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'replyTo' => $replyTo, - 'host' => $host, - 'port' => $port, - 'username' => $username, - 'password' => $password, - 'secure' => $secure, - ]; - } else { - $smtp = [ - 'enabled' => false - ]; - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::post('/v1/projects/:projectId/smtp/tests') - ->desc('Create SMTP test') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'createSmtpTest', - description: '/docs/references/projects/create-smtp-test.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.createSMTPTest', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'createSMTPTest', - description: '/docs/references/projects/create-smtp-test.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.') - ->param('senderName', System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'), new Text(255, 0), 'Name of the email sender') - ->param('senderEmail', System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM), new Email(), 'Email of the sender') - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->param('host', '', new HostName(), 'SMTP server host name') - ->param('port', 587, new Integer(), 'SMTP server port', true) - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('queueForMails') - ->inject('plan') - ->action(function (string $projectId, array $emails, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform, Mail $queueForMails, array $plan) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail; - - $subject = 'Custom SMTP email sample'; - $template = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-smtp-test.tpl'); - $template - ->setParam('{{from}}', "{$senderName} ({$senderEmail})") - ->setParam('{{replyTo}}', "{$senderName} ({$replyToEmail})") - ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL) - ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR) - ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER) - ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD) - ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE) - ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL) - ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL); - - foreach ($emails as $email) { - $queueForMails - ->setSmtpHost($host) - ->setSmtpPort($port) - ->setSmtpUsername($username) - ->setSmtpPassword($password) - ->setSmtpSecure($secure) - ->setSmtpReplyTo($replyTo) - ->setSmtpSenderEmail($senderEmail) - ->setSmtpSenderName($senderName) - ->setRecipient($email) - ->setName('') - ->setBodyTemplate(__DIR__ . '/../../config/locale/templates/email-base-styled.tpl') - ->setBody($template->render()) - ->setVariables([]) - ->setSubject($subject) - ->trigger(); - } - - $response->noContent(); - }); - Http::get('/v1/projects/:projectId/templates/email') ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Get custom email template') From b2233193d50aba158c24e8bd20bee2bf24d573e8 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 18:19:12 +0530 Subject: [PATCH 104/402] updated --- src/Appwrite/Messaging/Adapter/Realtime.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 3dd0cda328..eeb1387674 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -266,8 +266,13 @@ class Realtime extends MessagingAdapter } /** - * Recomputes the cached channels/roles on the connection entry from the subscriptions tree. - * Called after per-subscription removal so stale entries do not linger for later reads. + * Recomputes the cached channels on the connection entry from the subscriptions tree. + * Called after per-subscription removal so stale channel entries do not linger for later reads. + * + * Roles are deliberately NOT recomputed here. They represent the connection's authorization + * context (set at onOpen, replaced on `authentication` / permission-change) and must survive + * per-subscription removal — otherwise a client that unsubscribes every subscription and then + * resubscribes would subscribe with an empty roles array and silently receive nothing. * * @param mixed $connection * @return void @@ -280,18 +285,15 @@ class Realtime extends MessagingAdapter $projectId = $this->connections[$connection]['projectId'] ?? ''; $channels = []; - $roles = []; - foreach ($this->subscriptions[$projectId] ?? [] as $role => $byChannel) { + foreach ($this->subscriptions[$projectId] ?? [] as $byChannel) { foreach ($byChannel as $channel => $byConnection) { if (isset($byConnection[$connection])) { - $roles[$role] = true; $channels[$channel] = true; } } } - $this->connections[$connection]['roles'] = \array_keys($roles); $this->connections[$connection]['channels'] = \array_keys($channels); } From 8b41aed9196e94ddb06bd757cae1d1c6c611c23b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 14:49:43 +0200 Subject: [PATCH 105/402] Post-merge removal --- app/controllers/api/projects.php | 218 ------------------------------- 1 file changed, 218 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 6fc52d2158..5aeee1a8d7 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -619,224 +619,6 @@ Http::post('/v1/projects/:projectId/jwts') ])]), Response::MODEL_JWT); }); -Http::get('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Get custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'getEmailTemplate', - description: '/docs/references/projects/get-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); - - if (is_null($template)) { - /** - * different templates, different placeholders. - */ - $templateConfigs = [ - 'magicSession' => [ - 'file' => 'email-magic-url.tpl', - 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] - ], - 'mfaChallenge' => [ - 'file' => 'email-mfa-challenge.tpl', - 'placeholders' => ['description', 'clientInfo'] - ], - 'otpSession' => [ - 'file' => 'email-otp.tpl', - 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] - ], - 'sessionAlert' => [ - 'file' => 'email-session-alert.tpl', - 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] - ], - ]; - - // fallback to the base template. - $config = $templateConfigs[$type] ?? [ - 'file' => 'email-inner-base.tpl', - 'placeholders' => ['buttonText', 'body', 'footer'] - ]; - - $templateString = file_get_contents(__DIR__ . '/../../config/locale/templates/' . $config['file']); - - // We use `fromString` due to the replace above - $message = Template::fromString($templateString); - - // Set type-specific parameters - foreach ($config['placeholders'] as $param) { - $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); - $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml); - } - - $message - // common placeholders on all the templates - ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello")) - ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); - - // `useContent: false` will strip new lines! - $message = $message->render(useContent: true); - - $template = [ - 'message' => $message, - 'subject' => $localeObj->getText('emails.' . $type . '.subject'), - 'senderEmail' => '', - 'senderName' => '' - ]; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::patch('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Update custom email templates') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'updateEmailTemplate', - description: '/docs/references/projects/update-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->param('subject', '', new Text(255), 'Email Subject') - ->param('message', '', new Text(0), 'Template message') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $templates['email.' . $type . '-' . $locale] = [ - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::delete('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Delete custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteEmailTemplate', - description: '/docs/references/projects/delete-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ], - contentType: ContentType::JSON - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); - } - - unset($templates['email.' . $type . '-' . $locale]); - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $template['senderName'], - 'senderEmail' => $template['senderEmail'], - 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], - 'message' => $template['message'] - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - Http::patch('/v1/projects/:projectId/auth/session-invalidation') ->desc('Update invalidate session option of the project') ->groups(['api', 'projects']) From 52e3319a867eba490ef0325cd3af1e31113f8dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 14:50:12 +0200 Subject: [PATCH 106/402] Linter fix --- app/controllers/api/projects.php | 8 -------- .../Platform/Modules/Project/Http/Project/SMTP/Update.php | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 5aeee1a8d7..562ff15c39 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -3,29 +3,21 @@ use Ahc\Jwt\JWT; use Appwrite\Auth\Validator\MockNumber; use Appwrite\Event\Delete; -use Appwrite\Event\Mail; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; -use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; -use Appwrite\Template\Template; use Appwrite\Utopia\Database\Validator\Queries\Keys; use Appwrite\Utopia\Response; -use PHPMailer\PHPMailer\PHPMailer; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Validator\UID; -use Utopia\Emails\Validator\Email; use Utopia\Http\Http; -use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; -use Utopia\Validator\Hostname; -use Utopia\Validator\Integer; use Utopia\Validator\Nullable; use Utopia\Validator\Range; use Utopia\Validator\Text; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 58360058da..edfb45a6e6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -143,10 +143,10 @@ class Update extends Action if (!$valid) { throw new \Exception('Connection is not valid.'); } - + // Auto-enable if configuration is valid // Dont do this if specifically request to mark disabled - if(\is_null($enabled)) { + if (\is_null($enabled)) { $smtp['enabled'] = true; } } catch (Throwable $error) { From b8385fe92769c3ba382c110d4ba511700bc37fdb Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 18:27:48 +0530 Subject: [PATCH 107/402] updated --- app/realtime.php | 9 +++++++-- ...altimeCustomClientQueryTestWithMessage.php | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 32c71fa9a3..bd493dc64f 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -1104,7 +1104,9 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); } - $unsubscribeResults = []; + // Validate every payload before executing any removal so an invalid entry + // later in the batch does not leave earlier entries half-applied on the server. + $validatedIds = []; foreach ($message['data'] as $payload) { if ( !\is_array($payload) @@ -1114,8 +1116,11 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Each unsubscribe payload must include a non-empty subscriptionId.'); } + $validatedIds[] = $payload['subscriptionId']; + } - $subscriptionId = $payload['subscriptionId']; + $unsubscribeResults = []; + foreach ($validatedIds as $subscriptionId) { $wasRemoved = $realtime->unsubscribeSubscription($connection, $subscriptionId); $unsubscribeResults[] = [ 'subscriptionId' => $subscriptionId, diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php index 5dce7056e8..6376875157 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php @@ -548,6 +548,26 @@ class RealtimeCustomClientQueryTestWithMessage extends Scope $errNonList = $this->sendUnsubscribeMessage($client, ['subscriptionId' => $subB]); $this->assertEquals('error', $errNonList['type']); + // A batch with a valid id followed by an invalid one must be rejected atomically: + // the valid id must remain subscribed, not be quietly removed before validation fails. + $partial = $this->sendUnsubscribeMessage($client, [ + ['subscriptionId' => $subB], + ['subscriptionId' => 999], + ]); + $this->assertEquals('error', $partial['type']); + + $name = 'Partial Rejection Test ' . \uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', \array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), ['name' => $name]); + + $event = \json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertSame([$subB], $event['data']['subscriptions']); + // Bulk unsubscribe: remaining subB plus a never-existed id -- response mirrors input order $bulk = $this->sendUnsubscribeMessage($client, [ ['subscriptionId' => $subB], From 51f50b161c4a1f050fc1a652aa7a340a26e4318c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 15:28:05 +0200 Subject: [PATCH 108/402] Improved backwards compatibility --- .../Modules/Project/Services/Http.php | 1 + src/Appwrite/Utopia/Request/Filters/V23.php | 18 +++++++++++++++- src/Appwrite/Utopia/Response/Filters/V23.php | 21 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 2e7ceafe14..b2ee23aca6 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -53,6 +53,7 @@ class Http extends Service // SMTP $this->addAction(UpdateSMTP::getName(), new UpdateSMTP()); $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); + // Templates $this->addAction(GetTemplate::getName(), new GetTemplate()); $this->addAction(DeleteTemplate::getName(), new DeleteTemplate()); diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php index adb5d69aea..131844e6d5 100644 --- a/src/Appwrite/Utopia/Request/Filters/V23.php +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -17,14 +17,30 @@ class V23 extends Filter return $content; } + protected function parseReplyTo(array $content): array + { + if (isset($content['replyTo'])) { + $content['replyToEmail'] = $content['replyTo']; + unset($content['replyTo']); + } + + return $content; + } + public function parse(array $content, string $model): array { switch ($model) { case 'project.getEmailTemplate': - case 'project.updateEmailTemplate': case 'project.deleteEmailTemplate': $content = $this->parseEmailTemplate($content); break; + case 'project.updateEmailTemplate': + $content = $this->parseEmailTemplate($content); + $content = $this->parseReplyTo($content); + break; + case 'project.updateSMTP': + $content = $this->parseReplyTo($content); + break; } return $content; } diff --git a/src/Appwrite/Utopia/Response/Filters/V23.php b/src/Appwrite/Utopia/Response/Filters/V23.php index 54fcf8459f..8303e98eed 100644 --- a/src/Appwrite/Utopia/Response/Filters/V23.php +++ b/src/Appwrite/Utopia/Response/Filters/V23.php @@ -12,6 +12,7 @@ class V23 extends Filter { return match ($model) { Response::MODEL_EMAIL_TEMPLATE => $this->parseEmailTemplate($content), + Response::MODEL_PROJECT => $this->parseProject($content), default => $content, }; } @@ -23,6 +24,26 @@ class V23 extends Filter unset($content['templateId']); } + if (isset($content['replyToEmail'])) { + $content['replyTo'] = $content['replyToEmail']; + unset($content['replyToEmail']); + } + + unset($content['replyToName']); + unset($content['custom']); + + return $content; + } + + private function parseProject(array $content): array + { + if (isset($content['smtpReplyToEmail'])) { + $content['smtpReplyTo'] = $content['smtpReplyToEmail']; + unset($content['smtpReplyToEmail']); + } + + unset($content['smtpReplyToName']); + return $content; } } From 848f09956eced39a4e1802ea228959c66429c55a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 15:36:49 +0200 Subject: [PATCH 109/402] Improve backwards compatibility test coverage --- .../Http/Project/SMTP/Tests/Create.php | 34 +++++-- tests/e2e/Services/Project/SMTPBase.php | 98 +++++++++++++++++++ tests/e2e/Services/Project/TemplatesBase.php | 96 ++++++++++++++++++ 3 files changed, 218 insertions(+), 10 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php index a41292466c..a07d667d56 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -90,7 +90,9 @@ class Create extends Action Mail $queueForMails, array $plan ): void { - // Backwards compatibility: use inline params if provided, otherwise fall back to project SMTP config + // Backwards compatibility: use inline params if provided, otherwise fall back to project SMTP config. + // When inline params are provided they are treated as self-contained — project config is ignored + // so legacy (1.9.1) callers do not get project state (e.g. replyToName) leaked into their request. $hasInlineParams = !empty($paramHost); $smtp = $project->getAttribute('smtp', []); @@ -99,15 +101,27 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to send a test email.'); } - $senderName = $paramSenderName ?: ($smtp['senderName'] ?? ''); - $senderEmail = $paramSenderEmail ?: ($smtp['senderEmail'] ?? ''); - $replyToEmail = $paramReplyTo ?: ($smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''); // Includes backwards compatibility - $replyToName = $smtp['replyToName'] ?? ''; - $host = $paramHost ?: ($smtp['host'] ?? ''); - $port = $paramPort ?? ($smtp['port'] ?? ''); - $username = $paramUsername ?: ($smtp['username'] ?? ''); - $password = $paramPassword ?: ($smtp['password'] ?? ''); - $secure = $paramSecure ?: ($smtp['secure'] ?? ''); + if ($hasInlineParams) { + $senderName = $paramSenderName; + $senderEmail = $paramSenderEmail; + $replyToEmail = $paramReplyTo; + $replyToName = ''; // 1.9.1 inline params did not include replyToName + $host = $paramHost; + $port = $paramPort ?? 0; + $username = $paramUsername; + $password = $paramPassword; + $secure = $paramSecure; + } else { + $senderName = $smtp['senderName'] ?? ''; + $senderEmail = $smtp['senderEmail'] ?? ''; + $replyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; // Includes backwards compatibility + $replyToName = $smtp['replyToName'] ?? ''; + $host = $smtp['host'] ?? ''; + $port = $smtp['port'] ?? 0; + $username = $smtp['username'] ?? ''; + $password = $smtp['password'] ?? ''; + $secure = $smtp['secure'] ?? ''; + } if (empty($senderName)) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender name must be configured on the project to send a test email.'); diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 2cfe645290..c6f7ea4b67 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -534,6 +534,104 @@ trait SMTPBase $this->assertSame('project_smtp_config_invalid', $response['body']['type']); } + public function testUpdateSMTPLegacyReplyToAndResponseFormat(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + // Legacy client sends `replyTo` (not `replyToEmail`). Request filter maps it. + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + $headers, + [ + 'enabled' => true, + 'senderName' => 'Legacy Sender', + 'senderEmail' => 'legacy-sender@example.com', + 'host' => 'maildev', + 'port' => 1025, + 'replyTo' => 'legacy-reply@example.com', + ], + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + $this->assertSame('Legacy Sender', $response['body']['smtpSenderName']); + $this->assertSame('legacy-sender@example.com', $response['body']['smtpSenderEmail']); + + // Response filter must expose smtpReplyTo and strip smtpReplyToEmail / smtpReplyToName. + $this->assertArrayHasKey('smtpReplyTo', $response['body']); + $this->assertArrayNotHasKey('smtpReplyToEmail', $response['body']); + $this->assertArrayNotHasKey('smtpReplyToName', $response['body']); + $this->assertSame('legacy-reply@example.com', $response['body']['smtpReplyTo']); + + // Sanity-check: a modern (non-legacy) read sees the new field names. + $modern = $this->updateSMTP(enabled: true); + $this->assertArrayHasKey('smtpReplyToEmail', $modern['body']); + $this->assertSame('legacy-reply@example.com', $modern['body']['smtpReplyToEmail']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestLegacyInlineParams(): void + { + // Seed the project with a distinct SMTP config so we can prove the + // inline (1.9.1-style) params take precedence over project config. + $this->updateSMTP( + senderName: 'Project Sender', + senderEmail: 'project-sender@example.com', + host: 'maildev', + port: 1025, + replyToEmail: 'project-reply@example.com', + replyToName: 'Project Reply', + enabled: false, + ); + + $recipient = 'legacy-smtp-' . \uniqid() . '@appwrite.io'; + + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $response = $this->client->call( + Client::METHOD_POST, + '/project/smtp/tests', + $headers, + [ + 'emails' => [$recipient], + 'senderName' => 'Inline Legacy Sender', + 'senderEmail' => 'inline-legacy@appwrite.io', + 'replyTo' => 'inline-legacy-reply@appwrite.io', + 'host' => 'maildev', + 'port' => 1025, + 'username' => 'user', + 'password' => 'password', + ], + ); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Verify the email was sent using the inline params (not project SMTP). + $email = $this->getLastEmailByAddress($recipient, function ($email) { + $this->assertSame('Custom SMTP email sample', $email['subject']); + }); + + $this->assertSame('inline-legacy@appwrite.io', $email['from'][0]['address']); + $this->assertSame('Inline Legacy Sender', $email['from'][0]['name']); + $this->assertSame('inline-legacy-reply@appwrite.io', $email['replyTo'][0]['address']); + $this->assertSame('Inline Legacy Sender', $email['replyTo'][0]['name']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + public function testUpdateSMTPBackwardsCompatibilityDisable(): void { // First enable SMTP diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 422f890785..ade2bb3f2c 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -434,6 +434,102 @@ trait TemplatesBase $this->assertSame(400, $update['headers']['status-code']); } + public function testUpdateEmailTemplateLegacyReplyTo(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + // Legacy clients send replyTo (not replyToEmail) — request filter maps it. + $update = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + $headers, + [ + 'type' => 'invitation', + 'locale' => 'en', + 'subject' => 'Legacy reply-to subject', + 'message' => 'Legacy reply-to body', + 'senderName' => 'Legacy Sender', + 'senderEmail' => 'legacy-sender@appwrite.io', + 'replyTo' => 'legacy-reply@appwrite.io', + ], + ); + + $this->assertSame(200, $update['headers']['status-code']); + // Response filter should rename replyToEmail -> replyTo, strip replyToName / custom. + $this->assertArrayHasKey('replyTo', $update['body']); + $this->assertArrayNotHasKey('replyToEmail', $update['body']); + $this->assertArrayNotHasKey('replyToName', $update['body']); + $this->assertArrayNotHasKey('custom', $update['body']); + $this->assertSame('legacy-reply@appwrite.io', $update['body']['replyTo']); + $this->assertSame('Legacy Sender', $update['body']['senderName']); + $this->assertSame('legacy-sender@appwrite.io', $update['body']['senderEmail']); + + // Verify value is persisted and readable via the legacy GET shape. + $get = $this->client->call( + Client::METHOD_GET, + '/project/templates/email/invitation', + $headers, + ['locale' => 'en'], + ); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertArrayHasKey('replyTo', $get['body']); + $this->assertArrayNotHasKey('replyToEmail', $get['body']); + $this->assertArrayNotHasKey('replyToName', $get['body']); + $this->assertArrayNotHasKey('custom', $get['body']); + $this->assertSame('legacy-reply@appwrite.io', $get['body']['replyTo']); + + // Cleanup + $this->deleteEmailTemplate('invitation', 'en'); + } + + public function testGetEmailTemplateLegacyReplyTo(): void + { + // Seed a custom template using the current API (includes replyToEmail + replyToName). + $update = $this->updateEmailTemplate( + 'otpSession', + 'en', + 'Legacy OTP', + 'Legacy OTP body', + 'Legacy Sender', + 'legacy-sender@appwrite.io', + 'legacy-reply@appwrite.io', + 'Legacy Reply Team', + ); + $this->assertSame(200, $update['headers']['status-code']); + + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $get = $this->client->call( + Client::METHOD_GET, + '/project/templates/email/otpSession', + $headers, + ['locale' => 'en'], + ); + + $this->assertSame(200, $get['headers']['status-code']); + // Legacy fields present + $this->assertArrayHasKey('type', $get['body']); + $this->assertArrayHasKey('replyTo', $get['body']); + $this->assertSame('otpSession', $get['body']['type']); + $this->assertSame('legacy-reply@appwrite.io', $get['body']['replyTo']); + // New fields stripped + $this->assertArrayNotHasKey('templateId', $get['body']); + $this->assertArrayNotHasKey('replyToEmail', $get['body']); + $this->assertArrayNotHasKey('replyToName', $get['body']); + $this->assertArrayNotHasKey('custom', $get['body']); + + // Cleanup + $this->deleteEmailTemplate('otpSession', 'en'); + } + // ========================================================================= // Helpers // ========================================================================= From dfec2b3cb7787ee58207199f862697fcc0bfc46e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 16:11:22 +0200 Subject: [PATCH 110/402] Improve test coverage --- .../Http/Project/SMTP/Tests/Create.php | 4 - .../Project/Http/Project/SMTP/Update.php | 6 +- tests/e2e/Services/Project/SMTPBase.php | 150 ++++++++++++++++++ 3 files changed, 154 insertions(+), 6 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php index a07d667d56..7095c2d2d0 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -123,10 +123,6 @@ class Create extends Action $secure = $smtp['secure'] ?? ''; } - if (empty($senderName)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender name must be configured on the project to send a test email.'); - } - if (empty($senderEmail)) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender email must be configured on the project to send a test email.'); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index edfb45a6e6..63e9cf287e 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -115,7 +115,7 @@ class Update extends Action } // Validate SMTP credentials - if ($smtp['enabled'] === true) { + if (\is_null($smtp['enabled'] ?? null) || $smtp['enabled'] === true) { $mail = new PHPMailer(true); $mail->isSMTP(); @@ -150,7 +150,9 @@ class Update extends Action $smtp['enabled'] = true; } } catch (Throwable $error) { - throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); + if (($smtp['enabled'] ?? null) === true) { + throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); + } } } diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index c6f7ea4b67..c8cb06ac4e 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -656,6 +656,156 @@ trait SMTPBase $this->assertSame(false, $response['body']['smtpEnabled']); } + public function testUpdateSMTPRequiredFieldsOptionalAfterConfigured(): void + { + // Seed with a known configuration so required fields (host, port, senderEmail) are stored. + $this->updateSMTP( + senderName: 'Initial Sender', + senderEmail: 'initial@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + // Partial update: only update senderName, omitting host/port/senderEmail. + // Required fields should not be re-required because they are already stored. + $response = $this->updateSMTP(senderName: 'Updated Sender'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Updated Sender', $response['body']['smtpSenderName']); + $this->assertSame('initial@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPAllParamsOptionalAfterConfigured(): void + { + // Seed a configuration so all fields are stored. + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + // Issue a PATCH with no params at all. Once previously configured, this must succeed. + $response = $this->updateSMTP(); + + $this->assertSame(200, $response['headers']['status-code']); + // Previously-set values are preserved + $this->assertSame('Test Sender', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPEnabledTrueWithInvalidCredentials(): void + { + // Explicitly enabling SMTP with unreachable host/port must throw. + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: true, + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_smtp_config_invalid', $response['body']['type']); + } + + public function testUpdateSMTPEnabledFalseWithInvalidCredentials(): void + { + // enabled=false means SMTP is not in use, so invalid credentials must be accepted. + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: false, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + $this->assertSame('localhost', $response['body']['smtpHost']); + $this->assertSame(12345, $response['body']['smtpPort']); + + // Cleanup (restore valid disabled config) + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + } + + public function testUpdateSMTPEnabledNullWithInvalidCredentialsDoesNotThrow(): void + { + // Ensure SMTP is currently disabled so we aren't enforcing validation on an enabled config. + $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + // With enabled omitted (null) and invalid credentials, the request must not throw. + // SMTP remains disabled because the credentials could not be validated. + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + + // Cleanup (restore valid disabled config) + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + } + + public function testUpdateSMTPEnabledNullWithValidCredentialsAutoEnables(): void + { + // Start from a disabled state. + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + // With enabled omitted (null) and valid credentials, SMTP must be auto-enabled. + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + // Create SMTP test tests public function testCreateSMTPTest(): void From 8ea69e03212ecbdc53f8c23accfcf01f4e103815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 16:57:23 +0200 Subject: [PATCH 111/402] Fix password visibility test --- src/Appwrite/Utopia/Response/Model/Project.php | 9 +++------ tests/e2e/Services/Project/SMTPBase.php | 14 ++++++++++++-- .../Projects/ProjectsConsoleClientTest.php | 6 ++++-- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index aef4927ad7..11e23b02d8 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -277,15 +277,12 @@ class Project extends Model 'default' => '', 'example' => 'emailuser', ]) - /* - We intentionally do not expose SMTP password - it's write-only property. ->addRule('smtpPassword', [ 'type' => self::TYPE_STRING, - 'description' => 'SMTP server password', + 'description' => 'SMTP server password. This property is write-only and always returned empty.', 'default' => '', - 'example' => 'securepassword', + 'example' => '', ]) - */ ->addRule('smtpSecure', [ 'type' => self::TYPE_STRING, 'description' => 'SMTP server secure protocol', @@ -423,7 +420,7 @@ class Project extends Model $document->setAttribute('smtpHost', $smtp['host'] ?? ''); $document->setAttribute('smtpPort', $smtp['port'] ?? ''); $document->setAttribute('smtpUsername', $smtp['username'] ?? ''); - $document->setAttribute('smtpPassword', $smtp['password'] ?? ''); + $document->setAttribute('smtpPassword', ''); // Write-only: never expose the stored value $document->setAttribute('smtpSecure', $smtp['secure'] ?? ''); } diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index c8cb06ac4e..fa58891b08 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -88,6 +88,8 @@ trait SMTPBase senderEmail: 'sender@example.com', host: 'maildev', port: 1025, + username: 'user', + password: 'password', enabled: true, ); @@ -103,6 +105,8 @@ trait SMTPBase $this->assertArrayHasKey('smtpPort', $response['body']); $this->assertArrayHasKey('smtpUsername', $response['body']); $this->assertArrayHasKey('smtpPassword', $response['body']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertSame('', $response['body']['smtpPassword']); $this->assertArrayHasKey('smtpSecure', $response['body']); // Cleanup @@ -219,6 +223,8 @@ trait SMTPBase senderEmail: 'sender@example.com', host: 'maildev', port: 1025, + username: 'user', + password: 'password', ); $this->assertSame(200, $response['headers']['status-code']); @@ -233,6 +239,8 @@ trait SMTPBase $this->assertArrayHasKey('smtpPort', $response['body']); $this->assertArrayHasKey('smtpUsername', $response['body']); $this->assertArrayHasKey('smtpPassword', $response['body']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertSame('', $response['body']['smtpPassword']); $this->assertArrayHasKey('smtpSecure', $response['body']); // Cleanup @@ -455,7 +463,8 @@ trait SMTPBase ); $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame('p', $response['body']['smtpPassword']); + // smtpPassword is write-only: the accepted password must not be echoed back + $this->assertSame('', $response['body']['smtpPassword']); // Cleanup $this->updateSMTP(enabled: false); @@ -473,7 +482,8 @@ trait SMTPBase ); $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame($password, $response['body']['smtpPassword']); + // smtpPassword is write-only: the accepted password must not be echoed back + $this->assertSame('', $response['body']['smtpPassword']); // Cleanup $this->updateSMTP(enabled: false); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 4400c337ac..9506c1a963 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -971,7 +971,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals($smtpHost, $response['body']['smtpHost']); $this->assertEquals($smtpPort, $response['body']['smtpPort']); $this->assertEquals($smtpUsername, $response['body']['smtpUsername']); - $this->assertEquals($smtpPassword, $response['body']['smtpPassword']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertEquals('', $response['body']['smtpPassword']); $this->assertEquals('', $response['body']['smtpSecure']); // Check the project @@ -987,7 +988,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals($smtpHost, $response['body']['smtpHost']); $this->assertEquals($smtpPort, $response['body']['smtpPort']); $this->assertEquals($smtpUsername, $response['body']['smtpUsername']); - $this->assertEquals($smtpPassword, $response['body']['smtpPassword']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertEquals('', $response['body']['smtpPassword']); $this->assertEquals('', $response['body']['smtpSecure']); /** From 8c03db70e9484e5eeaeb688a70e721bce06f3a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:04:20 +0200 Subject: [PATCH 112/402] Finalize templates design --- app/config/errors.php | 5 + src/Appwrite/Extend/Exception.php | 1 + .../Http/Project/Platforms/Web/Create.php | 2 +- .../Http/Project/Templates/Email/Delete.php | 109 ---- .../Http/Project/Templates/Email/Get.php | 144 ++-- .../Http/Project/Templates/Email/Update.php | 67 +- .../Modules/Project/Services/Http.php | 2 - .../Utopia/Response/Model/TemplateEmail.php | 6 - tests/e2e/Services/Project/TemplatesBase.php | 617 ------------------ 9 files changed, 119 insertions(+), 834 deletions(-) delete mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php diff --git a/app/config/errors.php b/app/config/errors.php index 4190c6e277..1d9d4593b9 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1144,6 +1144,11 @@ return [ 'description' => 'Provided SMTP config is invalid. Please check the configured values and try again.', 'code' => 400, ], + Exception::PROJECT_TEMPLATE_CONFIG_INVALID => [ + 'name' => Exception::PROJECT_TEMPLATE_CONFIG_INVALID, + 'description' => 'Provided template config is invalid. Please check the configured values and try again.', + 'code' => 400, + ], Exception::PROJECT_TEMPLATE_DEFAULT_DELETION => [ 'name' => Exception::PROJECT_TEMPLATE_DEFAULT_DELETION, 'description' => 'You can\'t delete default template. If you are trying to reset your template changes, you can ignore this error as it\'s already been reset.', diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 58a21b5517..b2eb06b752 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -303,6 +303,7 @@ class Exception extends \Exception public const string ACCOUNT_KEY_EXPIRED = 'account_key_expired'; public const string PROJECT_SMTP_CONFIG_INVALID = 'project_smtp_config_invalid'; + public const string PROJECT_TEMPLATE_CONFIG_INVALID = 'project_template_config_invalid'; public const string PROJECT_TEMPLATE_DEFAULT_DELETION = 'project_template_default_deletion'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php index 2fca0ace6c..6c07727150 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php @@ -139,7 +139,7 @@ class Create extends Action if (empty($key) && empty($type)) { // Modern request, validate hostname if (empty($hostname)) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "hostname" is not optional.'); + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "hostname" is not optional.'); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php deleted file mode 100644 index 7928486192..0000000000 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php +++ /dev/null @@ -1,109 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) - ->setHttpPath('/v1/project/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale') - ->desc('Delete project email template') - ->groups(['api', 'project']) - ->label('scope', 'templates.write') - ->label('event', 'templates.[templateType].delete') - ->label('audits.event', 'project.template.delete') - ->label('audits.resource', 'project.template/{request.templateId}') - ->label('sdk', new Method( - namespace: 'project', - group: 'templates', - name: 'deleteEmailTemplate', - description: <<param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) - ->inject('response') - ->inject('queueForEvents') - ->inject('dbForPlatform') - ->inject('authorization') - ->inject('project') - ->inject('locale') - ->callback($this->action(...)); - } - - public function action( - string $templateId, - string $locale, - Response $response, - QueueEvent $queueForEvents, - Database $dbForPlatform, - Authorization $authorization, - Document $project, - Locale $localeObject, - ) { - $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $templateId . '-' . $locale] ?? null; - - if (is_null($template)) { - throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); - } - - unset($templates['email.' . $templateId . '-' . $locale]); - - $updates = new Document([ - 'templates' => $templates, - ]); - - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - - $queueForEvents->setParam('templateType', $templateId); - - $response->dynamic(new Document([ - 'templateId' => $templateId, - 'locale' => $locale, - 'senderName' => $template['senderName'] ?? '', - 'senderEmail' => $template['senderEmail'] ?? '', - 'subject' => $template['subject'] ?? '', - 'replyToEmail' => $template['replyToEmail'] ?? $template['replyTo'] ?? '', // Includes backwards compatibility - 'replyToName' => $template['replyToName'] ?? '', - 'message' => $template['message'] ?? '', - 'custom' => true, - ]), Response::MODEL_EMAIL_TEMPLATE); - } -} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 6e2b2ef56d..1843c556d4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -51,7 +51,6 @@ class Get extends Action ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) ->inject('response') ->inject('project') - ->inject('locale') ->callback($this->action(...)); } @@ -60,87 +59,84 @@ class Get extends Action string $locale, Response $response, Document $project, - Locale $localeObject, ) { $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + // Get custom template if available $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $templateId . '-' . $locale] ?? null; - - // Includes backwards compatibility: fall back to legacy `replyTo` key - if (!is_null($template)) { - $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; - $template['replyToName'] = $template['replyToName'] ?? ''; - } - - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); - - if (is_null($template)) { - /** - * different templates, different placeholders. - */ - $templateConfigs = [ - 'magicSession' => [ - 'file' => 'email-magic-url.tpl', - 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] - ], - 'mfaChallenge' => [ - 'file' => 'email-mfa-challenge.tpl', - 'placeholders' => ['description', 'clientInfo'] - ], - 'otpSession' => [ - 'file' => 'email-otp.tpl', - 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] - ], - 'sessionAlert' => [ - 'file' => 'email-session-alert.tpl', - 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] - ], - ]; - - // fallback to the base template. - $config = $templateConfigs[$templateId] ?? [ - 'file' => 'email-inner-base.tpl', - 'placeholders' => ['buttonText', 'body', 'footer'] - ]; - - $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); - - // We use `fromString` due to the replace above - $message = Template::fromString($templateString); - - // Set type-specific parameters - foreach ($config['placeholders'] as $param) { - $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); - $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml); - } - - $message - // common placeholders on all the templates - ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello")) - ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature")); - - // `useContent: false` will strip new lines! - $message = $message->render(useContent: true); - - $template = [ - 'message' => $message, - 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'), - 'senderEmail' => '', - 'senderName' => '', - 'replyToEmail' => '', - 'replyToName' => '', - 'custom' => false, - ]; - } else { - $template['custom'] = true; - } + $template = $templates['email.' . $templateId . '-' . $locale] ?? []; + // Enforced params $template['templateId'] = $templateId; $template['locale'] = $locale; + // Prepare default tempaltes + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + + $defaultSubject = $localeObj->getText('emails.' . $templateId . '.subject'); + $defaultMessage = $this->getDefaultMessage($templateId, $localeObj); + + // Apply defaults if needed + if (\is_null($template['message'])) { + $template['message'] = $defaultMessage; + } + + if (\is_null($template['subject'])) { + $template['subject'] = $defaultSubject; + } + + // Backwards compatibility + if (!\is_null($template['replyTo'])) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + } + $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); } + + protected function getDefaultMessage(string $templateId, Locale $localeObj): string + { + $templateConfigs = [ + 'magicSession' => [ + 'file' => 'email-magic-url.tpl', + 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] + ], + 'mfaChallenge' => [ + 'file' => 'email-mfa-challenge.tpl', + 'placeholders' => ['description', 'clientInfo'] + ], + 'otpSession' => [ + 'file' => 'email-otp.tpl', + 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] + ], + 'sessionAlert' => [ + 'file' => 'email-session-alert.tpl', + 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] + ], + ]; + + // fallback to the base template. + $config = $templateConfigs[$templateId] ?? [ + 'file' => 'email-inner-base.tpl', + 'placeholders' => ['buttonText', 'body', 'footer'] + ]; + + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); + $message = Template::fromString($templateString); + + // Set type-specific parameters + foreach ($config['placeholders'] as $param) { + $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); + $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml); + } + + $message + ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello")) + ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks")) + ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature")); + + $message = $message->render(useContent: true); + + return $message; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index 93cfa7a3fb..4a1d4a5a21 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Templates\Email; use Appwrite\Event\Event as QueueEvent; +use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -12,10 +13,10 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Emails\Validator\Email; -use Utopia\Locale\Locale; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; +use Utopia\Validator\Nullable; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; @@ -57,51 +58,68 @@ class Update extends Action )) ->param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) - ->param('subject', '', new Text(255), 'Subject of the email template. Can be up to 255 characters.') - ->param('message', '', new Text(10485760), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender.', true) - ->param('senderEmail', '', new Email(), 'Email of the sender.', true) - ->param('replyToEmail', '', new Email(), 'Reply to email.', true) - ->param('replyToName', '', new Text(255, 0), 'Reply to name.', true) + ->param('subject', null, new Nullable(new Text(255)), 'Subject of the email template. Can be up to 255 characters.') + ->param('message', null, new Nullable(new Text(10485760)), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') + ->param('senderName', null, new Nullable(new Text(255, 0)), 'Name of the email sender.', true) + ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', true) + ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', true) + ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', true) ->inject('response') ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('authorization') ->inject('project') - ->inject('locale') ->callback($this->action(...)); } public function action( string $templateId, string $locale, - string $subject, - string $message, - string $senderName, - string $senderEmail, - string $replyToEmail, - string $replyToName, + ?string $subject, + ?string $message, + ?string $senderName, + ?string $senderEmail, + ?string $replyToEmail, + ?string $replyToName, Response $response, QueueEvent $queueForEvents, Database $dbForPlatform, Authorization $authorization, Document $project, - Locale $localeObject, ) { $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); - $template = [ - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyToEmail' => $replyToEmail, - 'replyToName' => $replyToName, - 'message' => $message - ]; + // Prevent template update if custom SMTP is not configured + $smtp = $project->getAttribute('smtp', []); + if (($smtp['enabled'] ?? false) !== true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to configure custom email templates.'); + } + // Fetch current configuration $templates = $project->getAttribute('templates', []); - $templates['email.' . $templateId . '-' . $locale] = $template; + $template = $templates['email.' . $templateId . '-' . $locale] ?? []; + // Apply changes + $keys = ['senderName', 'senderEmail', 'replyToEmail', 'replyToName', 'message', 'subject']; + foreach ($keys as $key) { + if (!\is_null(${$key})) { + $template[$key] = ${$key}; + } + } + + // Backwards compatibility + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + + // Ensure required fields are set + $requiredKeys = ['subject', 'message']; + foreach ($requiredKeys as $key) { + if (empty($template[$key])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "' . $key . '" is not optional.'); + } + } + + // Save configuration + $templates['email.' . $templateId . '-' . $locale] = $template; $updates = new Document([ 'templates' => $templates, ]); @@ -119,7 +137,6 @@ class Update extends Action 'replyToEmail' => $template['replyToEmail'], 'replyToName' => $template['replyToName'], 'message' => $template['message'], - 'custom' => true, ]), Response::MODEL_EMAIL_TEMPLATE); } } diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index b2ee23aca6..5774b377c9 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -26,7 +26,6 @@ use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdatePro use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; -use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Delete as DeleteTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; @@ -56,7 +55,6 @@ class Http extends Service // Templates $this->addAction(GetTemplate::getName(), new GetTemplate()); - $this->addAction(DeleteTemplate::getName(), new DeleteTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); // Variables diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index 08ccd33524..833de90065 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -58,12 +58,6 @@ class TemplateEmail extends Model 'default' => '', 'example' => 'Please verify your email address', ]) - ->addRule('custom', [ - 'type' => self::TYPE_BOOLEAN, - 'description' => 'Whether the template has been customized for the project. Non-custom templates render from defaults.', - 'default' => false, - 'example' => false, - ]) ; } diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index ade2bb3f2c..b53db7b6eb 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -6,621 +6,4 @@ use Tests\E2E\Client; trait TemplatesBase { - // ========================================================================= - // Get email template tests - // ========================================================================= - - public function testGetEmailTemplateDefault(): void - { - $template = $this->getEmailTemplate('verification', 'en'); - - $this->assertSame(200, $template['headers']['status-code']); - $this->assertSame('verification', $template['body']['templateId']); - $this->assertSame('en', $template['body']['locale']); - $this->assertFalse($template['body']['custom']); - $this->assertNotEmpty($template['body']['subject']); - $this->assertNotEmpty($template['body']['message']); - } - - public function testGetEmailTemplateDefaultLocale(): void - { - $template = $this->getEmailTemplate('verification'); - - $this->assertSame(200, $template['headers']['status-code']); - $this->assertSame('verification', $template['body']['templateId']); - $this->assertSame('en', $template['body']['locale']); - $this->assertFalse($template['body']['custom']); - } - - public function testGetEmailTemplateCustom(): void - { - $update = $this->updateEmailTemplate('magicSession', 'en', 'Magic Subject', 'Magic Body'); - $this->assertSame(200, $update['headers']['status-code']); - - $get = $this->getEmailTemplate('magicSession', 'en'); - - $this->assertSame(200, $get['headers']['status-code']); - $this->assertSame('magicSession', $get['body']['templateId']); - $this->assertSame('en', $get['body']['locale']); - $this->assertTrue($get['body']['custom']); - $this->assertSame('Magic Subject', $get['body']['subject']); - $this->assertSame('Magic Body', $get['body']['message']); - - // Cleanup - $this->deleteEmailTemplate('magicSession', 'en'); - } - - public function testGetEmailTemplateInvalidType(): void - { - $template = $this->getEmailTemplate('notATemplate', 'en'); - - $this->assertSame(400, $template['headers']['status-code']); - } - - public function testGetEmailTemplateInvalidLocale(): void - { - $template = $this->getEmailTemplate('verification', 'not-a-locale'); - - $this->assertSame(400, $template['headers']['status-code']); - } - - public function testGetEmailTemplateWithoutAuthentication(): void - { - $template = $this->getEmailTemplate('verification', 'en', false); - - $this->assertSame(401, $template['headers']['status-code']); - } - - // ========================================================================= - // Update email template tests - // ========================================================================= - - public function testUpdateEmailTemplate(): void - { - $update = $this->updateEmailTemplate( - 'verification', - 'en', - 'Please verify your email', - 'Click here to verify: {{url}}', - ); - - $this->assertSame(200, $update['headers']['status-code']); - $this->assertSame('verification', $update['body']['templateId']); - $this->assertSame('en', $update['body']['locale']); - $this->assertSame('Please verify your email', $update['body']['subject']); - $this->assertSame('Click here to verify: {{url}}', $update['body']['message']); - $this->assertTrue($update['body']['custom']); - - // Verify persisted via GET - $get = $this->getEmailTemplate('verification', 'en'); - $this->assertSame(200, $get['headers']['status-code']); - $this->assertSame('Please verify your email', $get['body']['subject']); - $this->assertSame('Click here to verify: {{url}}', $get['body']['message']); - $this->assertTrue($get['body']['custom']); - - // Cleanup - $this->deleteEmailTemplate('verification', 'en'); - } - - public function testUpdateEmailTemplateWithOptionalFields(): void - { - $update = $this->updateEmailTemplate( - 'invitation', - 'en', - 'Team invitation', - 'You have been invited', - 'Appwrite Team', - 'team@appwrite.io', - 'reply@appwrite.io', - ); - - $this->assertSame(200, $update['headers']['status-code']); - $this->assertSame('Team invitation', $update['body']['subject']); - $this->assertSame('You have been invited', $update['body']['message']); - $this->assertSame('Appwrite Team', $update['body']['senderName']); - $this->assertSame('team@appwrite.io', $update['body']['senderEmail']); - $this->assertSame('reply@appwrite.io', $update['body']['replyToEmail']); - - // Cleanup - $this->deleteEmailTemplate('invitation', 'en'); - } - - public function testUpdateEmailTemplateDefaultLocale(): void - { - $update = $this->updateEmailTemplate( - 'sessionAlert', - null, - 'Session alert', - 'Someone signed in', - ); - - $this->assertSame(200, $update['headers']['status-code']); - $this->assertSame('sessionAlert', $update['body']['templateId']); - $this->assertSame('en', $update['body']['locale']); - - // Cleanup - $this->deleteEmailTemplate('sessionAlert', 'en'); - } - - public function testUpdateEmailTemplateOverwrite(): void - { - $this->updateEmailTemplate('otpSession', 'en', 'First', 'First body'); - - $second = $this->updateEmailTemplate('otpSession', 'en', 'Second', 'Second body'); - - $this->assertSame(200, $second['headers']['status-code']); - $this->assertSame('Second', $second['body']['subject']); - $this->assertSame('Second body', $second['body']['message']); - - $get = $this->getEmailTemplate('otpSession', 'en'); - $this->assertSame('Second', $get['body']['subject']); - - // Cleanup - $this->deleteEmailTemplate('otpSession', 'en'); - } - - public function testUpdateEmailTemplateInvalidType(): void - { - $update = $this->updateEmailTemplate('notATemplate', 'en', 'Subject', 'Message'); - - $this->assertSame(400, $update['headers']['status-code']); - } - - public function testUpdateEmailTemplateMissingSubject(): void - { - $update = $this->updateEmailTemplate('verification', 'en', null, 'Message only'); - - $this->assertSame(400, $update['headers']['status-code']); - } - - public function testUpdateEmailTemplateMissingMessage(): void - { - $update = $this->updateEmailTemplate('verification', 'en', 'Subject only', null); - - $this->assertSame(400, $update['headers']['status-code']); - } - - public function testUpdateEmailTemplateInvalidSenderEmail(): void - { - $update = $this->updateEmailTemplate( - 'verification', - 'en', - 'Subject', - 'Message', - 'Sender', - 'not-an-email', - ); - - $this->assertSame(400, $update['headers']['status-code']); - } - - public function testUpdateEmailTemplateInvalidReplyTo(): void - { - $update = $this->updateEmailTemplate( - 'verification', - 'en', - 'Subject', - 'Message', - null, - null, - 'not-an-email', - ); - - $this->assertSame(400, $update['headers']['status-code']); - } - - public function testUpdateEmailTemplateWithoutAuthentication(): void - { - $update = $this->updateEmailTemplate( - 'verification', - 'en', - 'Subject', - 'Message', - null, - null, - null, - false, - ); - - $this->assertSame(401, $update['headers']['status-code']); - } - - // ========================================================================= - // Delete email template tests - // ========================================================================= - - public function testDeleteEmailTemplate(): void - { - $update = $this->updateEmailTemplate('mfaChallenge', 'en', 'MFA', 'Enter code'); - $this->assertSame(200, $update['headers']['status-code']); - - $customBefore = $this->getEmailTemplate('mfaChallenge', 'en'); - $this->assertTrue($customBefore['body']['custom']); - - $delete = $this->deleteEmailTemplate('mfaChallenge', 'en'); - $this->assertSame(204, $delete['headers']['status-code']); - $this->assertEmpty($delete['body']); - - // Verify reset back to default - $after = $this->getEmailTemplate('mfaChallenge', 'en'); - $this->assertSame(200, $after['headers']['status-code']); - $this->assertFalse($after['body']['custom']); - $this->assertNotSame('MFA', $after['body']['subject']); - } - - public function testDeleteEmailTemplateDefault(): void - { - // Attempt to delete a template that was never customized - $delete = $this->deleteEmailTemplate('verification', 'fr'); - - $this->assertSame(401, $delete['headers']['status-code']); - $this->assertSame('project_template_default_deletion', $delete['body']['type']); - } - - public function testDeleteEmailTemplateInvalidType(): void - { - $delete = $this->deleteEmailTemplate('notATemplate', 'en'); - - $this->assertSame(400, $delete['headers']['status-code']); - } - - public function testDeleteEmailTemplateWithoutAuthentication(): void - { - $update = $this->updateEmailTemplate('recovery', 'en', 'Recovery', 'Reset password'); - $this->assertSame(200, $update['headers']['status-code']); - - $delete = $this->deleteEmailTemplate('recovery', 'en', false); - - $this->assertSame(401, $delete['headers']['status-code']); - - // Verify still customized - $get = $this->getEmailTemplate('recovery', 'en'); - $this->assertTrue($get['body']['custom']); - - // Cleanup - $this->deleteEmailTemplate('recovery', 'en'); - } - - // ========================================================================= - // Legacy response format tests (request + response filters) - // ========================================================================= - - public function testGetEmailTemplateLegacyResponseFormat(): void - { - $headers = \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()); - - $template = $this->client->call( - Client::METHOD_GET, - '/project/templates/email/verification', - $headers, - ); - - $this->assertSame(200, $template['headers']['status-code']); - // Response filter should rename templateId -> type for < 1.9.2 clients. - $this->assertArrayHasKey('type', $template['body']); - $this->assertArrayNotHasKey('templateId', $template['body']); - $this->assertSame('verification', $template['body']['type']); - $this->assertSame('en', $template['body']['locale']); - } - - public function testUpdateEmailTemplateLegacyResponseFormat(): void - { - $headers = \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()); - - // Request filter should accept legacy `type` and map it to `templateId`. - $update = $this->client->call( - Client::METHOD_PATCH, - '/project/templates/email', - $headers, - [ - 'type' => 'magicSession', - 'locale' => 'en', - 'subject' => 'Legacy Subject', - 'message' => 'Legacy Body', - ], - ); - - $this->assertSame(200, $update['headers']['status-code']); - // Response filter should rename templateId -> type for < 1.9.2 clients. - $this->assertArrayHasKey('type', $update['body']); - $this->assertArrayNotHasKey('templateId', $update['body']); - $this->assertSame('magicSession', $update['body']['type']); - $this->assertSame('Legacy Subject', $update['body']['subject']); - $this->assertSame('Legacy Body', $update['body']['message']); - $this->assertTrue($update['body']['custom']); - - // Verify persisted, then cleanup via legacy DELETE with `type`. - $get = $this->getEmailTemplate('magicSession', 'en'); - $this->assertSame(200, $get['headers']['status-code']); - $this->assertTrue($get['body']['custom']); - - $delete = $this->client->call( - Client::METHOD_DELETE, - '/project/templates/email', - $headers, - [ - 'type' => 'magicSession', - 'locale' => 'en', - ], - ); - $this->assertSame(204, $delete['headers']['status-code']); - - $after = $this->getEmailTemplate('magicSession', 'en'); - $this->assertFalse($after['body']['custom']); - } - - public function testDeleteEmailTemplateLegacyResponseFormat(): void - { - // Seed a custom template using the current API. - $update = $this->updateEmailTemplate('otpSession', 'en', 'Legacy OTP', 'Legacy OTP body'); - $this->assertSame(200, $update['headers']['status-code']); - - $headers = \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()); - - // Request filter should accept legacy `type` and map it to `templateId`. - $delete = $this->client->call( - Client::METHOD_DELETE, - '/project/templates/email', - $headers, - [ - 'type' => 'otpSession', - 'locale' => 'en', - ], - ); - - $this->assertSame(204, $delete['headers']['status-code']); - $this->assertEmpty($delete['body']); - - // Verify reset back to default. - $after = $this->getEmailTemplate('otpSession', 'en'); - $this->assertSame(200, $after['headers']['status-code']); - $this->assertFalse($after['body']['custom']); - $this->assertNotSame('Legacy OTP', $after['body']['subject']); - } - - public function testDeleteEmailTemplateLegacyInvalidType(): void - { - $headers = \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()); - - $delete = $this->client->call( - Client::METHOD_DELETE, - '/project/templates/email', - $headers, - [ - 'type' => 'notATemplate', - 'locale' => 'en', - ], - ); - - $this->assertSame(400, $delete['headers']['status-code']); - } - - public function testUpdateEmailTemplateLegacyInvalidType(): void - { - $headers = \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()); - - $update = $this->client->call( - Client::METHOD_PATCH, - '/project/templates/email', - $headers, - [ - 'type' => 'notATemplate', - 'locale' => 'en', - 'subject' => 'Subject', - 'message' => 'Message', - ], - ); - - $this->assertSame(400, $update['headers']['status-code']); - } - - public function testUpdateEmailTemplateLegacyReplyTo(): void - { - $headers = \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()); - - // Legacy clients send replyTo (not replyToEmail) — request filter maps it. - $update = $this->client->call( - Client::METHOD_PATCH, - '/project/templates/email', - $headers, - [ - 'type' => 'invitation', - 'locale' => 'en', - 'subject' => 'Legacy reply-to subject', - 'message' => 'Legacy reply-to body', - 'senderName' => 'Legacy Sender', - 'senderEmail' => 'legacy-sender@appwrite.io', - 'replyTo' => 'legacy-reply@appwrite.io', - ], - ); - - $this->assertSame(200, $update['headers']['status-code']); - // Response filter should rename replyToEmail -> replyTo, strip replyToName / custom. - $this->assertArrayHasKey('replyTo', $update['body']); - $this->assertArrayNotHasKey('replyToEmail', $update['body']); - $this->assertArrayNotHasKey('replyToName', $update['body']); - $this->assertArrayNotHasKey('custom', $update['body']); - $this->assertSame('legacy-reply@appwrite.io', $update['body']['replyTo']); - $this->assertSame('Legacy Sender', $update['body']['senderName']); - $this->assertSame('legacy-sender@appwrite.io', $update['body']['senderEmail']); - - // Verify value is persisted and readable via the legacy GET shape. - $get = $this->client->call( - Client::METHOD_GET, - '/project/templates/email/invitation', - $headers, - ['locale' => 'en'], - ); - $this->assertSame(200, $get['headers']['status-code']); - $this->assertArrayHasKey('replyTo', $get['body']); - $this->assertArrayNotHasKey('replyToEmail', $get['body']); - $this->assertArrayNotHasKey('replyToName', $get['body']); - $this->assertArrayNotHasKey('custom', $get['body']); - $this->assertSame('legacy-reply@appwrite.io', $get['body']['replyTo']); - - // Cleanup - $this->deleteEmailTemplate('invitation', 'en'); - } - - public function testGetEmailTemplateLegacyReplyTo(): void - { - // Seed a custom template using the current API (includes replyToEmail + replyToName). - $update = $this->updateEmailTemplate( - 'otpSession', - 'en', - 'Legacy OTP', - 'Legacy OTP body', - 'Legacy Sender', - 'legacy-sender@appwrite.io', - 'legacy-reply@appwrite.io', - 'Legacy Reply Team', - ); - $this->assertSame(200, $update['headers']['status-code']); - - $headers = \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()); - - $get = $this->client->call( - Client::METHOD_GET, - '/project/templates/email/otpSession', - $headers, - ['locale' => 'en'], - ); - - $this->assertSame(200, $get['headers']['status-code']); - // Legacy fields present - $this->assertArrayHasKey('type', $get['body']); - $this->assertArrayHasKey('replyTo', $get['body']); - $this->assertSame('otpSession', $get['body']['type']); - $this->assertSame('legacy-reply@appwrite.io', $get['body']['replyTo']); - // New fields stripped - $this->assertArrayNotHasKey('templateId', $get['body']); - $this->assertArrayNotHasKey('replyToEmail', $get['body']); - $this->assertArrayNotHasKey('replyToName', $get['body']); - $this->assertArrayNotHasKey('custom', $get['body']); - - // Cleanup - $this->deleteEmailTemplate('otpSession', 'en'); - } - - // ========================================================================= - // Helpers - // ========================================================================= - - protected function getEmailTemplate(string $type, ?string $locale = null, bool $authenticated = true): mixed - { - $headers = [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ]; - - if ($authenticated) { - $headers = \array_merge($headers, $this->getHeaders()); - } - - $params = []; - if ($locale !== null) { - $params['locale'] = $locale; - } - - return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $type, $headers, $params); - } - - protected function updateEmailTemplate( - string $type, - ?string $locale, - ?string $subject, - ?string $message, - ?string $senderName = null, - ?string $senderEmail = null, - ?string $replyToEmail = null, - ?string $replyToName = null, - bool $authenticated = true, - ): mixed { - $params = [ - 'templateId' => $type, - ]; - - if ($locale !== null) { - $params['locale'] = $locale; - } - if ($subject !== null) { - $params['subject'] = $subject; - } - if ($message !== null) { - $params['message'] = $message; - } - if ($senderName !== null) { - $params['senderName'] = $senderName; - } - if ($senderEmail !== null) { - $params['senderEmail'] = $senderEmail; - } - if ($replyToEmail !== null) { - $params['replyToEmail'] = $replyToEmail; - } - if ($replyToName !== null) { - $params['replyToName'] = $replyToName; - } - - $headers = [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ]; - - if ($authenticated) { - $headers = \array_merge($headers, $this->getHeaders()); - } - - return $this->client->call(Client::METHOD_PATCH, '/project/templates/email', $headers, $params); - } - - protected function deleteEmailTemplate(string $type, ?string $locale = null, bool $authenticated = true): mixed - { - $params = [ - 'templateId' => $type, - ]; - - if ($locale !== null) { - $params['locale'] = $locale; - } - - $headers = [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ]; - - if ($authenticated) { - $headers = \array_merge($headers, $this->getHeaders()); - } - - return $this->client->call(Client::METHOD_DELETE, '/project/templates/email', $headers, $params); - } } From 8bf2f54a511c75c2e19fb189462848f850c1a6c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:04:46 +0200 Subject: [PATCH 113/402] Leftovers --- app/config/errors.php | 5 ----- src/Appwrite/Extend/Exception.php | 1 - 2 files changed, 6 deletions(-) diff --git a/app/config/errors.php b/app/config/errors.php index 1d9d4593b9..4190c6e277 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1144,11 +1144,6 @@ return [ 'description' => 'Provided SMTP config is invalid. Please check the configured values and try again.', 'code' => 400, ], - Exception::PROJECT_TEMPLATE_CONFIG_INVALID => [ - 'name' => Exception::PROJECT_TEMPLATE_CONFIG_INVALID, - 'description' => 'Provided template config is invalid. Please check the configured values and try again.', - 'code' => 400, - ], Exception::PROJECT_TEMPLATE_DEFAULT_DELETION => [ 'name' => Exception::PROJECT_TEMPLATE_DEFAULT_DELETION, 'description' => 'You can\'t delete default template. If you are trying to reset your template changes, you can ignore this error as it\'s already been reset.', diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index b2eb06b752..58a21b5517 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -303,7 +303,6 @@ class Exception extends \Exception public const string ACCOUNT_KEY_EXPIRED = 'account_key_expired'; public const string PROJECT_SMTP_CONFIG_INVALID = 'project_smtp_config_invalid'; - public const string PROJECT_TEMPLATE_CONFIG_INVALID = 'project_template_config_invalid'; public const string PROJECT_TEMPLATE_DEFAULT_DELETION = 'project_template_default_deletion'; From e27719108b6cb6f1da58d82445ddd869a7ed624a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:15:03 +0200 Subject: [PATCH 114/402] Add tests --- tests/e2e/Services/Project/TemplatesBase.php | 731 +++++++++++++++++++ 1 file changed, 731 insertions(+) diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index b53db7b6eb..d6dbd0c6b6 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -6,4 +6,735 @@ use Tests\E2E\Client; trait TemplatesBase { + // Get email template tests + + public function testGetEmailTemplateDefault(): void + { + $response = $this->getEmailTemplate('verification', 'en'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + + public function testGetEmailTemplateDefaultLocale(): void + { + // When locale is omitted, the fallback locale (en) is applied server-side. + $response = $this->getEmailTemplate('recovery'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('recovery', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + + public function testGetEmailTemplateAllSupportedTypes(): void + { + $types = [ + 'verification', + 'magicSession', + 'recovery', + 'invitation', + 'mfaChallenge', + 'sessionAlert', + 'otpSession', + ]; + + foreach ($types as $type) { + $response = $this->getEmailTemplate($type, 'en'); + + $this->assertSame(200, $response['headers']['status-code'], "type={$type}"); + $this->assertSame($type, $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject'], "type={$type} must have default subject"); + $this->assertNotEmpty($response['body']['message'], "type={$type} must have default message"); + } + } + + public function testGetEmailTemplateNonDefaultLocale(): void + { + // Even a non-en locale that has no custom template must return defaults. + $response = $this->getEmailTemplate('verification', 'fr'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('fr', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + + public function testGetEmailTemplateResponseModel(): void + { + $response = $this->getEmailTemplate('verification', 'en'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('templateId', $response['body']); + $this->assertArrayHasKey('locale', $response['body']); + $this->assertArrayHasKey('subject', $response['body']); + $this->assertArrayHasKey('message', $response['body']); + $this->assertArrayHasKey('senderName', $response['body']); + $this->assertArrayHasKey('senderEmail', $response['body']); + $this->assertArrayHasKey('replyToEmail', $response['body']); + $this->assertArrayHasKey('replyToName', $response['body']); + } + + public function testGetEmailTemplateInvalidType(): void + { + $response = $this->getEmailTemplate('notATemplate', 'en'); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testGetEmailTemplateInvalidLocale(): void + { + $response = $this->getEmailTemplate('verification', 'not-a-locale'); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testGetEmailTemplateWithoutAuthentication(): void + { + $response = $this->getEmailTemplate('verification', 'en', false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testGetEmailTemplateReturnsCustomValues(): void + { + $this->ensureSMTPEnabled(); + + $subject = 'Custom invitation subject ' . \uniqid(); + $message = 'Custom invitation body ' . \uniqid(); + + $update = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'en', + subject: $subject, + message: $message, + senderName: 'Invitation Sender', + senderEmail: 'invitation@appwrite.io', + replyToEmail: 'reply-invitation@appwrite.io', + replyToName: 'Invitation Reply', + ); + $this->assertSame(200, $update['headers']['status-code']); + + $get = $this->getEmailTemplate('invitation', 'en'); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('invitation', $get['body']['templateId']); + $this->assertSame('en', $get['body']['locale']); + $this->assertSame($subject, $get['body']['subject']); + $this->assertSame($message, $get['body']['message']); + $this->assertSame('Invitation Sender', $get['body']['senderName']); + $this->assertSame('invitation@appwrite.io', $get['body']['senderEmail']); + $this->assertSame('reply-invitation@appwrite.io', $get['body']['replyToEmail']); + $this->assertSame('Invitation Reply', $get['body']['replyToName']); + } + + public function testGetEmailTemplateCustomizationIsLocaleScoped(): void + { + $this->ensureSMTPEnabled(); + + $enSubject = 'EN only subject ' . \uniqid(); + $update = $this->updateEmailTemplate( + templateId: 'mfaChallenge', + locale: 'en', + subject: $enSubject, + message: 'EN only message', + ); + $this->assertSame(200, $update['headers']['status-code']); + + // Another locale must still return its defaults — not the en customization. + $other = $this->getEmailTemplate('mfaChallenge', 'de'); + $this->assertSame(200, $other['headers']['status-code']); + $this->assertSame('de', $other['body']['locale']); + $this->assertNotSame($enSubject, $other['body']['subject']); + } + + // Update email template tests + + public function testUpdateEmailTemplateRequiredFields(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Please verify your email', + message: 'Click here to verify: {{url}}', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertSame('Please verify your email', $response['body']['subject']); + $this->assertSame('Click here to verify: {{url}}', $response['body']['message']); + } + + public function testUpdateEmailTemplateAllFields(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'en', + subject: 'Password reset', + message: 'Reset your password', + senderName: 'Security Team', + senderEmail: 'security@appwrite.io', + replyToEmail: 'noreply@appwrite.io', + replyToName: 'No Reply', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Password reset', $response['body']['subject']); + $this->assertSame('Reset your password', $response['body']['message']); + $this->assertSame('Security Team', $response['body']['senderName']); + $this->assertSame('security@appwrite.io', $response['body']['senderEmail']); + $this->assertSame('noreply@appwrite.io', $response['body']['replyToEmail']); + $this->assertSame('No Reply', $response['body']['replyToName']); + } + + public function testUpdateEmailTemplateDefaultLocale(): void + { + $this->ensureSMTPEnabled(); + + // Omit locale entirely; server falls back to `en`. + $response = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: null, + subject: 'Session alert', + message: 'Someone signed in', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('sessionAlert', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + } + + public function testUpdateEmailTemplateOverwritesPrevious(): void + { + $this->ensureSMTPEnabled(); + + $first = $this->updateEmailTemplate( + templateId: 'otpSession', + locale: 'en', + subject: 'First subject', + message: 'First body', + ); + $this->assertSame(200, $first['headers']['status-code']); + + $second = $this->updateEmailTemplate( + templateId: 'otpSession', + locale: 'en', + subject: 'Second subject', + message: 'Second body', + ); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame('Second subject', $second['body']['subject']); + $this->assertSame('Second body', $second['body']['message']); + + $get = $this->getEmailTemplate('otpSession', 'en'); + $this->assertSame('Second subject', $get['body']['subject']); + $this->assertSame('Second body', $get['body']['message']); + } + + public function testUpdateEmailTemplatePartialAfterSeed(): void + { + $this->ensureSMTPEnabled(); + + // Seed a fully configured template. + $seed = $this->updateEmailTemplate( + templateId: 'magicSession', + locale: 'en', + subject: 'Magic subject', + message: 'Magic body', + senderName: 'Magic Sender', + senderEmail: 'magic@appwrite.io', + replyToEmail: 'magic-reply@appwrite.io', + replyToName: 'Magic Reply', + ); + $this->assertSame(200, $seed['headers']['status-code']); + + // Once seeded, sending just one field is fine: previous subject/message persist. + $response = $this->updateEmailTemplate( + templateId: 'magicSession', + locale: 'en', + senderName: 'Updated Sender', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Updated Sender', $response['body']['senderName']); + $this->assertSame('Magic subject', $response['body']['subject']); + $this->assertSame('Magic body', $response['body']['message']); + $this->assertSame('magic@appwrite.io', $response['body']['senderEmail']); + $this->assertSame('magic-reply@appwrite.io', $response['body']['replyToEmail']); + $this->assertSame('Magic Reply', $response['body']['replyToName']); + } + + public function testUpdateEmailTemplateDifferentLocales(): void + { + $this->ensureSMTPEnabled(); + + $enUpdate = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'en', + subject: 'English subject', + message: 'English body', + ); + $this->assertSame(200, $enUpdate['headers']['status-code']); + $this->assertSame('en', $enUpdate['body']['locale']); + $this->assertSame('English subject', $enUpdate['body']['subject']); + + $frUpdate = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'fr', + subject: 'Sujet francais', + message: 'Corps francais', + ); + $this->assertSame(200, $frUpdate['headers']['status-code']); + $this->assertSame('fr', $frUpdate['body']['locale']); + $this->assertSame('Sujet francais', $frUpdate['body']['subject']); + + // Locales remain independent. + $enGet = $this->getEmailTemplate('invitation', 'en'); + $this->assertSame('English subject', $enGet['body']['subject']); + + $frGet = $this->getEmailTemplate('invitation', 'fr'); + $this->assertSame('Sujet francais', $frGet['body']['subject']); + } + + public function testUpdateEmailTemplateResponseModel(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Model check subject', + message: 'Model check body', + senderName: 'Sender', + senderEmail: 'sender@appwrite.io', + replyToEmail: 'reply@appwrite.io', + replyToName: 'Reply', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('templateId', $response['body']); + $this->assertArrayHasKey('locale', $response['body']); + $this->assertArrayHasKey('subject', $response['body']); + $this->assertArrayHasKey('message', $response['body']); + $this->assertArrayHasKey('senderName', $response['body']); + $this->assertArrayHasKey('senderEmail', $response['body']); + $this->assertArrayHasKey('replyToEmail', $response['body']); + $this->assertArrayHasKey('replyToName', $response['body']); + } + + public function testUpdateEmailTemplateSubjectMaxLength(): void + { + $this->ensureSMTPEnabled(); + + $subject = \str_repeat('a', 255); + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: $subject, + message: 'Body', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($subject, $response['body']['subject']); + } + + public function testUpdateEmailTemplateSubjectTooLong(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: \str_repeat('a', 256), + message: 'Body', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateSenderNameEmptyAllowed(): void + { + $this->ensureSMTPEnabled(); + + // senderName validator explicitly allows empty strings (Text(255, 0)). + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + senderName: '', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['senderName']); + } + + public function testUpdateEmailTemplateReplyToNameEmptyAllowed(): void + { + $this->ensureSMTPEnabled(); + + // replyToName validator explicitly allows empty strings (Text(255, 0)). + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + replyToName: '', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['replyToName']); + } + + public function testUpdateEmailTemplateSenderNameTooLong(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + senderName: \str_repeat('a', 256), + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidType(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'notATemplate', + locale: 'en', + subject: 'Subject', + message: 'Message', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidLocale(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'not-a-locale', + subject: 'Subject', + message: 'Message', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateMissingSubjectOnFirstWrite(): void + { + $this->ensureSMTPEnabled(); + + // 'recovery'/'de' was never customized, so there is no persisted subject + // to fall back on — the endpoint must reject the request. + $response = $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'de', + subject: null, + message: 'Body only', + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateEmailTemplateMissingMessageOnFirstWrite(): void + { + $this->ensureSMTPEnabled(); + + // 'invitation'/'es' was never customized, so there is no persisted message + // to fall back on — the endpoint must reject the request. + $response = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'es', + subject: 'Subject only', + message: null, + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateEmailTemplateEmptySubject(): void + { + $this->ensureSMTPEnabled(); + + // Text(255) validator requires min length 1 — empty subject is rejected. + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: '', + message: 'Body', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateEmptyMessage(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidSenderEmail(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + senderEmail: 'not-an-email', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidReplyToEmail(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + replyToEmail: 'not-an-email', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateWithoutAuthentication(): void + { + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + authenticated: false, + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateBlockedWhenSMTPDisabled(): void + { + // Custom templates only make sense alongside a custom SMTP configuration. + $this->patchSMTP(['enabled' => false]); + + try { + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Should be blocked', + message: 'Should be blocked', + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + $this->assertStringContainsStringIgnoringCase('SMTP', $response['body']['message']); + } finally { + $this->ensureSMTPEnabled(); + } + } + + // Backwards compatibility (x-appwrite-response-format: 1.9.1) + + public function testGetEmailTemplateLegacyResponseFormat(): void + { + $response = $this->client->call( + Client::METHOD_GET, + '/project/templates/email/verification', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), + ); + + $this->assertSame(200, $response['headers']['status-code']); + // The 1.9.1 response filter renames templateId -> type and strips replyToName. + $this->assertArrayHasKey('type', $response['body']); + $this->assertArrayNotHasKey('templateId', $response['body']); + $this->assertArrayNotHasKey('replyToName', $response['body']); + $this->assertSame('verification', $response['body']['type']); + $this->assertSame('en', $response['body']['locale']); + } + + public function testUpdateEmailTemplateLegacyRequestAndResponse(): void + { + $this->ensureSMTPEnabled(); + + // Legacy clients send `type` + `replyTo`; request filter maps both. + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), + [ + 'type' => 'magicSession', + 'locale' => 'en', + 'subject' => 'Legacy subject', + 'message' => 'Legacy body', + 'senderName' => 'Legacy Sender', + 'senderEmail' => 'legacy-sender@appwrite.io', + 'replyTo' => 'legacy-reply@appwrite.io', + ], + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('type', $response['body']); + $this->assertArrayNotHasKey('templateId', $response['body']); + $this->assertArrayHasKey('replyTo', $response['body']); + $this->assertArrayNotHasKey('replyToEmail', $response['body']); + $this->assertArrayNotHasKey('replyToName', $response['body']); + $this->assertSame('magicSession', $response['body']['type']); + $this->assertSame('Legacy subject', $response['body']['subject']); + $this->assertSame('Legacy body', $response['body']['message']); + $this->assertSame('Legacy Sender', $response['body']['senderName']); + $this->assertSame('legacy-sender@appwrite.io', $response['body']['senderEmail']); + $this->assertSame('legacy-reply@appwrite.io', $response['body']['replyTo']); + + // Modern clients see the new field names for the exact same record. + $modern = $this->getEmailTemplate('magicSession', 'en'); + $this->assertSame('magicSession', $modern['body']['templateId']); + $this->assertSame('legacy-reply@appwrite.io', $modern['body']['replyToEmail']); + } + + public function testUpdateEmailTemplateLegacyInvalidType(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), + [ + 'type' => 'notATemplate', + 'locale' => 'en', + 'subject' => 'Subject', + 'message' => 'Message', + ], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // Helpers + + protected function getEmailTemplate(string $templateId, ?string $locale = null, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($locale !== null) { + $params['locale'] = $locale; + } + + return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $templateId, $headers, $params); + } + + protected function updateEmailTemplate( + string $templateId, + ?string $locale = null, + ?string $subject = null, + ?string $message = null, + ?string $senderName = null, + ?string $senderEmail = null, + ?string $replyToEmail = null, + ?string $replyToName = null, + bool $authenticated = true, + ): mixed { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = ['templateId' => $templateId]; + + foreach (['locale', 'subject', 'message', 'senderName', 'senderEmail', 'replyToEmail', 'replyToName'] as $key) { + if (!\is_null(${$key})) { + $params[$key] = ${$key}; + } + } + + return $this->client->call(Client::METHOD_PATCH, '/project/templates/email', $headers, $params); + } + + protected function ensureSMTPEnabled(): void + { + $this->patchSMTP([ + 'enabled' => true, + 'senderName' => 'Mailer', + 'senderEmail' => 'mailer@appwrite.io', + 'host' => 'maildev', + 'port' => 1025, + 'username' => 'user', + 'password' => 'password', + ]); + } + + /** + * @param array $params + */ + protected function patchSMTP(array $params): void + { + $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + $params, + ); + } + } From 9e94f15f024a47c71021b93fa0317d9069062fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:23:34 +0200 Subject: [PATCH 115/402] Finalize tests --- app/controllers/api/projects.php | 27 ++++ tests/e2e/Services/Project/TemplatesBase.php | 153 ++++++++++++++++--- 2 files changed, 161 insertions(+), 19 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 562ff15c39..78583d4748 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -647,3 +647,30 @@ Http::patch('/v1/projects/:projectId/auth/session-invalidation') $response->dynamic($project, Response::MODEL_PROJECT); }); + +// Backwards compatibility +Http::delete('/v1/projects/:projectId/templates/email') + ->alias('/v1/projects/:projectId/templates/email/:type/:locale') + ->desc('Delete custom email template') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) + ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) + ->inject('response') + ->inject('dbForPlatform') + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + + $project = $dbForPlatform->getDocument('projects', $projectId); + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $templates = $project->getAttribute('templates', []); + unset($templates['email.' . $type . '-' . $locale]); + + $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); + + $response->noContent(); + }); diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index d6dbd0c6b6..efdc3e4579 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -3,6 +3,7 @@ namespace Tests\E2E\Services\Project; use Tests\E2E\Client; +use Utopia\Database\Helpers\ID; trait TemplatesBase { @@ -549,7 +550,15 @@ trait TemplatesBase public function testUpdateEmailTemplateBlockedWhenSMTPDisabled(): void { // Custom templates only make sense alongside a custom SMTP configuration. - $this->patchSMTP(['enabled' => false]); + $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + ['enabled' => false], + ); try { $response = $this->updateEmailTemplate( @@ -656,6 +665,121 @@ trait TemplatesBase $this->assertSame(400, $response['headers']['status-code']); } + // Session alert integration + + public function testSessionAlertUsesCustomTemplatePerLocale(): void + { + $this->ensureSMTPEnabled(); + + // session-alerts lives under /projects (console scope), so it's driven with the + // root console session rather than the current test's project-scoped headers. + $alertsResponse = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $this->getProject()['$id'] . '/auth/session-alerts', + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ], + ['alerts' => true], + ); + $this->assertSame(200, $alertsResponse['headers']['status-code'], 'failed to enable session alerts'); + + $runId = \uniqid(); + $enSubject = "EN alert subject {$runId}"; + $enMessage = "EN alert body marker {$runId}"; + $skSubject = "SK alert subject {$runId}"; + $skMessage = "SK alert body marker {$runId}"; + + // Configure custom EN template via the default-locale path (omit `locale`). + $enUpdate = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: null, + subject: $enSubject, + message: $enMessage, + ); + $this->assertSame(200, $enUpdate['headers']['status-code']); + $this->assertSame('en', $enUpdate['body']['locale']); + + // Configure custom SK template explicitly. + $skUpdate = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: 'sk', + subject: $skSubject, + message: $skMessage, + ); + $this->assertSame(200, $skUpdate['headers']['status-code']); + + // Matrix of request-time locales and the custom template each one must resolve to. + // `de` has no custom template stored, so it must fall back to the `en` custom template. + $cases = [ + ['requestLocale' => 'en', 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ['requestLocale' => null, 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ['requestLocale' => 'sk', 'expectedSubject' => $skSubject, 'expectedMessageMarker' => $skMessage], + ['requestLocale' => 'de', 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ]; + + foreach ($cases as $case) { + $localeLabel = $case['requestLocale'] ?? 'none'; + $email = "session-alert-{$runId}-{$localeLabel}@appwrite.io"; + $password = 'password123'; + + // Fresh user per case so the session count starts at zero. + $create = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-dev-key' => $this->getProject()['devKey'] ?? '', + ], [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Session Alert ' . $localeLabel, + ]); + $this->assertSame(201, $create['headers']['status-code'], "create user ({$localeLabel})"); + + // First session must NOT trigger an alert (count === 1 returns early). + $first = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $first['headers']['status-code'], "first session ({$localeLabel})"); + + // Second session — this one triggers the alert, with the test's request locale. + $headers = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + if ($case['requestLocale'] !== null) { + $headers['x-appwrite-locale'] = $case['requestLocale']; + } + $second = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $headers, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $second['headers']['status-code'], "second session ({$localeLabel})"); + + // The custom subject is uniquely tagged per run, so matching it proves both + // that an alert was sent and that the correct locale template was resolved. + $received = $this->getLastEmailByAddress($email, function ($mail) use ($case) { + $this->assertSame($case['expectedSubject'], $mail['subject']); + }); + + $this->assertSame($case['expectedSubject'], $received['subject'], "subject ({$localeLabel})"); + $this->assertStringContainsString( + $case['expectedMessageMarker'], + $received['text'] . $received['html'], + "message marker ({$localeLabel})", + ); + } + } + // Helpers protected function getEmailTemplate(string $templateId, ?string $locale = null, bool $authenticated = true): mixed @@ -709,22 +833,6 @@ trait TemplatesBase } protected function ensureSMTPEnabled(): void - { - $this->patchSMTP([ - 'enabled' => true, - 'senderName' => 'Mailer', - 'senderEmail' => 'mailer@appwrite.io', - 'host' => 'maildev', - 'port' => 1025, - 'username' => 'user', - 'password' => 'password', - ]); - } - - /** - * @param array $params - */ - protected function patchSMTP(array $params): void { $this->client->call( Client::METHOD_PATCH, @@ -733,8 +841,15 @@ trait TemplatesBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), - $params, + [ + 'enabled' => true, + 'senderName' => 'Mailer', + 'senderEmail' => 'mailer@appwrite.io', + 'host' => 'maildev', + 'port' => 1025, + 'username' => 'user', + 'password' => 'password', + ], ); } - } From a4ad1b6df3d48256978eb6b46fb5d106b1c1718e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:35:35 +0200 Subject: [PATCH 116/402] Code quality improvs --- .../Project/Http/Project/Templates/Email/Update.php | 12 ++++++------ src/Appwrite/SDK/Specification/Format.php | 1 - 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index 4a1d4a5a21..c4a96d3c3c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -38,7 +38,7 @@ class Update extends Action ->desc('Update project email template') ->groups(['api', 'project']) ->label('scope', 'templates.write') - ->label('event', 'templates.[templateType].update') + ->label('event', 'templates.[templateId].update') ->label('audits.event', 'project.template.update') ->label('audits.resource', 'project.template/{response.templateId}') ->label('sdk', new Method( @@ -126,17 +126,17 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - $queueForEvents->setParam('templateType', $templateId); + $queueForEvents->setParam('templateId', $templateId); $response->dynamic(new Document([ 'templateId' => $templateId, 'locale' => $locale, - 'senderName' => $template['senderName'], - 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], - 'replyToEmail' => $template['replyToEmail'], - 'replyToName' => $template['replyToName'], 'message' => $template['message'], + 'senderName' => $template['senderName'] ?? '', + 'senderEmail' => $template['senderEmail'] ?? '', + 'replyToEmail' => $template['replyToEmail'] ?? '', + 'replyToName' => $template['replyToName'] ?? '', ]), Response::MODEL_EMAIL_TEMPLATE); } } diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index e68e9438ca..1dad8764fa 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -763,7 +763,6 @@ abstract class Format switch ($method) { case 'getEmailTemplate': case 'updateEmailTemplate': - case 'deleteEmailTemplate': switch ($param) { case 'type': return 'EmailTemplateType'; From 8c31e9f206390a46b05eadb35c42093f16fab512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:36:47 +0200 Subject: [PATCH 117/402] Server error fixes --- .../Platform/Modules/Project/Http/Project/Labels/Update.php | 2 +- .../Platform/Modules/Project/Http/Project/SMTP/Update.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php index 304d9dc8a6..5eee625b0a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php @@ -32,7 +32,7 @@ class Update extends Action ->desc('Update project labels') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'labels.*.update') + ->label('event', 'project.labels.update') ->label('audits.event', 'project.labels.update') ->label('audits.resource', 'project.labels/{response.$id}') ->label('sdk', new Method( diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 63e9cf287e..820c0be287 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -40,7 +40,7 @@ class Update extends Action ->desc('Update project SMTP configuration') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'smtp.*.update') + ->label('event', 'project.smtp.update') ->label('audits.event', 'project.smtp.update') ->label('audits.resource', 'project.smtp/{response.$id}') ->label('sdk', new Method( From c3e411fcaa4db2f314606f79a3a33689ac02128d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:47:47 +0200 Subject: [PATCH 118/402] Fix tests --- .../Project/Http/Project/Labels/Update.php | 2 +- .../Modules/Project/Http/Project/SMTP/Update.php | 14 ++++++++------ .../Project/Http/Project/Templates/Email/Get.php | 6 +++--- .../Http/Project/Templates/Email/Update.php | 16 +++++++++------- tests/e2e/Services/Project/TemplatesBase.php | 5 ++++- 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php index 5eee625b0a..8a3506eb13 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php @@ -32,7 +32,7 @@ class Update extends Action ->desc('Update project labels') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'project.labels.update') + // ->label('event', 'project.labels.update') ->label('audits.event', 'project.labels.update') ->label('audits.resource', 'project.labels/{response.$id}') ->label('sdk', new Method( diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 820c0be287..736865f299 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -40,7 +40,7 @@ class Update extends Action ->desc('Update project SMTP configuration') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'project.smtp.update') + // ->label('event', 'project.smtp.update') ->label('audits.event', 'project.smtp.update') ->label('audits.resource', 'project.smtp/{response.$id}') ->label('sdk', new Method( @@ -106,11 +106,13 @@ class Update extends Action // Backwards compatibility $smtp['replyToEmail'] = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; - // Ensure required fields are set - $requiredKeys = ['host', 'port', 'senderEmail']; - foreach ($requiredKeys as $key) { - if (empty($smtp[$key])) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, '"' . $key . '" is required. Please provide a value.'); + if (($smtp['enabled'] ?? false) === true) { + // Ensure required fields are set + $requiredKeys = ['host', 'port', 'senderEmail']; + foreach ($requiredKeys as $key) { + if (empty($smtp[$key])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "' . $key . '" is not optional.'); + } } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 1843c556d4..02ba431775 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -78,16 +78,16 @@ class Get extends Action $defaultMessage = $this->getDefaultMessage($templateId, $localeObj); // Apply defaults if needed - if (\is_null($template['message'])) { + if (\is_null($template['message'] ?? null)) { $template['message'] = $defaultMessage; } - if (\is_null($template['subject'])) { + if (\is_null($template['subject'] ?? null)) { $template['subject'] = $defaultSubject; } // Backwards compatibility - if (!\is_null($template['replyTo'])) { + if (!\is_null($template['replyTo'] ?? null)) { $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index c4a96d3c3c..ef93abf683 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -58,12 +58,12 @@ class Update extends Action )) ->param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) - ->param('subject', null, new Nullable(new Text(255)), 'Subject of the email template. Can be up to 255 characters.') - ->param('message', null, new Nullable(new Text(10485760)), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') - ->param('senderName', null, new Nullable(new Text(255, 0)), 'Name of the email sender.', true) - ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', true) - ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', true) - ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', true) + ->param('subject', null, new Nullable(new Text(255)), 'Subject of the email template. Can be up to 255 characters.', optional: true) + ->param('message', null, new Nullable(new Text(10485760)), 'Plain or HTML body of the email template message. Can be up to 10MB of content.', optional: true) + ->param('senderName', null, new Nullable(new Text(255, 0)), 'Name of the email sender.', optional: true) + ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', optional: true) + ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', optional: true) + ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', optional: true) ->inject('response') ->inject('queueForEvents') ->inject('dbForPlatform') @@ -108,7 +108,9 @@ class Update extends Action } // Backwards compatibility - $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + if (!\is_null($template['replyTo'] ?? null)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + } // Ensure required fields are set $requiredKeys = ['subject', 'message']; diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index efdc3e4579..72a14210a5 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -550,7 +550,7 @@ trait TemplatesBase public function testUpdateEmailTemplateBlockedWhenSMTPDisabled(): void { // Custom templates only make sense alongside a custom SMTP configuration. - $this->client->call( + $response = $this->client->call( Client::METHOD_PATCH, '/project/smtp', \array_merge([ @@ -560,6 +560,9 @@ trait TemplatesBase ['enabled' => false], ); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + try { $response = $this->updateEmailTemplate( templateId: 'verification', From 72cd7671c43381b6e82eb7163b4a493b556ece70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 23:01:00 +0200 Subject: [PATCH 119/402] Fix projects tests --- .../Projects/ProjectsConsoleClientTest.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 9506c1a963..471d737f76 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1131,6 +1131,40 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('', $response['body']['senderEmail']); $this->assertEquals('verification', $response['body']['type']); $this->assertEquals('en-us', $response['body']['locale']); + + /** Update Email template, fail due to SMTP disabled */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), [ + 'subject' => 'Please verify your email', + 'message' => 'Please verify your email {{url}}', + 'senderName' => 'Appwrite Custom', + 'senderEmail' => 'custom@appwrite.io', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + /** Configure custom SMTP pointing to maildev, so changing template is allowed */ + $smtpHost = 'maildev'; + $smtpPort = 1025; + $smtpUsername = 'user'; + $smtpPassword = 'password'; + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/smtp', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), [ + 'enabled' => true, + 'senderEmail' => 'mailer@appwrite.io', + 'senderName' => 'Mailer', + 'host' => $smtpHost, + 'port' => $smtpPort, + 'username' => $smtpUsername, + 'password' => $smtpPassword, + ]); + $this->assertEquals(200, $response['headers']['status-code']); /** Update Email template */ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ From 4808cad08196b479b871f2cec45ec434d275290b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 23:01:53 +0200 Subject: [PATCH 120/402] Fix most of Project tests --- .../Project/Http/Project/SMTP/Update.php | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 736865f299..97e723f52c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -58,15 +58,15 @@ class Update extends Action ) ], )) - ->param('host', '', new Nullable(new Hostname()), 'SMTP server hostname (domain)', optional: true) - ->param('port', 587, new Nullable(new Integer()), 'SMTP server port', optional: true) - ->param('username', '', new Nullable(new Text(256)), 'SMTP server username. Leave empty for no authorization.', optional: true) - ->param('password', '', new Nullable(new Text(256)), 'SMTP server password. Leave empty for no authorization. This property is stored securely and cannot be read in future (write-only).', optional: true) - ->param('senderEmail', '', new Nullable(new Email()), 'Email address shown in inbox as the sender of the email.', optional: true) - ->param('senderName', '', new Nullable(new Text(256)), 'Name shown in inbox as the sender of the email.', optional: true) - ->param('replyToEmail', '', new Nullable(new Email()), 'Email used when user replies to the email.', optional: true) - ->param('replyToName', '', new Nullable(new Text(256)), 'Name used when user replies to the email.', optional: true) - ->param('secure', '', new Nullable(new WhiteList(['tls', 'ssl'], true)), 'Configures if communication with SMTP server is encrypted. Allowed values are: tls, ssl. Leave empty for no encryption.', optional: true) + ->param('host', null, new Nullable(new Hostname()), 'SMTP server hostname (domain)', optional: true) + ->param('port', null, new Nullable(new Integer()), 'SMTP server port', optional: true) + ->param('username', null, new Nullable(new Text(256)), 'SMTP server username. Leave empty for no authorization.', optional: true) + ->param('password', null, new Nullable(new Text(256)), 'SMTP server password. Leave empty for no authorization. This property is stored securely and cannot be read in future (write-only).', optional: true) + ->param('senderEmail', null, new Nullable(new Email()), 'Email address shown in inbox as the sender of the email.', optional: true) + ->param('senderName', null, new Nullable(new Text(256)), 'Name shown in inbox as the sender of the email.', optional: true) + ->param('replyToEmail', null, new Nullable(new Email()), 'Email used when user replies to the email.', optional: true) + ->param('replyToName', null, new Nullable(new Text(256)), 'Name used when user replies to the email.', optional: true) + ->param('secure', null, new Nullable(new WhiteList(['tls', 'ssl'], true)), 'Configures if communication with SMTP server is encrypted. Allowed values are: tls, ssl. Leave empty for no encryption.', optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'Enable or disable custom SMTP. Custom SMTP is useful for branding purposes, but also allows use of custom email templates.', optional: true) ->inject('response') ->inject('dbForPlatform') @@ -117,7 +117,10 @@ class Update extends Action } // Validate SMTP credentials - if (\is_null($smtp['enabled'] ?? null) || $smtp['enabled'] === true) { + // Validate when the caller is explicitly enabling or hasn't expressed a preference + // (so a credentials-only PATCH can auto-enable). Skip only when the caller is + // explicitly keeping/turning SMTP off. + if (\is_null($enabled) || $enabled === true) { $mail = new PHPMailer(true); $mail->isSMTP(); From b1d37bc4bea36263f8f48ba1eaf639932d661377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 23:03:16 +0200 Subject: [PATCH 121/402] Fix remaining test failures --- tests/e2e/Services/Project/SMTPBase.php | 2 +- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index fa58891b08..c3a9058a32 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -313,7 +313,7 @@ trait SMTPBase $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', - host: 'not a valid host!@#', + host: 'https://myhost.com/v1', port: 1025, ); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 471d737f76..d51c4c1128 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1131,7 +1131,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('', $response['body']['senderEmail']); $this->assertEquals('verification', $response['body']['type']); $this->assertEquals('en-us', $response['body']['locale']); - + /** Update Email template, fail due to SMTP disabled */ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', @@ -1145,7 +1145,7 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(400, $response['headers']['status-code']); - + /** Configure custom SMTP pointing to maildev, so changing template is allowed */ $smtpHost = 'maildev'; $smtpPort = 1025; From abd6f0add2988621712eb2d226e51a811c3cf0ac Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 21 Apr 2026 10:52:21 +0530 Subject: [PATCH 122/402] updated --- app/realtime.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/realtime.php b/app/realtime.php index b5d8859d7b..667b035b9b 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -1166,6 +1166,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.'); } + $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); + // Validate every payload before executing any removal so an invalid entry // later in the batch does not leave earlier entries half-applied on the server. $validatedIds = []; @@ -1189,6 +1191,11 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re 'removed' => $wasRemoved, ]; } + $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); + $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + if ($subscriptionDelta !== 0) { + $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); + } $unsubscribeResponsePayload = json_encode([ 'type' => 'response', From 5df65d541766ab5455810876811d4c34348d78f7 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 21 Apr 2026 10:58:34 +0530 Subject: [PATCH 123/402] updated --- app/realtime.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 667b035b9b..552823336f 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -804,7 +804,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ]); $realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId()); - $register->get('telemetry.workerSubscriptionCounter')->add(1, $register->get('telemetry.workerAttributes')); $realtime->connections[$connection]['authorization'] = $authorization; $server->send([$connection], $connectedPayloadJson); $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); From cce04b3bd1848cd454391c9e78ed5265a5f2dd31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 10:28:11 +0200 Subject: [PATCH 124/402] Improve test coverage --- tests/e2e/Services/Project/SMTPBase.php | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index c3a9058a32..4bdf073e19 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -531,19 +531,38 @@ trait SMTPBase $this->updateSMTP(enabled: false); } - public function testUpdateSMTPInvalidConnectionRefused(): void + public function testUpdateSMTPInvalidConnectionEnabled(): void { $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'localhost', port: 12345, + enabled: true, ); $this->assertSame(400, $response['headers']['status-code']); $this->assertSame('project_smtp_config_invalid', $response['body']['type']); } + public function testUpdateSMTPInvalidConnectionDisabled(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: false, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + $this->assertSame('Test', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('localhost', $response['body']['smtpHost']); + $this->assertSame(12345, $response['body']['smtpPort']); + } + public function testUpdateSMTPLegacyReplyToAndResponseFormat(): void { $headers = \array_merge([ From 4e4860e7f85b5a518c43f681096fd6244fa3d82c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 21 Apr 2026 08:27:51 +0000 Subject: [PATCH 125/402] refactor: move afterQuery hook into base listDocuments action Moves the DB-duration measurement and afterQuery() hook from the tablesDB-specific Rows/XList into the shared Databases/Collections/Documents/XList base. Because TablesDB Rows and DocumentsDB Documents both extend the legacy listDocuments base, a single override now covers all three endpoints: legacy listDocuments, listDocumentsDBDocuments, and tablesDB listRows. TablesDB Rows drops the ~200-line action() duplicate and keeps only the path/params/SDK overrides it needs, plus the extra ->inject('utopia') so its injection chain matches the new base action signature. DocumentsDB Documents gets the same one-line inject addition. Net -165 lines of duplication removed. Behaviour is unchanged for CE (afterQuery() is a no-op); downstream distributions overriding afterQuery() now observe every list-documents / list-rows call site for free. --- .../Databases/Collections/Documents/XList.php | 53 ++++- .../Collections/Documents/XList.php | 1 + .../Http/TablesDB/Tables/Rows/XList.php | 205 ------------------ 3 files changed, 47 insertions(+), 212 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index aeee280615..d03f67e4c1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -22,6 +22,7 @@ use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; +use Utopia\Http\Http; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; @@ -80,10 +81,11 @@ class XList extends Action ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('utopia') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, ?Http $utopia = null): void { $isAPIKey = $user->isApp($authorization->getRoles()); $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); @@ -126,19 +128,29 @@ class XList extends Action $cursor->setValue($cursorDocument); } + $dbDurationMs = 0.0; + $measure = function (callable $fn) use (&$dbDurationMs) { + $start = \microtime(true); + try { + return $fn(); + } finally { + $dbDurationMs += (\microtime(true) - $start) * 1000; + } + }; + try { $hasSelects = ! empty(Query::groupByType($queries)['selections'] ?? []); $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); // When there are no select queries, relationship loading is skipped on the // underlying find() to avoid pulling related documents the caller did not ask for. $find = $hasSelects - ? fn () => $dbForDatabases->find($collectionTableId, $queries) - : fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries)); + ? fn () => $measure(fn () => $dbForDatabases->find($collectionTableId, $queries)) + : fn () => $measure(fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries))); // Use transaction-aware document retrieval if transactionId is provided if ($transactionId !== null) { - $documents = $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries); - $total = $includeTotal ? $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries) : 0; + $documents = $measure(fn () => $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries)); + $total = $includeTotal ? $measure(fn () => $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries)) : 0; } elseif ((int)$ttl > 0) { $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); $roles = $dbForProject->getAuthorization()->getRoles(); @@ -180,7 +192,7 @@ class XList extends Action if ($cachedTotal !== null && $cachedTotal !== false) { $total = $cachedTotal; } else { - $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); + $total = $measure(fn () => $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT)); try { $dbForProject->getCache()->save($cacheKey, $total, $totalField); } catch (\Throwable) { @@ -193,7 +205,7 @@ class XList extends Action $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); } else { $documents = $find(); - $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; + $total = $includeTotal ? $measure(fn () => $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT)) : 0; } } catch (OrderException $e) { $documents = $this->isCollectionsAPI() ? 'documents' : 'rows'; @@ -229,5 +241,32 @@ class XList extends Action // rows or documents $this->getSDKGroup() => $documents, ]), $this->getResponseModel()); + + try { + $this->afterQuery($dbDurationMs, $database, $collection, $queries, $utopia); + } catch (\Throwable) { + // Observers must never break the response. + } + } + + /** + * Hook invoked after listDocuments/listRows completes the response. + * Under Swoole the client connection is already closed by $response->dynamic(), + * so observers here do not delay the client; under synchronous transports + * they run before bytes reach the client — keep work cheap regardless. + * + * Runs with the actual measured database duration (cache hits report + * near-zero, cache misses report only the DB portion). Intended for + * downstream distributions to override for slow-query logging or other + * observability. CE implementation is a no-op. + * + * The $utopia Http instance is passed so overrides can resolve additional + * resources via $utopia->getResource(...) without touching the inject chain. + * + * @param array $queries parsed Query objects + */ + protected function afterQuery(float $dbDurationMs, Document $database, Document $collection, array $queries, ?Http $utopia): void + { + // no-op in CE } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php index 9e0d0b10d9..51c0d67e8a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php @@ -63,6 +63,7 @@ class XList extends DocumentXList ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('utopia') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php index 315e1c2e67..87e276719e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php @@ -2,27 +2,15 @@ namespace Appwrite\Platform\Modules\Databases\Http\TablesDB\Tables\Rows; -use Appwrite\Databases\TransactionState; -use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\XList as DocumentXList; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; -use Appwrite\Usage\Context; -use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; -use Utopia\Database\Document; -use Utopia\Database\Exception\Order as OrderException; -use Utopia\Database\Exception\Query as QueryException; -use Utopia\Database\Exception\Timeout; -use Utopia\Database\Query; -use Utopia\Database\Validator\Authorization; -use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; -use Utopia\Http\Http; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; @@ -80,197 +68,4 @@ class XList extends DocumentXList ->inject('utopia') ->callback($this->action(...)); } - - /** - * List rows with actual database duration measurement and a post-query - * observability hook. Mirrors the parent listDocuments action body but - * wraps each DB call with a timer so subclasses can observe just the DB - * portion of the request via afterQuery(). - * - * @param array $queries - */ - public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, ?Http $utopia = null): void - { - $isAPIKey = $user->isApp($authorization->getRoles()); - $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); - - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]); - } - - $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); - if ($collection->isEmpty() || (!$collection->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception($this->getParentNotFoundException(), params: [$collectionId]); - } - - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - $dbForDatabases = $getDatabasesDB($database); - $cursor = Query::getCursorQueries($queries, false); - $cursor = \reset($cursor); - - if ($cursor !== false) { - $validator = new Cursor(); - if (!$validator->isValid($cursor)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $documentId = $cursor->getValue(); - - $cursorDocument = $authorization->skip(fn () => $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId)); - - if ($cursorDocument->isEmpty()) { - $type = ucfirst($this->getContext()); - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "$type '{$documentId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $dbDurationMs = 0.0; - $measure = function (callable $fn) use (&$dbDurationMs) { - $start = \microtime(true); - try { - return $fn(); - } finally { - $dbDurationMs += (\microtime(true) - $start) * 1000; - } - }; - - try { - $hasSelects = ! empty(Query::groupByType($queries)['selections'] ?? []); - $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); - $find = $hasSelects - ? fn () => $measure(fn () => $dbForDatabases->find($collectionTableId, $queries)) - : fn () => $measure(fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries))); - - if ($transactionId !== null) { - $documents = $measure(fn () => $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries)); - $total = $includeTotal ? $measure(fn () => $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries)) : 0; - } elseif ((int)$ttl > 0) { - $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); - $roles = $dbForProject->getAuthorization()->getRoles(); - $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS); - - $documentsCacheHit = false; - try { - $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); - } catch (\Throwable) { - $cachedDocuments = null; - } - - if ($cachedDocuments !== null && - $cachedDocuments !== false && - \is_array($cachedDocuments)) { - $documents = \array_map(function ($doc) { - return new Document($doc); - }, $cachedDocuments); - $documentsCacheHit = true; - } else { - $documents = $find(); - - $documentsArray = \array_map(function ($doc) { - return $doc->getArrayCopy(); - }, $documents); - try { - $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); - } catch (\Throwable) { - } - } - - if ($includeTotal) { - $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL); - try { - $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); - } catch (\Throwable) { - $cachedTotal = null; - } - if ($cachedTotal !== null && $cachedTotal !== false) { - $total = $cachedTotal; - } else { - $total = $measure(fn () => $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT)); - try { - $dbForProject->getCache()->save($cacheKey, $total, $totalField); - } catch (\Throwable) { - } - } - } else { - $total = 0; - } - - $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); - } else { - $documents = $find(); - $total = $includeTotal ? $measure(fn () => $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT)) : 0; - } - } catch (OrderException $e) { - $documents = $this->isCollectionsAPI() ? 'documents' : 'rows'; - $attribute = $this->isCollectionsAPI() ? 'attribute' : 'column'; - $message = "The order $attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all $documents order $attribute values are non-null."; - throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, $message); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } catch (Timeout) { - throw new Exception(Exception::DATABASE_TIMEOUT); - } - - $operations = 0; - $collectionsCache = []; - foreach ($documents as $document) { - $this->processDocument( - database: $database, - collection: $collection, - document: $document, - dbForProject: $dbForProject, - collectionsCache: $collectionsCache, - authorization: $authorization, - operations: $operations - ); - } - - $usage - ->addMetric($this->getDatabasesOperationReadMetric(), max($operations, 1)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), $operations); - - $response->dynamic(new Document([ - 'total' => $total, - $this->getSDKGroup() => $documents, - ]), $this->getResponseModel()); - - try { - $this->afterQuery($dbDurationMs, $database, $collection, $queries, $utopia); - } catch (\Throwable) { - // Observers must never break the response. - } - } - - /** - * Hook invoked after listRows completes the response. Under Swoole (the - * default transport) the client connection has already been closed by - * `$response->dynamic()`, so observers here do not delay the client. - * Under synchronous transports observers would run before the bytes - * reach the client — keep work here cheap regardless. - * - * Runs with the actual measured database duration (cache hits report - * near-zero). Intended to be overridden for observability (e.g., slow- - * query logging in downstream distributions). CE implementation is a - * no-op. - * - * The `$utopia` Http instance is passed so overrides can resolve - * additional resources (e.g., a downstream-specific logger) via - * `$utopia->getResource(...)` without needing to inject them here. - * - * @param array $queries parsed Query objects (pass directly to - * `Query::fingerprint()` if you need a - * shape hash) - */ - protected function afterQuery(float $dbDurationMs, Document $database, Document $collection, array $queries, ?Http $utopia): void - { - // no-op in CE - } } From b4f16522861bc3bb604064aa225dd1ea68d390b5 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 21 Apr 2026 08:41:11 +0000 Subject: [PATCH 126/402] refactor: simplify afterQuery DB timing to single wall-clock bracket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-call $measure closure with a single $dbStart timestamp taken right before the fetch block and a single subtraction right after it. Drops 6 lines of HOF indirection plus the $measure variable, at the cost of including cache GET/SET time (~0.5–5ms) in measurements when ttl > 0. For slow-query logging at a 100ms+ threshold that noise is negligible, and the default ttl=0 path has no cache ops at all so the measurement is pure DB engine time. The bracket captures the cursor lookup, find/count, and transaction state calls — everything between "query parsed" and "fetch done", as intended. processDocument's post-fetch relationship work is still outside the bracket, matching the original design. --- .../Databases/Collections/Documents/XList.php | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index d03f67e4c1..15015c0fe0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -128,15 +128,7 @@ class XList extends Action $cursor->setValue($cursorDocument); } - $dbDurationMs = 0.0; - $measure = function (callable $fn) use (&$dbDurationMs) { - $start = \microtime(true); - try { - return $fn(); - } finally { - $dbDurationMs += (\microtime(true) - $start) * 1000; - } - }; + $dbStart = \microtime(true); try { $hasSelects = ! empty(Query::groupByType($queries)['selections'] ?? []); @@ -144,13 +136,13 @@ class XList extends Action // When there are no select queries, relationship loading is skipped on the // underlying find() to avoid pulling related documents the caller did not ask for. $find = $hasSelects - ? fn () => $measure(fn () => $dbForDatabases->find($collectionTableId, $queries)) - : fn () => $measure(fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries))); + ? fn () => $dbForDatabases->find($collectionTableId, $queries) + : fn () => $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries)); // Use transaction-aware document retrieval if transactionId is provided if ($transactionId !== null) { - $documents = $measure(fn () => $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries)); - $total = $includeTotal ? $measure(fn () => $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries)) : 0; + $documents = $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries); + $total = $includeTotal ? $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries) : 0; } elseif ((int)$ttl > 0) { $cacheKey = $this->getListCacheKey($dbForProject, $collectionId); $roles = $dbForProject->getAuthorization()->getRoles(); @@ -192,7 +184,7 @@ class XList extends Action if ($cachedTotal !== null && $cachedTotal !== false) { $total = $cachedTotal; } else { - $total = $measure(fn () => $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT)); + $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); try { $dbForProject->getCache()->save($cacheKey, $total, $totalField); } catch (\Throwable) { @@ -205,7 +197,7 @@ class XList extends Action $response->addHeader('X-Appwrite-Cache', $documentsCacheHit ? 'hit' : 'miss'); } else { $documents = $find(); - $total = $includeTotal ? $measure(fn () => $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT)) : 0; + $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; } } catch (OrderException $e) { $documents = $this->isCollectionsAPI() ? 'documents' : 'rows'; @@ -218,6 +210,8 @@ class XList extends Action throw new Exception(Exception::DATABASE_TIMEOUT); } + $dbDurationMs = (\microtime(true) - $dbStart) * 1000; + $operations = 0; $collectionsCache = []; foreach ($documents as $document) { From f465e2267a76fea8649bd31b6ef424992cd61f47 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 21 Apr 2026 08:43:20 +0000 Subject: [PATCH 127/402] chore: tighten afterQuery docblock --- .../Databases/Collections/Documents/XList.php | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index 15015c0fe0..102a6ae7c1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -244,23 +244,11 @@ class XList extends Action } /** - * Hook invoked after listDocuments/listRows completes the response. - * Under Swoole the client connection is already closed by $response->dynamic(), - * so observers here do not delay the client; under synchronous transports - * they run before bytes reach the client — keep work cheap regardless. + * Override to observe list DB duration. No-op in CE. * - * Runs with the actual measured database duration (cache hits report - * near-zero, cache misses report only the DB portion). Intended for - * downstream distributions to override for slow-query logging or other - * observability. CE implementation is a no-op. - * - * The $utopia Http instance is passed so overrides can resolve additional - * resources via $utopia->getResource(...) without touching the inject chain. - * - * @param array $queries parsed Query objects + * @param array $queries */ protected function afterQuery(float $dbDurationMs, Document $database, Document $collection, array $queries, ?Http $utopia): void { - // no-op in CE } } From 50bd2877f4e25bf99795151686bc4bfedf0241ce Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 21 Apr 2026 08:46:21 +0000 Subject: [PATCH 128/402] chore: shorten afterQuery docblock --- .../Databases/Http/Databases/Collections/Documents/XList.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index 102a6ae7c1..c1297b98a0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -244,7 +244,7 @@ class XList extends Action } /** - * Override to observe list DB duration. No-op in CE. + * After query hook. * * @param array $queries */ From 774a0d7022d1e758de3537dd19530766ddeb4972 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 14:48:12 +0530 Subject: [PATCH 129/402] Improve HTTP benchmark coverage --- tests/benchmarks/http.js | 1028 +++++++++++++++++++++++++++++++++++++- 1 file changed, 1006 insertions(+), 22 deletions(-) diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 799c8fb23c..85f3daee95 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -1,34 +1,1018 @@ import http from 'k6/http'; -import { check } from 'k6'; -import { Counter } from 'k6/metrics'; +import { check, group, sleep } from 'k6'; +import { Counter, Trend } from 'k6/metrics'; -// A simple counter for http requests -export const requests = new Counter('http_reqs'); +const ENDPOINT = (__ENV.APPWRITE_ENDPOINT || 'http://localhost/v1').replace(/\/+$/, ''); +const MAILDEV_ENDPOINT = __ENV.APPWRITE_MAILDEV_ENDPOINT || 'http://localhost:9503/email'; +const CONSOLE_PROJECT = __ENV.APPWRITE_CONSOLE_PROJECT || 'console'; +const REGION = __ENV.APPWRITE_REGION || 'default'; +const REDIRECT_URL = __ENV.APPWRITE_BENCHMARK_REDIRECT_URL || 'http://localhost'; +const PASSWORD = __ENV.APPWRITE_BENCHMARK_PASSWORD || 'Password123!'; +const MAIL_TIMEOUT_MS = Number(__ENV.APPWRITE_MAIL_TIMEOUT_MS || 20000); +const WORKER_TIMEOUT_MS = Number(__ENV.APPWRITE_WORKER_TIMEOUT_MS || 60000); +const ITERATIONS = Number(__ENV.APPWRITE_BENCHMARK_ITERATIONS || 1); +const VUS = Number(__ENV.APPWRITE_BENCHMARK_VUS || 1); +const SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_SUMMARY_PATH || 'tests/benchmarks/http-summary.json'; +const PREVIOUS_SUMMARY = loadPreviousSummary(); -// you can specify stages of your test (ramp up/down patterns) through the options object -// target is the number of VUs you are aiming for +export const apiDuration = new Trend('appwrite_api_duration', true); +export const databaseWorkerDuration = new Trend('appwrite_worker_database_duration', true); +export const tablesWorkerDuration = new Trend('appwrite_worker_tables_duration', true); +export const mailsWorkerDuration = new Trend('appwrite_worker_mails_duration', true); +export const messagingWorkerDuration = new Trend('appwrite_worker_messaging_duration', true); +export const flowFailures = new Counter('appwrite_benchmark_flow_failures'); export const options = { - stages: [ - { target: 50, duration: '1m' }, - // { target: 15, duration: '1m' }, - // { target: 0, duration: '1m' }, - ], + scenarios: { + curated_flows: { + executor: 'shared-iterations', + exec: 'curatedFlows', + vus: VUS, + iterations: ITERATIONS, + maxDuration: __ENV.APPWRITE_BENCHMARK_MAX_DURATION || '30m', + }, + }, thresholds: { - requests: ['count < 100'], + http_req_failed: ['rate<0.05'], + appwrite_api_duration: ['p(95)<2000'], + appwrite_benchmark_flow_failures: ['count<1'], }, }; -export default function () { - const config = { - headers: { - 'X-Appwrite-Key': '24356eb021863f81eb7dd77c7750304d0464e141cad6e9a8befa1f7d2b066fde190df3dab1e8d2639dbb82ee848da30501424923f4cd80d887ee40ad77ded62763ee489448523f6e39667f290f9a54b2ab8fad131a0bc985e6c0f760015f7f3411e40626c75646bb19d2bb2f7bf2f63130918220a206758cbc48845fd725a695', - 'X-Appwrite-Project': '60479fe35d95d' - }} +const API_SCOPES = [ + 'sessions.write', + 'users.read', + 'users.write', + 'teams.read', + 'teams.write', + 'databases.read', + 'databases.write', + 'collections.read', + 'collections.write', + 'tables.read', + 'tables.write', + 'attributes.read', + 'attributes.write', + 'columns.read', + 'columns.write', + 'indexes.read', + 'indexes.write', + 'documents.read', + 'documents.write', + 'rows.read', + 'rows.write', + 'files.read', + 'files.write', + 'buckets.read', + 'buckets.write', + 'functions.read', + 'functions.write', + 'sites.read', + 'sites.write', + 'log.read', + 'log.write', + 'execution.read', + 'execution.write', + 'locale.read', + 'avatars.read', + 'health.read', + 'providers.read', + 'providers.write', + 'messages.read', + 'messages.write', + 'topics.read', + 'topics.write', + 'subscribers.read', + 'subscribers.write', + 'targets.read', + 'targets.write', + 'rules.read', + 'rules.write', + 'migrations.read', + 'migrations.write', + 'vcs.read', + 'vcs.write', + 'assistant.read', + 'tokens.read', + 'tokens.write', + 'platforms.read', + 'platforms.write', +]; - const resDb = http.get('http://localhost:9501/', config); +const BASE_PERMISSIONS = [ + 'read("any")', + 'create("any")', + 'update("any")', + 'delete("any")', +]; - check(resDb, { - 'status is 200': (r) => r.status === 200, +const ITEM_PERMISSIONS = [ + 'read("any")', + 'update("any")', + 'delete("any")', +]; + +export function setup() { + const runId = unique('run'); + const consoleEmail = __ENV.APPWRITE_ADMIN_EMAIL || `bench-admin-${runId}@example.com`; + const consolePassword = __ENV.APPWRITE_ADMIN_PASSWORD || PASSWORD; + + const consoleHeaders = { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': CONSOLE_PROJECT, + }; + + const account = rawRequest('POST', '/account', { + userId: unique('admin'), + email: consoleEmail, + password: consolePassword, + name: 'Benchmark Admin', + }, consoleHeaders, 'setup.account.create'); + + if (![201, 409].includes(account.status)) { + failResponse(account, 'Unable to create or reuse the benchmark console account'); + } + + const session = rawRequest('POST', '/account/sessions/email', { + email: consoleEmail, + password: consolePassword, + }, consoleHeaders, 'setup.account.session'); + + assertStatus(session, [201], 'console session created'); + + const consoleSessionHeaders = { + ...consoleHeaders, + Cookie: cookieHeader(session), + }; + + const team = api('POST', '/teams', { + teamId: unique('team'), + name: `Benchmark Team ${runId}`, + }, consoleSessionHeaders, [201], 'setup.teams.create'); + + const teamId = team.json('$id'); + const project = api('POST', '/projects', { + projectId: unique('project'), + name: `Benchmark Project ${runId}`, + teamId, + region: REGION, + }, consoleSessionHeaders, [201], 'setup.projects.create'); + + const projectId = project.json('$id'); + const key = api('POST', `/projects/${projectId}/keys`, { + keyId: unique('key'), + name: 'Benchmark API key', + scopes: API_SCOPES, + }, consoleSessionHeaders, [201], 'setup.projects.keys.create'); + + const apiHeaders = { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': projectId, + 'X-Appwrite-Key': key.json('secret'), + }; + + const platform = api('POST', '/project/platforms/web', { + platformId: unique('web'), + name: 'Benchmark web', + hostname: hostnameFromUrl(REDIRECT_URL), + }, apiHeaders, [201, 409], 'setup.project.platforms.web.create'); + + const smtp = rawRequest('PATCH', `/projects/${projectId}/smtp`, { + enabled: true, + senderName: 'Benchmark', + senderEmail: 'benchmark@appwrite.io', + replyTo: 'benchmark@appwrite.io', + host: __ENV.APPWRITE_SMTP_HOST || 'maildev', + port: Number(__ENV.APPWRITE_SMTP_PORT || 1025), + username: __ENV.APPWRITE_SMTP_USERNAME || 'user', + password: __ENV.APPWRITE_SMTP_PASSWORD || 'password', + ...(String(__ENV.APPWRITE_SMTP_SECURE || '') !== '' ? { secure: __ENV.APPWRITE_SMTP_SECURE } : {}), + }, consoleSessionHeaders, 'setup.projects.smtp.update'); + + if (smtp.status !== 200) { + console.warn(`Custom SMTP was not enabled (${smtp.status}). Mail worker timings may be unavailable.`); + } + + return { + runId, + teamId, + projectId, + consoleSessionHeaders, + apiHeaders, + platformStatus: platform.status, + }; +} + +export function curatedFlows(data) { + const ctx = { ...data }; + + try { + group('account and mail worker', () => accountFlow(ctx)); + group('databases documents flow', () => databasesFlow(ctx)); + group('tablesdb rows flow', () => tablesDbFlow(ctx)); + group('storage files and tokens flow', () => storageFlow(ctx)); + group('messaging worker flow', () => messagingFlow(ctx)); + group('functions and sites control-plane flow', () => computeFlow(ctx)); + group('health and queue probes', () => healthFlow(ctx)); + } catch (error) { + flowFailures.add(1); + throw error; + } +} + +export function teardown(data) { + if (data && data.teamId && data.consoleSessionHeaders) { + rawRequest('DELETE', `/teams/${data.teamId}`, null, data.consoleSessionHeaders, 'teardown.teams.delete'); + } +} + +function accountFlow(ctx) { + const userId = unique('user'); + const email = `bench-user-${unique('mail')}@example.com`; + const headers = projectHeaders(ctx.projectId); + + api('POST', '/account', { + userId, + email, + password: PASSWORD, + name: 'Benchmark User', + }, headers, [201], 'account.create'); + + const session = api('POST', '/account/sessions/email', { + email, + password: PASSWORD, + }, headers, [201], 'account.sessions.email.create'); + + const sessionHeaders = { + ...headers, + Cookie: cookieHeader(session), + }; + + ctx.userId = userId; + ctx.userEmail = email; + ctx.sessionHeaders = sessionHeaders; + + const jwt = api('POST', '/account/jwts', null, sessionHeaders, [201], 'account.jwts.create'); + ctx.jwtHeaders = { + ...headers, + 'X-Appwrite-JWT': jwt.json('jwt'), + }; + + api('GET', '/account', null, sessionHeaders, [200], 'account.get'); + api('GET', '/account/logs', null, sessionHeaders, [200], 'account.logs.list'); + api('PATCH', '/account/prefs', { prefs: { benchmark: true, runId: ctx.runId } }, sessionHeaders, [200], 'account.prefs.update'); + api('PATCH', '/account/name', { name: 'Benchmark User Updated' }, sessionHeaders, [200], 'account.name.update'); + api('PATCH', '/account/password', { password: `${PASSWORD}2`, oldPassword: PASSWORD }, sessionHeaders, [200], 'account.password.update'); + + const verificationStarted = Date.now(); + api('POST', '/account/verifications/email', { url: REDIRECT_URL }, sessionHeaders, [201], 'account.emailVerification.create'); + const verificationEmail = waitForEmail(email, (message) => { + return includes(message.subject, 'verify') + || includes(message.subject, 'verification') + || includes(message.html, 'verify') + || includes(message.html, 'verification') + || includes(message.text, 'verify') + || includes(message.text, 'verification'); + }, MAIL_TIMEOUT_MS); + mailsWorkerDuration.add(Date.now() - verificationStarted, { job: 'email_verification' }); + + const verification = extractQueryParams(verificationEmail); + if (verification.userId && verification.secret) { + api('PUT', '/account/verifications/email', { + userId: verification.userId, + secret: verification.secret, + }, sessionHeaders, [200], 'account.emailVerification.update'); + } + + const recoveryStarted = Date.now(); + api('POST', '/account/recovery', { email, url: REDIRECT_URL }, headers, [201], 'account.recovery.create'); + const recoveryEmail = waitForEmail(email, (message) => { + return includes(message.subject, 'recovery') + || includes(message.subject, 'recover') + || includes(message.subject, 'reset') + || includes(message.html, 'recovery') + || includes(message.html, 'recover') + || includes(message.html, 'reset') + || includes(message.text, 'recovery') + || includes(message.text, 'recover') + || includes(message.text, 'reset'); + }, MAIL_TIMEOUT_MS); + mailsWorkerDuration.add(Date.now() - recoveryStarted, { job: 'password_recovery' }); + + const recovery = extractQueryParams(recoveryEmail); + if (recovery.userId && recovery.secret) { + api('DELETE', '/account/sessions/current', null, sessionHeaders, [204], 'account.sessions.current.delete'); + + api('PUT', '/account/recovery', { + userId: recovery.userId, + secret: recovery.secret, + password: `${PASSWORD}3`, + }, headers, [200], 'account.recovery.update'); + + const recoveredSession = api('POST', '/account/sessions/email', { + email, + password: `${PASSWORD}3`, + }, headers, [201], 'account.sessions.email.recovered'); + + ctx.sessionHeaders = { + ...headers, + Cookie: cookieHeader(recoveredSession), + }; + + const recoveredJwt = api('POST', '/account/jwts', null, ctx.sessionHeaders, [201], 'account.jwts.recovered'); + ctx.jwtHeaders = { + ...headers, + 'X-Appwrite-JWT': recoveredJwt.json('jwt'), + }; + } +} + +function databasesFlow(ctx) { + const databaseId = unique('db'); + const collectionId = unique('col'); + const documentId = unique('doc'); + const indexKey = unique('idx'); + + api('POST', '/databases', { databaseId, name: 'Benchmark DB' }, ctx.apiHeaders, [201], 'databases.create'); + api('POST', `/databases/${databaseId}/collections`, { + collectionId, + name: 'Benchmark Collection', + permissions: BASE_PERMISSIONS, + documentSecurity: false, + }, ctx.apiHeaders, [201], 'databases.collections.create'); + + const attributes = [ + ['string', 'title', { size: 128 }], + ['integer', 'count', { min: 0, max: 100000 }], + ['email', 'email', {}], + ['boolean', 'active', {}], + ['datetime', 'publishedAt', {}], + ['float', 'score', { min: 0, max: 1000 }], + ['url', 'url', {}], + ['ip', 'ip', {}], + ]; + + for (const [type, key, extra] of attributes) { + const started = Date.now(); + api('POST', `/databases/${databaseId}/collections/${collectionId}/attributes/${type}`, { + key, + required: false, + array: false, + ...extra, + }, ctx.apiHeaders, [202], `databases.attributes.${type}.create`); + waitForStatus(`/databases/${databaseId}/collections/${collectionId}/attributes/${key}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); + databaseWorkerDuration.add(Date.now() - started, { job: `attribute_${type}` }); + } + + const indexStarted = Date.now(); + api('POST', `/databases/${databaseId}/collections/${collectionId}/indexes`, { + key: indexKey, + type: 'key', + attributes: ['title'], + orders: ['asc'], + }, ctx.apiHeaders, [202], 'databases.indexes.create'); + waitForStatus(`/databases/${databaseId}/collections/${collectionId}/indexes/${indexKey}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); + databaseWorkerDuration.add(Date.now() - indexStarted, { job: 'index' }); + + api('POST', `/databases/${databaseId}/collections/${collectionId}/documents`, { + documentId, + data: documentPayload(), + permissions: ITEM_PERMISSIONS, + }, ctx.apiHeaders, [201], 'databases.documents.create'); + api('GET', `/databases/${databaseId}/collections/${collectionId}/documents`, null, ctx.apiHeaders, [200], 'databases.documents.list'); + api('GET', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, null, ctx.apiHeaders, [200], 'databases.documents.get'); + api('PATCH', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, { + data: { title: 'Benchmark Document Updated' }, + }, ctx.apiHeaders, [200], 'databases.documents.update'); + api('PATCH', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}/count/increment`, { + value: 1, + }, ctx.apiHeaders, [200], 'databases.documents.increment'); + api('PATCH', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}/count/decrement`, { + value: 1, + }, ctx.apiHeaders, [200], 'databases.documents.decrement'); + api('DELETE', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, null, ctx.apiHeaders, [204], 'databases.documents.delete'); + api('DELETE', `/databases/${databaseId}`, null, ctx.apiHeaders, [204], 'databases.delete'); +} + +function tablesDbFlow(ctx) { + const databaseId = unique('tdb'); + const tableId = unique('tbl'); + const rowId = unique('row'); + const indexKey = unique('tidx'); + + api('POST', '/tablesdb', { databaseId, name: 'Benchmark TablesDB' }, ctx.apiHeaders, [201], 'tablesdb.create'); + api('POST', `/tablesdb/${databaseId}/tables`, { + tableId, + name: 'Benchmark Table', + permissions: BASE_PERMISSIONS, + rowSecurity: false, + }, ctx.apiHeaders, [201], 'tablesdb.tables.create'); + + const columns = [ + ['string', 'title', { size: 128 }], + ['integer', 'count', { min: 0, max: 100000 }], + ['email', 'email', {}], + ['boolean', 'active', {}], + ]; + + for (const [type, key, extra] of columns) { + const started = Date.now(); + api('POST', `/tablesdb/${databaseId}/tables/${tableId}/columns/${type}`, { + key, + required: false, + array: false, + ...extra, + }, ctx.apiHeaders, [202], `tablesdb.columns.${type}.create`); + waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); + tablesWorkerDuration.add(Date.now() - started, { job: `column_${type}` }); + } + + const indexStarted = Date.now(); + api('POST', `/tablesdb/${databaseId}/tables/${tableId}/indexes`, { + key: indexKey, + type: 'key', + columns: ['title'], + orders: ['asc'], + }, ctx.apiHeaders, [202], 'tablesdb.indexes.create'); + waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/indexes/${indexKey}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); + tablesWorkerDuration.add(Date.now() - indexStarted, { job: 'index' }); + + api('POST', `/tablesdb/${databaseId}/tables/${tableId}/rows`, { + rowId, + data: tablePayload(), + permissions: ITEM_PERMISSIONS, + }, ctx.sessionHeaders, [201], 'tablesdb.rows.create'); + api('GET', `/tablesdb/${databaseId}/tables/${tableId}/rows`, null, ctx.sessionHeaders, [200], 'tablesdb.rows.list'); + api('GET', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, null, ctx.sessionHeaders, [200], 'tablesdb.rows.get'); + api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, { + data: { title: 'Benchmark Row Updated' }, + }, ctx.sessionHeaders, [200], 'tablesdb.rows.update'); + api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/count/increment`, { + value: 1, + }, ctx.sessionHeaders, [200], 'tablesdb.rows.increment'); + api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/count/decrement`, { + value: 1, + }, ctx.sessionHeaders, [200], 'tablesdb.rows.decrement'); + api('DELETE', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, null, ctx.sessionHeaders, [204], 'tablesdb.rows.delete'); + api('DELETE', `/tablesdb/${databaseId}`, null, ctx.apiHeaders, [204], 'tablesdb.delete'); +} + +function storageFlow(ctx) { + const bucketId = unique('bucket'); + const fileId = unique('file'); + + api('POST', '/storage/buckets', { + bucketId, + name: 'Benchmark Bucket', + permissions: BASE_PERMISSIONS, + fileSecurity: false, + enabled: true, + maximumFileSize: 30000000, + allowedFileExtensions: [], + compression: 'none', + encryption: false, + antivirus: false, + }, ctx.apiHeaders, [201], 'storage.buckets.create'); + + const multipartHeaders = { ...ctx.sessionHeaders }; + delete multipartHeaders['Content-Type']; + + const upload = http.post(`${ENDPOINT}/storage/buckets/${bucketId}/files`, { + fileId, + file: http.file(onePixelPng(), 'benchmark.png', 'image/png'), + ...flattenMultipartArray('permissions', ITEM_PERMISSIONS), + }, { + headers: multipartHeaders, + tags: { name: 'storage.files.create' }, }); -} \ No newline at end of file + + apiDuration.add(upload.timings.duration, { name: 'storage.files.create' }); + assertStatus(upload, [201], 'storage file created'); + + api('GET', `/storage/buckets/${bucketId}/files`, null, ctx.sessionHeaders, [200], 'storage.files.list'); + api('GET', `/storage/buckets/${bucketId}/files/${fileId}`, null, ctx.sessionHeaders, [200], 'storage.files.get'); + api('GET', `/storage/buckets/${bucketId}/files/${fileId}/view`, null, ctx.sessionHeaders, [200], 'storage.files.view'); + api('GET', `/storage/buckets/${bucketId}/files/${fileId}/download`, null, ctx.sessionHeaders, [200], 'storage.files.download'); + api('GET', `/storage/buckets/${bucketId}/files/${fileId}/preview`, null, ctx.sessionHeaders, [200], 'storage.files.preview'); + api('PUT', `/storage/buckets/${bucketId}/files/${fileId}`, { + name: 'benchmark-renamed.png', + permissions: ITEM_PERMISSIONS, + }, ctx.sessionHeaders, [200], 'storage.files.update'); + + const token = api('POST', `/tokens/buckets/${bucketId}/files/${fileId}`, {}, ctx.apiHeaders, [201], 'tokens.files.create'); + api('GET', `/tokens/buckets/${bucketId}/files/${fileId}`, null, ctx.apiHeaders, [200], 'tokens.files.list'); + api('GET', `/tokens/${token.json('$id')}`, null, ctx.apiHeaders, [200], 'tokens.get'); + api('PATCH', `/tokens/${token.json('$id')}`, { expire: null }, ctx.apiHeaders, [200], 'tokens.update'); + api('DELETE', `/tokens/${token.json('$id')}`, null, ctx.apiHeaders, [204], 'tokens.delete'); + + api('DELETE', `/storage/buckets/${bucketId}/files/${fileId}`, null, ctx.sessionHeaders, [204], 'storage.files.delete'); + api('DELETE', `/storage/buckets/${bucketId}`, null, ctx.apiHeaders, [204], 'storage.buckets.delete'); +} + +function messagingFlow(ctx) { + const providerId = unique('smtp'); + let targetId = unique('target'); + const topicId = unique('topic'); + const subscriberId = unique('sub'); + const messageId = unique('msg'); + + api('POST', '/messaging/providers/smtp', { + providerId, + name: 'Benchmark SMTP', + host: __ENV.APPWRITE_SMTP_HOST || 'maildev', + port: Number(__ENV.APPWRITE_SMTP_PORT || 1025), + username: __ENV.APPWRITE_SMTP_USERNAME || 'user', + password: __ENV.APPWRITE_SMTP_PASSWORD || 'password', + encryption: __ENV.APPWRITE_SMTP_ENCRYPTION || 'none', + autoTLS: false, + fromName: 'Benchmark', + fromEmail: 'benchmark@appwrite.io', + replyToName: 'Benchmark', + replyToEmail: 'benchmark@appwrite.io', + enabled: true, + }, ctx.apiHeaders, [201], 'messaging.providers.smtp.create'); + + const targets = api('GET', `/users/${ctx.userId}/targets`, null, ctx.apiHeaders, [200], 'users.targets.list'); + const existingTarget = (targets.json('targets') || []).find((target) => { + return target.providerType === 'email' && target.identifier === ctx.userEmail; + }); + + if (existingTarget) { + targetId = existingTarget.$id; + api('PATCH', `/users/${ctx.userId}/targets/${targetId}`, { + providerId, + name: 'Benchmark email target', + }, ctx.apiHeaders, [200], 'users.targets.update'); + } else { + api('POST', `/users/${ctx.userId}/targets`, { + targetId, + providerType: 'email', + identifier: ctx.userEmail, + providerId, + name: 'Benchmark email target', + }, ctx.apiHeaders, [201], 'users.targets.create'); + } + + api('POST', '/messaging/topics', { + topicId, + name: 'Benchmark Topic', + subscribe: ['users'], + }, ctx.apiHeaders, [201], 'messaging.topics.create'); + + api('POST', `/messaging/topics/${topicId}/subscribers`, { + subscriberId, + targetId, + }, ctx.sessionHeaders, [201], 'messaging.subscribers.create'); + + const started = Date.now(); + api('POST', '/messaging/messages/email', { + messageId, + subject: `Benchmark message ${ctx.runId}`, + content: `Benchmark messaging worker probe ${ctx.runId}`, + targets: [targetId], + draft: false, + html: false, + }, ctx.apiHeaders, [201], 'messaging.messages.email.create'); + + waitForMessage(messageId, ctx.apiHeaders, WORKER_TIMEOUT_MS); + waitForEmail(ctx.userEmail, (message) => includes(message.subject, `Benchmark message ${ctx.runId}`), MAIL_TIMEOUT_MS, true); + messagingWorkerDuration.add(Date.now() - started, { job: 'email_message' }); + + api('GET', '/messaging/messages', null, ctx.apiHeaders, [200], 'messaging.messages.list'); + api('GET', `/messaging/messages/${messageId}/logs`, null, ctx.apiHeaders, [200], 'messaging.messages.logs.list'); + api('GET', `/messaging/messages/${messageId}/targets`, null, ctx.apiHeaders, [200], 'messaging.messages.targets.list'); + api('GET', `/messaging/providers/${providerId}/logs`, null, ctx.apiHeaders, [200], 'messaging.providers.logs.list'); + api('GET', `/messaging/topics/${topicId}/logs`, null, ctx.apiHeaders, [200], 'messaging.topics.logs.list'); + api('GET', `/messaging/subscribers/${subscriberId}/logs`, null, ctx.apiHeaders, [200], 'messaging.subscribers.logs.list'); + api('DELETE', `/messaging/topics/${topicId}/subscribers/${subscriberId}`, null, ctx.sessionHeaders, [204], 'messaging.subscribers.delete'); + api('DELETE', `/messaging/topics/${topicId}`, null, ctx.apiHeaders, [204], 'messaging.topics.delete'); + api('DELETE', `/messaging/messages/${messageId}`, null, ctx.apiHeaders, [204], 'messaging.messages.delete'); + api('DELETE', `/messaging/providers/${providerId}`, null, ctx.apiHeaders, [204], 'messaging.providers.delete'); +} + +function computeFlow(ctx) { + const functionId = unique('fn'); + let functionVariableId; + const siteId = unique('site'); + let siteVariableId; + + api('POST', '/functions', { + functionId, + name: 'Benchmark Function', + runtime: __ENV.APPWRITE_BENCHMARK_RUNTIME || 'node-22', + execute: ['any'], + events: [], + schedule: '', + timeout: 15, + enabled: true, + logging: true, + entrypoint: 'index.js', + commands: 'npm install', + scopes: ['users.read'], + }, ctx.apiHeaders, [201], 'functions.create'); + api('GET', '/functions/runtimes', null, ctx.sessionHeaders, [200], 'functions.runtimes.list'); + api('GET', '/functions/specifications', null, ctx.apiHeaders, [200], 'functions.specifications.list'); + const functionVariable = api('POST', `/functions/${functionId}/variables`, { + key: 'BENCHMARK', + value: 'true', + secret: false, + }, ctx.apiHeaders, [201], 'functions.variables.create'); + functionVariableId = functionVariable.json('$id'); + + api('PUT', `/functions/${functionId}/variables/${functionVariableId}`, { + key: 'BENCHMARK', + value: 'updated', + secret: false, + }, ctx.apiHeaders, [200], 'functions.variables.update'); + api('GET', `/functions/${functionId}/variables/${functionVariableId}`, null, ctx.apiHeaders, [200], 'functions.variables.get'); + api('DELETE', `/functions/${functionId}/variables/${functionVariableId}`, null, ctx.apiHeaders, [204], 'functions.variables.delete'); + api('DELETE', `/functions/${functionId}`, null, ctx.apiHeaders, [204], 'functions.delete'); + + api('POST', '/sites', { + siteId, + name: 'Benchmark Site', + framework: 'other', + adapter: 'static', + buildRuntime: __ENV.APPWRITE_BENCHMARK_RUNTIME || 'node-22', + buildCommand: '', + outputDirectory: '.', + installCommand: '', + fallbackFile: 'index.html', + providerRootDirectory: '.', + specification: '', + }, ctx.apiHeaders, [201], 'sites.create'); + api('GET', '/sites/frameworks', null, ctx.sessionHeaders, [200], 'sites.frameworks.list'); + api('GET', '/sites/specifications', null, ctx.apiHeaders, [200], 'sites.specifications.list'); + const siteVariable = api('POST', `/sites/${siteId}/variables`, { + key: 'BENCHMARK', + value: 'true', + secret: false, + }, ctx.apiHeaders, [201], 'sites.variables.create'); + siteVariableId = siteVariable.json('$id'); + + api('PUT', `/sites/${siteId}/variables/${siteVariableId}`, { + key: 'BENCHMARK', + value: 'updated', + secret: false, + }, ctx.apiHeaders, [200], 'sites.variables.update'); + api('GET', `/sites/${siteId}/variables/${siteVariableId}`, null, ctx.apiHeaders, [200], 'sites.variables.get'); + api('DELETE', `/sites/${siteId}/variables/${siteVariableId}`, null, ctx.apiHeaders, [204], 'sites.variables.delete'); + api('DELETE', `/sites/${siteId}`, null, ctx.apiHeaders, [204], 'sites.delete'); +} + +function healthFlow(ctx) { + const probes = [ + '/health', + '/health/db', + '/health/cache', + '/health/pubsub', + '/health/storage', + '/health/storage/local', + '/health/time', + '/health/queue/databases', + '/health/queue/mails', + '/health/queue/messaging', + '/health/queue/functions', + '/health/queue/builds', + '/health/queue/deletes', + '/health/queue/webhooks', + '/health/queue/stats-resources', + '/health/queue/stats-usage', + '/health/queue/failed/v1-mails', + ]; + + for (const path of probes) { + api('GET', path, null, ctx.apiHeaders, [200], `health${path.replace(/\//g, '.')}`); + } +} + +function api(method, path, body, headers, expected, name) { + const response = rawRequest(method, path, body, headers, name); + apiDuration.add(response.timings.duration, { name }); + assertStatus(response, expected, name); + return response; +} + +function rawRequest(method, path, body, headers, name) { + const params = { + headers, + tags: { name }, + }; + const payload = body === null || body === undefined ? null : JSON.stringify(body); + return http.request(method, `${ENDPOINT}${path}`, payload, params); +} + +function waitForStatus(path, headers, wantedStatus, timeoutMs) { + const started = Date.now(); + + while (Date.now() - started < timeoutMs) { + const response = api('GET', path, null, headers, [200], `wait${path}`); + if (response.json('status') === wantedStatus) { + return response; + } + sleep(0.5); + } + + throw new Error(`Timed out waiting for ${path} to become ${wantedStatus}`); +} + +function waitForMessage(messageId, headers, timeoutMs) { + const started = Date.now(); + + while (Date.now() - started < timeoutMs) { + const response = api('GET', `/messaging/messages/${messageId}`, null, headers, [200], 'messaging.messages.poll'); + const status = response.json('status'); + + if (['sent', 'failed'].includes(status)) { + if (status === 'failed') { + throw new Error(`Messaging worker marked message ${messageId} as failed`); + } + return response; + } + + sleep(0.5); + } + + throw new Error(`Timed out waiting for messaging worker to send message ${messageId}`); +} + +function waitForEmail(address, predicate, timeoutMs, allowMissingRecipient = false) { + const started = Date.now(); + + while (Date.now() - started < timeoutMs) { + const response = http.get(MAILDEV_ENDPOINT, { tags: { name: 'maildev.email.list' } }); + if (response.status === 200) { + const emails = response.json(); + for (let i = emails.length - 1; i >= 0; i--) { + const message = emails[i]; + if ((emailMatches(message, address) || (allowMissingRecipient && emailRecipientMissing(message))) && predicate(message)) { + return message; + } + } + } + sleep(0.5); + } + + throw new Error(`Timed out waiting for email to ${address}`); +} + +function emailMatches(message, address) { + const recipients = message.to || []; + return recipients.some((recipient) => recipient.address === address); +} + +function emailRecipientMissing(message) { + const recipients = message.to || []; + return recipients.length === 0 || recipients.every((recipient) => !recipient.address); +} + +function extractQueryParams(message) { + const content = `${message.html || ''}\n${message.text || ''}`; + const links = []; + const hrefPattern = /href="([^"]+)"/g; + let hrefMatch = hrefPattern.exec(content); + + while (hrefMatch !== null) { + links.push(hrefMatch[1]); + hrefMatch = hrefPattern.exec(content); + } + + if (links.length === 0) { + links.push(content); + } + + for (const link of links) { + const queryStart = link.indexOf('?'); + if (queryStart === -1) { + continue; + } + + const query = link.slice(queryStart + 1).split('#')[0].replace(/&/g, '&'); + const params = {}; + + for (const pair of query.split('&')) { + const [key, value] = pair.split('='); + params[decodeURIComponent(key)] = decodeURIComponent(value || ''); + } + + if (params.userId && params.secret) { + return params; + } + } + + return {}; +} + +function assertStatus(response, expected, name) { + const ok = check(response, { + [`${name} status ${expected.join('|')}`]: (r) => expected.includes(r.status), + }); + + if (!ok) { + failResponse(response, `${name} returned an unexpected status`); + } +} + +function failResponse(response, message) { + throw new Error(`${message}. Status: ${response.status}. Body: ${response.body}`); +} + +function cookieHeader(response) { + return response.headers['Set-Cookie'] || response.headers['set-cookie'] || ''; +} + +function projectHeaders(projectId) { + return { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': projectId, + }; +} + +function documentPayload() { + return { + title: 'Benchmark Document', + count: 1, + email: 'document@example.com', + active: true, + publishedAt: new Date().toISOString(), + score: 10.5, + url: 'https://appwrite.io', + ip: '127.0.0.1', + }; +} + +function tablePayload() { + return { + title: 'Benchmark Row', + count: 1, + email: 'row@example.com', + active: true, + }; +} + +function onePixelPng() { + return base64ToBinary('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='); +} + +function base64ToBinary(input) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + let output = ''; + let buffer = 0; + let bits = 0; + + for (let i = 0; i < input.length; i++) { + const value = chars.indexOf(input.charAt(i)); + if (value < 0 || value === 64) { + continue; + } + + buffer = (buffer << 6) | value; + bits += 6; + + if (bits >= 8) { + bits -= 8; + output += String.fromCharCode((buffer >> bits) & 0xff); + } + } + + return output; +} + +function flattenMultipartArray(key, values) { + const output = {}; + values.forEach((value, index) => { + output[`${key}[${index}]`] = value; + }); + return output; +} + +function unique(prefix) { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .slice(0, 36); +} + +function includes(value, needle) { + return String(value || '').toLowerCase().includes(String(needle).toLowerCase()); +} + +function hostnameFromUrl(value) { + return value.replace(/^https?:\/\//, '').split('/')[0].split(':')[0]; +} + +export function handleSummary(data) { + const lines = [ + 'Appwrite curated benchmark review', + '', + 'Before/after comparison', + '', + comparisonTable(PREVIOUS_SUMMARY, data), + '', + 'Current run details', + '', + metricLine(data, 'http_req_duration', 'HTTP total'), + metricLine(data, 'appwrite_api_duration', 'API endpoints'), + metricLine(data, 'appwrite_worker_database_duration', 'Database worker schema jobs'), + metricLine(data, 'appwrite_worker_tables_duration', 'TablesDB worker schema jobs'), + metricLine(data, 'appwrite_worker_mails_duration', 'Mail worker delivery'), + metricLine(data, 'appwrite_worker_messaging_duration', 'Messaging worker delivery'), + counterLine(data, 'appwrite_benchmark_flow_failures', 'Flow failures'), + '', + `Endpoint: ${ENDPOINT}`, + `Maildev API: ${MAILDEV_ENDPOINT}`, + '', + ]; + + return { + stdout: `${lines.filter(Boolean).join('\n')}\n`, + [SUMMARY_PATH]: JSON.stringify(data, null, 2), + }; +} + +function loadPreviousSummary() { + try { + if (SUMMARY_PATH === 'tests/benchmarks/http-summary.json') { + return JSON.parse(open('http-summary.json')); + } + + return JSON.parse(open(SUMMARY_PATH)); + } catch (error) { + return null; + } +} + +function comparisonTable(before, after) { + const rows = [ + ['HTTP total p95', trendMetric(before, 'http_req_duration', 'p(95)'), trendMetric(after, 'http_req_duration', 'p(95)'), 'ms'], + ['API endpoints p95', trendMetric(before, 'appwrite_api_duration', 'p(95)'), trendMetric(after, 'appwrite_api_duration', 'p(95)'), 'ms'], + ['Database worker p95', trendMetric(before, 'appwrite_worker_database_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'], + ['TablesDB worker p95', trendMetric(before, 'appwrite_worker_tables_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'], + ['Mail worker p95', trendMetric(before, 'appwrite_worker_mails_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'], + ['Messaging worker p95', trendMetric(before, 'appwrite_worker_messaging_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'], + ['Flow failures', counterMetric(before, 'appwrite_benchmark_flow_failures'), counterMetric(after, 'appwrite_benchmark_flow_failures'), ''], + ['Check failures', checkFailures(before), checkFailures(after), ''], + ]; + + return [ + '| Metric | Before | After | Delta |', + '| --- | ---: | ---: | ---: |', + ...rows.map(([label, beforeValue, afterValue, unit]) => { + return `| ${label} | ${formatValue(beforeValue, unit)} | ${formatValue(afterValue, unit)} | ${formatDelta(beforeValue, afterValue, unit)} |`; + }), + ].join('\n'); +} + +function trendMetric(data, metric, stat) { + return data && data.metrics[metric] && data.metrics[metric].values + ? data.metrics[metric].values[stat] + : null; +} + +function counterMetric(data, metric) { + return data && data.metrics[metric] && data.metrics[metric].values + ? data.metrics[metric].values.count + : null; +} + +function checkFailures(data) { + return data && data.metrics.checks && data.metrics.checks.values + ? data.metrics.checks.values.fails + : null; +} + +function formatValue(value, unit) { + if (value === null || value === undefined || Number.isNaN(value)) { + return 'n/a'; + } + + return `${round(value)}${unit}`; +} + +function formatDelta(before, after, unit) { + if (before === null || before === undefined || after === null || after === undefined || Number.isNaN(before) || Number.isNaN(after)) { + return 'n/a'; + } + + const delta = round(after - before); + const sign = delta > 0 ? '+' : ''; + return `${sign}${delta}${unit}`; +} + +function metricLine(data, metric, label) { + const values = data.metrics[metric] && data.metrics[metric].values; + if (!values || values.count === 0) { + return `${label}: no samples`; + } + + return `${label}: avg=${round(values.avg)}ms p90=${round(values['p(90)'])}ms p95=${round(values['p(95)'])}ms max=${round(values.max)}ms`; +} + +function counterLine(data, metric, label) { + const values = data.metrics[metric] && data.metrics[metric].values; + return `${label}: ${values ? values.count : 0}`; +} + +function round(value) { + return Math.round((value || 0) * 100) / 100; +} From e4f74a3fb140f2f0d40296dbb4404a9b1717212a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 14:56:33 +0530 Subject: [PATCH 130/402] Run curated HTTP benchmark in CI --- .github/workflows/ci.yml | 66 ++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 40 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b02d021f1a..3f5aa9034f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -676,56 +676,42 @@ jobs: docker compose up -d sleep 10 - - name: Install Oha + - name: Benchmark baseline run: | - echo "deb [signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian/ stable main" | sudo tee /etc/apt/sources.list.d/azlux.list - sudo wget -O /usr/share/keyrings/azlux-archive-keyring.gpg https://azlux.fr/repo.gpg - sudo apt update - sudo apt install oha - oha --version + rm -f tests/benchmarks/http-summary.json benchmark-before.txt benchmark.txt + docker run --rm -i --network host -v "$PWD:/scripts" -w /scripts grafana/k6 run --quiet \ + -e APPWRITE_ENDPOINT=http://localhost/v1 \ + -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ + -e APPWRITE_BENCHMARK_ITERATIONS=1 \ + -e APPWRITE_BENCHMARK_VUS=1 \ + tests/benchmarks/http.js | tee benchmark-before.txt - - name: Benchmark PR - run: 'oha -z 180s http://localhost/v1/health/version --output-format json > benchmark.json' - - - name: Cleaning - run: docker compose down -v - - - name: Installing latest version + - name: Benchmark after run: | - rm docker-compose.yml - rm .env - curl https://appwrite.io/install/compose -o docker-compose.yml - curl https://appwrite.io/install/env -o .env - sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env - docker compose up -d - sleep 10 - - - name: Benchmark Latest - run: oha -z 180s http://localhost/v1/health/version --output-format json > benchmark-latest.json + docker run --rm -i --network host -v "$PWD:/scripts" -w /scripts grafana/k6 run --quiet \ + -e APPWRITE_ENDPOINT=http://localhost/v1 \ + -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ + -e APPWRITE_BENCHMARK_ITERATIONS=1 \ + -e APPWRITE_BENCHMARK_VUS=1 \ + tests/benchmarks/http.js | tee benchmark.txt - name: Prepare comment run: | - echo '## :sparkles: Benchmark results' > benchmark.txt - echo ' ' >> benchmark.txt - echo "- Requests per second: $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt - echo "- Requests with 200 status code: $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt - echo "- P99 latency: $(jq -r '.latencyPercentiles.p99' benchmark.json )" >> benchmark.txt - echo " " >> benchmark.txt - echo " " >> benchmark.txt - echo "## :zap: Benchmark Comparison" >> benchmark.txt - echo " " >> benchmark.txt - echo "| Metric | This PR | Latest version | " >> benchmark.txt - echo "| --- | --- | --- | " >> benchmark.txt - echo "| RPS | $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.summary.requestsPerSec|tonumber|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt - echo "| 200 | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt - echo "| P99 | $(jq -r '.latencyPercentiles.p99' benchmark.json ) | $(jq -r '.latencyPercentiles.p99' benchmark-latest.json ) | " >> benchmark.txt + { + echo '## :sparkles: Benchmark results' + echo + cat benchmark.txt + } > benchmark-comment.txt - name: Save results uses: actions/upload-artifact@v7 if: ${{ !cancelled() }} with: - name: benchmark.json - path: benchmark.json + name: benchmark-results + path: | + benchmark-before.txt + benchmark.txt + tests/benchmarks/http-summary.json retention-days: 7 - name: Find Comment @@ -743,5 +729,5 @@ jobs: with: comment-id: ${{ steps.fc.outputs.comment-id }} issue-number: ${{ github.event.pull_request.number }} - body-path: benchmark.txt + body-path: benchmark-comment.txt edit-mode: replace From 15e45df81e900f6306cbd7d0f47a07bdb28b1fed Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 15:07:20 +0530 Subject: [PATCH 131/402] Address HTTP benchmark review feedback --- .github/workflows/ci.yml | 71 ++++++++++++++++++++++++++++++++++++---- tests/benchmarks/http.js | 41 ++++++++--------------- 2 files changed, 78 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f5aa9034f..6f2f0c3bc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -678,13 +678,14 @@ jobs: - name: Benchmark baseline run: | - rm -f tests/benchmarks/http-summary.json benchmark-before.txt benchmark.txt + rm -f tests/benchmarks/http-summary.json benchmark-before-summary.json benchmark-after-summary.json benchmark-before.txt benchmark.txt docker run --rm -i --network host -v "$PWD:/scripts" -w /scripts grafana/k6 run --quiet \ -e APPWRITE_ENDPOINT=http://localhost/v1 \ -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ -e APPWRITE_BENCHMARK_ITERATIONS=1 \ -e APPWRITE_BENCHMARK_VUS=1 \ tests/benchmarks/http.js | tee benchmark-before.txt + cp tests/benchmarks/http-summary.json benchmark-before-summary.json - name: Benchmark after run: | @@ -694,14 +695,69 @@ jobs: -e APPWRITE_BENCHMARK_ITERATIONS=1 \ -e APPWRITE_BENCHMARK_VUS=1 \ tests/benchmarks/http.js | tee benchmark.txt + cp tests/benchmarks/http-summary.json benchmark-after-summary.json - name: Prepare comment run: | - { - echo '## :sparkles: Benchmark results' - echo - cat benchmark.txt - } > benchmark-comment.txt + node <<'NODE' > benchmark-comment.txt + const fs = require('fs'); + + const before = JSON.parse(fs.readFileSync('benchmark-before-summary.json', 'utf8')); + const after = JSON.parse(fs.readFileSync('benchmark-after-summary.json', 'utf8')); + + const trend = (data, metric, stat) => data.metrics?.[metric]?.values?.[stat]; + const value = (data, metric, stat) => data.metrics?.[metric]?.values?.[stat]; + const counter = (data, metric) => value(data, metric, 'count'); + const delta = (beforeValue, afterValue, suffix = '') => { + if (beforeValue === undefined || afterValue === undefined) { + return 'n/a'; + } + + const difference = afterValue - beforeValue; + return `${difference > 0 ? '+' : ''}${formatNumber(difference)}${suffix}`; + }; + const format = (value, suffix = '') => value === undefined ? 'n/a' : `${formatNumber(value)}${suffix}`; + const formatNumber = (value) => Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, ''); + const row = (label, beforeValue, afterValue, suffix = '') => `| ${label} | ${format(beforeValue, suffix)} | ${format(afterValue, suffix)} | ${delta(beforeValue, afterValue, suffix)} |`; + + const rows = [ + row('HTTP total p95', trend(before, 'http_req_duration', 'p(95)'), trend(after, 'http_req_duration', 'p(95)'), 'ms'), + row('API endpoints p95', trend(before, 'appwrite_api_duration', 'p(95)'), trend(after, 'appwrite_api_duration', 'p(95)'), 'ms'), + row('Database worker p95', trend(before, 'appwrite_worker_database_duration', 'p(95)'), trend(after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'), + row('TablesDB worker p95', trend(before, 'appwrite_worker_tables_duration', 'p(95)'), trend(after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'), + row('Mail worker p95', trend(before, 'appwrite_worker_mails_duration', 'p(95)'), trend(after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'), + row('Messaging worker p95', trend(before, 'appwrite_worker_messaging_duration', 'p(95)'), trend(after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'), + row('Flow failures', counter(before, 'appwrite_benchmark_flow_failures'), counter(after, 'appwrite_benchmark_flow_failures')), + row('Check failures', value(before, 'checks', 'fails'), value(after, 'checks', 'fails')), + ]; + + const detail = (label, metric, suffix = 'ms') => { + const values = after.metrics?.[metric]?.values; + if (!values) { + return `${label}: no samples`; + } + + return `${label}: avg=${format(values.avg, suffix)} p90=${format(values['p(90)'], suffix)} p95=${format(values['p(95)'], suffix)} max=${format(values.max, suffix)}`; + }; + + console.log('## :sparkles: Benchmark results'); + console.log(); + console.log('Appwrite curated benchmark review'); + console.log('Before/after comparison'); + console.log('| Metric | Before | After | Delta |'); + console.log('| --- | ---: | ---: | ---: |'); + console.log(rows.join('\n')); + console.log('Current run details'); + console.log(detail('HTTP total', 'http_req_duration')); + console.log(detail('API endpoints', 'appwrite_api_duration')); + console.log(detail('Database worker schema jobs', 'appwrite_worker_database_duration')); + console.log(detail('TablesDB worker schema jobs', 'appwrite_worker_tables_duration')); + console.log(detail('Mail worker delivery', 'appwrite_worker_mails_duration')); + console.log(detail('Messaging worker delivery', 'appwrite_worker_messaging_duration')); + console.log(`Flow failures: ${format(counter(after, 'appwrite_benchmark_flow_failures'))}`); + console.log('Endpoint: http://localhost/v1'); + console.log('Maildev API: http://localhost:9503/email'); + NODE - name: Save results uses: actions/upload-artifact@v7 @@ -711,7 +767,8 @@ jobs: path: | benchmark-before.txt benchmark.txt - tests/benchmarks/http-summary.json + benchmark-before-summary.json + benchmark-after-summary.json retention-days: 7 - name: Find Comment diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 85f3daee95..7e04b40cc2 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -1,5 +1,6 @@ import http from 'k6/http'; import { check, group, sleep } from 'k6'; +import encoding from 'k6/encoding'; import { Counter, Trend } from 'k6/metrics'; const ENDPOINT = (__ENV.APPWRITE_ENDPOINT || 'http://localhost/v1').replace(/\/+$/, ''); @@ -221,6 +222,10 @@ export function curatedFlows(data) { } export function teardown(data) { + if (data && data.projectId && data.consoleSessionHeaders) { + rawRequest('DELETE', `/projects/${data.projectId}`, null, data.consoleSessionHeaders, 'teardown.projects.delete'); + } + if (data && data.teamId && data.consoleSessionHeaders) { rawRequest('DELETE', `/teams/${data.teamId}`, null, data.consoleSessionHeaders, 'teardown.teams.delete'); } @@ -706,7 +711,8 @@ function waitForStatus(path, headers, wantedStatus, timeoutMs) { const started = Date.now(); while (Date.now() - started < timeoutMs) { - const response = api('GET', path, null, headers, [200], `wait${path}`); + const response = rawRequest('GET', path, null, headers, `wait${path}`); + assertStatus(response, [200], `wait${path}`); if (response.json('status') === wantedStatus) { return response; } @@ -720,7 +726,8 @@ function waitForMessage(messageId, headers, timeoutMs) { const started = Date.now(); while (Date.now() - started < timeoutMs) { - const response = api('GET', `/messaging/messages/${messageId}`, null, headers, [200], 'messaging.messages.poll'); + const response = rawRequest('GET', `/messaging/messages/${messageId}`, null, headers, 'messaging.messages.poll'); + assertStatus(response, [200], 'messaging.messages.poll'); const status = response.json('status'); if (['sent', 'failed'].includes(status)) { @@ -851,28 +858,12 @@ function tablePayload() { } function onePixelPng() { - return base64ToBinary('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='); -} - -function base64ToBinary(input) { - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + const bytes = new Uint8Array(encoding.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=')); let output = ''; - let buffer = 0; - let bits = 0; - for (let i = 0; i < input.length; i++) { - const value = chars.indexOf(input.charAt(i)); - if (value < 0 || value === 64) { - continue; - } - - buffer = (buffer << 6) | value; - bits += 6; - - if (bits >= 8) { - bits -= 8; - output += String.fromCharCode((buffer >> bits) & 0xff); - } + // Appwrite's multipart upload path accepts this k6 fixture as a binary string. + for (let i = 0; i < bytes.length; i++) { + output += String.fromCharCode(bytes[i]); } return output; @@ -932,11 +923,7 @@ export function handleSummary(data) { function loadPreviousSummary() { try { - if (SUMMARY_PATH === 'tests/benchmarks/http-summary.json') { - return JSON.parse(open('http-summary.json')); - } - - return JSON.parse(open(SUMMARY_PATH)); + return JSON.parse(open('http-summary.json')); } catch (error) { return null; } From 2cfe40e98e5abd7efd927ae9b1bc84f5bae7d075 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 15:11:24 +0530 Subject: [PATCH 132/402] Compare benchmark against base branch --- .github/workflows/ci.yml | 48 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f2f0c3bc7..812b00fab2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -536,6 +536,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + fetch-depth: 1 - name: Download Docker Image uses: actions/download-artifact@v7 @@ -656,6 +658,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + fetch-depth: 1 - name: Download Docker Image uses: actions/download-artifact@v7 @@ -669,14 +673,50 @@ jobs: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Load and Start Appwrite + - name: Load Appwrite image + run: | + docker load --input /tmp/${{ env.IMAGE }}.tar + + - name: Prepare benchmark baseline + if: github.event_name == 'pull_request' + run: | + git fetch --depth=1 origin ${{ github.event.pull_request.base.ref }} + git worktree add --detach /tmp/appwrite-benchmark-baseline FETCH_HEAD + + - name: Start baseline Appwrite + if: github.event_name == 'pull_request' + working-directory: /tmp/appwrite-benchmark-baseline run: | sed -i 's/traefik/localhost/g' .env - docker load --input /tmp/${{ env.IMAGE }}.tar - docker compose up -d - sleep 10 + docker compose up -d --wait - name: Benchmark baseline + if: github.event_name == 'pull_request' + run: | + rm -f tests/benchmarks/http-summary.json benchmark-before-summary.json benchmark-after-summary.json benchmark-before.txt benchmark.txt + docker run --rm -i --network host -v "$PWD:/scripts" -w /scripts grafana/k6 run --quiet \ + -e APPWRITE_ENDPOINT=http://localhost/v1 \ + -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ + -e APPWRITE_BENCHMARK_ITERATIONS=1 \ + -e APPWRITE_BENCHMARK_VUS=1 \ + tests/benchmarks/http.js | tee benchmark-before.txt + cp tests/benchmarks/http-summary.json benchmark-before-summary.json + + - name: Stop baseline Appwrite + if: always() && github.event_name == 'pull_request' + run: | + if [ -d /tmp/appwrite-benchmark-baseline ]; then + cd /tmp/appwrite-benchmark-baseline + docker compose down -v + fi + + - name: Start PR Appwrite + run: | + sed -i 's/traefik/localhost/g' .env + docker compose up -d --wait + + - name: Seed workflow benchmark baseline + if: github.event_name != 'pull_request' run: | rm -f tests/benchmarks/http-summary.json benchmark-before-summary.json benchmark-after-summary.json benchmark-before.txt benchmark.txt docker run --rm -i --network host -v "$PWD:/scripts" -w /scripts grafana/k6 run --quiet \ From 6aeb2d2be08bb974f12bc7dcbe48182ee0da61d1 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 15:51:30 +0530 Subject: [PATCH 133/402] Fix benchmark before branch comparison --- .github/workflows/ci.yml | 64 +++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 812b00fab2..3af7581b66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -651,9 +651,12 @@ jobs: benchmark: name: Benchmark + if: github.event_name == 'pull_request' runs-on: ubuntu-latest needs: build permissions: + actions: read + contents: read pull-requests: write steps: - name: Checkout repository @@ -676,66 +679,61 @@ jobs: - name: Load Appwrite image run: | docker load --input /tmp/${{ env.IMAGE }}.tar + docker tag ${{ env.IMAGE }} ${{ env.IMAGE }}:after - - name: Prepare benchmark baseline - if: github.event_name == 'pull_request' + - name: Prepare benchmark before run: | - git fetch --depth=1 origin ${{ github.event.pull_request.base.ref }} - git worktree add --detach /tmp/appwrite-benchmark-baseline FETCH_HEAD + git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} + git worktree add --detach /tmp/appwrite-benchmark-before ${{ github.event.pull_request.base.sha }} + docker build \ + --target development \ + --build-arg DEBUG=false \ + --build-arg TESTING=true \ + --build-arg VERSION=dev \ + --tag ${{ env.IMAGE }}:before \ + /tmp/appwrite-benchmark-before - - name: Start baseline Appwrite - if: github.event_name == 'pull_request' - working-directory: /tmp/appwrite-benchmark-baseline + - name: Start before Appwrite + working-directory: /tmp/appwrite-benchmark-before run: | + docker tag ${{ env.IMAGE }}:before ${{ env.IMAGE }} sed -i 's/traefik/localhost/g' .env - docker compose up -d --wait + docker compose up -d --wait --no-build - - name: Benchmark baseline - if: github.event_name == 'pull_request' + - name: Benchmark before run: | - rm -f tests/benchmarks/http-summary.json benchmark-before-summary.json benchmark-after-summary.json benchmark-before.txt benchmark.txt - docker run --rm -i --network host -v "$PWD:/scripts" -w /scripts grafana/k6 run --quiet \ + rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before.txt benchmark.txt + docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts grafana/k6 run --quiet \ -e APPWRITE_ENDPOINT=http://localhost/v1 \ -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ -e APPWRITE_BENCHMARK_ITERATIONS=1 \ -e APPWRITE_BENCHMARK_VUS=1 \ + -e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-before-summary.json \ tests/benchmarks/http.js | tee benchmark-before.txt - cp tests/benchmarks/http-summary.json benchmark-before-summary.json - - name: Stop baseline Appwrite - if: always() && github.event_name == 'pull_request' + - name: Stop before Appwrite + if: always() run: | - if [ -d /tmp/appwrite-benchmark-baseline ]; then - cd /tmp/appwrite-benchmark-baseline + if [ -d /tmp/appwrite-benchmark-before ]; then + cd /tmp/appwrite-benchmark-before docker compose down -v fi - - name: Start PR Appwrite + - name: Start after Appwrite run: | + docker tag ${{ env.IMAGE }}:after ${{ env.IMAGE }} sed -i 's/traefik/localhost/g' .env - docker compose up -d --wait - - - name: Seed workflow benchmark baseline - if: github.event_name != 'pull_request' - run: | - rm -f tests/benchmarks/http-summary.json benchmark-before-summary.json benchmark-after-summary.json benchmark-before.txt benchmark.txt - docker run --rm -i --network host -v "$PWD:/scripts" -w /scripts grafana/k6 run --quiet \ - -e APPWRITE_ENDPOINT=http://localhost/v1 \ - -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ - -e APPWRITE_BENCHMARK_ITERATIONS=1 \ - -e APPWRITE_BENCHMARK_VUS=1 \ - tests/benchmarks/http.js | tee benchmark-before.txt - cp tests/benchmarks/http-summary.json benchmark-before-summary.json + docker compose up -d --wait --no-build - name: Benchmark after run: | - docker run --rm -i --network host -v "$PWD:/scripts" -w /scripts grafana/k6 run --quiet \ + docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts grafana/k6 run --quiet \ -e APPWRITE_ENDPOINT=http://localhost/v1 \ -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ -e APPWRITE_BENCHMARK_ITERATIONS=1 \ -e APPWRITE_BENCHMARK_VUS=1 \ + -e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-after-summary.json \ tests/benchmarks/http.js | tee benchmark.txt - cp tests/benchmarks/http-summary.json benchmark-after-summary.json - name: Prepare comment run: | From dcd01a8fb04fbd1d6d364ce237e3fac3862dacfb Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 16:06:30 +0530 Subject: [PATCH 134/402] Tidy benchmark PR comment --- .github/workflows/ci.yml | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3af7581b66..d0d1330700 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -657,6 +657,7 @@ jobs: permissions: actions: read contents: read + issues: write pull-requests: write steps: - name: Checkout repository @@ -772,29 +773,32 @@ jobs: const detail = (label, metric, suffix = 'ms') => { const values = after.metrics?.[metric]?.values; if (!values) { - return `${label}: no samples`; + return `- **${label}:** no samples`; } - return `${label}: avg=${format(values.avg, suffix)} p90=${format(values['p(90)'], suffix)} p95=${format(values['p(95)'], suffix)} max=${format(values.max, suffix)}`; + return `- **${label}:** avg=${format(values.avg, suffix)} p90=${format(values['p(90)'], suffix)} p95=${format(values['p(95)'], suffix)} max=${format(values.max, suffix)}`; }; + console.log(''); console.log('## :sparkles: Benchmark results'); console.log(); - console.log('Appwrite curated benchmark review'); - console.log('Before/after comparison'); + console.log(`Comparing \`${{ github.event.pull_request.base.ref }}\` (before) to \`${{ github.event.pull_request.head.ref }}\` (after).`); + console.log(); console.log('| Metric | Before | After | Delta |'); console.log('| --- | ---: | ---: | ---: |'); console.log(rows.join('\n')); - console.log('Current run details'); + console.log(); + console.log('
'); + console.log('Current run details'); + console.log(); console.log(detail('HTTP total', 'http_req_duration')); console.log(detail('API endpoints', 'appwrite_api_duration')); console.log(detail('Database worker schema jobs', 'appwrite_worker_database_duration')); console.log(detail('TablesDB worker schema jobs', 'appwrite_worker_tables_duration')); console.log(detail('Mail worker delivery', 'appwrite_worker_mails_duration')); console.log(detail('Messaging worker delivery', 'appwrite_worker_messaging_duration')); - console.log(`Flow failures: ${format(counter(after, 'appwrite_benchmark_flow_failures'))}`); - console.log('Endpoint: http://localhost/v1'); - console.log('Maildev API: http://localhost:9503/email'); + console.log(); + console.log('
'); NODE - name: Save results @@ -813,6 +817,15 @@ jobs: if: github.event.pull_request.head.repo.full_name == github.repository uses: peter-evans/find-comment@v3 id: fc + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: appwrite-benchmark-results + + - name: Find Legacy Comment + if: github.event.pull_request.head.repo.full_name == github.repository && steps.fc.outputs.comment-id == '' + uses: peter-evans/find-comment@v3 + id: legacy_fc with: issue-number: ${{ github.event.pull_request.number }} comment-author: 'github-actions[bot]' @@ -822,7 +835,7 @@ jobs: if: github.event.pull_request.head.repo.full_name == github.repository uses: peter-evans/create-or-update-comment@v4 with: - comment-id: ${{ steps.fc.outputs.comment-id }} + comment-id: ${{ steps.fc.outputs.comment-id || steps.legacy_fc.outputs.comment-id }} issue-number: ${{ github.event.pull_request.number }} body-path: benchmark-comment.txt edit-mode: replace From 83f182b444228b64839dead5af848d5dfaa6d951 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 16:10:08 +0530 Subject: [PATCH 135/402] Address benchmark review feedback --- .github/workflows/ci.yml | 9 ++++++++- tests/benchmarks/http.js | 19 +++++-------------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0d1330700..bf9831582f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -704,13 +704,15 @@ jobs: - name: Benchmark before run: | rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before.txt benchmark.txt - docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts grafana/k6 run --quiet \ + docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "/tmp/appwrite-benchmark-before:/scripts" -w /scripts grafana/k6 run --quiet \ + --summary-export benchmark-before-summary.json \ -e APPWRITE_ENDPOINT=http://localhost/v1 \ -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ -e APPWRITE_BENCHMARK_ITERATIONS=1 \ -e APPWRITE_BENCHMARK_VUS=1 \ -e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-before-summary.json \ tests/benchmarks/http.js | tee benchmark-before.txt + cp /tmp/appwrite-benchmark-before/benchmark-before-summary.json benchmark-before-summary.json - name: Stop before Appwrite if: always() @@ -733,9 +735,14 @@ jobs: -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ -e APPWRITE_BENCHMARK_ITERATIONS=1 \ -e APPWRITE_BENCHMARK_VUS=1 \ + -e APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH=/scripts/benchmark-before-summary.json \ -e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-after-summary.json \ tests/benchmarks/http.js | tee benchmark.txt + - name: Stop after Appwrite + if: always() + run: docker compose down -v + - name: Prepare comment run: | node <<'NODE' > benchmark-comment.txt diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 7e04b40cc2..6ce5afd661 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -14,6 +14,7 @@ const WORKER_TIMEOUT_MS = Number(__ENV.APPWRITE_WORKER_TIMEOUT_MS || 60000); const ITERATIONS = Number(__ENV.APPWRITE_BENCHMARK_ITERATIONS || 1); const VUS = Number(__ENV.APPWRITE_BENCHMARK_VUS || 1); const SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_SUMMARY_PATH || 'tests/benchmarks/http-summary.json'; +const PREVIOUS_SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH || SUMMARY_PATH; const PREVIOUS_SUMMARY = loadPreviousSummary(); export const apiDuration = new Trend('appwrite_api_duration', true); @@ -712,8 +713,7 @@ function waitForStatus(path, headers, wantedStatus, timeoutMs) { while (Date.now() - started < timeoutMs) { const response = rawRequest('GET', path, null, headers, `wait${path}`); - assertStatus(response, [200], `wait${path}`); - if (response.json('status') === wantedStatus) { + if (response.status === 200 && response.json('status') === wantedStatus) { return response; } sleep(0.5); @@ -727,8 +727,7 @@ function waitForMessage(messageId, headers, timeoutMs) { while (Date.now() - started < timeoutMs) { const response = rawRequest('GET', `/messaging/messages/${messageId}`, null, headers, 'messaging.messages.poll'); - assertStatus(response, [200], 'messaging.messages.poll'); - const status = response.json('status'); + const status = response.status === 200 ? response.json('status') : null; if (['sent', 'failed'].includes(status)) { if (status === 'failed') { @@ -858,15 +857,7 @@ function tablePayload() { } function onePixelPng() { - const bytes = new Uint8Array(encoding.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=')); - let output = ''; - - // Appwrite's multipart upload path accepts this k6 fixture as a binary string. - for (let i = 0; i < bytes.length; i++) { - output += String.fromCharCode(bytes[i]); - } - - return output; + return encoding.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'std', 'b'); } function flattenMultipartArray(key, values) { @@ -923,7 +914,7 @@ export function handleSummary(data) { function loadPreviousSummary() { try { - return JSON.parse(open('http-summary.json')); + return JSON.parse(open(PREVIOUS_SUMMARY_PATH)); } catch (error) { return null; } From 51bc3dc1d55321df3f4da4db8f306c2176182e92 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 16:22:04 +0530 Subject: [PATCH 136/402] Migrate HTTP benchmark to PHP --- .github/workflows/ci.yml | 139 ++-- tests/benchmarks/http.js | 996 ---------------------------- tests/benchmarks/http.php | 1299 +++++++++++++++++++++++++++++++++++++ 3 files changed, 1375 insertions(+), 1059 deletions(-) delete mode 100644 tests/benchmarks/http.js create mode 100644 tests/benchmarks/http.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf9831582f..78d959779c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -704,15 +704,13 @@ jobs: - name: Benchmark before run: | rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before.txt benchmark.txt - docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "/tmp/appwrite-benchmark-before:/scripts" -w /scripts grafana/k6 run --quiet \ - --summary-export benchmark-before-summary.json \ + docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts \ -e APPWRITE_ENDPOINT=http://localhost/v1 \ -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ -e APPWRITE_BENCHMARK_ITERATIONS=1 \ -e APPWRITE_BENCHMARK_VUS=1 \ -e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-before-summary.json \ - tests/benchmarks/http.js | tee benchmark-before.txt - cp /tmp/appwrite-benchmark-before/benchmark-before-summary.json benchmark-before-summary.json + ${{ env.IMAGE }}:after php tests/benchmarks/http.php | tee benchmark-before.txt - name: Stop before Appwrite if: always() @@ -730,14 +728,14 @@ jobs: - name: Benchmark after run: | - docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts grafana/k6 run --quiet \ + docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts \ -e APPWRITE_ENDPOINT=http://localhost/v1 \ -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ -e APPWRITE_BENCHMARK_ITERATIONS=1 \ -e APPWRITE_BENCHMARK_VUS=1 \ - -e APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH=/scripts/benchmark-before-summary.json \ + -e APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH=benchmark-before-summary.json \ -e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-after-summary.json \ - tests/benchmarks/http.js | tee benchmark.txt + ${{ env.IMAGE }}:after php tests/benchmarks/http.php | tee benchmark.txt - name: Stop after Appwrite if: always() @@ -745,68 +743,83 @@ jobs: - name: Prepare comment run: | - node <<'NODE' > benchmark-comment.txt - const fs = require('fs'); + docker run --rm -i -v "$PWD:/scripts" -w /scripts ${{ env.IMAGE }}:after php <<'PHP' > benchmark-comment.txt + data.metrics?.[metric]?.values?.[stat]; - const value = (data, metric, stat) => data.metrics?.[metric]?.values?.[stat]; - const counter = (data, metric) => value(data, metric, 'count'); - const delta = (beforeValue, afterValue, suffix = '') => { - if (beforeValue === undefined || afterValue === undefined) { - return 'n/a'; - } + function metric_value(?array $data, string $metric, string $stat): mixed + { + return $data['metrics'][$metric]['values'][$stat] ?? null; + } - const difference = afterValue - beforeValue; - return `${difference > 0 ? '+' : ''}${formatNumber(difference)}${suffix}`; - }; - const format = (value, suffix = '') => value === undefined ? 'n/a' : `${formatNumber(value)}${suffix}`; - const formatNumber = (value) => Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, ''); - const row = (label, beforeValue, afterValue, suffix = '') => `| ${label} | ${format(beforeValue, suffix)} | ${format(afterValue, suffix)} | ${delta(beforeValue, afterValue, suffix)} |`; + function format_number(mixed $value): string + { + $value = round((float) $value, 2); + return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); + } - const rows = [ - row('HTTP total p95', trend(before, 'http_req_duration', 'p(95)'), trend(after, 'http_req_duration', 'p(95)'), 'ms'), - row('API endpoints p95', trend(before, 'appwrite_api_duration', 'p(95)'), trend(after, 'appwrite_api_duration', 'p(95)'), 'ms'), - row('Database worker p95', trend(before, 'appwrite_worker_database_duration', 'p(95)'), trend(after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'), - row('TablesDB worker p95', trend(before, 'appwrite_worker_tables_duration', 'p(95)'), trend(after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'), - row('Mail worker p95', trend(before, 'appwrite_worker_mails_duration', 'p(95)'), trend(after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'), - row('Messaging worker p95', trend(before, 'appwrite_worker_messaging_duration', 'p(95)'), trend(after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'), - row('Flow failures', counter(before, 'appwrite_benchmark_flow_failures'), counter(after, 'appwrite_benchmark_flow_failures')), - row('Check failures', value(before, 'checks', 'fails'), value(after, 'checks', 'fails')), + function format_value(mixed $value, string $suffix = ''): string + { + return $value === null ? 'n/a' : format_number($value) . $suffix; + } + + function delta(mixed $beforeValue, mixed $afterValue, string $suffix = ''): string + { + if ($beforeValue === null || $afterValue === null) { + return 'n/a'; + } + + $difference = round((float) $afterValue - (float) $beforeValue, 2); + return ($difference > 0 ? '+' : '') . format_number($difference) . $suffix; + } + + function row(string $label, mixed $beforeValue, mixed $afterValue, string $suffix = ''): string + { + return '| ' . $label . ' | ' . format_value($beforeValue, $suffix) . ' | ' . format_value($afterValue, $suffix) . ' | ' . delta($beforeValue, $afterValue, $suffix) . ' |'; + } + + function detail(array $after, string $label, string $metric, string $suffix = 'ms'): string + { + $values = $after['metrics'][$metric]['values'] ?? null; + if (!is_array($values)) { + return '- **' . $label . ':** no samples'; + } + + return '- **' . $label . ':** avg=' . format_value($values['avg'] ?? null, $suffix) + . ' p90=' . format_value($values['p(90)'] ?? null, $suffix) + . ' p95=' . format_value($values['p(95)'] ?? null, $suffix) + . ' max=' . format_value($values['max'] ?? null, $suffix); + } + + $rows = [ + row('HTTP total p95', metric_value($before, 'http_req_duration', 'p(95)'), metric_value($after, 'http_req_duration', 'p(95)'), 'ms'), + row('API endpoints p95', metric_value($before, 'appwrite_api_duration', 'p(95)'), metric_value($after, 'appwrite_api_duration', 'p(95)'), 'ms'), + row('Database worker p95', metric_value($before, 'appwrite_worker_database_duration', 'p(95)'), metric_value($after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'), + row('TablesDB worker p95', metric_value($before, 'appwrite_worker_tables_duration', 'p(95)'), metric_value($after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'), + row('Mail worker p95', metric_value($before, 'appwrite_worker_mails_duration', 'p(95)'), metric_value($after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'), + row('Messaging worker p95', metric_value($before, 'appwrite_worker_messaging_duration', 'p(95)'), metric_value($after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'), + row('Flow failures', metric_value($before, 'appwrite_benchmark_flow_failures', 'count'), metric_value($after, 'appwrite_benchmark_flow_failures', 'count')), + row('Check failures', metric_value($before, 'checks', 'fails'), metric_value($after, 'checks', 'fails')), ]; - const detail = (label, metric, suffix = 'ms') => { - const values = after.metrics?.[metric]?.values; - if (!values) { - return `- **${label}:** no samples`; - } - - return `- **${label}:** avg=${format(values.avg, suffix)} p90=${format(values['p(90)'], suffix)} p95=${format(values['p(95)'], suffix)} max=${format(values.max, suffix)}`; - }; - - console.log(''); - console.log('## :sparkles: Benchmark results'); - console.log(); - console.log(`Comparing \`${{ github.event.pull_request.base.ref }}\` (before) to \`${{ github.event.pull_request.head.ref }}\` (after).`); - console.log(); - console.log('| Metric | Before | After | Delta |'); - console.log('| --- | ---: | ---: | ---: |'); - console.log(rows.join('\n')); - console.log(); - console.log('
'); - console.log('Current run details'); - console.log(); - console.log(detail('HTTP total', 'http_req_duration')); - console.log(detail('API endpoints', 'appwrite_api_duration')); - console.log(detail('Database worker schema jobs', 'appwrite_worker_database_duration')); - console.log(detail('TablesDB worker schema jobs', 'appwrite_worker_tables_duration')); - console.log(detail('Mail worker delivery', 'appwrite_worker_mails_duration')); - console.log(detail('Messaging worker delivery', 'appwrite_worker_messaging_duration')); - console.log(); - console.log('
'); - NODE + echo "\n"; + echo "## :sparkles: Benchmark results\n\n"; + echo 'Comparing `${{ github.event.pull_request.base.ref }}` (before) to `${{ github.event.pull_request.head.ref }}` (after).' . "\n\n"; + echo "| Metric | Before | After | Delta |\n"; + echo "| --- | ---: | ---: | ---: |\n"; + echo implode("\n", $rows) . "\n\n"; + echo "
\n"; + echo "Current run details\n\n"; + echo detail($after, 'HTTP total', 'http_req_duration') . "\n"; + echo detail($after, 'API endpoints', 'appwrite_api_duration') . "\n"; + echo detail($after, 'Database worker schema jobs', 'appwrite_worker_database_duration') . "\n"; + echo detail($after, 'TablesDB worker schema jobs', 'appwrite_worker_tables_duration') . "\n"; + echo detail($after, 'Mail worker delivery', 'appwrite_worker_mails_duration') . "\n"; + echo detail($after, 'Messaging worker delivery', 'appwrite_worker_messaging_duration') . "\n\n"; + echo "
\n"; + PHP - name: Save results uses: actions/upload-artifact@v7 diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js deleted file mode 100644 index 6ce5afd661..0000000000 --- a/tests/benchmarks/http.js +++ /dev/null @@ -1,996 +0,0 @@ -import http from 'k6/http'; -import { check, group, sleep } from 'k6'; -import encoding from 'k6/encoding'; -import { Counter, Trend } from 'k6/metrics'; - -const ENDPOINT = (__ENV.APPWRITE_ENDPOINT || 'http://localhost/v1').replace(/\/+$/, ''); -const MAILDEV_ENDPOINT = __ENV.APPWRITE_MAILDEV_ENDPOINT || 'http://localhost:9503/email'; -const CONSOLE_PROJECT = __ENV.APPWRITE_CONSOLE_PROJECT || 'console'; -const REGION = __ENV.APPWRITE_REGION || 'default'; -const REDIRECT_URL = __ENV.APPWRITE_BENCHMARK_REDIRECT_URL || 'http://localhost'; -const PASSWORD = __ENV.APPWRITE_BENCHMARK_PASSWORD || 'Password123!'; -const MAIL_TIMEOUT_MS = Number(__ENV.APPWRITE_MAIL_TIMEOUT_MS || 20000); -const WORKER_TIMEOUT_MS = Number(__ENV.APPWRITE_WORKER_TIMEOUT_MS || 60000); -const ITERATIONS = Number(__ENV.APPWRITE_BENCHMARK_ITERATIONS || 1); -const VUS = Number(__ENV.APPWRITE_BENCHMARK_VUS || 1); -const SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_SUMMARY_PATH || 'tests/benchmarks/http-summary.json'; -const PREVIOUS_SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH || SUMMARY_PATH; -const PREVIOUS_SUMMARY = loadPreviousSummary(); - -export const apiDuration = new Trend('appwrite_api_duration', true); -export const databaseWorkerDuration = new Trend('appwrite_worker_database_duration', true); -export const tablesWorkerDuration = new Trend('appwrite_worker_tables_duration', true); -export const mailsWorkerDuration = new Trend('appwrite_worker_mails_duration', true); -export const messagingWorkerDuration = new Trend('appwrite_worker_messaging_duration', true); -export const flowFailures = new Counter('appwrite_benchmark_flow_failures'); - -export const options = { - scenarios: { - curated_flows: { - executor: 'shared-iterations', - exec: 'curatedFlows', - vus: VUS, - iterations: ITERATIONS, - maxDuration: __ENV.APPWRITE_BENCHMARK_MAX_DURATION || '30m', - }, - }, - thresholds: { - http_req_failed: ['rate<0.05'], - appwrite_api_duration: ['p(95)<2000'], - appwrite_benchmark_flow_failures: ['count<1'], - }, -}; - -const API_SCOPES = [ - 'sessions.write', - 'users.read', - 'users.write', - 'teams.read', - 'teams.write', - 'databases.read', - 'databases.write', - 'collections.read', - 'collections.write', - 'tables.read', - 'tables.write', - 'attributes.read', - 'attributes.write', - 'columns.read', - 'columns.write', - 'indexes.read', - 'indexes.write', - 'documents.read', - 'documents.write', - 'rows.read', - 'rows.write', - 'files.read', - 'files.write', - 'buckets.read', - 'buckets.write', - 'functions.read', - 'functions.write', - 'sites.read', - 'sites.write', - 'log.read', - 'log.write', - 'execution.read', - 'execution.write', - 'locale.read', - 'avatars.read', - 'health.read', - 'providers.read', - 'providers.write', - 'messages.read', - 'messages.write', - 'topics.read', - 'topics.write', - 'subscribers.read', - 'subscribers.write', - 'targets.read', - 'targets.write', - 'rules.read', - 'rules.write', - 'migrations.read', - 'migrations.write', - 'vcs.read', - 'vcs.write', - 'assistant.read', - 'tokens.read', - 'tokens.write', - 'platforms.read', - 'platforms.write', -]; - -const BASE_PERMISSIONS = [ - 'read("any")', - 'create("any")', - 'update("any")', - 'delete("any")', -]; - -const ITEM_PERMISSIONS = [ - 'read("any")', - 'update("any")', - 'delete("any")', -]; - -export function setup() { - const runId = unique('run'); - const consoleEmail = __ENV.APPWRITE_ADMIN_EMAIL || `bench-admin-${runId}@example.com`; - const consolePassword = __ENV.APPWRITE_ADMIN_PASSWORD || PASSWORD; - - const consoleHeaders = { - 'Content-Type': 'application/json', - 'X-Appwrite-Project': CONSOLE_PROJECT, - }; - - const account = rawRequest('POST', '/account', { - userId: unique('admin'), - email: consoleEmail, - password: consolePassword, - name: 'Benchmark Admin', - }, consoleHeaders, 'setup.account.create'); - - if (![201, 409].includes(account.status)) { - failResponse(account, 'Unable to create or reuse the benchmark console account'); - } - - const session = rawRequest('POST', '/account/sessions/email', { - email: consoleEmail, - password: consolePassword, - }, consoleHeaders, 'setup.account.session'); - - assertStatus(session, [201], 'console session created'); - - const consoleSessionHeaders = { - ...consoleHeaders, - Cookie: cookieHeader(session), - }; - - const team = api('POST', '/teams', { - teamId: unique('team'), - name: `Benchmark Team ${runId}`, - }, consoleSessionHeaders, [201], 'setup.teams.create'); - - const teamId = team.json('$id'); - const project = api('POST', '/projects', { - projectId: unique('project'), - name: `Benchmark Project ${runId}`, - teamId, - region: REGION, - }, consoleSessionHeaders, [201], 'setup.projects.create'); - - const projectId = project.json('$id'); - const key = api('POST', `/projects/${projectId}/keys`, { - keyId: unique('key'), - name: 'Benchmark API key', - scopes: API_SCOPES, - }, consoleSessionHeaders, [201], 'setup.projects.keys.create'); - - const apiHeaders = { - 'Content-Type': 'application/json', - 'X-Appwrite-Project': projectId, - 'X-Appwrite-Key': key.json('secret'), - }; - - const platform = api('POST', '/project/platforms/web', { - platformId: unique('web'), - name: 'Benchmark web', - hostname: hostnameFromUrl(REDIRECT_URL), - }, apiHeaders, [201, 409], 'setup.project.platforms.web.create'); - - const smtp = rawRequest('PATCH', `/projects/${projectId}/smtp`, { - enabled: true, - senderName: 'Benchmark', - senderEmail: 'benchmark@appwrite.io', - replyTo: 'benchmark@appwrite.io', - host: __ENV.APPWRITE_SMTP_HOST || 'maildev', - port: Number(__ENV.APPWRITE_SMTP_PORT || 1025), - username: __ENV.APPWRITE_SMTP_USERNAME || 'user', - password: __ENV.APPWRITE_SMTP_PASSWORD || 'password', - ...(String(__ENV.APPWRITE_SMTP_SECURE || '') !== '' ? { secure: __ENV.APPWRITE_SMTP_SECURE } : {}), - }, consoleSessionHeaders, 'setup.projects.smtp.update'); - - if (smtp.status !== 200) { - console.warn(`Custom SMTP was not enabled (${smtp.status}). Mail worker timings may be unavailable.`); - } - - return { - runId, - teamId, - projectId, - consoleSessionHeaders, - apiHeaders, - platformStatus: platform.status, - }; -} - -export function curatedFlows(data) { - const ctx = { ...data }; - - try { - group('account and mail worker', () => accountFlow(ctx)); - group('databases documents flow', () => databasesFlow(ctx)); - group('tablesdb rows flow', () => tablesDbFlow(ctx)); - group('storage files and tokens flow', () => storageFlow(ctx)); - group('messaging worker flow', () => messagingFlow(ctx)); - group('functions and sites control-plane flow', () => computeFlow(ctx)); - group('health and queue probes', () => healthFlow(ctx)); - } catch (error) { - flowFailures.add(1); - throw error; - } -} - -export function teardown(data) { - if (data && data.projectId && data.consoleSessionHeaders) { - rawRequest('DELETE', `/projects/${data.projectId}`, null, data.consoleSessionHeaders, 'teardown.projects.delete'); - } - - if (data && data.teamId && data.consoleSessionHeaders) { - rawRequest('DELETE', `/teams/${data.teamId}`, null, data.consoleSessionHeaders, 'teardown.teams.delete'); - } -} - -function accountFlow(ctx) { - const userId = unique('user'); - const email = `bench-user-${unique('mail')}@example.com`; - const headers = projectHeaders(ctx.projectId); - - api('POST', '/account', { - userId, - email, - password: PASSWORD, - name: 'Benchmark User', - }, headers, [201], 'account.create'); - - const session = api('POST', '/account/sessions/email', { - email, - password: PASSWORD, - }, headers, [201], 'account.sessions.email.create'); - - const sessionHeaders = { - ...headers, - Cookie: cookieHeader(session), - }; - - ctx.userId = userId; - ctx.userEmail = email; - ctx.sessionHeaders = sessionHeaders; - - const jwt = api('POST', '/account/jwts', null, sessionHeaders, [201], 'account.jwts.create'); - ctx.jwtHeaders = { - ...headers, - 'X-Appwrite-JWT': jwt.json('jwt'), - }; - - api('GET', '/account', null, sessionHeaders, [200], 'account.get'); - api('GET', '/account/logs', null, sessionHeaders, [200], 'account.logs.list'); - api('PATCH', '/account/prefs', { prefs: { benchmark: true, runId: ctx.runId } }, sessionHeaders, [200], 'account.prefs.update'); - api('PATCH', '/account/name', { name: 'Benchmark User Updated' }, sessionHeaders, [200], 'account.name.update'); - api('PATCH', '/account/password', { password: `${PASSWORD}2`, oldPassword: PASSWORD }, sessionHeaders, [200], 'account.password.update'); - - const verificationStarted = Date.now(); - api('POST', '/account/verifications/email', { url: REDIRECT_URL }, sessionHeaders, [201], 'account.emailVerification.create'); - const verificationEmail = waitForEmail(email, (message) => { - return includes(message.subject, 'verify') - || includes(message.subject, 'verification') - || includes(message.html, 'verify') - || includes(message.html, 'verification') - || includes(message.text, 'verify') - || includes(message.text, 'verification'); - }, MAIL_TIMEOUT_MS); - mailsWorkerDuration.add(Date.now() - verificationStarted, { job: 'email_verification' }); - - const verification = extractQueryParams(verificationEmail); - if (verification.userId && verification.secret) { - api('PUT', '/account/verifications/email', { - userId: verification.userId, - secret: verification.secret, - }, sessionHeaders, [200], 'account.emailVerification.update'); - } - - const recoveryStarted = Date.now(); - api('POST', '/account/recovery', { email, url: REDIRECT_URL }, headers, [201], 'account.recovery.create'); - const recoveryEmail = waitForEmail(email, (message) => { - return includes(message.subject, 'recovery') - || includes(message.subject, 'recover') - || includes(message.subject, 'reset') - || includes(message.html, 'recovery') - || includes(message.html, 'recover') - || includes(message.html, 'reset') - || includes(message.text, 'recovery') - || includes(message.text, 'recover') - || includes(message.text, 'reset'); - }, MAIL_TIMEOUT_MS); - mailsWorkerDuration.add(Date.now() - recoveryStarted, { job: 'password_recovery' }); - - const recovery = extractQueryParams(recoveryEmail); - if (recovery.userId && recovery.secret) { - api('DELETE', '/account/sessions/current', null, sessionHeaders, [204], 'account.sessions.current.delete'); - - api('PUT', '/account/recovery', { - userId: recovery.userId, - secret: recovery.secret, - password: `${PASSWORD}3`, - }, headers, [200], 'account.recovery.update'); - - const recoveredSession = api('POST', '/account/sessions/email', { - email, - password: `${PASSWORD}3`, - }, headers, [201], 'account.sessions.email.recovered'); - - ctx.sessionHeaders = { - ...headers, - Cookie: cookieHeader(recoveredSession), - }; - - const recoveredJwt = api('POST', '/account/jwts', null, ctx.sessionHeaders, [201], 'account.jwts.recovered'); - ctx.jwtHeaders = { - ...headers, - 'X-Appwrite-JWT': recoveredJwt.json('jwt'), - }; - } -} - -function databasesFlow(ctx) { - const databaseId = unique('db'); - const collectionId = unique('col'); - const documentId = unique('doc'); - const indexKey = unique('idx'); - - api('POST', '/databases', { databaseId, name: 'Benchmark DB' }, ctx.apiHeaders, [201], 'databases.create'); - api('POST', `/databases/${databaseId}/collections`, { - collectionId, - name: 'Benchmark Collection', - permissions: BASE_PERMISSIONS, - documentSecurity: false, - }, ctx.apiHeaders, [201], 'databases.collections.create'); - - const attributes = [ - ['string', 'title', { size: 128 }], - ['integer', 'count', { min: 0, max: 100000 }], - ['email', 'email', {}], - ['boolean', 'active', {}], - ['datetime', 'publishedAt', {}], - ['float', 'score', { min: 0, max: 1000 }], - ['url', 'url', {}], - ['ip', 'ip', {}], - ]; - - for (const [type, key, extra] of attributes) { - const started = Date.now(); - api('POST', `/databases/${databaseId}/collections/${collectionId}/attributes/${type}`, { - key, - required: false, - array: false, - ...extra, - }, ctx.apiHeaders, [202], `databases.attributes.${type}.create`); - waitForStatus(`/databases/${databaseId}/collections/${collectionId}/attributes/${key}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); - databaseWorkerDuration.add(Date.now() - started, { job: `attribute_${type}` }); - } - - const indexStarted = Date.now(); - api('POST', `/databases/${databaseId}/collections/${collectionId}/indexes`, { - key: indexKey, - type: 'key', - attributes: ['title'], - orders: ['asc'], - }, ctx.apiHeaders, [202], 'databases.indexes.create'); - waitForStatus(`/databases/${databaseId}/collections/${collectionId}/indexes/${indexKey}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); - databaseWorkerDuration.add(Date.now() - indexStarted, { job: 'index' }); - - api('POST', `/databases/${databaseId}/collections/${collectionId}/documents`, { - documentId, - data: documentPayload(), - permissions: ITEM_PERMISSIONS, - }, ctx.apiHeaders, [201], 'databases.documents.create'); - api('GET', `/databases/${databaseId}/collections/${collectionId}/documents`, null, ctx.apiHeaders, [200], 'databases.documents.list'); - api('GET', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, null, ctx.apiHeaders, [200], 'databases.documents.get'); - api('PATCH', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, { - data: { title: 'Benchmark Document Updated' }, - }, ctx.apiHeaders, [200], 'databases.documents.update'); - api('PATCH', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}/count/increment`, { - value: 1, - }, ctx.apiHeaders, [200], 'databases.documents.increment'); - api('PATCH', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}/count/decrement`, { - value: 1, - }, ctx.apiHeaders, [200], 'databases.documents.decrement'); - api('DELETE', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, null, ctx.apiHeaders, [204], 'databases.documents.delete'); - api('DELETE', `/databases/${databaseId}`, null, ctx.apiHeaders, [204], 'databases.delete'); -} - -function tablesDbFlow(ctx) { - const databaseId = unique('tdb'); - const tableId = unique('tbl'); - const rowId = unique('row'); - const indexKey = unique('tidx'); - - api('POST', '/tablesdb', { databaseId, name: 'Benchmark TablesDB' }, ctx.apiHeaders, [201], 'tablesdb.create'); - api('POST', `/tablesdb/${databaseId}/tables`, { - tableId, - name: 'Benchmark Table', - permissions: BASE_PERMISSIONS, - rowSecurity: false, - }, ctx.apiHeaders, [201], 'tablesdb.tables.create'); - - const columns = [ - ['string', 'title', { size: 128 }], - ['integer', 'count', { min: 0, max: 100000 }], - ['email', 'email', {}], - ['boolean', 'active', {}], - ]; - - for (const [type, key, extra] of columns) { - const started = Date.now(); - api('POST', `/tablesdb/${databaseId}/tables/${tableId}/columns/${type}`, { - key, - required: false, - array: false, - ...extra, - }, ctx.apiHeaders, [202], `tablesdb.columns.${type}.create`); - waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); - tablesWorkerDuration.add(Date.now() - started, { job: `column_${type}` }); - } - - const indexStarted = Date.now(); - api('POST', `/tablesdb/${databaseId}/tables/${tableId}/indexes`, { - key: indexKey, - type: 'key', - columns: ['title'], - orders: ['asc'], - }, ctx.apiHeaders, [202], 'tablesdb.indexes.create'); - waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/indexes/${indexKey}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); - tablesWorkerDuration.add(Date.now() - indexStarted, { job: 'index' }); - - api('POST', `/tablesdb/${databaseId}/tables/${tableId}/rows`, { - rowId, - data: tablePayload(), - permissions: ITEM_PERMISSIONS, - }, ctx.sessionHeaders, [201], 'tablesdb.rows.create'); - api('GET', `/tablesdb/${databaseId}/tables/${tableId}/rows`, null, ctx.sessionHeaders, [200], 'tablesdb.rows.list'); - api('GET', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, null, ctx.sessionHeaders, [200], 'tablesdb.rows.get'); - api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, { - data: { title: 'Benchmark Row Updated' }, - }, ctx.sessionHeaders, [200], 'tablesdb.rows.update'); - api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/count/increment`, { - value: 1, - }, ctx.sessionHeaders, [200], 'tablesdb.rows.increment'); - api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/count/decrement`, { - value: 1, - }, ctx.sessionHeaders, [200], 'tablesdb.rows.decrement'); - api('DELETE', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, null, ctx.sessionHeaders, [204], 'tablesdb.rows.delete'); - api('DELETE', `/tablesdb/${databaseId}`, null, ctx.apiHeaders, [204], 'tablesdb.delete'); -} - -function storageFlow(ctx) { - const bucketId = unique('bucket'); - const fileId = unique('file'); - - api('POST', '/storage/buckets', { - bucketId, - name: 'Benchmark Bucket', - permissions: BASE_PERMISSIONS, - fileSecurity: false, - enabled: true, - maximumFileSize: 30000000, - allowedFileExtensions: [], - compression: 'none', - encryption: false, - antivirus: false, - }, ctx.apiHeaders, [201], 'storage.buckets.create'); - - const multipartHeaders = { ...ctx.sessionHeaders }; - delete multipartHeaders['Content-Type']; - - const upload = http.post(`${ENDPOINT}/storage/buckets/${bucketId}/files`, { - fileId, - file: http.file(onePixelPng(), 'benchmark.png', 'image/png'), - ...flattenMultipartArray('permissions', ITEM_PERMISSIONS), - }, { - headers: multipartHeaders, - tags: { name: 'storage.files.create' }, - }); - - apiDuration.add(upload.timings.duration, { name: 'storage.files.create' }); - assertStatus(upload, [201], 'storage file created'); - - api('GET', `/storage/buckets/${bucketId}/files`, null, ctx.sessionHeaders, [200], 'storage.files.list'); - api('GET', `/storage/buckets/${bucketId}/files/${fileId}`, null, ctx.sessionHeaders, [200], 'storage.files.get'); - api('GET', `/storage/buckets/${bucketId}/files/${fileId}/view`, null, ctx.sessionHeaders, [200], 'storage.files.view'); - api('GET', `/storage/buckets/${bucketId}/files/${fileId}/download`, null, ctx.sessionHeaders, [200], 'storage.files.download'); - api('GET', `/storage/buckets/${bucketId}/files/${fileId}/preview`, null, ctx.sessionHeaders, [200], 'storage.files.preview'); - api('PUT', `/storage/buckets/${bucketId}/files/${fileId}`, { - name: 'benchmark-renamed.png', - permissions: ITEM_PERMISSIONS, - }, ctx.sessionHeaders, [200], 'storage.files.update'); - - const token = api('POST', `/tokens/buckets/${bucketId}/files/${fileId}`, {}, ctx.apiHeaders, [201], 'tokens.files.create'); - api('GET', `/tokens/buckets/${bucketId}/files/${fileId}`, null, ctx.apiHeaders, [200], 'tokens.files.list'); - api('GET', `/tokens/${token.json('$id')}`, null, ctx.apiHeaders, [200], 'tokens.get'); - api('PATCH', `/tokens/${token.json('$id')}`, { expire: null }, ctx.apiHeaders, [200], 'tokens.update'); - api('DELETE', `/tokens/${token.json('$id')}`, null, ctx.apiHeaders, [204], 'tokens.delete'); - - api('DELETE', `/storage/buckets/${bucketId}/files/${fileId}`, null, ctx.sessionHeaders, [204], 'storage.files.delete'); - api('DELETE', `/storage/buckets/${bucketId}`, null, ctx.apiHeaders, [204], 'storage.buckets.delete'); -} - -function messagingFlow(ctx) { - const providerId = unique('smtp'); - let targetId = unique('target'); - const topicId = unique('topic'); - const subscriberId = unique('sub'); - const messageId = unique('msg'); - - api('POST', '/messaging/providers/smtp', { - providerId, - name: 'Benchmark SMTP', - host: __ENV.APPWRITE_SMTP_HOST || 'maildev', - port: Number(__ENV.APPWRITE_SMTP_PORT || 1025), - username: __ENV.APPWRITE_SMTP_USERNAME || 'user', - password: __ENV.APPWRITE_SMTP_PASSWORD || 'password', - encryption: __ENV.APPWRITE_SMTP_ENCRYPTION || 'none', - autoTLS: false, - fromName: 'Benchmark', - fromEmail: 'benchmark@appwrite.io', - replyToName: 'Benchmark', - replyToEmail: 'benchmark@appwrite.io', - enabled: true, - }, ctx.apiHeaders, [201], 'messaging.providers.smtp.create'); - - const targets = api('GET', `/users/${ctx.userId}/targets`, null, ctx.apiHeaders, [200], 'users.targets.list'); - const existingTarget = (targets.json('targets') || []).find((target) => { - return target.providerType === 'email' && target.identifier === ctx.userEmail; - }); - - if (existingTarget) { - targetId = existingTarget.$id; - api('PATCH', `/users/${ctx.userId}/targets/${targetId}`, { - providerId, - name: 'Benchmark email target', - }, ctx.apiHeaders, [200], 'users.targets.update'); - } else { - api('POST', `/users/${ctx.userId}/targets`, { - targetId, - providerType: 'email', - identifier: ctx.userEmail, - providerId, - name: 'Benchmark email target', - }, ctx.apiHeaders, [201], 'users.targets.create'); - } - - api('POST', '/messaging/topics', { - topicId, - name: 'Benchmark Topic', - subscribe: ['users'], - }, ctx.apiHeaders, [201], 'messaging.topics.create'); - - api('POST', `/messaging/topics/${topicId}/subscribers`, { - subscriberId, - targetId, - }, ctx.sessionHeaders, [201], 'messaging.subscribers.create'); - - const started = Date.now(); - api('POST', '/messaging/messages/email', { - messageId, - subject: `Benchmark message ${ctx.runId}`, - content: `Benchmark messaging worker probe ${ctx.runId}`, - targets: [targetId], - draft: false, - html: false, - }, ctx.apiHeaders, [201], 'messaging.messages.email.create'); - - waitForMessage(messageId, ctx.apiHeaders, WORKER_TIMEOUT_MS); - waitForEmail(ctx.userEmail, (message) => includes(message.subject, `Benchmark message ${ctx.runId}`), MAIL_TIMEOUT_MS, true); - messagingWorkerDuration.add(Date.now() - started, { job: 'email_message' }); - - api('GET', '/messaging/messages', null, ctx.apiHeaders, [200], 'messaging.messages.list'); - api('GET', `/messaging/messages/${messageId}/logs`, null, ctx.apiHeaders, [200], 'messaging.messages.logs.list'); - api('GET', `/messaging/messages/${messageId}/targets`, null, ctx.apiHeaders, [200], 'messaging.messages.targets.list'); - api('GET', `/messaging/providers/${providerId}/logs`, null, ctx.apiHeaders, [200], 'messaging.providers.logs.list'); - api('GET', `/messaging/topics/${topicId}/logs`, null, ctx.apiHeaders, [200], 'messaging.topics.logs.list'); - api('GET', `/messaging/subscribers/${subscriberId}/logs`, null, ctx.apiHeaders, [200], 'messaging.subscribers.logs.list'); - api('DELETE', `/messaging/topics/${topicId}/subscribers/${subscriberId}`, null, ctx.sessionHeaders, [204], 'messaging.subscribers.delete'); - api('DELETE', `/messaging/topics/${topicId}`, null, ctx.apiHeaders, [204], 'messaging.topics.delete'); - api('DELETE', `/messaging/messages/${messageId}`, null, ctx.apiHeaders, [204], 'messaging.messages.delete'); - api('DELETE', `/messaging/providers/${providerId}`, null, ctx.apiHeaders, [204], 'messaging.providers.delete'); -} - -function computeFlow(ctx) { - const functionId = unique('fn'); - let functionVariableId; - const siteId = unique('site'); - let siteVariableId; - - api('POST', '/functions', { - functionId, - name: 'Benchmark Function', - runtime: __ENV.APPWRITE_BENCHMARK_RUNTIME || 'node-22', - execute: ['any'], - events: [], - schedule: '', - timeout: 15, - enabled: true, - logging: true, - entrypoint: 'index.js', - commands: 'npm install', - scopes: ['users.read'], - }, ctx.apiHeaders, [201], 'functions.create'); - api('GET', '/functions/runtimes', null, ctx.sessionHeaders, [200], 'functions.runtimes.list'); - api('GET', '/functions/specifications', null, ctx.apiHeaders, [200], 'functions.specifications.list'); - const functionVariable = api('POST', `/functions/${functionId}/variables`, { - key: 'BENCHMARK', - value: 'true', - secret: false, - }, ctx.apiHeaders, [201], 'functions.variables.create'); - functionVariableId = functionVariable.json('$id'); - - api('PUT', `/functions/${functionId}/variables/${functionVariableId}`, { - key: 'BENCHMARK', - value: 'updated', - secret: false, - }, ctx.apiHeaders, [200], 'functions.variables.update'); - api('GET', `/functions/${functionId}/variables/${functionVariableId}`, null, ctx.apiHeaders, [200], 'functions.variables.get'); - api('DELETE', `/functions/${functionId}/variables/${functionVariableId}`, null, ctx.apiHeaders, [204], 'functions.variables.delete'); - api('DELETE', `/functions/${functionId}`, null, ctx.apiHeaders, [204], 'functions.delete'); - - api('POST', '/sites', { - siteId, - name: 'Benchmark Site', - framework: 'other', - adapter: 'static', - buildRuntime: __ENV.APPWRITE_BENCHMARK_RUNTIME || 'node-22', - buildCommand: '', - outputDirectory: '.', - installCommand: '', - fallbackFile: 'index.html', - providerRootDirectory: '.', - specification: '', - }, ctx.apiHeaders, [201], 'sites.create'); - api('GET', '/sites/frameworks', null, ctx.sessionHeaders, [200], 'sites.frameworks.list'); - api('GET', '/sites/specifications', null, ctx.apiHeaders, [200], 'sites.specifications.list'); - const siteVariable = api('POST', `/sites/${siteId}/variables`, { - key: 'BENCHMARK', - value: 'true', - secret: false, - }, ctx.apiHeaders, [201], 'sites.variables.create'); - siteVariableId = siteVariable.json('$id'); - - api('PUT', `/sites/${siteId}/variables/${siteVariableId}`, { - key: 'BENCHMARK', - value: 'updated', - secret: false, - }, ctx.apiHeaders, [200], 'sites.variables.update'); - api('GET', `/sites/${siteId}/variables/${siteVariableId}`, null, ctx.apiHeaders, [200], 'sites.variables.get'); - api('DELETE', `/sites/${siteId}/variables/${siteVariableId}`, null, ctx.apiHeaders, [204], 'sites.variables.delete'); - api('DELETE', `/sites/${siteId}`, null, ctx.apiHeaders, [204], 'sites.delete'); -} - -function healthFlow(ctx) { - const probes = [ - '/health', - '/health/db', - '/health/cache', - '/health/pubsub', - '/health/storage', - '/health/storage/local', - '/health/time', - '/health/queue/databases', - '/health/queue/mails', - '/health/queue/messaging', - '/health/queue/functions', - '/health/queue/builds', - '/health/queue/deletes', - '/health/queue/webhooks', - '/health/queue/stats-resources', - '/health/queue/stats-usage', - '/health/queue/failed/v1-mails', - ]; - - for (const path of probes) { - api('GET', path, null, ctx.apiHeaders, [200], `health${path.replace(/\//g, '.')}`); - } -} - -function api(method, path, body, headers, expected, name) { - const response = rawRequest(method, path, body, headers, name); - apiDuration.add(response.timings.duration, { name }); - assertStatus(response, expected, name); - return response; -} - -function rawRequest(method, path, body, headers, name) { - const params = { - headers, - tags: { name }, - }; - const payload = body === null || body === undefined ? null : JSON.stringify(body); - return http.request(method, `${ENDPOINT}${path}`, payload, params); -} - -function waitForStatus(path, headers, wantedStatus, timeoutMs) { - const started = Date.now(); - - while (Date.now() - started < timeoutMs) { - const response = rawRequest('GET', path, null, headers, `wait${path}`); - if (response.status === 200 && response.json('status') === wantedStatus) { - return response; - } - sleep(0.5); - } - - throw new Error(`Timed out waiting for ${path} to become ${wantedStatus}`); -} - -function waitForMessage(messageId, headers, timeoutMs) { - const started = Date.now(); - - while (Date.now() - started < timeoutMs) { - const response = rawRequest('GET', `/messaging/messages/${messageId}`, null, headers, 'messaging.messages.poll'); - const status = response.status === 200 ? response.json('status') : null; - - if (['sent', 'failed'].includes(status)) { - if (status === 'failed') { - throw new Error(`Messaging worker marked message ${messageId} as failed`); - } - return response; - } - - sleep(0.5); - } - - throw new Error(`Timed out waiting for messaging worker to send message ${messageId}`); -} - -function waitForEmail(address, predicate, timeoutMs, allowMissingRecipient = false) { - const started = Date.now(); - - while (Date.now() - started < timeoutMs) { - const response = http.get(MAILDEV_ENDPOINT, { tags: { name: 'maildev.email.list' } }); - if (response.status === 200) { - const emails = response.json(); - for (let i = emails.length - 1; i >= 0; i--) { - const message = emails[i]; - if ((emailMatches(message, address) || (allowMissingRecipient && emailRecipientMissing(message))) && predicate(message)) { - return message; - } - } - } - sleep(0.5); - } - - throw new Error(`Timed out waiting for email to ${address}`); -} - -function emailMatches(message, address) { - const recipients = message.to || []; - return recipients.some((recipient) => recipient.address === address); -} - -function emailRecipientMissing(message) { - const recipients = message.to || []; - return recipients.length === 0 || recipients.every((recipient) => !recipient.address); -} - -function extractQueryParams(message) { - const content = `${message.html || ''}\n${message.text || ''}`; - const links = []; - const hrefPattern = /href="([^"]+)"/g; - let hrefMatch = hrefPattern.exec(content); - - while (hrefMatch !== null) { - links.push(hrefMatch[1]); - hrefMatch = hrefPattern.exec(content); - } - - if (links.length === 0) { - links.push(content); - } - - for (const link of links) { - const queryStart = link.indexOf('?'); - if (queryStart === -1) { - continue; - } - - const query = link.slice(queryStart + 1).split('#')[0].replace(/&/g, '&'); - const params = {}; - - for (const pair of query.split('&')) { - const [key, value] = pair.split('='); - params[decodeURIComponent(key)] = decodeURIComponent(value || ''); - } - - if (params.userId && params.secret) { - return params; - } - } - - return {}; -} - -function assertStatus(response, expected, name) { - const ok = check(response, { - [`${name} status ${expected.join('|')}`]: (r) => expected.includes(r.status), - }); - - if (!ok) { - failResponse(response, `${name} returned an unexpected status`); - } -} - -function failResponse(response, message) { - throw new Error(`${message}. Status: ${response.status}. Body: ${response.body}`); -} - -function cookieHeader(response) { - return response.headers['Set-Cookie'] || response.headers['set-cookie'] || ''; -} - -function projectHeaders(projectId) { - return { - 'Content-Type': 'application/json', - 'X-Appwrite-Project': projectId, - }; -} - -function documentPayload() { - return { - title: 'Benchmark Document', - count: 1, - email: 'document@example.com', - active: true, - publishedAt: new Date().toISOString(), - score: 10.5, - url: 'https://appwrite.io', - ip: '127.0.0.1', - }; -} - -function tablePayload() { - return { - title: 'Benchmark Row', - count: 1, - email: 'row@example.com', - active: true, - }; -} - -function onePixelPng() { - return encoding.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'std', 'b'); -} - -function flattenMultipartArray(key, values) { - const output = {}; - values.forEach((value, index) => { - output[`${key}[${index}]`] = value; - }); - return output; -} - -function unique(prefix) { - return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` - .toLowerCase() - .replace(/[^a-z0-9-]/g, '-') - .slice(0, 36); -} - -function includes(value, needle) { - return String(value || '').toLowerCase().includes(String(needle).toLowerCase()); -} - -function hostnameFromUrl(value) { - return value.replace(/^https?:\/\//, '').split('/')[0].split(':')[0]; -} - -export function handleSummary(data) { - const lines = [ - 'Appwrite curated benchmark review', - '', - 'Before/after comparison', - '', - comparisonTable(PREVIOUS_SUMMARY, data), - '', - 'Current run details', - '', - metricLine(data, 'http_req_duration', 'HTTP total'), - metricLine(data, 'appwrite_api_duration', 'API endpoints'), - metricLine(data, 'appwrite_worker_database_duration', 'Database worker schema jobs'), - metricLine(data, 'appwrite_worker_tables_duration', 'TablesDB worker schema jobs'), - metricLine(data, 'appwrite_worker_mails_duration', 'Mail worker delivery'), - metricLine(data, 'appwrite_worker_messaging_duration', 'Messaging worker delivery'), - counterLine(data, 'appwrite_benchmark_flow_failures', 'Flow failures'), - '', - `Endpoint: ${ENDPOINT}`, - `Maildev API: ${MAILDEV_ENDPOINT}`, - '', - ]; - - return { - stdout: `${lines.filter(Boolean).join('\n')}\n`, - [SUMMARY_PATH]: JSON.stringify(data, null, 2), - }; -} - -function loadPreviousSummary() { - try { - return JSON.parse(open(PREVIOUS_SUMMARY_PATH)); - } catch (error) { - return null; - } -} - -function comparisonTable(before, after) { - const rows = [ - ['HTTP total p95', trendMetric(before, 'http_req_duration', 'p(95)'), trendMetric(after, 'http_req_duration', 'p(95)'), 'ms'], - ['API endpoints p95', trendMetric(before, 'appwrite_api_duration', 'p(95)'), trendMetric(after, 'appwrite_api_duration', 'p(95)'), 'ms'], - ['Database worker p95', trendMetric(before, 'appwrite_worker_database_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'], - ['TablesDB worker p95', trendMetric(before, 'appwrite_worker_tables_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'], - ['Mail worker p95', trendMetric(before, 'appwrite_worker_mails_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'], - ['Messaging worker p95', trendMetric(before, 'appwrite_worker_messaging_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'], - ['Flow failures', counterMetric(before, 'appwrite_benchmark_flow_failures'), counterMetric(after, 'appwrite_benchmark_flow_failures'), ''], - ['Check failures', checkFailures(before), checkFailures(after), ''], - ]; - - return [ - '| Metric | Before | After | Delta |', - '| --- | ---: | ---: | ---: |', - ...rows.map(([label, beforeValue, afterValue, unit]) => { - return `| ${label} | ${formatValue(beforeValue, unit)} | ${formatValue(afterValue, unit)} | ${formatDelta(beforeValue, afterValue, unit)} |`; - }), - ].join('\n'); -} - -function trendMetric(data, metric, stat) { - return data && data.metrics[metric] && data.metrics[metric].values - ? data.metrics[metric].values[stat] - : null; -} - -function counterMetric(data, metric) { - return data && data.metrics[metric] && data.metrics[metric].values - ? data.metrics[metric].values.count - : null; -} - -function checkFailures(data) { - return data && data.metrics.checks && data.metrics.checks.values - ? data.metrics.checks.values.fails - : null; -} - -function formatValue(value, unit) { - if (value === null || value === undefined || Number.isNaN(value)) { - return 'n/a'; - } - - return `${round(value)}${unit}`; -} - -function formatDelta(before, after, unit) { - if (before === null || before === undefined || after === null || after === undefined || Number.isNaN(before) || Number.isNaN(after)) { - return 'n/a'; - } - - const delta = round(after - before); - const sign = delta > 0 ? '+' : ''; - return `${sign}${delta}${unit}`; -} - -function metricLine(data, metric, label) { - const values = data.metrics[metric] && data.metrics[metric].values; - if (!values || values.count === 0) { - return `${label}: no samples`; - } - - return `${label}: avg=${round(values.avg)}ms p90=${round(values['p(90)'])}ms p95=${round(values['p(95)'])}ms max=${round(values.max)}ms`; -} - -function counterLine(data, metric, label) { - const values = data.metrics[metric] && data.metrics[metric].values; - return `${label}: ${values ? values.count : 0}`; -} - -function round(value) { - return Math.round((value || 0) * 100) / 100; -} diff --git a/tests/benchmarks/http.php b/tests/benchmarks/http.php new file mode 100644 index 0000000000..1d91246352 --- /dev/null +++ b/tests/benchmarks/http.php @@ -0,0 +1,1299 @@ +jsonParsed) { + $this->json = json_decode($this->body, true); + $this->jsonParsed = true; + } + + if ($key === null) { + return $this->json; + } + + return is_array($this->json) ? ($this->json[$key] ?? null) : null; + } + + public function header(string $name): string + { + $key = strtolower($name); + return isset($this->headers[$key]) ? implode(', ', $this->headers[$key]) : ''; + } + + public function cookieHeader(): string + { + $cookies = []; + + foreach ($this->headers['set-cookie'] ?? [] as $cookie) { + $cookies[] = explode(';', $cookie, 2)[0]; + } + + return implode('; ', $cookies); + } +} + +final class BenchmarkMetrics +{ + private array $trends = []; + private array $counters = [ + 'appwrite_benchmark_flow_failures' => 0, + ]; + private int $checksPassed = 0; + private int $checksFailed = 0; + + public function addTrend(string $name, float $value): void + { + $this->trends[$name] ??= []; + $this->trends[$name][] = $value; + } + + public function addCounter(string $name, int $value = 1): void + { + $this->counters[$name] ??= 0; + $this->counters[$name] += $value; + } + + public function addCheck(bool $passed): void + { + if ($passed) { + $this->checksPassed++; + return; + } + + $this->checksFailed++; + } + + public function summary(): array + { + $metrics = []; + + foreach ($this->trends as $name => $values) { + $metrics[$name] = [ + 'type' => 'trend', + 'contains' => 'time', + 'values' => $this->trendValues($values), + ]; + } + + foreach ($this->counters as $name => $count) { + $metrics[$name] = [ + 'type' => 'counter', + 'contains' => 'default', + 'values' => [ + 'count' => $count, + ], + ]; + } + + $totalChecks = $this->checksPassed + $this->checksFailed; + $metrics['checks'] = [ + 'type' => 'rate', + 'contains' => 'default', + 'values' => [ + 'rate' => $totalChecks > 0 ? $this->checksPassed / $totalChecks : 1, + 'passes' => $this->checksPassed, + 'fails' => $this->checksFailed, + ], + ]; + + return ['metrics' => $metrics]; + } + + public function failedChecks(): int + { + return $this->checksFailed; + } + + public function flowFailures(): int + { + return $this->counters['appwrite_benchmark_flow_failures'] ?? 0; + } + + private function trendValues(array $values): array + { + sort($values, SORT_NUMERIC); + $count = count($values); + + if ($count === 0) { + return [ + 'count' => 0, + 'min' => null, + 'avg' => null, + 'med' => null, + 'max' => null, + 'p(90)' => null, + 'p(95)' => null, + ]; + } + + return [ + 'count' => $count, + 'min' => $values[0], + 'avg' => array_sum($values) / $count, + 'med' => $this->percentile($values, 50), + 'max' => $values[$count - 1], + 'p(90)' => $this->percentile($values, 90), + 'p(95)' => $this->percentile($values, 95), + ]; + } + + private function percentile(array $sortedValues, int $percentile): float + { + $count = count($sortedValues); + + if ($count === 1) { + return (float) $sortedValues[0]; + } + + $rank = ($percentile / 100) * ($count - 1); + $lower = (int) floor($rank); + $upper = (int) ceil($rank); + + if ($lower === $upper) { + return (float) $sortedValues[$lower]; + } + + $weight = $rank - $lower; + return (float) ($sortedValues[$lower] + (($sortedValues[$upper] - $sortedValues[$lower]) * $weight)); + } +} + +final class HttpBenchmark +{ + private const API_SCOPES = [ + 'sessions.write', + 'users.read', + 'users.write', + 'teams.read', + 'teams.write', + 'databases.read', + 'databases.write', + 'collections.read', + 'collections.write', + 'tables.read', + 'tables.write', + 'attributes.read', + 'attributes.write', + 'columns.read', + 'columns.write', + 'indexes.read', + 'indexes.write', + 'documents.read', + 'documents.write', + 'rows.read', + 'rows.write', + 'files.read', + 'files.write', + 'buckets.read', + 'buckets.write', + 'functions.read', + 'functions.write', + 'sites.read', + 'sites.write', + 'log.read', + 'log.write', + 'execution.read', + 'execution.write', + 'locale.read', + 'avatars.read', + 'health.read', + 'providers.read', + 'providers.write', + 'messages.read', + 'messages.write', + 'topics.read', + 'topics.write', + 'subscribers.read', + 'subscribers.write', + 'targets.read', + 'targets.write', + 'rules.read', + 'rules.write', + 'migrations.read', + 'migrations.write', + 'vcs.read', + 'vcs.write', + 'assistant.read', + 'tokens.read', + 'tokens.write', + 'platforms.read', + 'platforms.write', + ]; + + private const BASE_PERMISSIONS = [ + 'read("any")', + 'create("any")', + 'update("any")', + 'delete("any")', + ]; + + private const ITEM_PERMISSIONS = [ + 'read("any")', + 'update("any")', + 'delete("any")', + ]; + + private const PNG_1X1 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='; + + private BenchmarkMetrics $metrics; + private string $endpoint; + private string $maildevEndpoint; + private string $consoleProject; + private string $region; + private string $redirectUrl; + private string $password; + private int $mailTimeoutMs; + private int $workerTimeoutMs; + private int $iterations; + private int $vus; + private string $summaryPath; + private ?array $previousSummary; + + public function __construct() + { + $this->metrics = new BenchmarkMetrics(); + $this->endpoint = rtrim($this->env('APPWRITE_ENDPOINT', 'http://localhost/v1'), '/'); + $this->maildevEndpoint = $this->env('APPWRITE_MAILDEV_ENDPOINT', 'http://localhost:9503/email'); + $this->consoleProject = $this->env('APPWRITE_CONSOLE_PROJECT', 'console'); + $this->region = $this->env('APPWRITE_REGION', 'default'); + $this->redirectUrl = $this->env('APPWRITE_BENCHMARK_REDIRECT_URL', 'http://localhost'); + $this->password = $this->env('APPWRITE_BENCHMARK_PASSWORD', 'Password123!'); + $this->mailTimeoutMs = (int) $this->env('APPWRITE_MAIL_TIMEOUT_MS', '20000'); + $this->workerTimeoutMs = (int) $this->env('APPWRITE_WORKER_TIMEOUT_MS', '60000'); + $this->iterations = max(1, (int) $this->env('APPWRITE_BENCHMARK_ITERATIONS', '1')); + $this->vus = max(1, (int) $this->env('APPWRITE_BENCHMARK_VUS', '1')); + $this->summaryPath = $this->env('APPWRITE_BENCHMARK_SUMMARY_PATH', 'tests/benchmarks/http-summary.json'); + $this->previousSummary = $this->loadPreviousSummary($this->env('APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH', $this->summaryPath)); + } + + public function run(): int + { + $context = null; + $exitCode = 0; + + try { + $context = $this->setup(); + + for ($i = 0; $i < $this->iterations * $this->vus; $i++) { + $this->curatedFlows($context); + } + } catch (Throwable $error) { + $exitCode = 1; + fwrite(STDERR, $error->getMessage() . PHP_EOL); + } finally { + if (is_array($context)) { + $this->teardown($context); + } + + $summary = $this->metrics->summary(); + $this->writeSummary($summary); + echo $this->renderSummary($summary); + } + + if ($this->metrics->failedChecks() > 0 || $this->metrics->flowFailures() > 0) { + $exitCode = 1; + } + + return $exitCode; + } + + private function setup(): array + { + $runId = $this->unique('run'); + $consoleEmail = $this->env('APPWRITE_ADMIN_EMAIL', "bench-admin-{$runId}@example.com"); + $consolePassword = $this->env('APPWRITE_ADMIN_PASSWORD', $this->password); + $consoleHeaders = [ + 'Content-Type' => 'application/json', + 'X-Appwrite-Project' => $this->consoleProject, + ]; + + $account = $this->rawRequest('POST', '/account', [ + 'userId' => $this->unique('admin'), + 'email' => $consoleEmail, + 'password' => $consolePassword, + 'name' => 'Benchmark Admin', + ], $consoleHeaders, 'setup.account.create'); + + if (!in_array($account->status, [201, 409], true)) { + $this->failResponse($account, 'Unable to create or reuse the benchmark console account'); + } + + $session = $this->rawRequest('POST', '/account/sessions/email', [ + 'email' => $consoleEmail, + 'password' => $consolePassword, + ], $consoleHeaders, 'setup.account.session'); + $this->assertStatus($session, [201], 'console session created'); + + $consoleSessionHeaders = [ + ...$consoleHeaders, + 'Cookie' => $session->cookieHeader(), + ]; + + $team = $this->api('POST', '/teams', [ + 'teamId' => $this->unique('team'), + 'name' => "Benchmark Team {$runId}", + ], $consoleSessionHeaders, [201], 'setup.teams.create'); + + $teamId = (string) $team->json('$id'); + $project = $this->api('POST', '/projects', [ + 'projectId' => $this->unique('project'), + 'name' => "Benchmark Project {$runId}", + 'teamId' => $teamId, + 'region' => $this->region, + ], $consoleSessionHeaders, [201], 'setup.projects.create'); + + $projectId = (string) $project->json('$id'); + $key = $this->api('POST', "/projects/{$projectId}/keys", [ + 'keyId' => $this->unique('key'), + 'name' => 'Benchmark API key', + 'scopes' => self::API_SCOPES, + ], $consoleSessionHeaders, [201], 'setup.projects.keys.create'); + + $apiHeaders = [ + 'Content-Type' => 'application/json', + 'X-Appwrite-Project' => $projectId, + 'X-Appwrite-Key' => (string) $key->json('secret'), + ]; + + $platform = $this->api('POST', '/project/platforms/web', [ + 'platformId' => $this->unique('web'), + 'name' => 'Benchmark web', + 'hostname' => $this->hostnameFromUrl($this->redirectUrl), + ], $apiHeaders, [201, 409], 'setup.project.platforms.web.create'); + + $smtpBody = [ + 'enabled' => true, + 'senderName' => 'Benchmark', + 'senderEmail' => 'benchmark@appwrite.io', + 'replyTo' => 'benchmark@appwrite.io', + 'host' => $this->env('APPWRITE_SMTP_HOST', 'maildev'), + 'port' => (int) $this->env('APPWRITE_SMTP_PORT', '1025'), + 'username' => $this->env('APPWRITE_SMTP_USERNAME', 'user'), + 'password' => $this->env('APPWRITE_SMTP_PASSWORD', 'password'), + ]; + + if ($this->env('APPWRITE_SMTP_SECURE', '') !== '') { + $smtpBody['secure'] = $this->env('APPWRITE_SMTP_SECURE', ''); + } + + $smtp = $this->rawRequest('PATCH', "/projects/{$projectId}/smtp", $smtpBody, $consoleSessionHeaders, 'setup.projects.smtp.update'); + if ($smtp->status !== 200) { + fwrite(STDERR, "Custom SMTP was not enabled ({$smtp->status}). Mail worker timings may be unavailable." . PHP_EOL); + } + + return [ + 'runId' => $runId, + 'teamId' => $teamId, + 'projectId' => $projectId, + 'consoleSessionHeaders' => $consoleSessionHeaders, + 'apiHeaders' => $apiHeaders, + 'platformStatus' => $platform->status, + ]; + } + + private function curatedFlows(array &$context): void + { + try { + $this->accountFlow($context); + $this->databasesFlow($context); + $this->tablesDbFlow($context); + $this->storageFlow($context); + $this->messagingFlow($context); + $this->computeFlow($context); + $this->healthFlow($context); + } catch (Throwable $error) { + $this->metrics->addCounter('appwrite_benchmark_flow_failures'); + throw $error; + } + } + + private function teardown(array $context): void + { + if (($context['projectId'] ?? null) && ($context['consoleSessionHeaders'] ?? null)) { + $this->rawRequest('DELETE', "/projects/{$context['projectId']}", null, $context['consoleSessionHeaders'], 'teardown.projects.delete'); + } + + if (($context['teamId'] ?? null) && ($context['consoleSessionHeaders'] ?? null)) { + $this->rawRequest('DELETE', "/teams/{$context['teamId']}", null, $context['consoleSessionHeaders'], 'teardown.teams.delete'); + } + } + + private function accountFlow(array &$context): void + { + $userId = $this->unique('user'); + $email = 'bench-user-' . $this->unique('mail') . '@example.com'; + $headers = $this->projectHeaders($context['projectId']); + + $this->api('POST', '/account', [ + 'userId' => $userId, + 'email' => $email, + 'password' => $this->password, + 'name' => 'Benchmark User', + ], $headers, [201], 'account.create'); + + $session = $this->api('POST', '/account/sessions/email', [ + 'email' => $email, + 'password' => $this->password, + ], $headers, [201], 'account.sessions.email.create'); + + $sessionHeaders = [ + ...$headers, + 'Cookie' => $session->cookieHeader(), + ]; + + $context['userId'] = $userId; + $context['userEmail'] = $email; + $context['sessionHeaders'] = $sessionHeaders; + + $jwt = $this->api('POST', '/account/jwts', null, $sessionHeaders, [201], 'account.jwts.create'); + $context['jwtHeaders'] = [ + ...$headers, + 'X-Appwrite-JWT' => (string) $jwt->json('jwt'), + ]; + + $this->api('GET', '/account', null, $sessionHeaders, [200], 'account.get'); + $this->api('GET', '/account/logs', null, $sessionHeaders, [200], 'account.logs.list'); + $this->api('PATCH', '/account/prefs', ['prefs' => ['benchmark' => true, 'runId' => $context['runId']]], $sessionHeaders, [200], 'account.prefs.update'); + $this->api('PATCH', '/account/name', ['name' => 'Benchmark User Updated'], $sessionHeaders, [200], 'account.name.update'); + $this->api('PATCH', '/account/password', ['password' => $this->password . '2', 'oldPassword' => $this->password], $sessionHeaders, [200], 'account.password.update'); + + $verificationStarted = $this->nowMs(); + $this->api('POST', '/account/verifications/email', ['url' => $this->redirectUrl], $sessionHeaders, [201], 'account.emailVerification.create'); + $verificationEmail = $this->waitForEmail($email, fn (array $message): bool => $this->messageIncludes($message, ['verify', 'verification']), $this->mailTimeoutMs); + $this->metrics->addTrend('appwrite_worker_mails_duration', $this->nowMs() - $verificationStarted); + + $verification = $this->extractQueryParams($verificationEmail); + if (($verification['userId'] ?? null) && ($verification['secret'] ?? null)) { + $this->api('PUT', '/account/verifications/email', [ + 'userId' => $verification['userId'], + 'secret' => $verification['secret'], + ], $sessionHeaders, [200], 'account.emailVerification.update'); + } + + $recoveryStarted = $this->nowMs(); + $this->api('POST', '/account/recovery', ['email' => $email, 'url' => $this->redirectUrl], $headers, [201], 'account.recovery.create'); + $recoveryEmail = $this->waitForEmail($email, fn (array $message): bool => $this->messageIncludes($message, ['recovery', 'recover', 'reset']), $this->mailTimeoutMs); + $this->metrics->addTrend('appwrite_worker_mails_duration', $this->nowMs() - $recoveryStarted); + + $recovery = $this->extractQueryParams($recoveryEmail); + if (($recovery['userId'] ?? null) && ($recovery['secret'] ?? null)) { + $this->api('DELETE', '/account/sessions/current', null, $sessionHeaders, [204], 'account.sessions.current.delete'); + $this->api('PUT', '/account/recovery', [ + 'userId' => $recovery['userId'], + 'secret' => $recovery['secret'], + 'password' => $this->password . '3', + ], $headers, [200], 'account.recovery.update'); + + $recoveredSession = $this->api('POST', '/account/sessions/email', [ + 'email' => $email, + 'password' => $this->password . '3', + ], $headers, [201], 'account.sessions.email.recovered'); + + $context['sessionHeaders'] = [ + ...$headers, + 'Cookie' => $recoveredSession->cookieHeader(), + ]; + + $recoveredJwt = $this->api('POST', '/account/jwts', null, $context['sessionHeaders'], [201], 'account.jwts.recovered'); + $context['jwtHeaders'] = [ + ...$headers, + 'X-Appwrite-JWT' => (string) $recoveredJwt->json('jwt'), + ]; + } + } + + private function databasesFlow(array $context): void + { + $databaseId = $this->unique('db'); + $collectionId = $this->unique('col'); + $documentId = $this->unique('doc'); + $indexKey = $this->unique('idx'); + + $this->api('POST', '/databases', ['databaseId' => $databaseId, 'name' => 'Benchmark DB'], $context['apiHeaders'], [201], 'databases.create'); + $this->api('POST', "/databases/{$databaseId}/collections", [ + 'collectionId' => $collectionId, + 'name' => 'Benchmark Collection', + 'permissions' => self::BASE_PERMISSIONS, + 'documentSecurity' => false, + ], $context['apiHeaders'], [201], 'databases.collections.create'); + + $attributes = [ + ['string', 'title', ['size' => 128]], + ['integer', 'count', ['min' => 0, 'max' => 100000]], + ['email', 'email', []], + ['boolean', 'active', []], + ['datetime', 'publishedAt', []], + ['float', 'score', ['min' => 0, 'max' => 1000]], + ['url', 'url', []], + ['ip', 'ip', []], + ]; + + foreach ($attributes as [$type, $key, $extra]) { + $started = $this->nowMs(); + $this->api('POST', "/databases/{$databaseId}/collections/{$collectionId}/attributes/{$type}", [ + 'key' => $key, + 'required' => false, + 'array' => false, + ...$extra, + ], $context['apiHeaders'], [202], "databases.attributes.{$type}.create"); + $this->waitForStatus("/databases/{$databaseId}/collections/{$collectionId}/attributes/{$key}", $context['apiHeaders'], 'available', $this->workerTimeoutMs); + $this->metrics->addTrend('appwrite_worker_database_duration', $this->nowMs() - $started); + } + + $indexStarted = $this->nowMs(); + $this->api('POST', "/databases/{$databaseId}/collections/{$collectionId}/indexes", [ + 'key' => $indexKey, + 'type' => 'key', + 'attributes' => ['title'], + 'orders' => ['asc'], + ], $context['apiHeaders'], [202], 'databases.indexes.create'); + $this->waitForStatus("/databases/{$databaseId}/collections/{$collectionId}/indexes/{$indexKey}", $context['apiHeaders'], 'available', $this->workerTimeoutMs); + $this->metrics->addTrend('appwrite_worker_database_duration', $this->nowMs() - $indexStarted); + + $this->api('POST', "/databases/{$databaseId}/collections/{$collectionId}/documents", [ + 'documentId' => $documentId, + 'data' => $this->documentPayload(), + 'permissions' => self::ITEM_PERMISSIONS, + ], $context['apiHeaders'], [201], 'databases.documents.create'); + $this->api('GET', "/databases/{$databaseId}/collections/{$collectionId}/documents", null, $context['apiHeaders'], [200], 'databases.documents.list'); + $this->api('GET', "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", null, $context['apiHeaders'], [200], 'databases.documents.get'); + $this->api('PATCH', "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", ['data' => ['title' => 'Benchmark Document Updated']], $context['apiHeaders'], [200], 'databases.documents.update'); + $this->api('PATCH', "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}/count/increment", ['value' => 1], $context['apiHeaders'], [200], 'databases.documents.increment'); + $this->api('PATCH', "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}/count/decrement", ['value' => 1], $context['apiHeaders'], [200], 'databases.documents.decrement'); + $this->api('DELETE', "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", null, $context['apiHeaders'], [204], 'databases.documents.delete'); + $this->api('DELETE', "/databases/{$databaseId}", null, $context['apiHeaders'], [204], 'databases.delete'); + } + + private function tablesDbFlow(array $context): void + { + $databaseId = $this->unique('tdb'); + $tableId = $this->unique('tbl'); + $rowId = $this->unique('row'); + $indexKey = $this->unique('tidx'); + + $this->api('POST', '/tablesdb', ['databaseId' => $databaseId, 'name' => 'Benchmark TablesDB'], $context['apiHeaders'], [201], 'tablesdb.create'); + $this->api('POST', "/tablesdb/{$databaseId}/tables", [ + 'tableId' => $tableId, + 'name' => 'Benchmark Table', + 'permissions' => self::BASE_PERMISSIONS, + 'rowSecurity' => false, + ], $context['apiHeaders'], [201], 'tablesdb.tables.create'); + + $columns = [ + ['string', 'title', ['size' => 128]], + ['integer', 'count', ['min' => 0, 'max' => 100000]], + ['email', 'email', []], + ['boolean', 'active', []], + ]; + + foreach ($columns as [$type, $key, $extra]) { + $started = $this->nowMs(); + $this->api('POST', "/tablesdb/{$databaseId}/tables/{$tableId}/columns/{$type}", [ + 'key' => $key, + 'required' => false, + 'array' => false, + ...$extra, + ], $context['apiHeaders'], [202], "tablesdb.columns.{$type}.create"); + $this->waitForStatus("/tablesdb/{$databaseId}/tables/{$tableId}/columns/{$key}", $context['apiHeaders'], 'available', $this->workerTimeoutMs); + $this->metrics->addTrend('appwrite_worker_tables_duration', $this->nowMs() - $started); + } + + $indexStarted = $this->nowMs(); + $this->api('POST', "/tablesdb/{$databaseId}/tables/{$tableId}/indexes", [ + 'key' => $indexKey, + 'type' => 'key', + 'columns' => ['title'], + 'orders' => ['asc'], + ], $context['apiHeaders'], [202], 'tablesdb.indexes.create'); + $this->waitForStatus("/tablesdb/{$databaseId}/tables/{$tableId}/indexes/{$indexKey}", $context['apiHeaders'], 'available', $this->workerTimeoutMs); + $this->metrics->addTrend('appwrite_worker_tables_duration', $this->nowMs() - $indexStarted); + + $this->api('POST', "/tablesdb/{$databaseId}/tables/{$tableId}/rows", [ + 'rowId' => $rowId, + 'data' => $this->tablePayload(), + 'permissions' => self::ITEM_PERMISSIONS, + ], $context['sessionHeaders'], [201], 'tablesdb.rows.create'); + $this->api('GET', "/tablesdb/{$databaseId}/tables/{$tableId}/rows", null, $context['sessionHeaders'], [200], 'tablesdb.rows.list'); + $this->api('GET', "/tablesdb/{$databaseId}/tables/{$tableId}/rows/{$rowId}", null, $context['sessionHeaders'], [200], 'tablesdb.rows.get'); + $this->api('PATCH', "/tablesdb/{$databaseId}/tables/{$tableId}/rows/{$rowId}", ['data' => ['title' => 'Benchmark Row Updated']], $context['sessionHeaders'], [200], 'tablesdb.rows.update'); + $this->api('PATCH', "/tablesdb/{$databaseId}/tables/{$tableId}/rows/{$rowId}/count/increment", ['value' => 1], $context['sessionHeaders'], [200], 'tablesdb.rows.increment'); + $this->api('PATCH', "/tablesdb/{$databaseId}/tables/{$tableId}/rows/{$rowId}/count/decrement", ['value' => 1], $context['sessionHeaders'], [200], 'tablesdb.rows.decrement'); + $this->api('DELETE', "/tablesdb/{$databaseId}/tables/{$tableId}/rows/{$rowId}", null, $context['sessionHeaders'], [204], 'tablesdb.rows.delete'); + $this->api('DELETE', "/tablesdb/{$databaseId}", null, $context['apiHeaders'], [204], 'tablesdb.delete'); + } + + private function storageFlow(array $context): void + { + $bucketId = $this->unique('bucket'); + $fileId = $this->unique('file'); + + $this->api('POST', '/storage/buckets', [ + 'bucketId' => $bucketId, + 'name' => 'Benchmark Bucket', + 'permissions' => self::BASE_PERMISSIONS, + 'fileSecurity' => false, + 'enabled' => true, + 'maximumFileSize' => 30000000, + 'allowedFileExtensions' => [], + 'compression' => 'none', + 'encryption' => false, + 'antivirus' => false, + ], $context['apiHeaders'], [201], 'storage.buckets.create'); + + $tmpFile = tempnam(sys_get_temp_dir(), 'appwrite-benchmark-'); + if ($tmpFile === false) { + throw new RuntimeException('Unable to create temporary PNG fixture'); + } + + file_put_contents($tmpFile, base64_decode(self::PNG_1X1, true)); + + try { + $fields = [ + 'fileId' => $fileId, + 'file' => new CURLFile($tmpFile, 'image/png', 'benchmark.png'), + ...$this->flattenMultipartArray('permissions', self::ITEM_PERMISSIONS), + ]; + $multipartHeaders = $context['sessionHeaders']; + unset($multipartHeaders['Content-Type']); + + $upload = $this->rawMultipartRequest('POST', "/storage/buckets/{$bucketId}/files", $fields, $multipartHeaders, 'storage.files.create'); + $this->metrics->addTrend('appwrite_api_duration', $upload->duration); + $this->assertStatus($upload, [201], 'storage file created'); + } finally { + @unlink($tmpFile); + } + + $this->api('GET', "/storage/buckets/{$bucketId}/files", null, $context['sessionHeaders'], [200], 'storage.files.list'); + $this->api('GET', "/storage/buckets/{$bucketId}/files/{$fileId}", null, $context['sessionHeaders'], [200], 'storage.files.get'); + $this->api('GET', "/storage/buckets/{$bucketId}/files/{$fileId}/view", null, $context['sessionHeaders'], [200], 'storage.files.view'); + $this->api('GET', "/storage/buckets/{$bucketId}/files/{$fileId}/download", null, $context['sessionHeaders'], [200], 'storage.files.download'); + $this->api('GET', "/storage/buckets/{$bucketId}/files/{$fileId}/preview", null, $context['sessionHeaders'], [200], 'storage.files.preview'); + $this->api('PUT', "/storage/buckets/{$bucketId}/files/{$fileId}", [ + 'name' => 'benchmark-renamed.png', + 'permissions' => self::ITEM_PERMISSIONS, + ], $context['sessionHeaders'], [200], 'storage.files.update'); + + $token = $this->api('POST', "/tokens/buckets/{$bucketId}/files/{$fileId}", (object) [], $context['apiHeaders'], [201], 'tokens.files.create'); + $tokenId = (string) $token->json('$id'); + $this->api('GET', "/tokens/buckets/{$bucketId}/files/{$fileId}", null, $context['apiHeaders'], [200], 'tokens.files.list'); + $this->api('GET', "/tokens/{$tokenId}", null, $context['apiHeaders'], [200], 'tokens.get'); + $this->api('PATCH', "/tokens/{$tokenId}", ['expire' => null], $context['apiHeaders'], [200], 'tokens.update'); + $this->api('DELETE', "/tokens/{$tokenId}", null, $context['apiHeaders'], [204], 'tokens.delete'); + + $this->api('DELETE', "/storage/buckets/{$bucketId}/files/{$fileId}", null, $context['sessionHeaders'], [204], 'storage.files.delete'); + $this->api('DELETE', "/storage/buckets/{$bucketId}", null, $context['apiHeaders'], [204], 'storage.buckets.delete'); + } + + private function messagingFlow(array $context): void + { + $providerId = $this->unique('smtp'); + $targetId = $this->unique('target'); + $existingTarget = false; + $topicId = $this->unique('topic'); + $subscriberId = $this->unique('sub'); + $messageId = $this->unique('msg'); + + $this->api('POST', '/messaging/providers/smtp', [ + 'providerId' => $providerId, + 'name' => 'Benchmark SMTP', + 'host' => $this->env('APPWRITE_SMTP_HOST', 'maildev'), + 'port' => (int) $this->env('APPWRITE_SMTP_PORT', '1025'), + 'username' => $this->env('APPWRITE_SMTP_USERNAME', 'user'), + 'password' => $this->env('APPWRITE_SMTP_PASSWORD', 'password'), + 'encryption' => $this->env('APPWRITE_SMTP_ENCRYPTION', 'none'), + 'autoTLS' => false, + 'fromName' => 'Benchmark', + 'fromEmail' => 'benchmark@appwrite.io', + 'replyToName' => 'Benchmark', + 'replyToEmail' => 'benchmark@appwrite.io', + 'enabled' => true, + ], $context['apiHeaders'], [201], 'messaging.providers.smtp.create'); + + $targets = $this->api('GET', "/users/{$context['userId']}/targets", null, $context['apiHeaders'], [200], 'users.targets.list'); + foreach ($targets->json('targets') ?? [] as $target) { + if (($target['providerType'] ?? '') === 'email' && ($target['identifier'] ?? '') === $context['userEmail']) { + $targetId = (string) $target['$id']; + $existingTarget = true; + break; + } + } + + if ($existingTarget) { + $this->api('PATCH', "/users/{$context['userId']}/targets/{$targetId}", [ + 'providerId' => $providerId, + 'name' => 'Benchmark email target', + ], $context['apiHeaders'], [200], 'users.targets.update'); + } else { + $this->api('POST', "/users/{$context['userId']}/targets", [ + 'targetId' => $targetId, + 'providerType' => 'email', + 'identifier' => $context['userEmail'], + 'providerId' => $providerId, + 'name' => 'Benchmark email target', + ], $context['apiHeaders'], [201], 'users.targets.create'); + } + + $this->api('POST', '/messaging/topics', [ + 'topicId' => $topicId, + 'name' => 'Benchmark Topic', + 'subscribe' => ['users'], + ], $context['apiHeaders'], [201], 'messaging.topics.create'); + + $this->api('POST', "/messaging/topics/{$topicId}/subscribers", [ + 'subscriberId' => $subscriberId, + 'targetId' => $targetId, + ], $context['sessionHeaders'], [201], 'messaging.subscribers.create'); + + $started = $this->nowMs(); + $this->api('POST', '/messaging/messages/email', [ + 'messageId' => $messageId, + 'subject' => "Benchmark message {$context['runId']}", + 'content' => "Benchmark messaging worker probe {$context['runId']}", + 'targets' => [$targetId], + 'draft' => false, + 'html' => false, + ], $context['apiHeaders'], [201], 'messaging.messages.email.create'); + + $this->waitForMessage($messageId, $context['apiHeaders'], $this->workerTimeoutMs); + $this->waitForEmail($context['userEmail'], fn (array $message): bool => $this->includes($message['subject'] ?? '', "Benchmark message {$context['runId']}"), $this->mailTimeoutMs, true); + $this->metrics->addTrend('appwrite_worker_messaging_duration', $this->nowMs() - $started); + + $this->api('GET', '/messaging/messages', null, $context['apiHeaders'], [200], 'messaging.messages.list'); + $this->api('GET', "/messaging/messages/{$messageId}/logs", null, $context['apiHeaders'], [200], 'messaging.messages.logs.list'); + $this->api('GET', "/messaging/messages/{$messageId}/targets", null, $context['apiHeaders'], [200], 'messaging.messages.targets.list'); + $this->api('GET', "/messaging/providers/{$providerId}/logs", null, $context['apiHeaders'], [200], 'messaging.providers.logs.list'); + $this->api('GET', "/messaging/topics/{$topicId}/logs", null, $context['apiHeaders'], [200], 'messaging.topics.logs.list'); + $this->api('GET', "/messaging/subscribers/{$subscriberId}/logs", null, $context['apiHeaders'], [200], 'messaging.subscribers.logs.list'); + $this->api('DELETE', "/messaging/topics/{$topicId}/subscribers/{$subscriberId}", null, $context['sessionHeaders'], [204], 'messaging.subscribers.delete'); + $this->api('DELETE', "/messaging/topics/{$topicId}", null, $context['apiHeaders'], [204], 'messaging.topics.delete'); + $this->api('DELETE', "/messaging/messages/{$messageId}", null, $context['apiHeaders'], [204], 'messaging.messages.delete'); + $this->api('DELETE', "/messaging/providers/{$providerId}", null, $context['apiHeaders'], [204], 'messaging.providers.delete'); + } + + private function computeFlow(array $context): void + { + $functionId = $this->unique('fn'); + $siteId = $this->unique('site'); + $runtime = $this->env('APPWRITE_BENCHMARK_RUNTIME', 'node-22'); + + $this->api('POST', '/functions', [ + 'functionId' => $functionId, + 'name' => 'Benchmark Function', + 'runtime' => $runtime, + 'execute' => ['any'], + 'events' => [], + 'schedule' => '', + 'timeout' => 15, + 'enabled' => true, + 'logging' => true, + 'entrypoint' => 'index.js', + 'commands' => 'npm install', + 'scopes' => ['users.read'], + ], $context['apiHeaders'], [201], 'functions.create'); + $this->api('GET', '/functions/runtimes', null, $context['sessionHeaders'], [200], 'functions.runtimes.list'); + $this->api('GET', '/functions/specifications', null, $context['apiHeaders'], [200], 'functions.specifications.list'); + + $functionVariable = $this->api('POST', "/functions/{$functionId}/variables", [ + 'key' => 'BENCHMARK', + 'value' => 'true', + 'secret' => false, + ], $context['apiHeaders'], [201], 'functions.variables.create'); + $functionVariableId = (string) $functionVariable->json('$id'); + $this->api('PUT', "/functions/{$functionId}/variables/{$functionVariableId}", ['key' => 'BENCHMARK', 'value' => 'updated', 'secret' => false], $context['apiHeaders'], [200], 'functions.variables.update'); + $this->api('GET', "/functions/{$functionId}/variables/{$functionVariableId}", null, $context['apiHeaders'], [200], 'functions.variables.get'); + $this->api('DELETE', "/functions/{$functionId}/variables/{$functionVariableId}", null, $context['apiHeaders'], [204], 'functions.variables.delete'); + $this->api('DELETE', "/functions/{$functionId}", null, $context['apiHeaders'], [204], 'functions.delete'); + + $this->api('POST', '/sites', [ + 'siteId' => $siteId, + 'name' => 'Benchmark Site', + 'framework' => 'other', + 'adapter' => 'static', + 'buildRuntime' => $runtime, + 'buildCommand' => '', + 'outputDirectory' => '.', + 'installCommand' => '', + 'fallbackFile' => 'index.html', + 'providerRootDirectory' => '.', + 'specification' => '', + ], $context['apiHeaders'], [201], 'sites.create'); + $this->api('GET', '/sites/frameworks', null, $context['sessionHeaders'], [200], 'sites.frameworks.list'); + $this->api('GET', '/sites/specifications', null, $context['apiHeaders'], [200], 'sites.specifications.list'); + + $siteVariable = $this->api('POST', "/sites/{$siteId}/variables", ['key' => 'BENCHMARK', 'value' => 'true', 'secret' => false], $context['apiHeaders'], [201], 'sites.variables.create'); + $siteVariableId = (string) $siteVariable->json('$id'); + $this->api('PUT', "/sites/{$siteId}/variables/{$siteVariableId}", ['key' => 'BENCHMARK', 'value' => 'updated', 'secret' => false], $context['apiHeaders'], [200], 'sites.variables.update'); + $this->api('GET', "/sites/{$siteId}/variables/{$siteVariableId}", null, $context['apiHeaders'], [200], 'sites.variables.get'); + $this->api('DELETE', "/sites/{$siteId}/variables/{$siteVariableId}", null, $context['apiHeaders'], [204], 'sites.variables.delete'); + $this->api('DELETE', "/sites/{$siteId}", null, $context['apiHeaders'], [204], 'sites.delete'); + } + + private function healthFlow(array $context): void + { + $probes = [ + '/health', + '/health/db', + '/health/cache', + '/health/pubsub', + '/health/storage', + '/health/storage/local', + '/health/time', + '/health/queue/databases', + '/health/queue/mails', + '/health/queue/messaging', + '/health/queue/functions', + '/health/queue/builds', + '/health/queue/deletes', + '/health/queue/webhooks', + '/health/queue/stats-resources', + '/health/queue/stats-usage', + '/health/queue/failed/v1-mails', + ]; + + foreach ($probes as $path) { + $this->api('GET', $path, null, $context['apiHeaders'], [200], 'health' . str_replace('/', '.', $path)); + } + } + + private function api(string $method, string $path, mixed $body, array $headers, array $expected, string $name): BenchmarkResponse + { + $response = $this->rawRequest($method, $path, $body, $headers, $name); + $this->metrics->addTrend('appwrite_api_duration', $response->duration); + $this->assertStatus($response, $expected, $name); + return $response; + } + + private function rawRequest(string $method, string $path, mixed $body, array $headers, string $name): BenchmarkResponse + { + return $this->send($method, str_starts_with($path, 'http') ? $path : $this->endpoint . $path, $body, $headers, $name, false); + } + + private function rawMultipartRequest(string $method, string $path, array $fields, array $headers, string $name): BenchmarkResponse + { + return $this->send($method, $this->endpoint . $path, $fields, $headers, $name, true); + } + + private function send(string $method, string $url, mixed $body, array $headers, string $name, bool $multipart): BenchmarkResponse + { + $handle = curl_init($url); + if ($handle === false) { + throw new RuntimeException("Unable to initialize curl for {$url}"); + } + + $headerLines = []; + foreach ($headers as $key => $value) { + $headerLines[] = "{$key}: {$value}"; + } + + curl_setopt_array($handle, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => $headerLines, + CURLOPT_TIMEOUT => 120, + ]); + + if ($body !== null) { + curl_setopt($handle, CURLOPT_POSTFIELDS, $multipart ? $body : json_encode($body, JSON_UNESCAPED_SLASHES)); + } elseif (in_array($method, ['POST', 'PUT', 'PATCH'], true)) { + curl_setopt($handle, CURLOPT_POSTFIELDS, ''); + } + + $started = hrtime(true); + $raw = curl_exec($handle); + $duration = (hrtime(true) - $started) / 1_000_000; + $this->metrics->addTrend('http_req_duration', $duration); + + if ($raw === false) { + $error = curl_error($handle); + throw new RuntimeException("{$name} curl error: {$error}"); + } + + $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); + $headerSize = (int) curl_getinfo($handle, CURLINFO_HEADER_SIZE); + + return new BenchmarkResponse( + $status, + substr($raw, $headerSize), + $this->parseHeaders(substr($raw, 0, $headerSize)), + $duration, + ); + } + + private function waitForStatus(string $path, array $headers, string $wantedStatus, int $timeoutMs): BenchmarkResponse + { + $started = $this->nowMs(); + + while ($this->nowMs() - $started < $timeoutMs) { + $response = $this->rawRequest('GET', $path, null, $headers, "wait{$path}"); + if ($response->status === 200 && $response->json('status') === $wantedStatus) { + return $response; + } + + usleep(500_000); + } + + throw new RuntimeException("Timed out waiting for {$path} to become {$wantedStatus}"); + } + + private function waitForMessage(string $messageId, array $headers, int $timeoutMs): BenchmarkResponse + { + $started = $this->nowMs(); + + while ($this->nowMs() - $started < $timeoutMs) { + $response = $this->rawRequest('GET', "/messaging/messages/{$messageId}", null, $headers, 'messaging.messages.poll'); + $status = $response->status === 200 ? $response->json('status') : null; + + if (in_array($status, ['sent', 'failed'], true)) { + if ($status === 'failed') { + throw new RuntimeException("Messaging worker marked message {$messageId} as failed"); + } + + return $response; + } + + usleep(500_000); + } + + throw new RuntimeException("Timed out waiting for messaging worker to send message {$messageId}"); + } + + private function waitForEmail(string $address, callable $predicate, int $timeoutMs, bool $allowMissingRecipient = false): array + { + $started = $this->nowMs(); + + while ($this->nowMs() - $started < $timeoutMs) { + $response = $this->rawRequest('GET', $this->maildevEndpoint, null, [], 'maildev.email.list'); + + if ($response->status === 200) { + $emails = $response->json(); + if (is_array($emails)) { + for ($i = count($emails) - 1; $i >= 0; $i--) { + $message = $emails[$i]; + if (!is_array($message)) { + continue; + } + + if (($this->emailMatches($message, $address) || ($allowMissingRecipient && $this->emailRecipientMissing($message))) && $predicate($message)) { + return $message; + } + } + } + } + + usleep(500_000); + } + + throw new RuntimeException("Timed out waiting for email to {$address}"); + } + + private function assertStatus(BenchmarkResponse $response, array $expected, string $name): void + { + $passed = in_array($response->status, $expected, true); + $this->metrics->addCheck($passed); + + if (!$passed) { + $this->failResponse($response, "{$name} returned an unexpected status"); + } + } + + private function failResponse(BenchmarkResponse $response, string $message): never + { + throw new RuntimeException("{$message}. Status: {$response->status}. Body: {$response->body}"); + } + + private function parseHeaders(string $rawHeaders): array + { + $blocks = preg_split("/\r\n\r\n|\n\n/", trim($rawHeaders)) ?: []; + $headerBlock = end($blocks) ?: ''; + $headers = []; + + foreach (preg_split("/\r\n|\n|\r/", $headerBlock) ?: [] as $line) { + if (!str_contains($line, ':')) { + continue; + } + + [$name, $value] = explode(':', $line, 2); + $headers[strtolower(trim($name))][] = trim($value); + } + + return $headers; + } + + private function emailMatches(array $message, string $address): bool + { + foreach ($message['to'] ?? [] as $recipient) { + if (($recipient['address'] ?? null) === $address) { + return true; + } + } + + return false; + } + + private function emailRecipientMissing(array $message): bool + { + $recipients = $message['to'] ?? []; + if ($recipients === []) { + return true; + } + + foreach ($recipients as $recipient) { + if ($recipient['address'] ?? null) { + return false; + } + } + + return true; + } + + private function extractQueryParams(array $message): array + { + $content = ($message['html'] ?? '') . "\n" . ($message['text'] ?? ''); + preg_match_all('/href="([^"]+)"/', $content, $matches); + $links = $matches[1] ?: [$content]; + + foreach ($links as $link) { + $query = parse_url(html_entity_decode($link), PHP_URL_QUERY); + if (!is_string($query)) { + continue; + } + + parse_str($query, $params); + if (($params['userId'] ?? null) && ($params['secret'] ?? null)) { + return $params; + } + } + + return []; + } + + private function projectHeaders(string $projectId): array + { + return [ + 'Content-Type' => 'application/json', + 'X-Appwrite-Project' => $projectId, + ]; + } + + private function documentPayload(): array + { + return [ + 'title' => 'Benchmark Document', + 'count' => 1, + 'email' => 'document@example.com', + 'active' => true, + 'publishedAt' => gmdate('c'), + 'score' => 10.5, + 'url' => 'https://appwrite.io', + 'ip' => '127.0.0.1', + ]; + } + + private function tablePayload(): array + { + return [ + 'title' => 'Benchmark Row', + 'count' => 1, + 'email' => 'row@example.com', + 'active' => true, + ]; + } + + private function flattenMultipartArray(string $key, array $values): array + { + $output = []; + + foreach (array_values($values) as $index => $value) { + $output["{$key}[{$index}]"] = $value; + } + + return $output; + } + + private function messageIncludes(array $message, array $needles): bool + { + $content = implode("\n", [ + (string) ($message['subject'] ?? ''), + (string) ($message['html'] ?? ''), + (string) ($message['text'] ?? ''), + ]); + + foreach ($needles as $needle) { + if ($this->includes($content, $needle)) { + return true; + } + } + + return false; + } + + private function includes(string $value, string $needle): bool + { + return str_contains(strtolower($value), strtolower($needle)); + } + + private function hostnameFromUrl(string $value): string + { + $host = parse_url($value, PHP_URL_HOST); + if (is_string($host) && $host !== '') { + return $host; + } + + return explode(':', explode('/', preg_replace('/^https?:\/\//', '', $value) ?? '')[0])[0]; + } + + private function unique(string $prefix): string + { + $id = strtolower($prefix . '-' . base_convert((string) ((int) (microtime(true) * 1000)), 10, 36) . '-' . bin2hex(random_bytes(4))); + return substr(preg_replace('/[^a-z0-9-]/', '-', $id) ?? $id, 0, 36); + } + + private function nowMs(): float + { + return hrtime(true) / 1_000_000; + } + + private function env(string $name, string $default): string + { + $value = getenv($name); + return $value === false || $value === '' ? $default : $value; + } + + private function loadPreviousSummary(string $path): ?array + { + if (!is_file($path)) { + return null; + } + + $summary = json_decode((string) file_get_contents($path), true); + return is_array($summary) ? $summary : null; + } + + private function writeSummary(array $summary): void + { + $directory = dirname($this->summaryPath); + if ($directory !== '.' && !is_dir($directory)) { + mkdir($directory, 0777, true); + } + + file_put_contents($this->summaryPath, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } + + private function renderSummary(array $summary): string + { + $lines = [ + 'Appwrite curated benchmark review', + '', + 'Before/after comparison', + '', + $this->comparisonTable($this->previousSummary, $summary), + '', + 'Current run details', + '', + $this->metricLine($summary, 'http_req_duration', 'HTTP total'), + $this->metricLine($summary, 'appwrite_api_duration', 'API endpoints'), + $this->metricLine($summary, 'appwrite_worker_database_duration', 'Database worker schema jobs'), + $this->metricLine($summary, 'appwrite_worker_tables_duration', 'TablesDB worker schema jobs'), + $this->metricLine($summary, 'appwrite_worker_mails_duration', 'Mail worker delivery'), + $this->metricLine($summary, 'appwrite_worker_messaging_duration', 'Messaging worker delivery'), + $this->counterLine($summary, 'appwrite_benchmark_flow_failures', 'Flow failures'), + '', + ]; + + return implode(PHP_EOL, array_filter($lines, fn (string $line): bool => $line !== '')) . PHP_EOL; + } + + private function comparisonTable(?array $before, array $after): string + { + $rows = [ + ['HTTP total p95', $this->trendMetric($before, 'http_req_duration', 'p(95)'), $this->trendMetric($after, 'http_req_duration', 'p(95)'), 'ms'], + ['API endpoints p95', $this->trendMetric($before, 'appwrite_api_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_api_duration', 'p(95)'), 'ms'], + ['Database worker p95', $this->trendMetric($before, 'appwrite_worker_database_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'], + ['TablesDB worker p95', $this->trendMetric($before, 'appwrite_worker_tables_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'], + ['Mail worker p95', $this->trendMetric($before, 'appwrite_worker_mails_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'], + ['Messaging worker p95', $this->trendMetric($before, 'appwrite_worker_messaging_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'], + ['Flow failures', $this->counterMetric($before, 'appwrite_benchmark_flow_failures'), $this->counterMetric($after, 'appwrite_benchmark_flow_failures'), ''], + ['Check failures', $this->checkFailures($before), $this->checkFailures($after), ''], + ]; + + $table = [ + '| Metric | Before | After | Delta |', + '| --- | ---: | ---: | ---: |', + ]; + + foreach ($rows as [$label, $beforeValue, $afterValue, $unit]) { + $table[] = "| {$label} | {$this->formatValue($beforeValue, $unit)} | {$this->formatValue($afterValue, $unit)} | {$this->formatDelta($beforeValue, $afterValue, $unit)} |"; + } + + return implode(PHP_EOL, $table); + } + + private function trendMetric(?array $data, string $metric, string $stat): ?float + { + return $data['metrics'][$metric]['values'][$stat] ?? null; + } + + private function counterMetric(?array $data, string $metric): ?float + { + return $data['metrics'][$metric]['values']['count'] ?? null; + } + + private function checkFailures(?array $data): ?float + { + return $data['metrics']['checks']['values']['fails'] ?? null; + } + + private function metricLine(array $data, string $metric, string $label): string + { + $values = $data['metrics'][$metric]['values'] ?? null; + if (!is_array($values) || ($values['count'] ?? 0) === 0) { + return "{$label}: no samples"; + } + + return "{$label}: avg={$this->round($values['avg'])}ms p90={$this->round($values['p(90)'])}ms p95={$this->round($values['p(95)'])}ms max={$this->round($values['max'])}ms"; + } + + private function counterLine(array $data, string $metric, string $label): string + { + return "{$label}: " . ($data['metrics'][$metric]['values']['count'] ?? 0); + } + + private function formatValue(?float $value, string $unit): string + { + return $value === null || is_nan($value) ? 'n/a' : $this->round($value) . $unit; + } + + private function formatDelta(?float $before, ?float $after, string $unit): string + { + if ($before === null || $after === null || is_nan($before) || is_nan($after)) { + return 'n/a'; + } + + $delta = $this->round($after - $before); + return ($delta > 0 ? '+' : '') . $delta . $unit; + } + + private function round(float|int|null $value): string + { + $rounded = round((float) ($value ?? 0), 2); + return rtrim(rtrim(number_format($rounded, 2, '.', ''), '0'), '.'); + } +} + +exit((new HttpBenchmark())->run()); From 566eebfaecf5e81b0afc5933f7d4460d3705524a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 16:31:57 +0530 Subject: [PATCH 137/402] Fix benchmark PNG fixture --- tests/benchmarks/http.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/benchmarks/http.php b/tests/benchmarks/http.php index 1d91246352..3d7556e8d7 100644 --- a/tests/benchmarks/http.php +++ b/tests/benchmarks/http.php @@ -248,7 +248,7 @@ final class HttpBenchmark 'delete("any")', ]; - private const PNG_1X1 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='; + private const PNG_1X1 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII='; private BenchmarkMetrics $metrics; private string $endpoint; From ef08d5a04c2c6a2b7ac2e00de837dcdc54d65088 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 16:33:09 +0530 Subject: [PATCH 138/402] Run before benchmark with base image --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78d959779c..595f496e1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -710,7 +710,7 @@ jobs: -e APPWRITE_BENCHMARK_ITERATIONS=1 \ -e APPWRITE_BENCHMARK_VUS=1 \ -e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-before-summary.json \ - ${{ env.IMAGE }}:after php tests/benchmarks/http.php | tee benchmark-before.txt + ${{ env.IMAGE }}:before php tests/benchmarks/http.php | tee benchmark-before.txt - name: Stop before Appwrite if: always() From 4317ee5617d477ef9c2a108102f8aac685fe4de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 13:11:42 +0200 Subject: [PATCH 139/402] Move some of auth settings to project policies --- app/controllers/api/projects.php | 265 ------------------ app/init/constants.php | 2 - .../Policies/PasswordDictionary/Update.php | 78 ++++++ .../Policies/PasswordHistory/Update.php | 84 ++++++ .../Policies/PasswordPersonalData/Update.php | 78 ++++++ .../Policies/SessionDuration/Update.php | 78 ++++++ .../Policies/SessionInvalidation/Update.php | 78 ++++++ .../Project/Policies/SessionLimit/Update.php | 84 ++++++ .../Project/Policies/UserLimit/Update.php | 84 ++++++ .../Modules/Projects/Http/Projects/Create.php | 2 +- 10 files changed, 565 insertions(+), 268 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 439692e1dd..87552c6508 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -218,82 +218,6 @@ Http::patch('/v1/projects/:projectId/auth/memberships-privacy') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/auth/limit') - ->desc('Update project users limit') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthLimit', - description: '/docs/references/projects/update-auth-limit.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('limit', false, new Range(0, APP_LIMIT_USERS), 'Set the max number of users allowed in this project. Use 0 for unlimited.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['limit'] = $limit; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/duration') - ->desc('Update project authentication duration') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthDuration', - description: '/docs/references/projects/update-auth-duration.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('duration', 31536000, new Range(0, 31536000), 'Project session length in seconds. Max length: 31536000 seconds.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $duration, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['duration'] = $duration; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::patch('/v1/projects/:projectId/auth/:method') ->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.') ->groups(['api', 'projects']) @@ -335,158 +259,6 @@ Http::patch('/v1/projects/:projectId/auth/:method') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/auth/password-history') - ->desc('Update authentication password history. Use this endpoint to set the number of password history to save and 0 to disable password history.') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthPasswordHistory', - description: '/docs/references/projects/update-auth-password-history.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('limit', 0, new Range(0, APP_LIMIT_USER_PASSWORD_HISTORY), 'Set the max number of passwords to store in user history. User can\'t choose a new password that is already stored in the password history list. Max number of passwords allowed in history is' . APP_LIMIT_USER_PASSWORD_HISTORY . '. Default value is 0') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['passwordHistory'] = $limit; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/password-dictionary') - ->desc('Update authentication password dictionary status. Use this endpoint to enable or disable the dicitonary check for user password') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthPasswordDictionary', - description: '/docs/references/projects/update-auth-password-dictionary.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(false), 'Set whether or not to enable checking user\'s password against most commonly used passwords. Default is false.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['passwordDictionary'] = $enabled; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/personal-data') - ->desc('Update personal data check') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updatePersonalDataCheck', - description: '/docs/references/projects/update-personal-data-check.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(false), 'Set whether or not to check a password for similarity with personal data. Default is false.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['personalDataCheck'] = $enabled; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/max-sessions') - ->desc('Update project user sessions limit') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthSessionsLimit', - description: '/docs/references/projects/update-auth-sessions-limit.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('limit', false, new Range(1, APP_LIMIT_USER_SESSIONS_MAX), 'Set the max number of users allowed in this project. Value allowed is between 1-' . APP_LIMIT_USER_SESSIONS_MAX . '. Default is ' . APP_LIMIT_USER_SESSIONS_DEFAULT) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['maxSessions'] = $limit; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::patch('/v1/projects/:projectId/auth/mock-numbers') ->desc('Update the mock numbers for the project') ->groups(['api', 'projects']) @@ -1050,40 +822,3 @@ Http::delete('/v1/projects/:projectId/templates/email') 'message' => $template['message'] ]), Response::MODEL_EMAIL_TEMPLATE); }); - -Http::patch('/v1/projects/:projectId/auth/session-invalidation') - ->desc('Update invalidate session option of the project') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateSessionInvalidation', - description: '/docs/references/projects/update-session-invalidation.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(), 'Update authentication session invalidation status. Use this endpoint to enable or disable session invalidation on password change') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['invalidateSessions'] = $enabled; - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); diff --git a/app/init/constants.php b/app/init/constants.php index f2127cd666..443aaaa680 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -24,8 +24,6 @@ const APP_MODE_ADMIN = 'admin'; const APP_PAGING_LIMIT = 12; const APP_LIMIT_COUNT = 5000; const APP_LIMIT_USERS = 10_000; -const APP_LIMIT_USER_PASSWORD_HISTORY = 20; -const APP_LIMIT_USER_SESSIONS_MAX = 100; const APP_LIMIT_USER_SESSIONS_DEFAULT = 10; const APP_LIMIT_ANTIVIRUS = 20_000_000; //20MB const APP_LIMIT_ENCRYPTION = 20_000_000; //20MB diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php new file mode 100644 index 0000000000..6218165daf --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/password-dictionary') + ->httpAlias('/v1/projects/:projectId/auth/password-dictionary') + ->desc('Update password dictionary policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.password-dictionary.update') + ->label('audits.event', 'policies.password-dictionary.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updatePasswordDictionaryPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle password dictionary policy. Set to true if you want password change to block passwords in the dictionary, or false to allow them. When changing this policy, existing passwords remain valid.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['passwordDictionary'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php new file mode 100644 index 0000000000..437cc29b33 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php @@ -0,0 +1,84 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/password-history') + ->httpAlias('/v1/projects/:projectId/auth/password-history') + ->desc('Update password history policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.password-history.update') + ->label('audits.event', 'policies.password-history.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updatePasswordHistoryPolicy', + description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT), 'Set the password history length per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + + if (\is_null($total)) { + $auths['passwordHistory'] = 0; + } else { + $auths['passwordHistory'] = $total; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php new file mode 100644 index 0000000000..cc661366d7 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/password-personal-data') + ->httpAlias('/v1/projects/:projectId/auth/personal-data') + ->desc('Update password personal data policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.password-personal-data.update') + ->label('audits.event', 'policies.password-personal-data.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updatePasswordPersonalDataPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle password personal data policy. Set to true if you want to block passwords including user\'s personal data, or false to allow it. When changing this policy, existing passwords remain valid.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['personalDataCheck'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php new file mode 100644 index 0000000000..ad2540172c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-duration') + ->httpAlias('/v1/projects/:projectId/auth/duration') + ->desc('Update session duration policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.session-duration.update') + ->label('audits.event', 'policies.session-duration.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionDurationPolicy', + description: <<param('duration', null, new Range(60, 31536000), 'Maximum session length in seconds. Minium allowed value is 60 seconds, and maximum is 1 year, which is 31536000 seconds.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + int $duration, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['duration'] = $duration; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php new file mode 100644 index 0000000000..0963d7eb56 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-invalidation') + ->httpAlias('/v1/projects/:projectId/auth/session-invalidation') + ->desc('Update session invalidation policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.session-invalidation.update') + ->label('audits.event', 'policies.session-invalidation.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionInvalidationPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle session invalidation policy. Set to true if you want password change to invalidate all sessions of an user, or false to keep sessions active.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['invalidateSessions'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php new file mode 100644 index 0000000000..3860b7ccc4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php @@ -0,0 +1,84 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-limit') + ->httpAlias('/v1/projects/:projectId/auth/max-sessions') + ->desc('Update session limit policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.session-limit.update') + ->label('audits.event', 'policies.session-limit.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionLimitPolicy', + description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT), 'Set the maximum number of sessions allowed per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + + if (\is_null($total)) { + $auths['maxSessions'] = 0; + } else { + $auths['maxSessions'] = $total; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php new file mode 100644 index 0000000000..6b21a22138 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php @@ -0,0 +1,84 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/user-limit') + ->httpAlias('/v1/projects/:projectId/auth/limit') + ->desc('Update user limit policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.user-limit.update') + ->label('audits.event', 'policies.user-limit.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateUserLimitPolicy', + description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT), 'Set the maximum number of users allowed in the project. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + + if (\is_null($total)) { + $auths['limit'] = 0; + } else { + $auths['limit'] = $total; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index c509a565cd..e190b5a719 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -107,7 +107,7 @@ class Create extends Action $auth = Config::getParam('auth', []); $auths = [ 'limit' => 0, - 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, + 'maxSessions' => 0, 'passwordHistory' => 0, 'passwordDictionary' => false, 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, From 2f272f0480611e9818db0ef96d8d815ed89008db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 13:27:08 +0200 Subject: [PATCH 140/402] Cleanup unused markdowns descriptions --- docs/references/account/create-2fa-challenge.md | 1 - docs/references/account/delete-session-current.md | 1 - docs/references/documentsdb/get-collection-logs.md | 1 - docs/references/documentsdb/get-document-logs.md | 1 - docs/references/documentsdb/list-attributes.md | 1 - docs/references/functions/create-build.md | 1 - docs/references/functions/create-deployment.md | 5 ----- docs/references/functions/create-execution.md | 1 - docs/references/functions/create-function.md | 1 - docs/references/functions/create-variable.md | 1 - docs/references/functions/delete-deployment.md | 1 - docs/references/functions/delete-execution.md | 1 - docs/references/functions/delete-function.md | 1 - docs/references/functions/delete-variable.md | 1 - docs/references/functions/get-deployment-download.md | 1 - docs/references/functions/get-deployment.md | 1 - docs/references/functions/get-execution.md | 1 - docs/references/functions/get-function-usage.md | 1 - docs/references/functions/get-function.md | 1 - docs/references/functions/get-functions-usage.md | 1 - docs/references/functions/get-template.md | 1 - docs/references/functions/get-variable.md | 1 - docs/references/functions/list-deployments.md | 1 - docs/references/functions/list-executions.md | 1 - docs/references/functions/list-functions.md | 1 - docs/references/functions/list-runtimes.md | 1 - docs/references/functions/list-specifications.md | 1 - docs/references/functions/list-templates.md | 1 - docs/references/functions/list-variables.md | 1 - docs/references/functions/update-deployment-build.md | 1 - docs/references/functions/update-function-deployment.md | 1 - docs/references/functions/update-function.md | 1 - docs/references/functions/update-variable.md | 1 - docs/references/health/get-queue-stats-usage-dump.md | 1 - docs/references/health/get-queue-tasks.md | 1 - docs/references/health/get-queue.md | 1 - docs/references/messaging/delete.md | 1 - docs/references/project/create-variable.md | 1 - docs/references/project/delete-variable.md | 1 - docs/references/project/get-variable.md | 1 - docs/references/project/list-variables.md | 1 - docs/references/project/update-variable.md | 1 - docs/references/projects/create-key.md | 1 - docs/references/projects/create-platform.md | 1 - docs/references/projects/create-webhook.md | 1 - docs/references/projects/delete-key.md | 1 - docs/references/projects/delete-platform.md | 1 - docs/references/projects/delete-webhook.md | 1 - docs/references/projects/get-key.md | 1 - docs/references/projects/get-platform.md | 1 - docs/references/projects/get-webhook.md | 1 - docs/references/projects/list-keys.md | 1 - docs/references/projects/list-platforms.md | 1 - docs/references/projects/list-webhooks.md | 1 - docs/references/projects/update-api-status-all.md | 1 - docs/references/projects/update-api-status.md | 1 - docs/references/projects/update-auth-duration.md | 1 - docs/references/projects/update-auth-limit.md | 1 - docs/references/projects/update-auth-password-dictionary.md | 1 - docs/references/projects/update-auth-password-history.md | 1 - docs/references/projects/update-auth-sessions-limit.md | 1 - docs/references/projects/update-key.md | 1 - docs/references/projects/update-memberships-privacy.md | 1 - docs/references/projects/update-personal-data-check.md | 1 - docs/references/projects/update-platform.md | 1 - docs/references/projects/update-service-status-all.md | 1 - docs/references/projects/update-service-status.md | 1 - docs/references/projects/update-session-invalidation.md | 1 - docs/references/projects/update-webhook-signature.md | 1 - docs/references/projects/update-webhook.md | 1 - docs/references/tablesdb/get-database.md | 1 - docs/references/vectorsdb/decrement-document-attribute.md | 1 - docs/references/vectorsdb/get-collection-logs.md | 1 - docs/references/vectorsdb/get-document-logs.md | 1 - docs/references/vectorsdb/increment-document-attribute.md | 1 - docs/references/vectorsdb/list-attributes.md | 1 - 76 files changed, 80 deletions(-) delete mode 100644 docs/references/account/create-2fa-challenge.md delete mode 100644 docs/references/account/delete-session-current.md delete mode 100644 docs/references/documentsdb/get-collection-logs.md delete mode 100644 docs/references/documentsdb/get-document-logs.md delete mode 100644 docs/references/documentsdb/list-attributes.md delete mode 100644 docs/references/functions/create-build.md delete mode 100644 docs/references/functions/create-deployment.md delete mode 100644 docs/references/functions/create-execution.md delete mode 100644 docs/references/functions/create-function.md delete mode 100644 docs/references/functions/create-variable.md delete mode 100644 docs/references/functions/delete-deployment.md delete mode 100644 docs/references/functions/delete-execution.md delete mode 100644 docs/references/functions/delete-function.md delete mode 100644 docs/references/functions/delete-variable.md delete mode 100644 docs/references/functions/get-deployment-download.md delete mode 100644 docs/references/functions/get-deployment.md delete mode 100644 docs/references/functions/get-execution.md delete mode 100644 docs/references/functions/get-function-usage.md delete mode 100644 docs/references/functions/get-function.md delete mode 100644 docs/references/functions/get-functions-usage.md delete mode 100644 docs/references/functions/get-template.md delete mode 100644 docs/references/functions/get-variable.md delete mode 100644 docs/references/functions/list-deployments.md delete mode 100644 docs/references/functions/list-executions.md delete mode 100644 docs/references/functions/list-functions.md delete mode 100644 docs/references/functions/list-runtimes.md delete mode 100644 docs/references/functions/list-specifications.md delete mode 100644 docs/references/functions/list-templates.md delete mode 100644 docs/references/functions/list-variables.md delete mode 100644 docs/references/functions/update-deployment-build.md delete mode 100644 docs/references/functions/update-function-deployment.md delete mode 100644 docs/references/functions/update-function.md delete mode 100644 docs/references/functions/update-variable.md delete mode 100644 docs/references/health/get-queue-stats-usage-dump.md delete mode 100644 docs/references/health/get-queue-tasks.md delete mode 100644 docs/references/health/get-queue.md delete mode 100644 docs/references/messaging/delete.md delete mode 100644 docs/references/project/create-variable.md delete mode 100644 docs/references/project/delete-variable.md delete mode 100644 docs/references/project/get-variable.md delete mode 100644 docs/references/project/list-variables.md delete mode 100644 docs/references/project/update-variable.md delete mode 100644 docs/references/projects/create-key.md delete mode 100644 docs/references/projects/create-platform.md delete mode 100644 docs/references/projects/create-webhook.md delete mode 100644 docs/references/projects/delete-key.md delete mode 100644 docs/references/projects/delete-platform.md delete mode 100644 docs/references/projects/delete-webhook.md delete mode 100644 docs/references/projects/get-key.md delete mode 100644 docs/references/projects/get-platform.md delete mode 100644 docs/references/projects/get-webhook.md delete mode 100644 docs/references/projects/list-keys.md delete mode 100644 docs/references/projects/list-platforms.md delete mode 100644 docs/references/projects/list-webhooks.md delete mode 100644 docs/references/projects/update-api-status-all.md delete mode 100644 docs/references/projects/update-api-status.md delete mode 100644 docs/references/projects/update-auth-duration.md delete mode 100644 docs/references/projects/update-auth-limit.md delete mode 100644 docs/references/projects/update-auth-password-dictionary.md delete mode 100644 docs/references/projects/update-auth-password-history.md delete mode 100644 docs/references/projects/update-auth-sessions-limit.md delete mode 100644 docs/references/projects/update-key.md delete mode 100644 docs/references/projects/update-memberships-privacy.md delete mode 100644 docs/references/projects/update-personal-data-check.md delete mode 100644 docs/references/projects/update-platform.md delete mode 100644 docs/references/projects/update-service-status-all.md delete mode 100644 docs/references/projects/update-service-status.md delete mode 100644 docs/references/projects/update-session-invalidation.md delete mode 100644 docs/references/projects/update-webhook-signature.md delete mode 100644 docs/references/projects/update-webhook.md delete mode 100644 docs/references/tablesdb/get-database.md delete mode 100644 docs/references/vectorsdb/decrement-document-attribute.md delete mode 100644 docs/references/vectorsdb/get-collection-logs.md delete mode 100644 docs/references/vectorsdb/get-document-logs.md delete mode 100644 docs/references/vectorsdb/increment-document-attribute.md delete mode 100644 docs/references/vectorsdb/list-attributes.md diff --git a/docs/references/account/create-2fa-challenge.md b/docs/references/account/create-2fa-challenge.md deleted file mode 100644 index ee6ef2f2ac..0000000000 --- a/docs/references/account/create-2fa-challenge.md +++ /dev/null @@ -1 +0,0 @@ -Initialize an MFA challenge of the specified factor. The factor must be available on the account. \ No newline at end of file diff --git a/docs/references/account/delete-session-current.md b/docs/references/account/delete-session-current.md deleted file mode 100644 index d38520f479..0000000000 --- a/docs/references/account/delete-session-current.md +++ /dev/null @@ -1 +0,0 @@ -Use this endpoint to log out the currently logged in user from their account. When successful this endpoint will delete the user session and remove the session secret cookie from the user client. \ No newline at end of file diff --git a/docs/references/documentsdb/get-collection-logs.md b/docs/references/documentsdb/get-collection-logs.md deleted file mode 100644 index 8578cef03c..0000000000 --- a/docs/references/documentsdb/get-collection-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the collection activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/get-document-logs.md b/docs/references/documentsdb/get-document-logs.md deleted file mode 100644 index 9b96df5ad4..0000000000 --- a/docs/references/documentsdb/get-document-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the document activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/list-attributes.md b/docs/references/documentsdb/list-attributes.md deleted file mode 100644 index 72ad6d727f..0000000000 --- a/docs/references/documentsdb/list-attributes.md +++ /dev/null @@ -1 +0,0 @@ -List attributes in the collection. \ No newline at end of file diff --git a/docs/references/functions/create-build.md b/docs/references/functions/create-build.md deleted file mode 100644 index 160a04c291..0000000000 --- a/docs/references/functions/create-build.md +++ /dev/null @@ -1 +0,0 @@ -Create a new build for an existing function deployment. This endpoint allows you to rebuild a deployment with the updated function configuration, including its entrypoint and build commands if they have been modified. The build process will be queued and executed asynchronously. The original deployment's code will be preserved and used for the new build. \ No newline at end of file diff --git a/docs/references/functions/create-deployment.md b/docs/references/functions/create-deployment.md deleted file mode 100644 index 3bbdbfc848..0000000000 --- a/docs/references/functions/create-deployment.md +++ /dev/null @@ -1,5 +0,0 @@ -Create a new function code deployment. Use this endpoint to upload a new version of your code function. To execute your newly uploaded code, you'll need to update the function's deployment to use your new deployment UID. - -This endpoint accepts a tar.gz file compressed with your code. Make sure to include any dependencies your code has within the compressed file. You can learn more about code packaging in the [Appwrite Cloud Functions tutorial](https://appwrite.io/docs/functions). - -Use the "command" param to set the entrypoint used to execute your code. \ No newline at end of file diff --git a/docs/references/functions/create-execution.md b/docs/references/functions/create-execution.md deleted file mode 100644 index 6089c4ff01..0000000000 --- a/docs/references/functions/create-execution.md +++ /dev/null @@ -1 +0,0 @@ -Trigger a function execution. The returned object will return you the current execution status. You can ping the `Get Execution` endpoint to get updates on the current execution status. Once this endpoint is called, your function execution process will start asynchronously. \ No newline at end of file diff --git a/docs/references/functions/create-function.md b/docs/references/functions/create-function.md deleted file mode 100644 index 1ac9143f45..0000000000 --- a/docs/references/functions/create-function.md +++ /dev/null @@ -1 +0,0 @@ -Create a new function. You can pass a list of [permissions](https://appwrite.io/docs/permissions) to allow different project users or team with access to execute the function using the client API. \ No newline at end of file diff --git a/docs/references/functions/create-variable.md b/docs/references/functions/create-variable.md deleted file mode 100644 index 40fabd75a8..0000000000 --- a/docs/references/functions/create-variable.md +++ /dev/null @@ -1 +0,0 @@ -Create a new function environment variable. These variables can be accessed in the function at runtime as environment variables. \ No newline at end of file diff --git a/docs/references/functions/delete-deployment.md b/docs/references/functions/delete-deployment.md deleted file mode 100644 index 19c74965bd..0000000000 --- a/docs/references/functions/delete-deployment.md +++ /dev/null @@ -1 +0,0 @@ -Delete a code deployment by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/delete-execution.md b/docs/references/functions/delete-execution.md deleted file mode 100644 index d7cad98ac1..0000000000 --- a/docs/references/functions/delete-execution.md +++ /dev/null @@ -1 +0,0 @@ -Delete a function execution by its unique ID. diff --git a/docs/references/functions/delete-function.md b/docs/references/functions/delete-function.md deleted file mode 100644 index 92835e3c82..0000000000 --- a/docs/references/functions/delete-function.md +++ /dev/null @@ -1 +0,0 @@ -Delete a function by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/delete-variable.md b/docs/references/functions/delete-variable.md deleted file mode 100644 index 9b1326d96f..0000000000 --- a/docs/references/functions/delete-variable.md +++ /dev/null @@ -1 +0,0 @@ -Delete a variable by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-deployment-download.md b/docs/references/functions/get-deployment-download.md deleted file mode 100644 index e662ae2733..0000000000 --- a/docs/references/functions/get-deployment-download.md +++ /dev/null @@ -1 +0,0 @@ -Get a Deployment's contents by its unique ID. This endpoint supports range requests for partial or streaming file download. \ No newline at end of file diff --git a/docs/references/functions/get-deployment.md b/docs/references/functions/get-deployment.md deleted file mode 100644 index 6d73976eb1..0000000000 --- a/docs/references/functions/get-deployment.md +++ /dev/null @@ -1 +0,0 @@ -Get a code deployment by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-execution.md b/docs/references/functions/get-execution.md deleted file mode 100644 index fc38260bdb..0000000000 --- a/docs/references/functions/get-execution.md +++ /dev/null @@ -1 +0,0 @@ -Get a function execution log by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-function-usage.md b/docs/references/functions/get-function-usage.md deleted file mode 100644 index 4498abb05b..0000000000 --- a/docs/references/functions/get-function-usage.md +++ /dev/null @@ -1 +0,0 @@ -Get usage metrics and statistics for a for a specific function. View statistics including total deployments, builds, executions, storage usage, and compute time. The response includes both current totals and historical data for each metric. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, defaults to 30 days. \ No newline at end of file diff --git a/docs/references/functions/get-function.md b/docs/references/functions/get-function.md deleted file mode 100644 index 557ec316ba..0000000000 --- a/docs/references/functions/get-function.md +++ /dev/null @@ -1 +0,0 @@ -Get a function by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-functions-usage.md b/docs/references/functions/get-functions-usage.md deleted file mode 100644 index 14427d335d..0000000000 --- a/docs/references/functions/get-functions-usage.md +++ /dev/null @@ -1 +0,0 @@ -Get usage metrics and statistics for a for all functions. View statistics including total functions, deployments, builds, executions, storage usage, and compute time. The response includes both current totals and historical data for each metric. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, defaults to 30 days. \ No newline at end of file diff --git a/docs/references/functions/get-template.md b/docs/references/functions/get-template.md deleted file mode 100644 index ccdcce7352..0000000000 --- a/docs/references/functions/get-template.md +++ /dev/null @@ -1 +0,0 @@ -Get a function template using ID. You can use template details in [createFunction](/docs/references/cloud/server-nodejs/functions#create) method. \ No newline at end of file diff --git a/docs/references/functions/get-variable.md b/docs/references/functions/get-variable.md deleted file mode 100644 index f0fa853655..0000000000 --- a/docs/references/functions/get-variable.md +++ /dev/null @@ -1 +0,0 @@ -Get a variable by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/list-deployments.md b/docs/references/functions/list-deployments.md deleted file mode 100644 index 80bbba1bf6..0000000000 --- a/docs/references/functions/list-deployments.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all the function's code deployments. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/functions/list-executions.md b/docs/references/functions/list-executions.md deleted file mode 100644 index 168c795b20..0000000000 --- a/docs/references/functions/list-executions.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all the current user function execution logs. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/functions/list-functions.md b/docs/references/functions/list-functions.md deleted file mode 100644 index 9ad432fdc0..0000000000 --- a/docs/references/functions/list-functions.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all the project's functions. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/functions/list-runtimes.md b/docs/references/functions/list-runtimes.md deleted file mode 100644 index d4d3d23b18..0000000000 --- a/docs/references/functions/list-runtimes.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all runtimes that are currently active on your instance. \ No newline at end of file diff --git a/docs/references/functions/list-specifications.md b/docs/references/functions/list-specifications.md deleted file mode 100644 index d65a215827..0000000000 --- a/docs/references/functions/list-specifications.md +++ /dev/null @@ -1 +0,0 @@ -List allowed function specifications for this instance. diff --git a/docs/references/functions/list-templates.md b/docs/references/functions/list-templates.md deleted file mode 100644 index ed43b9cbf4..0000000000 --- a/docs/references/functions/list-templates.md +++ /dev/null @@ -1 +0,0 @@ -List available function templates. You can use template details in [createFunction](/docs/references/cloud/server-nodejs/functions#create) method. \ No newline at end of file diff --git a/docs/references/functions/list-variables.md b/docs/references/functions/list-variables.md deleted file mode 100644 index 68bd5e17e1..0000000000 --- a/docs/references/functions/list-variables.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all variables of a specific function. \ No newline at end of file diff --git a/docs/references/functions/update-deployment-build.md b/docs/references/functions/update-deployment-build.md deleted file mode 100644 index d047990adf..0000000000 --- a/docs/references/functions/update-deployment-build.md +++ /dev/null @@ -1 +0,0 @@ -Cancel an ongoing function deployment build. If the build is already in progress, it will be stopped and marked as canceled. If the build hasn't started yet, it will be marked as canceled without executing. You cannot cancel builds that have already completed (status 'ready') or failed. The response includes the final build status and details. \ No newline at end of file diff --git a/docs/references/functions/update-function-deployment.md b/docs/references/functions/update-function-deployment.md deleted file mode 100644 index 7a85188842..0000000000 --- a/docs/references/functions/update-function-deployment.md +++ /dev/null @@ -1 +0,0 @@ -Update the function code deployment ID using the unique function ID. Use this endpoint to switch the code deployment that should be executed by the execution endpoint. \ No newline at end of file diff --git a/docs/references/functions/update-function.md b/docs/references/functions/update-function.md deleted file mode 100644 index 5a9a84ad94..0000000000 --- a/docs/references/functions/update-function.md +++ /dev/null @@ -1 +0,0 @@ -Update function by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/update-variable.md b/docs/references/functions/update-variable.md deleted file mode 100644 index af2c38aea2..0000000000 --- a/docs/references/functions/update-variable.md +++ /dev/null @@ -1 +0,0 @@ -Update variable by its unique ID. \ No newline at end of file diff --git a/docs/references/health/get-queue-stats-usage-dump.md b/docs/references/health/get-queue-stats-usage-dump.md deleted file mode 100644 index 3c95da1b8a..0000000000 --- a/docs/references/health/get-queue-stats-usage-dump.md +++ /dev/null @@ -1 +0,0 @@ -Get the number of projects containing metrics that are waiting to be processed in the Appwrite internal queue server. \ No newline at end of file diff --git a/docs/references/health/get-queue-tasks.md b/docs/references/health/get-queue-tasks.md deleted file mode 100644 index ea6fa22087..0000000000 --- a/docs/references/health/get-queue-tasks.md +++ /dev/null @@ -1 +0,0 @@ -Get the number of tasks that are waiting to be processed in the Appwrite internal queue server. \ No newline at end of file diff --git a/docs/references/health/get-queue.md b/docs/references/health/get-queue.md deleted file mode 100644 index e4558f941f..0000000000 --- a/docs/references/health/get-queue.md +++ /dev/null @@ -1 +0,0 @@ -Check the Appwrite queue messaging servers are up and connection is successful. \ No newline at end of file diff --git a/docs/references/messaging/delete.md b/docs/references/messaging/delete.md deleted file mode 100644 index b07d020900..0000000000 --- a/docs/references/messaging/delete.md +++ /dev/null @@ -1 +0,0 @@ -Delete a message by its unique ID. \ No newline at end of file diff --git a/docs/references/project/create-variable.md b/docs/references/project/create-variable.md deleted file mode 100644 index 2bbee5bf99..0000000000 --- a/docs/references/project/create-variable.md +++ /dev/null @@ -1 +0,0 @@ -Create a new project variable. This variable will be accessible in all Appwrite Functions at runtime. \ No newline at end of file diff --git a/docs/references/project/delete-variable.md b/docs/references/project/delete-variable.md deleted file mode 100644 index 9be15f83ca..0000000000 --- a/docs/references/project/delete-variable.md +++ /dev/null @@ -1 +0,0 @@ -Delete a project variable by its unique ID. \ No newline at end of file diff --git a/docs/references/project/get-variable.md b/docs/references/project/get-variable.md deleted file mode 100644 index 8636768434..0000000000 --- a/docs/references/project/get-variable.md +++ /dev/null @@ -1 +0,0 @@ -Get a project variable by its unique ID. \ No newline at end of file diff --git a/docs/references/project/list-variables.md b/docs/references/project/list-variables.md deleted file mode 100644 index fbe191178a..0000000000 --- a/docs/references/project/list-variables.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all project variables. These variables will be accessible in all Appwrite Functions at runtime. \ No newline at end of file diff --git a/docs/references/project/update-variable.md b/docs/references/project/update-variable.md deleted file mode 100644 index 603622b2c7..0000000000 --- a/docs/references/project/update-variable.md +++ /dev/null @@ -1 +0,0 @@ -Update project variable by its unique ID. This variable will be accessible in all Appwrite Functions at runtime. \ No newline at end of file diff --git a/docs/references/projects/create-key.md b/docs/references/projects/create-key.md deleted file mode 100644 index d6633d936d..0000000000 --- a/docs/references/projects/create-key.md +++ /dev/null @@ -1 +0,0 @@ -Create a new API key. It's recommended to have multiple API keys with strict scopes for separate functions within your project. \ No newline at end of file diff --git a/docs/references/projects/create-platform.md b/docs/references/projects/create-platform.md deleted file mode 100644 index b5d8be0ff9..0000000000 --- a/docs/references/projects/create-platform.md +++ /dev/null @@ -1 +0,0 @@ -Create a new platform for your project. Use this endpoint to register a new platform where your users will run your application which will interact with the Appwrite API. \ No newline at end of file diff --git a/docs/references/projects/create-webhook.md b/docs/references/projects/create-webhook.md deleted file mode 100644 index cd0e93332b..0000000000 --- a/docs/references/projects/create-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Create a new webhook. Use this endpoint to configure a URL that will receive events from Appwrite when specific events occur. \ No newline at end of file diff --git a/docs/references/projects/delete-key.md b/docs/references/projects/delete-key.md deleted file mode 100644 index 9f3774b419..0000000000 --- a/docs/references/projects/delete-key.md +++ /dev/null @@ -1 +0,0 @@ -Delete a key by its unique ID. Once deleted, the key can no longer be used to authenticate API calls. \ No newline at end of file diff --git a/docs/references/projects/delete-platform.md b/docs/references/projects/delete-platform.md deleted file mode 100644 index 7d538cac26..0000000000 --- a/docs/references/projects/delete-platform.md +++ /dev/null @@ -1 +0,0 @@ -Delete a platform by its unique ID. This endpoint removes the platform and all its configurations from the project. \ No newline at end of file diff --git a/docs/references/projects/delete-webhook.md b/docs/references/projects/delete-webhook.md deleted file mode 100644 index 74fee2bcec..0000000000 --- a/docs/references/projects/delete-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Delete a webhook by its unique ID. Once deleted, the webhook will no longer receive project events. \ No newline at end of file diff --git a/docs/references/projects/get-key.md b/docs/references/projects/get-key.md deleted file mode 100644 index bd6351f420..0000000000 --- a/docs/references/projects/get-key.md +++ /dev/null @@ -1 +0,0 @@ -Get a key by its unique ID. This endpoint returns details about a specific API key in your project including it's scopes. \ No newline at end of file diff --git a/docs/references/projects/get-platform.md b/docs/references/projects/get-platform.md deleted file mode 100644 index 87129b829d..0000000000 --- a/docs/references/projects/get-platform.md +++ /dev/null @@ -1 +0,0 @@ -Get a platform by its unique ID. This endpoint returns the platform's details, including its name, type, and key configurations. \ No newline at end of file diff --git a/docs/references/projects/get-webhook.md b/docs/references/projects/get-webhook.md deleted file mode 100644 index 559c73c748..0000000000 --- a/docs/references/projects/get-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Get a webhook by its unique ID. This endpoint returns details about a specific webhook configured for a project. \ No newline at end of file diff --git a/docs/references/projects/list-keys.md b/docs/references/projects/list-keys.md deleted file mode 100644 index a7b701b0d7..0000000000 --- a/docs/references/projects/list-keys.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all API keys from the current project. \ No newline at end of file diff --git a/docs/references/projects/list-platforms.md b/docs/references/projects/list-platforms.md deleted file mode 100644 index ed9ade0852..0000000000 --- a/docs/references/projects/list-platforms.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all platforms in the project. This endpoint returns an array of all platforms and their configurations. \ No newline at end of file diff --git a/docs/references/projects/list-webhooks.md b/docs/references/projects/list-webhooks.md deleted file mode 100644 index bbbf4c7376..0000000000 --- a/docs/references/projects/list-webhooks.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all webhooks belonging to the project. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/projects/update-api-status-all.md b/docs/references/projects/update-api-status-all.md deleted file mode 100644 index 654070759f..0000000000 --- a/docs/references/projects/update-api-status-all.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of all API types. Use this endpoint to enable or disable API types such as REST, GraphQL and Realtime all at once. \ No newline at end of file diff --git a/docs/references/projects/update-api-status.md b/docs/references/projects/update-api-status.md deleted file mode 100644 index af10a0d4f4..0000000000 --- a/docs/references/projects/update-api-status.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of a specific API type. Use this endpoint to enable or disable API types such as REST, GraphQL and Realtime. \ No newline at end of file diff --git a/docs/references/projects/update-auth-duration.md b/docs/references/projects/update-auth-duration.md deleted file mode 100644 index bdc75fa6f0..0000000000 --- a/docs/references/projects/update-auth-duration.md +++ /dev/null @@ -1 +0,0 @@ -Update how long sessions created within a project should stay active for. \ No newline at end of file diff --git a/docs/references/projects/update-auth-limit.md b/docs/references/projects/update-auth-limit.md deleted file mode 100644 index c8faa3fe37..0000000000 --- a/docs/references/projects/update-auth-limit.md +++ /dev/null @@ -1 +0,0 @@ -Update the maximum number of users allowed in this project. Set to 0 for unlimited users. \ No newline at end of file diff --git a/docs/references/projects/update-auth-password-dictionary.md b/docs/references/projects/update-auth-password-dictionary.md deleted file mode 100644 index 1d47d30bb5..0000000000 --- a/docs/references/projects/update-auth-password-dictionary.md +++ /dev/null @@ -1 +0,0 @@ -Enable or disable checking user passwords against common passwords dictionary. This helps ensure users don't use common and insecure passwords. \ No newline at end of file diff --git a/docs/references/projects/update-auth-password-history.md b/docs/references/projects/update-auth-password-history.md deleted file mode 100644 index 3a892915d5..0000000000 --- a/docs/references/projects/update-auth-password-history.md +++ /dev/null @@ -1 +0,0 @@ -Update the authentication password history requirement. Use this endpoint to require new passwords to be different than the last X amount of previously used ones. \ No newline at end of file diff --git a/docs/references/projects/update-auth-sessions-limit.md b/docs/references/projects/update-auth-sessions-limit.md deleted file mode 100644 index 7d5fdffae7..0000000000 --- a/docs/references/projects/update-auth-sessions-limit.md +++ /dev/null @@ -1 +0,0 @@ -Update the maximum number of sessions allowed per user within the project, if the limit is hit the oldest session will be deleted to make room for new sessions. \ No newline at end of file diff --git a/docs/references/projects/update-key.md b/docs/references/projects/update-key.md deleted file mode 100644 index 4934a51497..0000000000 --- a/docs/references/projects/update-key.md +++ /dev/null @@ -1 +0,0 @@ -Update a key by its unique ID. Use this endpoint to update the name, scopes, or expiration time of an API key. \ No newline at end of file diff --git a/docs/references/projects/update-memberships-privacy.md b/docs/references/projects/update-memberships-privacy.md deleted file mode 100644 index a1affc1166..0000000000 --- a/docs/references/projects/update-memberships-privacy.md +++ /dev/null @@ -1 +0,0 @@ -Update project membership privacy settings. Use this endpoint to control what user information is visible to other team members, such as user name, email, and MFA status. \ No newline at end of file diff --git a/docs/references/projects/update-personal-data-check.md b/docs/references/projects/update-personal-data-check.md deleted file mode 100644 index 42847fdbfc..0000000000 --- a/docs/references/projects/update-personal-data-check.md +++ /dev/null @@ -1 +0,0 @@ -Enable or disable checking user passwords against their personal data. This helps prevent users from using personal information in their passwords. \ No newline at end of file diff --git a/docs/references/projects/update-platform.md b/docs/references/projects/update-platform.md deleted file mode 100644 index d04b07bafd..0000000000 --- a/docs/references/projects/update-platform.md +++ /dev/null @@ -1 +0,0 @@ -Update a platform by its unique ID. Use this endpoint to update the platform's name, key, platform store ID, or hostname. \ No newline at end of file diff --git a/docs/references/projects/update-service-status-all.md b/docs/references/projects/update-service-status-all.md deleted file mode 100644 index f05e7d8c5c..0000000000 --- a/docs/references/projects/update-service-status-all.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of all services. Use this endpoint to enable or disable all optional services at once. \ No newline at end of file diff --git a/docs/references/projects/update-service-status.md b/docs/references/projects/update-service-status.md deleted file mode 100644 index 9d3b0743a8..0000000000 --- a/docs/references/projects/update-service-status.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of a specific service. Use this endpoint to enable or disable a service in your project. \ No newline at end of file diff --git a/docs/references/projects/update-session-invalidation.md b/docs/references/projects/update-session-invalidation.md deleted file mode 100644 index cbaf378624..0000000000 --- a/docs/references/projects/update-session-invalidation.md +++ /dev/null @@ -1 +0,0 @@ -Invalidate all existing sessions. An optional auth security setting for projects, and enabled by default for console project. \ No newline at end of file diff --git a/docs/references/projects/update-webhook-signature.md b/docs/references/projects/update-webhook-signature.md deleted file mode 100644 index 8525a05777..0000000000 --- a/docs/references/projects/update-webhook-signature.md +++ /dev/null @@ -1 +0,0 @@ -Update the webhook signature key. This endpoint can be used to regenerate the signature key used to sign and validate payload deliveries for a specific webhook. \ No newline at end of file diff --git a/docs/references/projects/update-webhook.md b/docs/references/projects/update-webhook.md deleted file mode 100644 index 745e4aebe1..0000000000 --- a/docs/references/projects/update-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Update a webhook by its unique ID. Use this endpoint to update the URL, events, or status of an existing webhook. \ No newline at end of file diff --git a/docs/references/tablesdb/get-database.md b/docs/references/tablesdb/get-database.md deleted file mode 100644 index 24183f6f6b..0000000000 --- a/docs/references/tablesdb/get-database.md +++ /dev/null @@ -1 +0,0 @@ -Get a database by its unique ID. This endpoint response returns a JSON object with the database metadata. \ No newline at end of file diff --git a/docs/references/vectorsdb/decrement-document-attribute.md b/docs/references/vectorsdb/decrement-document-attribute.md deleted file mode 100644 index b7b32d6148..0000000000 --- a/docs/references/vectorsdb/decrement-document-attribute.md +++ /dev/null @@ -1 +0,0 @@ -Decrement a specific column of a row by a given value. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-collection-logs.md b/docs/references/vectorsdb/get-collection-logs.md deleted file mode 100644 index 8578cef03c..0000000000 --- a/docs/references/vectorsdb/get-collection-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the collection activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-document-logs.md b/docs/references/vectorsdb/get-document-logs.md deleted file mode 100644 index 9b96df5ad4..0000000000 --- a/docs/references/vectorsdb/get-document-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the document activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/increment-document-attribute.md b/docs/references/vectorsdb/increment-document-attribute.md deleted file mode 100644 index 7a19b3fbc7..0000000000 --- a/docs/references/vectorsdb/increment-document-attribute.md +++ /dev/null @@ -1 +0,0 @@ -Increment a specific column of a row by a given value. \ No newline at end of file diff --git a/docs/references/vectorsdb/list-attributes.md b/docs/references/vectorsdb/list-attributes.md deleted file mode 100644 index 72ad6d727f..0000000000 --- a/docs/references/vectorsdb/list-attributes.md +++ /dev/null @@ -1 +0,0 @@ -List attributes in the collection. \ No newline at end of file From 30cfbb2d992de8bd926a338f234698a7c46ea1d4 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 16:58:31 +0530 Subject: [PATCH 141/402] Polish benchmark reporting --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++-- tests/benchmarks/http.php | 9 +++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 595f496e1d..23aa1f21de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -746,8 +746,30 @@ jobs: docker run --rm -i -v "$PWD:/scripts" -w /scripts ${{ env.IMAGE }}:after php <<'PHP' > benchmark-comment.txt getMessage()}\n"); + exit(1); + } + + if (!is_array($summary)) { + fwrite(STDERR, "Invalid benchmark summary {$path}: expected JSON object\n"); + exit(1); + } + + return $summary; + } + + $before = read_summary('benchmark-before-summary.json'); + $after = read_summary('benchmark-after-summary.json'); function metric_value(?array $data, string $metric, string $stat): mixed { diff --git a/tests/benchmarks/http.php b/tests/benchmarks/http.php index 3d7556e8d7..1c28ef33ea 100644 --- a/tests/benchmarks/http.php +++ b/tests/benchmarks/http.php @@ -290,7 +290,12 @@ final class HttpBenchmark $context = $this->setup(); for ($i = 0; $i < $this->iterations * $this->vus; $i++) { - $this->curatedFlows($context); + try { + $this->curatedFlows($context); + } catch (Throwable $error) { + $exitCode = 1; + fwrite(STDERR, 'Iteration ' . ($i + 1) . ' failed: ' . $error->getMessage() . PHP_EOL); + } } } catch (Throwable $error) { $exitCode = 1; @@ -1216,7 +1221,7 @@ final class HttpBenchmark '', ]; - return implode(PHP_EOL, array_filter($lines, fn (string $line): bool => $line !== '')) . PHP_EOL; + return implode(PHP_EOL, $lines) . PHP_EOL; } private function comparisonTable(?array $before, array $after): string From 63b2a1fb7fff0c7c22c9105585e8b95a26f37be7 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 17:07:05 +0530 Subject: [PATCH 142/402] Harden benchmark baseline reporting --- .github/workflows/ci.yml | 41 ++++++++++++++++++++++++++++----------- tests/benchmarks/http.php | 6 +++--- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23aa1f21de..403160a8aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -683,10 +683,13 @@ jobs: docker tag ${{ env.IMAGE }} ${{ env.IMAGE }}:after - name: Prepare benchmark before + id: benchmark_before_prepare + continue-on-error: true run: | git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} git worktree add --detach /tmp/appwrite-benchmark-before ${{ github.event.pull_request.base.sha }} docker build \ + --cache-from ${{ env.IMAGE }}:after \ --target development \ --build-arg DEBUG=false \ --build-arg TESTING=true \ @@ -695,6 +698,9 @@ jobs: /tmp/appwrite-benchmark-before - name: Start before Appwrite + id: benchmark_before_start + if: steps.benchmark_before_prepare.outcome == 'success' + continue-on-error: true working-directory: /tmp/appwrite-benchmark-before run: | docker tag ${{ env.IMAGE }}:before ${{ env.IMAGE }} @@ -702,13 +708,15 @@ jobs: docker compose up -d --wait --no-build - name: Benchmark before + if: steps.benchmark_before_start.outcome == 'success' + continue-on-error: true run: | rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before.txt benchmark.txt docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts \ -e APPWRITE_ENDPOINT=http://localhost/v1 \ -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ -e APPWRITE_BENCHMARK_ITERATIONS=1 \ - -e APPWRITE_BENCHMARK_VUS=1 \ + -e APPWRITE_BENCHMARK_RUNS=1 \ -e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-before-summary.json \ ${{ env.IMAGE }}:before php tests/benchmarks/http.php | tee benchmark-before.txt @@ -717,7 +725,7 @@ jobs: run: | if [ -d /tmp/appwrite-benchmark-before ]; then cd /tmp/appwrite-benchmark-before - docker compose down -v + docker compose down -v || true fi - name: Start after Appwrite @@ -732,7 +740,7 @@ jobs: -e APPWRITE_ENDPOINT=http://localhost/v1 \ -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ -e APPWRITE_BENCHMARK_ITERATIONS=1 \ - -e APPWRITE_BENCHMARK_VUS=1 \ + -e APPWRITE_BENCHMARK_RUNS=1 \ -e APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH=benchmark-before-summary.json \ -e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-after-summary.json \ ${{ env.IMAGE }}:after php tests/benchmarks/http.php | tee benchmark.txt @@ -746,29 +754,37 @@ jobs: docker run --rm -i -v "$PWD:/scripts" -w /scripts ${{ env.IMAGE }}:after php <<'PHP' > benchmark-comment.txt getMessage()}\n"); - exit(1); + fail_summary("Invalid benchmark summary {$path}: {$error->getMessage()}", $required); + return null; } if (!is_array($summary)) { - fwrite(STDERR, "Invalid benchmark summary {$path}: expected JSON object\n"); - exit(1); + fail_summary("Invalid benchmark summary {$path}: expected JSON object", $required); + return null; } return $summary; } - $before = read_summary('benchmark-before-summary.json'); + $before = read_summary('benchmark-before-summary.json', false); $after = read_summary('benchmark-after-summary.json'); function metric_value(?array $data, string $metric, string $stat): mixed @@ -829,6 +845,9 @@ jobs: echo "\n"; echo "## :sparkles: Benchmark results\n\n"; echo 'Comparing `${{ github.event.pull_request.base.ref }}` (before) to `${{ github.event.pull_request.head.ref }}` (after).' . "\n\n"; + if ($before === null) { + echo "> Before benchmark did not complete; showing current branch metrics only.\n\n"; + } echo "| Metric | Before | After | Delta |\n"; echo "| --- | ---: | ---: | ---: |\n"; echo implode("\n", $rows) . "\n\n"; diff --git a/tests/benchmarks/http.php b/tests/benchmarks/http.php index 1c28ef33ea..c44677c4ec 100644 --- a/tests/benchmarks/http.php +++ b/tests/benchmarks/http.php @@ -260,7 +260,7 @@ final class HttpBenchmark private int $mailTimeoutMs; private int $workerTimeoutMs; private int $iterations; - private int $vus; + private int $runs; private string $summaryPath; private ?array $previousSummary; @@ -276,7 +276,7 @@ final class HttpBenchmark $this->mailTimeoutMs = (int) $this->env('APPWRITE_MAIL_TIMEOUT_MS', '20000'); $this->workerTimeoutMs = (int) $this->env('APPWRITE_WORKER_TIMEOUT_MS', '60000'); $this->iterations = max(1, (int) $this->env('APPWRITE_BENCHMARK_ITERATIONS', '1')); - $this->vus = max(1, (int) $this->env('APPWRITE_BENCHMARK_VUS', '1')); + $this->runs = max(1, (int) $this->env('APPWRITE_BENCHMARK_RUNS', $this->env('APPWRITE_BENCHMARK_VUS', '1'))); $this->summaryPath = $this->env('APPWRITE_BENCHMARK_SUMMARY_PATH', 'tests/benchmarks/http-summary.json'); $this->previousSummary = $this->loadPreviousSummary($this->env('APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH', $this->summaryPath)); } @@ -289,7 +289,7 @@ final class HttpBenchmark try { $context = $this->setup(); - for ($i = 0; $i < $this->iterations * $this->vus; $i++) { + for ($i = 0; $i < $this->iterations * $this->runs; $i++) { try { $this->curatedFlows($context); } catch (Throwable $error) { From d0f853d4cd909a54b2b759853d9986e0dfba7443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 13:38:27 +0200 Subject: [PATCH 143/402] Add more project policies --- app/controllers/api/projects.php | 80 ---------------- .../projects/update-session-alerts.md | 1 - .../Policies/MembershipPrivacy/Update.php | 91 +++++++++++++++++++ .../Policies/PasswordPersonalData/Update.php | 1 + .../Project/Policies/SessionAlert/Update.php | 78 ++++++++++++++++ .../Modules/Projects/Http/Projects/Create.php | 2 + .../Modules/Teams/Http/Memberships/Get.php | 16 +++- .../Modules/Teams/Http/Memberships/XList.php | 16 +++- .../Utopia/Response/Model/Membership.php | 6 ++ .../Utopia/Response/Model/Project.php | 14 +++ 10 files changed, 218 insertions(+), 87 deletions(-) delete mode 100644 docs/references/projects/update-session-alerts.md create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 87552c6508..ea5e5754d6 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -138,86 +138,6 @@ Http::patch('/v1/projects/:projectId/oauth2') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/auth/session-alerts') - ->desc('Update project sessions emails') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateSessionAlerts', - description: '/docs/references/projects/update-session-alerts.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('alerts', false, new Boolean(true), 'Set to true to enable session emails.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $alerts, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['sessionAlerts'] = $alerts; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/memberships-privacy') - ->desc('Update project memberships privacy attributes') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateMembershipsPrivacy', - description: '/docs/references/projects/update-memberships-privacy.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('userName', true, new Boolean(true), 'Set to true to show userName to members of a team.') - ->param('userEmail', true, new Boolean(true), 'Set to true to show email to members of a team.') - ->param('mfa', true, new Boolean(true), 'Set to true to show mfa to members of a team.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $userName, bool $userEmail, bool $mfa, Response $response, Database $dbForPlatform) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - - $auths['membershipsUserName'] = $userName; - $auths['membershipsUserEmail'] = $userEmail; - $auths['membershipsMfa'] = $mfa; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::patch('/v1/projects/:projectId/auth/:method') ->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.') ->groups(['api', 'projects']) diff --git a/docs/references/projects/update-session-alerts.md b/docs/references/projects/update-session-alerts.md deleted file mode 100644 index 36859e0c1e..0000000000 --- a/docs/references/projects/update-session-alerts.md +++ /dev/null @@ -1 +0,0 @@ -Enable or disable session email alerts. When enabled, users will receive email notifications when new sessions are created. \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php new file mode 100644 index 0000000000..2b0b0432cc --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php @@ -0,0 +1,91 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/membership-privacy') + ->httpAlias('/v1/projects/:projectId/auth/memberships-privacy') + ->desc('Update membership privacy policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.membership-privacy.update') + ->label('audits.event', 'policies.membership-privacy.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateMembershipPrivacyPolicy', + description: <<param('userId', null, new Boolean(), 'Set to true if you want make user ID visible to all team members, or false to hide it.') + ->param('userEmail', null, new Boolean(), 'Set to true if you want make user email visible to all team members, or false to hide it.') + ->param('userPhone', null, new Boolean(), 'Set to true if you want make user phone number visible to all team members, or false to hide it.') + ->param('userName', null, new Boolean(), 'Set to true if you want make user name visible to all team members, or false to hide it.') + ->param('userMFA', null, new Boolean(), 'Set to true if you want make user MFA status visible to all team members, or false to hide it.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + bool $userId, + bool $userEmail, + bool $userPhone, + bool $userName, + bool $userMFA, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + + $auths['membershipsUserName'] = $userName; + $auths['membershipsUserEmail'] = $userEmail; + $auths['membershipsMfa'] = $userMFA; + $auths['membershipsUserId'] = $userId; + $auths['membershipsUserPhone'] = $userPhone; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php index cc661366d7..4ba90045c8 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php @@ -49,6 +49,7 @@ class Update extends Action ) ], )) + // TODO: Split into more toggles, simiplar to membership privacy policy ->param('enabled', null, new Boolean(), 'Toggle password personal data policy. Set to true if you want to block passwords including user\'s personal data, or false to allow it. When changing this policy, existing passwords remain valid.') ->inject('response') ->inject('dbForPlatform') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php new file mode 100644 index 0000000000..fe9a0dac0b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-alert') + ->httpAlias('/v1/projects/:projectId/auth/session-alerts') + ->desc('Update session alert policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.session-alert.update') + ->label('audits.event', 'policies.session-alert.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionAlertPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle session alert policy. Set to true if you want users to receive email notifications when a sessions are created for their users, or false to not send email alerts.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['sessionAlerts'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index e190b5a719..363c99dc1f 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -120,6 +120,8 @@ class Create extends Action 'membershipsUserName' => false, 'membershipsUserEmail' => false, 'membershipsMfa' => false, + 'membershipsUserId' => false, + 'membershipsUserPhone' => false, 'invalidateSessions' => true ]; diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php index f3fd9a4bb9..9cd784e5d0 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php @@ -71,9 +71,11 @@ class Get extends Action } $membershipsPrivacy = [ - 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, - 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, - 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true, + 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? false, + 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? false, + 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? false, + 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? false, + 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? false, ]; $roles = $authorization->getRoles(); @@ -113,6 +115,14 @@ class Get extends Action $membership->setAttribute('userEmail', $memberUser->getAttribute('email')); } + if ($membershipsPrivacy['userId']) { + $membership->setAttribute('userId', $memberUser->getId()); + } + + if ($membershipsPrivacy['userPhone']) { + $membership->setAttribute('userPhone', $memberUser->getAttribute('phone')); + } + $membership->setAttribute('teamName', $team->getAttribute('name')); $response->dynamic($membership, Response::MODEL_MEMBERSHIP); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php index 364f92e1c5..ca18ed4920 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php @@ -124,9 +124,11 @@ class XList extends Action $memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId'))); $membershipsPrivacy = [ - 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, - 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, - 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true, + 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? false, + 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? false, + 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? false, + 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? false, + 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? false, ]; $roles = $authorization->getRoles(); @@ -167,6 +169,14 @@ class XList extends Action $membership->setAttribute('userEmail', $memberUser->getAttribute('email')); } + if ($membershipsPrivacy['userId']) { + $membership->setAttribute('userId', $memberUser->getId()); + } + + if ($membershipsPrivacy['userPhone']) { + $membership->setAttribute('userPhone', $memberUser->getAttribute('phone')); + } + $membership->setAttribute('teamName', $team->getAttribute('name')); return $membership; diff --git a/src/Appwrite/Utopia/Response/Model/Membership.php b/src/Appwrite/Utopia/Response/Model/Membership.php index 46153842bc..9be7102145 100644 --- a/src/Appwrite/Utopia/Response/Model/Membership.php +++ b/src/Appwrite/Utopia/Response/Model/Membership.php @@ -46,6 +46,12 @@ class Membership extends Model 'default' => '', 'example' => 'john@appwrite.io', ]) + ->addRule('userPhone', [ + 'type' => self::TYPE_STRING, + 'description' => 'User phone number. Hide this attribute by toggling membership privacy in the Console.', + 'default' => '', + 'example' => '+1 555 555 5555', + ]) ->addRule('teamId', [ 'type' => self::TYPE_STRING, 'description' => 'Team ID.', diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 4cb038fc37..ea219d6047 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -181,6 +181,18 @@ class Project extends Model 'default' => false, 'example' => true, ]) + ->addRule('authMembershipsUserId', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not to show user IDs in the teams membership response.', + 'default' => false, + 'example' => true, + ]) + ->addRule('authMembershipsUserPhone', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not to show user phone numbers in the teams membership response.', + 'default' => false, + 'example' => true, + ]) ->addRule('authInvalidateSessions', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Whether or not all existing sessions should be invalidated on password change', @@ -475,6 +487,8 @@ class Project extends Model $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? true); $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? true); $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? true); + $document->setAttribute('authMembershipsUserId', $authValues['membershipsUserId'] ?? true); + $document->setAttribute('authMembershipsUserPhone', $authValues['membershipsUserPhone'] ?? true); $document->setAttribute('authInvalidateSessions', $authValues['invalidateSessions'] ?? false); foreach ($auth as $method) { From 9c65609d730c549fcbd84fac0b903d6d6e7f6a10 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 17:13:00 +0530 Subject: [PATCH 144/402] Check benchmark summary writes --- tests/benchmarks/http.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/benchmarks/http.php b/tests/benchmarks/http.php index c44677c4ec..73fa6ea9ec 100644 --- a/tests/benchmarks/http.php +++ b/tests/benchmarks/http.php @@ -1193,11 +1193,18 @@ final class HttpBenchmark private function writeSummary(array $summary): void { $directory = dirname($this->summaryPath); - if ($directory !== '.' && !is_dir($directory)) { - mkdir($directory, 0777, true); + if ($directory !== '.' && !is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { + throw new RuntimeException("Unable to create benchmark summary directory: {$directory}"); } - file_put_contents($this->summaryPath, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + $json = json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Unable to encode benchmark summary: ' . json_last_error_msg()); + } + + if (file_put_contents($this->summaryPath, $json) === false) { + throw new RuntimeException("Unable to write benchmark summary: {$this->summaryPath}"); + } } private function renderSummary(array $summary): string From b9d01617a42f01b7b2c758aa55b07164d57f5f6f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 17:24:26 +0530 Subject: [PATCH 145/402] Address benchmark review hardening --- .github/workflows/ci.yml | 23 +++++++++++++++++++++-- tests/benchmarks/http.php | 18 ++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 403160a8aa..5db20064dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -750,10 +750,27 @@ jobs: run: docker compose down -v - name: Prepare comment + env: + BENCHMARK_BASE_REF: ${{ github.event.pull_request.base.ref }} + BENCHMARK_HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | - docker run --rm -i -v "$PWD:/scripts" -w /scripts ${{ env.IMAGE }}:after php <<'PHP' > benchmark-comment.txt + docker run --rm -i -v "$PWD:/scripts" -w /scripts \ + -e BENCHMARK_BASE_REF \ + -e BENCHMARK_HEAD_REF \ + ${{ env.IMAGE }}:after php <<'PHP' > benchmark-comment.txt \n"; echo "## :sparkles: Benchmark results\n\n"; - echo 'Comparing `${{ github.event.pull_request.base.ref }}` (before) to `${{ github.event.pull_request.head.ref }}` (after).' . "\n\n"; + echo "Comparing {$baseRef} (before) to {$headRef} (after).\n\n"; if ($before === null) { echo "> Before benchmark did not complete; showing current branch metrics only.\n\n"; } diff --git a/tests/benchmarks/http.php b/tests/benchmarks/http.php index 73fa6ea9ec..c71991a062 100644 --- a/tests/benchmarks/http.php +++ b/tests/benchmarks/http.php @@ -302,12 +302,22 @@ final class HttpBenchmark fwrite(STDERR, $error->getMessage() . PHP_EOL); } finally { if (is_array($context)) { - $this->teardown($context); + try { + $this->teardown($context); + } catch (Throwable $error) { + $exitCode = 1; + fwrite(STDERR, 'Teardown failed: ' . $error->getMessage() . PHP_EOL); + } } $summary = $this->metrics->summary(); - $this->writeSummary($summary); echo $this->renderSummary($summary); + try { + $this->writeSummary($summary); + } catch (Throwable $error) { + $exitCode = 1; + fwrite(STDERR, $error->getMessage() . PHP_EOL); + } } if ($this->metrics->failedChecks() > 0 || $this->metrics->flowFailures() > 0) { @@ -586,6 +596,10 @@ final class HttpBenchmark private function tablesDbFlow(array $context): void { + if (!isset($context['sessionHeaders']) || !is_array($context['sessionHeaders'])) { + throw new RuntimeException('accountFlow must run before tablesDbFlow'); + } + $databaseId = $this->unique('tdb'); $tableId = $this->unique('tbl'); $rowId = $this->unique('row'); From 5f9dc0fcd8604c1195a77937c9b96f7f43e2244b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 13:58:36 +0200 Subject: [PATCH 146/402] Req & res filters, review fixes --- app/config/roles.php | 1 + app/config/scopes/project.php | 4 ++ app/controllers/general.php | 8 ++++ app/init/constants.php | 4 +- src/Appwrite/Migration/Migration.php | 1 + .../Policies/MembershipPrivacy/Update.php | 40 ++++++++++++------- .../Policies/PasswordHistory/Update.php | 4 +- .../Project/Policies/SessionLimit/Update.php | 4 +- .../Project/Policies/UserLimit/Update.php | 4 +- .../Modules/Project/Services/Http.php | 20 ++++++++++ src/Appwrite/Platform/Workers/Migrations.php | 1 + src/Appwrite/Utopia/Request/Filters/V23.php | 28 +++++++++++++ src/Appwrite/Utopia/Response/Filters/V23.php | 36 +++++++++++++++++ .../Utopia/Response/Model/Project.php | 10 ++--- tests/e2e/Scopes/ProjectCustom.php | 1 + 15 files changed, 138 insertions(+), 28 deletions(-) create mode 100644 src/Appwrite/Utopia/Request/Filters/V23.php create mode 100644 src/Appwrite/Utopia/Response/Filters/V23.php diff --git a/app/config/roles.php b/app/config/roles.php index 116e8ac932..2eaa81ce49 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -55,6 +55,7 @@ $admins = [ 'tables.write', 'platforms.read', 'platforms.write', + 'policies.write', 'projects.write', 'keys.read', 'keys.write', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 6c7f75c08e..74c14ea933 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -204,4 +204,8 @@ return [ // List of publicly visible scopes "description" => "Access to create, update, and delete project\'s platforms", ], + "policies.write" => [ + "description" => + "Access to update project\'s policies", + ], ]; diff --git a/app/controllers/general.php b/app/controllers/general.php index b4f4a5c1d1..596fbd0926 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -26,6 +26,7 @@ use Appwrite\Utopia\Request\Filters\V19 as RequestV19; use Appwrite\Utopia\Request\Filters\V20 as RequestV20; use Appwrite\Utopia\Request\Filters\V21 as RequestV21; use Appwrite\Utopia\Request\Filters\V22 as RequestV22; +use Appwrite\Utopia\Request\Filters\V23 as RequestV23; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filters\V16 as ResponseV16; use Appwrite\Utopia\Response\Filters\V17 as ResponseV17; @@ -34,6 +35,7 @@ use Appwrite\Utopia\Response\Filters\V19 as ResponseV19; use Appwrite\Utopia\Response\Filters\V20 as ResponseV20; use Appwrite\Utopia\Response\Filters\V21 as ResponseV21; use Appwrite\Utopia\Response\Filters\V22 as ResponseV22; +use Appwrite\Utopia\Response\Filters\V23 as ResponseV23; use Appwrite\Utopia\View; use Executor\Executor; use MaxMind\Db\Reader; @@ -897,6 +899,9 @@ Http::init() if (version_compare($requestFormat, '1.9.1', '<')) { $request->addFilter(new RequestV22()); } + if (version_compare($requestFormat, '1.9.2', '<')) { + $request->addFilter(new RequestV23()); + } } $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); @@ -921,6 +926,9 @@ Http::init() */ $responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); if ($responseFormat) { + if (version_compare($responseFormat, '1.9.2', '<')) { + $response->addFilter(new ResponseV23()); + } if (version_compare($responseFormat, '1.9.1', '<')) { $response->addFilter(new ResponseV22()); } diff --git a/app/init/constants.php b/app/init/constants.php index 443aaaa680..8503032266 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -44,8 +44,8 @@ const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours -const APP_CACHE_BUSTER = 4322; -const APP_VERSION_STABLE = '1.9.1'; +const APP_CACHE_BUSTER = 4323; +const APP_VERSION_STABLE = '1.9.2'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; const APP_DATABASE_ATTRIBUTE_IP = 'ip'; diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index a01031de9b..ef0dd9f8b5 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -94,6 +94,7 @@ abstract class Migration '1.8.1' => 'V23', '1.9.0' => 'V24', '1.9.1' => 'V24', + '1.9.2' => 'V24', ]; /** diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php index 2b0b0432cc..fcaf44aff4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php @@ -49,11 +49,11 @@ class Update extends Action ) ], )) - ->param('userId', null, new Boolean(), 'Set to true if you want make user ID visible to all team members, or false to hide it.') - ->param('userEmail', null, new Boolean(), 'Set to true if you want make user email visible to all team members, or false to hide it.') - ->param('userPhone', null, new Boolean(), 'Set to true if you want make user phone number visible to all team members, or false to hide it.') - ->param('userName', null, new Boolean(), 'Set to true if you want make user name visible to all team members, or false to hide it.') - ->param('userMFA', null, new Boolean(), 'Set to true if you want make user MFA status visible to all team members, or false to hide it.') + ->param('userId', null, new Boolean(), 'Set to true if you want make user ID visible to all team members, or false to hide it.', optional: true) + ->param('userEmail', null, new Boolean(), 'Set to true if you want make user email visible to all team members, or false to hide it.', optional: true) + ->param('userPhone', null, new Boolean(), 'Set to true if you want make user phone number visible to all team members, or false to hide it.', optional: true) + ->param('userName', null, new Boolean(), 'Set to true if you want make user name visible to all team members, or false to hide it.', optional: true) + ->param('userMFA', null, new Boolean(), 'Set to true if you want make user MFA status visible to all team members, or false to hide it.', optional: true) ->inject('response') ->inject('dbForPlatform') ->inject('project') @@ -62,11 +62,11 @@ class Update extends Action } public function action( - bool $userId, - bool $userEmail, - bool $userPhone, - bool $userName, - bool $userMFA, + ?bool $userId, + ?bool $userEmail, + ?bool $userPhone, + ?bool $userName, + ?bool $userMFA, Response $response, Database $dbForPlatform, Document $project, @@ -74,11 +74,21 @@ class Update extends Action ): void { $auths = $project->getAttribute('auths', []); - $auths['membershipsUserName'] = $userName; - $auths['membershipsUserEmail'] = $userEmail; - $auths['membershipsMfa'] = $userMFA; - $auths['membershipsUserId'] = $userId; - $auths['membershipsUserPhone'] = $userPhone; + if ($userId !== null) { + $auths['membershipsUserId'] = $userId; + } + if ($userEmail !== null) { + $auths['membershipsUserEmail'] = $userEmail; + } + if ($userPhone !== null) { + $auths['membershipsUserPhone'] = $userPhone; + } + if ($userName !== null) { + $auths['membershipsUserName'] = $userName; + } + if ($userMFA !== null) { + $auths['membershipsMfa'] = $userMFA; + } $updates = new Document([ 'auths' => $auths, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php index 437cc29b33..012f3e11f0 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php @@ -50,7 +50,7 @@ class Update extends Action ) ], )) - ->param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT), 'Set the password history length per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')) + ->param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the password history length per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.') ->inject('response') ->inject('dbForPlatform') ->inject('project') @@ -59,7 +59,7 @@ class Update extends Action } public function action( - int $total, + ?int $total, Response $response, Database $dbForPlatform, Document $project, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php index 3860b7ccc4..407e7e43a6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php @@ -50,7 +50,7 @@ class Update extends Action ) ], )) - ->param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT), 'Set the maximum number of sessions allowed per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')) + ->param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the maximum number of sessions allowed per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.') ->inject('response') ->inject('dbForPlatform') ->inject('project') @@ -59,7 +59,7 @@ class Update extends Action } public function action( - int $total, + ?int $total, Response $response, Database $dbForPlatform, Document $project, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php index 6b21a22138..6b614fdedc 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php @@ -50,7 +50,7 @@ class Update extends Action ) ], )) - ->param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT), 'Set the maximum number of users allowed in the project. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')) + ->param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the maximum number of users allowed in the project. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.') ->inject('response') ->inject('dbForPlatform') ->inject('project') @@ -59,7 +59,7 @@ class Update extends Action } public function action( - int $total, + ?int $total, Response $response, Database $dbForPlatform, Document $project, diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index bcab75a8c5..e70f495bb5 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -22,6 +22,15 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as Updat use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as CreateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\MembershipPrivacy\Update as UpdateMembershipPrivacyPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordDictionary\Update as UpdatePasswordDictionaryPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordHistory\Update as UpdatePasswordHistoryPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordPersonalData\Update as UpdatePasswordPersonalDataPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionAlert\Update as UpdateSessionAlertPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionDuration\Update as UpdateSessionDurationPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionInvalidation\Update as UpdateSessionInvalidationPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionLimit\Update as UpdateSessionLimitPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\UserLimit\Update as UpdateUserLimitPolicy; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdateProjectProtocol; use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; @@ -73,5 +82,16 @@ class Http extends Service $this->addAction(CreateLinuxPlatform::getName(), new CreateLinuxPlatform()); $this->addAction(GetPlatform::getName(), new GetPlatform()); $this->addAction(ListPlatforms::getName(), new ListPlatforms()); + + // Policies + $this->addAction(UpdateMembershipPrivacyPolicy::getName(), new UpdateMembershipPrivacyPolicy()); + $this->addAction(UpdatePasswordDictionaryPolicy::getName(), new UpdatePasswordDictionaryPolicy()); + $this->addAction(UpdatePasswordHistoryPolicy::getName(), new UpdatePasswordHistoryPolicy()); + $this->addAction(UpdatePasswordPersonalDataPolicy::getName(), new UpdatePasswordPersonalDataPolicy()); + $this->addAction(UpdateSessionAlertPolicy::getName(), new UpdateSessionAlertPolicy()); + $this->addAction(UpdateSessionDurationPolicy::getName(), new UpdateSessionDurationPolicy()); + $this->addAction(UpdateSessionInvalidationPolicy::getName(), new UpdateSessionInvalidationPolicy()); + $this->addAction(UpdateSessionLimitPolicy::getName(), new UpdateSessionLimitPolicy()); + $this->addAction(UpdateUserLimitPolicy::getName(), new UpdateUserLimitPolicy()); } } diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 339084727d..e19d705553 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -392,6 +392,7 @@ class Migrations extends Action 'keys.write', 'platforms.read', 'platforms.write', + 'policies.write', ] ]); diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php new file mode 100644 index 0000000000..8e92f9c13a --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -0,0 +1,28 @@ +parseUpdateMembershipPrivacyPolicy($content); + break; + } + + return $content; + } + + protected function parseUpdateMembershipPrivacyPolicy(array $content): array + { + $content['userId'] = false; + $content['userPhone'] = false; + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Filters/V23.php b/src/Appwrite/Utopia/Response/Filters/V23.php new file mode 100644 index 0000000000..ccceb13f44 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V23.php @@ -0,0 +1,36 @@ + $this->parseMembership($content), + Response::MODEL_MEMBERSHIP_LIST => $this->handleList($content, 'memberships', fn ($item) => $this->parseMembership($item)), + Response::MODEL_PROJECT => $this->parseProject($content), + Response::MODEL_PROJECT_LIST => $this->handleList($content, 'projects', fn ($item) => $this->parseProject($item)), + default => $content, + }; + } + + private function parseMembership(array $content): array + { + unset($content['userPhone']); + + return $content; + } + + private function parseProject(array $content): array + { + unset($content['authMembershipsUserId']); + unset($content['authMembershipsUserPhone']); + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index ea219d6047..75d92ac013 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -484,11 +484,11 @@ class Project extends Model $document->setAttribute('authFreeEmails', $authValues['freeEmails'] ?? false); $document->setAttribute('authMockNumbers', $authValues['mockNumbers'] ?? []); $document->setAttribute('authSessionAlerts', $authValues['sessionAlerts'] ?? false); - $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? true); - $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? true); - $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? true); - $document->setAttribute('authMembershipsUserId', $authValues['membershipsUserId'] ?? true); - $document->setAttribute('authMembershipsUserPhone', $authValues['membershipsUserPhone'] ?? true); + $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? false); + $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? false); + $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? false); + $document->setAttribute('authMembershipsUserId', $authValues['membershipsUserId'] ?? false); + $document->setAttribute('authMembershipsUserPhone', $authValues['membershipsUserPhone'] ?? false); $document->setAttribute('authInvalidateSessions', $authValues['invalidateSessions'] ?? false); foreach ($auth as $method) { diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index a62a1e8ba3..d0b9ef4b4f 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -169,6 +169,7 @@ trait ProjectCustom 'keys.write', 'platforms.read', 'platforms.write', + 'policies.write', ], ]); From 6adabae62090acd41155f6330486ac4daee423d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 14:05:46 +0200 Subject: [PATCH 147/402] Add policy tests --- tests/e2e/Services/Project/PoliciesBase.php | 825 ++++++++++++++++++ .../Project/PoliciesConsoleClientTest.php | 14 + .../Project/PoliciesCustomServerTest.php | 14 + 3 files changed, 853 insertions(+) create mode 100644 tests/e2e/Services/Project/PoliciesBase.php create mode 100644 tests/e2e/Services/Project/PoliciesConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/PoliciesCustomServerTest.php diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php new file mode 100644 index 0000000000..666ca55fd6 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesBase.php @@ -0,0 +1,825 @@ +updatePasswordDictionaryPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authPasswordDictionary']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authPasswordDictionary']); + + // Cleanup + $this->updatePasswordDictionaryPolicy(false); + } + + public function testUpdatePasswordDictionaryPolicyDisable(): void + { + $this->updatePasswordDictionaryPolicy(true); + + $response = $this->updatePasswordDictionaryPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authPasswordDictionary']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authPasswordDictionary']); + } + + public function testUpdatePasswordDictionaryPolicyIdempotent(): void + { + $first = $this->updatePasswordDictionaryPolicy(true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['authPasswordDictionary']); + + $second = $this->updatePasswordDictionaryPolicy(true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['authPasswordDictionary']); + + // Cleanup + $this->updatePasswordDictionaryPolicy(false); + } + + public function testUpdatePasswordDictionaryPolicyWithoutAuth(): void + { + $response = $this->updatePasswordDictionaryPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdatePasswordDictionaryPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordDictionaryPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // ========================================================================= + // Password History Policy + // ========================================================================= + + public function testUpdatePasswordHistoryPolicyEnable(): void + { + $response = $this->updatePasswordHistoryPolicy(5); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(5, $response['body']['authPasswordHistory']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(5, $project['body']['authPasswordHistory']); + + // Cleanup (disable by setting total to null which maps to 0) + $this->updatePasswordHistoryPolicy(null); + } + + public function testUpdatePasswordHistoryPolicyMin(): void + { + $response = $this->updatePasswordHistoryPolicy(1); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(1, $response['body']['authPasswordHistory']); + + // Cleanup + $this->updatePasswordHistoryPolicy(null); + } + + public function testUpdatePasswordHistoryPolicyMax(): void + { + $response = $this->updatePasswordHistoryPolicy(5000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5000, $response['body']['authPasswordHistory']); + + // Cleanup + $this->updatePasswordHistoryPolicy(null); + } + + public function testUpdatePasswordHistoryPolicyDisable(): void + { + $this->updatePasswordHistoryPolicy(5); + + $response = $this->updatePasswordHistoryPolicy(null); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authPasswordHistory']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(0, $project['body']['authPasswordHistory']); + } + + public function testUpdatePasswordHistoryPolicyBelowMin(): void + { + $response = $this->updatePasswordHistoryPolicy(0); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyAboveMax(): void + { + $response = $this->updatePasswordHistoryPolicy(5001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders(), [ + 'total' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyWithoutAuth(): void + { + $response = $this->updatePasswordHistoryPolicy(5, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Password Personal Data Policy + // ========================================================================= + + public function testUpdatePasswordPersonalDataPolicyEnable(): void + { + $response = $this->updatePasswordPersonalDataPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authPersonalDataCheck']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authPersonalDataCheck']); + + // Cleanup + $this->updatePasswordPersonalDataPolicy(false); + } + + public function testUpdatePasswordPersonalDataPolicyDisable(): void + { + $this->updatePasswordPersonalDataPolicy(true); + + $response = $this->updatePasswordPersonalDataPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authPersonalDataCheck']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authPersonalDataCheck']); + } + + public function testUpdatePasswordPersonalDataPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordPersonalDataPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordPersonalDataPolicyWithoutAuth(): void + { + $response = $this->updatePasswordPersonalDataPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Alert Policy + // ========================================================================= + + public function testUpdateSessionAlertPolicyEnable(): void + { + $response = $this->updateSessionAlertPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authSessionAlerts']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authSessionAlerts']); + + // Cleanup + $this->updateSessionAlertPolicy(false); + } + + public function testUpdateSessionAlertPolicyDisable(): void + { + $this->updateSessionAlertPolicy(true); + + $response = $this->updateSessionAlertPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authSessionAlerts']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authSessionAlerts']); + } + + public function testUpdateSessionAlertPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionAlertPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionAlertPolicyWithoutAuth(): void + { + $response = $this->updateSessionAlertPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Duration Policy + // ========================================================================= + + public function testUpdateSessionDurationPolicy(): void + { + $response = $this->updateSessionDurationPolicy(3600); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(3600, $response['body']['authDuration']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(3600, $project['body']['authDuration']); + + // Cleanup (reset to default 1 year) + $this->updateSessionDurationPolicy(31536000); + } + + public function testUpdateSessionDurationPolicyMin(): void + { + $response = $this->updateSessionDurationPolicy(60); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(60, $response['body']['authDuration']); + + // Cleanup + $this->updateSessionDurationPolicy(31536000); + } + + public function testUpdateSessionDurationPolicyMax(): void + { + $response = $this->updateSessionDurationPolicy(31536000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(31536000, $response['body']['authDuration']); + } + + public function testUpdateSessionDurationPolicyBelowMin(): void + { + $response = $this->updateSessionDurationPolicy(59); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyAboveMax(): void + { + $response = $this->updateSessionDurationPolicy(31536001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders(), [ + 'duration' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyWithoutAuth(): void + { + $response = $this->updateSessionDurationPolicy(3600, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Invalidation Policy + // ========================================================================= + + public function testUpdateSessionInvalidationPolicyEnable(): void + { + $response = $this->updateSessionInvalidationPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authInvalidateSessions']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authInvalidateSessions']); + + // Cleanup + $this->updateSessionInvalidationPolicy(false); + } + + public function testUpdateSessionInvalidationPolicyDisable(): void + { + $this->updateSessionInvalidationPolicy(true); + + $response = $this->updateSessionInvalidationPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authInvalidateSessions']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authInvalidateSessions']); + } + + public function testUpdateSessionInvalidationPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionInvalidationPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionInvalidationPolicyWithoutAuth(): void + { + $response = $this->updateSessionInvalidationPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Limit Policy + // ========================================================================= + + public function testUpdateSessionLimitPolicy(): void + { + $response = $this->updateSessionLimitPolicy(5); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(5, $response['body']['authSessionsLimit']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(5, $project['body']['authSessionsLimit']); + + // Cleanup (reset to default) + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyMin(): void + { + $response = $this->updateSessionLimitPolicy(1); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(1, $response['body']['authSessionsLimit']); + + // Cleanup + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyMax(): void + { + $response = $this->updateSessionLimitPolicy(5000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5000, $response['body']['authSessionsLimit']); + + // Cleanup + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyDisable(): void + { + $response = $this->updateSessionLimitPolicy(null); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authSessionsLimit']); + + // Cleanup + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyBelowMin(): void + { + $response = $this->updateSessionLimitPolicy(0); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyAboveMax(): void + { + $response = $this->updateSessionLimitPolicy(5001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders(), [ + 'total' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyWithoutAuth(): void + { + $response = $this->updateSessionLimitPolicy(5, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // User Limit Policy + // ========================================================================= + + public function testUpdateUserLimitPolicy(): void + { + $response = $this->updateUserLimitPolicy(100); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(100, $response['body']['authLimit']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(100, $project['body']['authLimit']); + + // Cleanup + $this->updateUserLimitPolicy(null); + } + + public function testUpdateUserLimitPolicyMin(): void + { + $response = $this->updateUserLimitPolicy(1); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(1, $response['body']['authLimit']); + + // Cleanup + $this->updateUserLimitPolicy(null); + } + + public function testUpdateUserLimitPolicyMax(): void + { + $response = $this->updateUserLimitPolicy(5000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5000, $response['body']['authLimit']); + + // Cleanup + $this->updateUserLimitPolicy(null); + } + + public function testUpdateUserLimitPolicyDisable(): void + { + $this->updateUserLimitPolicy(100); + + $response = $this->updateUserLimitPolicy(null); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authLimit']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(0, $project['body']['authLimit']); + } + + public function testUpdateUserLimitPolicyBelowMin(): void + { + $response = $this->updateUserLimitPolicy(0); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyAboveMax(): void + { + $response = $this->updateUserLimitPolicy(5001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders(), [ + 'total' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyWithoutAuth(): void + { + $response = $this->updateUserLimitPolicy(100, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Membership Privacy Policy + // ========================================================================= + + public function testUpdateMembershipPrivacyPolicyAllEnabled(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authMembershipsUserId']); + $this->assertSame(true, $response['body']['authMembershipsUserEmail']); + $this->assertSame(true, $response['body']['authMembershipsUserPhone']); + $this->assertSame(true, $response['body']['authMembershipsUserName']); + $this->assertSame(true, $response['body']['authMembershipsMfa']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authMembershipsUserId']); + $this->assertSame(true, $project['body']['authMembershipsUserEmail']); + $this->assertSame(true, $project['body']['authMembershipsUserPhone']); + $this->assertSame(true, $project['body']['authMembershipsUserName']); + $this->assertSame(true, $project['body']['authMembershipsMfa']); + } + + public function testUpdateMembershipPrivacyPolicyAllDisabled(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserEmail']); + $this->assertSame(false, $response['body']['authMembershipsUserPhone']); + $this->assertSame(false, $response['body']['authMembershipsUserName']); + $this->assertSame(false, $response['body']['authMembershipsMfa']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authMembershipsUserId']); + $this->assertSame(false, $project['body']['authMembershipsUserEmail']); + $this->assertSame(false, $project['body']['authMembershipsUserPhone']); + $this->assertSame(false, $project['body']['authMembershipsUserName']); + $this->assertSame(false, $project['body']['authMembershipsMfa']); + + // Cleanup (restore defaults) + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyMixed(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => false, + 'userPhone' => true, + 'userName' => false, + 'userMFA' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserEmail']); + $this->assertSame(true, $response['body']['authMembershipsUserPhone']); + $this->assertSame(false, $response['body']['authMembershipsUserName']); + $this->assertSame(true, $response['body']['authMembershipsMfa']); + + // Cleanup + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyMissingParam(): void + { + // Missing userMFA + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders(), [ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateMembershipPrivacyPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders(), [ + 'userId' => 'not-a-boolean', + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateMembershipPrivacyPolicyWithoutAuth(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ], false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + protected function buildHeaders(bool $authenticated = true): array + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $headers; + } + + protected function getProjectDocument(): array + { + return $this->client->call(Client::METHOD_GET, '/projects/' . $this->getProject()['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]); + } + + protected function updatePasswordDictionaryPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updatePasswordHistoryPolicy(?int $total, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders($authenticated), [ + 'total' => $total, + ]); + } + + protected function updatePasswordPersonalDataPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updateSessionAlertPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updateSessionDurationPolicy(int $duration, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders($authenticated), [ + 'duration' => $duration, + ]); + } + + protected function updateSessionInvalidationPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updateSessionLimitPolicy(?int $total, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders($authenticated), [ + 'total' => $total, + ]); + } + + protected function updateUserLimitPolicy(?int $total, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders($authenticated), [ + 'total' => $total, + ]); + } + + /** + * @param array $params + */ + protected function updateMembershipPrivacyPolicy(array $params, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders($authenticated), $params); + } +} diff --git a/tests/e2e/Services/Project/PoliciesConsoleClientTest.php b/tests/e2e/Services/Project/PoliciesConsoleClientTest.php new file mode 100644 index 0000000000..2db8e57a35 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesConsoleClientTest.php @@ -0,0 +1,14 @@ + Date: Tue, 21 Apr 2026 17:49:50 +0530 Subject: [PATCH 148/402] Polish benchmark comment details --- .github/workflows/ci.yml | 38 +++++++++++++++++++++++--------------- tests/benchmarks/http.php | 25 +++++++++++++++++-------- 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5db20064dc..9e1a79cb3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -837,17 +837,24 @@ jobs: return '| ' . $label . ' | ' . format_value($beforeValue, $suffix) . ' | ' . format_value($afterValue, $suffix) . ' | ' . delta($beforeValue, $afterValue, $suffix) . ' |'; } - function detail(array $after, string $label, string $metric, string $suffix = 'ms'): string + function format_detail_value(mixed $value, string $suffix = ''): string + { + return $value === null ? 'n/a' : number_format((float) $value, 2, '.', '') . $suffix; + } + + function detail_row(array $after, string $label, string $metric, string $suffix = 'ms'): string { $values = $after['metrics'][$metric]['values'] ?? null; if (!is_array($values)) { - return '- **' . $label . ':** no samples'; + return '| ' . $label . ' | n/a | n/a | n/a | n/a |'; } - return '- **' . $label . ':** avg=' . format_value($values['avg'] ?? null, $suffix) - . ' p90=' . format_value($values['p(90)'] ?? null, $suffix) - . ' p95=' . format_value($values['p(95)'] ?? null, $suffix) - . ' max=' . format_value($values['max'] ?? null, $suffix); + return '| ' . $label + . ' | ' . format_detail_value($values['avg'] ?? null, $suffix) + . ' | ' . format_detail_value($values['p(90)'] ?? null, $suffix) + . ' | ' . format_detail_value($values['p(95)'] ?? null, $suffix) + . ' | ' . format_detail_value($values['max'] ?? null, $suffix) + . ' |'; } $rows = [ @@ -857,8 +864,6 @@ jobs: row('TablesDB worker p95', metric_value($before, 'appwrite_worker_tables_duration', 'p(95)'), metric_value($after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'), row('Mail worker p95', metric_value($before, 'appwrite_worker_mails_duration', 'p(95)'), metric_value($after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'), row('Messaging worker p95', metric_value($before, 'appwrite_worker_messaging_duration', 'p(95)'), metric_value($after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'), - row('Flow failures', metric_value($before, 'appwrite_benchmark_flow_failures', 'count'), metric_value($after, 'appwrite_benchmark_flow_failures', 'count')), - row('Check failures', metric_value($before, 'checks', 'fails'), metric_value($after, 'checks', 'fails')), ]; echo "\n"; @@ -871,13 +876,16 @@ jobs: echo "| --- | ---: | ---: | ---: |\n"; echo implode("\n", $rows) . "\n\n"; echo "
\n"; - echo "Current run details\n\n"; - echo detail($after, 'HTTP total', 'http_req_duration') . "\n"; - echo detail($after, 'API endpoints', 'appwrite_api_duration') . "\n"; - echo detail($after, 'Database worker schema jobs', 'appwrite_worker_database_duration') . "\n"; - echo detail($after, 'TablesDB worker schema jobs', 'appwrite_worker_tables_duration') . "\n"; - echo detail($after, 'Mail worker delivery', 'appwrite_worker_mails_duration') . "\n"; - echo detail($after, 'Messaging worker delivery', 'appwrite_worker_messaging_duration') . "\n\n"; + echo "Current run details\n\n"; + echo "
\n\n"; + echo "| Scenario | Avg | P90 | P95 | Max |\n"; + echo "| --- | ---: | ---: | ---: | ---: |\n"; + echo detail_row($after, 'HTTP total', 'http_req_duration') . "\n"; + echo detail_row($after, 'API endpoints', 'appwrite_api_duration') . "\n"; + echo detail_row($after, 'Database worker schema jobs', 'appwrite_worker_database_duration') . "\n"; + echo detail_row($after, 'TablesDB worker schema jobs', 'appwrite_worker_tables_duration') . "\n"; + echo detail_row($after, 'Mail worker delivery', 'appwrite_worker_mails_duration') . "\n"; + echo detail_row($after, 'Messaging worker delivery', 'appwrite_worker_messaging_duration') . "\n\n"; echo "
\n"; PHP diff --git a/tests/benchmarks/http.php b/tests/benchmarks/http.php index c71991a062..7924351409 100644 --- a/tests/benchmarks/http.php +++ b/tests/benchmarks/http.php @@ -897,17 +897,17 @@ final class HttpBenchmark return $response; } - private function rawRequest(string $method, string $path, mixed $body, array $headers, string $name): BenchmarkResponse + private function rawRequest(string $method, string $path, mixed $body, array $headers, string $name, bool $recordHttpDuration = true): BenchmarkResponse { - return $this->send($method, str_starts_with($path, 'http') ? $path : $this->endpoint . $path, $body, $headers, $name, false); + return $this->send($method, str_starts_with($path, 'http') ? $path : $this->endpoint . $path, $body, $headers, $name, false, $recordHttpDuration); } private function rawMultipartRequest(string $method, string $path, array $fields, array $headers, string $name): BenchmarkResponse { - return $this->send($method, $this->endpoint . $path, $fields, $headers, $name, true); + return $this->send($method, $this->endpoint . $path, $fields, $headers, $name, true, true); } - private function send(string $method, string $url, mixed $body, array $headers, string $name, bool $multipart): BenchmarkResponse + private function send(string $method, string $url, mixed $body, array $headers, string $name, bool $multipart, bool $recordHttpDuration): BenchmarkResponse { $handle = curl_init($url); if ($handle === false) { @@ -936,7 +936,9 @@ final class HttpBenchmark $started = hrtime(true); $raw = curl_exec($handle); $duration = (hrtime(true) - $started) / 1_000_000; - $this->metrics->addTrend('http_req_duration', $duration); + if ($recordHttpDuration) { + $this->metrics->addTrend('http_req_duration', $duration); + } if ($raw === false) { $error = curl_error($handle); @@ -960,8 +962,15 @@ final class HttpBenchmark while ($this->nowMs() - $started < $timeoutMs) { $response = $this->rawRequest('GET', $path, null, $headers, "wait{$path}"); - if ($response->status === 200 && $response->json('status') === $wantedStatus) { - return $response; + if ($response->status === 200) { + $status = $response->json('status'); + if ($status === $wantedStatus) { + return $response; + } + + if ($status === 'failed') { + throw new RuntimeException("Resource {$path} failed while waiting for {$wantedStatus}"); + } } usleep(500_000); @@ -997,7 +1006,7 @@ final class HttpBenchmark $started = $this->nowMs(); while ($this->nowMs() - $started < $timeoutMs) { - $response = $this->rawRequest('GET', $this->maildevEndpoint, null, [], 'maildev.email.list'); + $response = $this->rawRequest('GET', $this->maildevEndpoint, null, [], 'maildev.email.list', false); if ($response->status === 200) { $emails = $response->json(); From 30bf9deae0574154f1c7583d1f84cba04bfe9c5f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 17:54:36 +0530 Subject: [PATCH 149/402] Remove benchmark failure rows from output --- tests/benchmarks/http.php | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/tests/benchmarks/http.php b/tests/benchmarks/http.php index 7924351409..016fedb383 100644 --- a/tests/benchmarks/http.php +++ b/tests/benchmarks/http.php @@ -1247,7 +1247,6 @@ final class HttpBenchmark $this->metricLine($summary, 'appwrite_worker_tables_duration', 'TablesDB worker schema jobs'), $this->metricLine($summary, 'appwrite_worker_mails_duration', 'Mail worker delivery'), $this->metricLine($summary, 'appwrite_worker_messaging_duration', 'Messaging worker delivery'), - $this->counterLine($summary, 'appwrite_benchmark_flow_failures', 'Flow failures'), '', ]; @@ -1263,8 +1262,6 @@ final class HttpBenchmark ['TablesDB worker p95', $this->trendMetric($before, 'appwrite_worker_tables_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'], ['Mail worker p95', $this->trendMetric($before, 'appwrite_worker_mails_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'], ['Messaging worker p95', $this->trendMetric($before, 'appwrite_worker_messaging_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'], - ['Flow failures', $this->counterMetric($before, 'appwrite_benchmark_flow_failures'), $this->counterMetric($after, 'appwrite_benchmark_flow_failures'), ''], - ['Check failures', $this->checkFailures($before), $this->checkFailures($after), ''], ]; $table = [ @@ -1284,16 +1281,6 @@ final class HttpBenchmark return $data['metrics'][$metric]['values'][$stat] ?? null; } - private function counterMetric(?array $data, string $metric): ?float - { - return $data['metrics'][$metric]['values']['count'] ?? null; - } - - private function checkFailures(?array $data): ?float - { - return $data['metrics']['checks']['values']['fails'] ?? null; - } - private function metricLine(array $data, string $metric, string $label): string { $values = $data['metrics'][$metric]['values'] ?? null; @@ -1304,11 +1291,6 @@ final class HttpBenchmark return "{$label}: avg={$this->round($values['avg'])}ms p90={$this->round($values['p(90)'])}ms p95={$this->round($values['p(95)'])}ms max={$this->round($values['max'])}ms"; } - private function counterLine(array $data, string $metric, string $label): string - { - return "{$label}: " . ($data['metrics'][$metric]['values']['count'] ?? 0); - } - private function formatValue(?float $value, string $unit): string { return $value === null || is_nan($value) ? 'n/a' : $this->round($value) . $unit; From 211ac32080a78f8c20cc95bb05851028385db835 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 17:59:09 +0530 Subject: [PATCH 150/402] Guard benchmark account-dependent flows --- tests/benchmarks/http.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/benchmarks/http.php b/tests/benchmarks/http.php index 016fedb383..9bddf57327 100644 --- a/tests/benchmarks/http.php +++ b/tests/benchmarks/http.php @@ -658,6 +658,10 @@ final class HttpBenchmark private function storageFlow(array $context): void { + if (!isset($context['sessionHeaders']) || !is_array($context['sessionHeaders'])) { + throw new RuntimeException('accountFlow must run before storageFlow'); + } + $bucketId = $this->unique('bucket'); $fileId = $this->unique('file'); @@ -720,6 +724,13 @@ final class HttpBenchmark private function messagingFlow(array $context): void { + if ( + !isset($context['sessionHeaders']) || !is_array($context['sessionHeaders']) + || !isset($context['userId'], $context['userEmail']) + ) { + throw new RuntimeException('accountFlow must run before messagingFlow'); + } + $providerId = $this->unique('smtp'); $targetId = $this->unique('target'); $existingTarget = false; @@ -806,6 +817,10 @@ final class HttpBenchmark private function computeFlow(array $context): void { + if (!isset($context['sessionHeaders']) || !is_array($context['sessionHeaders'])) { + throw new RuntimeException('accountFlow must run before computeFlow'); + } + $functionId = $this->unique('fn'); $siteId = $this->unique('site'); $runtime = $this->env('APPWRITE_BENCHMARK_RUNTIME', 'node-22'); From 4ac1b68bbc9a7ab325169699e3dda23cc0a3dd6f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 21 Apr 2026 18:03:18 +0530 Subject: [PATCH 151/402] Fix OpenAPI enum keys analysis --- src/Appwrite/SDK/Specification/Format/OpenAPI3.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index bcb5a5486c..66c2cd7c1c 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -771,7 +771,7 @@ class OpenAPI3 extends Format /// If the enum flag is Set, add the enum values to the body $body['content'][$consumes[0]]['schema']['properties'][$name]['enum'] = $node['schema']['enum']; $body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-name'] = $node['schema']['x-enum-name'] ?? null; - $body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-keys'] = $node['schema']['x-enum-keys'] ?? null; + $body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-keys'] = $node['schema']['x-enum-keys']; } if ($node['schema']['x-upload-id'] ?? false) { From 99d230c70d43c1baf427cf6f038b1c8ecc122090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 14:39:02 +0200 Subject: [PATCH 152/402] Integration tests --- .../Policies/PasswordHistory/Update.php | 2 + ...liciesMembershipPrivacyIntegrationTest.php | 176 ++++++++++++++++++ ...iciesPasswordDictionaryIntegrationTest.php | 68 +++++++ ...PoliciesPasswordHistoryIntegrationTest.php | 152 +++++++++++++++ .../PoliciesSessionLimitIntegrationTest.php | 121 ++++++++++++ .../PoliciesUserLimitIntegrationTest.php | 87 +++++++++ 6 files changed, 606 insertions(+) create mode 100644 tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php index 012f3e11f0..a1aea6b0a4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php @@ -41,6 +41,8 @@ class Update extends Action name: 'updatePasswordHistoryPolicy', description: <<getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // Step 1: Configure privacy to false + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $serverHeaders, [ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertFalse($response['body']['authMembershipsUserId']); + $this->assertFalse($response['body']['authMembershipsUserEmail']); + $this->assertFalse($response['body']['authMembershipsUserPhone']); + $this->assertFalse($response['body']['authMembershipsUserName']); + $this->assertFalse($response['body']['authMembershipsMfa']); + + // Step 2: Setup two users + $user1Email = 'user1_' . uniqid() . '@localhost.test'; + $user1Name = 'Alice Anderson'; + $user1Phone = '+12025550101'; + $password = 'password1234'; + + $user1 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $user1Email, + 'password' => $password, + 'name' => $user1Name, + ]); + $this->assertSame(201, $user1['headers']['status-code']); + $user1Id = $user1['body']['$id']; + + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $user1Id . '/phone', $serverHeaders, [ + 'number' => $user1Phone, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + $user2Email = 'user2_' . uniqid() . '@localhost.test'; + $user2Name = 'Bob Baker'; + $user2Phone = '+12025550102'; + + $user2 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $user2Email, + 'password' => $password, + 'name' => $user2Name, + ]); + $this->assertSame(201, $user2['headers']['status-code']); + $user2Id = $user2['body']['$id']; + + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $user2Id . '/phone', $serverHeaders, [ + 'number' => $user2Phone, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Step 3: Create team and add both users as members + $team = $this->client->call(Client::METHOD_POST, '/teams', $serverHeaders, [ + 'teamId' => ID::unique(), + 'name' => 'Privacy Team', + 'roles' => ['member'], + ]); + $this->assertSame(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; + + $membership1 = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $serverHeaders, [ + 'userId' => $user1Id, + 'roles' => ['member'], + ]); + $this->assertSame(201, $membership1['headers']['status-code']); + $this->assertTrue($membership1['body']['confirm']); + + $membership2 = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $serverHeaders, [ + 'userId' => $user2Id, + 'roles' => ['member'], + ]); + $this->assertSame(201, $membership2['headers']['status-code']); + $this->assertTrue($membership2['body']['confirm']); + + // Step 4: Sign in as user1 and list memberships with privacy disabled + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $user1Email, + 'password' => $password, + ]); + $this->assertSame(201, $session['headers']['status-code']); + $user1Session = $session['cookies']['a_session_' . $projectId]; + + $clientHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $user1Session, + ]; + + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamId . '/memberships', $clientHeaders); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(2, $response['body']['total']); + $this->assertCount(2, $response['body']['memberships']); + + foreach ($response['body']['memberships'] as $membership) { + $this->assertSame('', $membership['userName']); + $this->assertSame('', $membership['userEmail']); + $this->assertSame('', $membership['userPhone']); + $this->assertSame('', $membership['userId']); + $this->assertFalse($membership['mfa']); + } + + // Step 5: Update privacy to true + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $serverHeaders, [ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['authMembershipsUserId']); + $this->assertTrue($response['body']['authMembershipsUserEmail']); + $this->assertTrue($response['body']['authMembershipsUserPhone']); + $this->assertTrue($response['body']['authMembershipsUserName']); + $this->assertTrue($response['body']['authMembershipsMfa']); + + // Step 6: List memberships with privacy enabled - user details exposed + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamId . '/memberships', $clientHeaders); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(2, $response['body']['total']); + $this->assertCount(2, $response['body']['memberships']); + + $membershipsByUser = []; + foreach ($response['body']['memberships'] as $membership) { + $membershipsByUser[$membership['userId']] = $membership; + } + + $this->assertArrayHasKey($user1Id, $membershipsByUser); + $this->assertSame($user1Id, $membershipsByUser[$user1Id]['userId']); + $this->assertSame($user1Name, $membershipsByUser[$user1Id]['userName']); + $this->assertSame($user1Email, $membershipsByUser[$user1Id]['userEmail']); + $this->assertSame($user1Phone, $membershipsByUser[$user1Id]['userPhone']); + $this->assertFalse($membershipsByUser[$user1Id]['mfa']); + + $this->assertArrayHasKey($user2Id, $membershipsByUser); + $this->assertSame($user2Id, $membershipsByUser[$user2Id]['userId']); + $this->assertSame($user2Name, $membershipsByUser[$user2Id]['userName']); + $this->assertSame($user2Email, $membershipsByUser[$user2Id]['userEmail']); + $this->assertSame($user2Phone, $membershipsByUser[$user2Id]['userPhone']); + $this->assertFalse($membershipsByUser[$user2Id]['mfa']); + } +} diff --git a/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php new file mode 100644 index 0000000000..2d0e15a70f --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php @@ -0,0 +1,68 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // "password" is the top entry in the common-passwords dictionary and is 8 chars (min length). + $commonPassword = 'football'; + + // Step 1: Disable password dictionary policy + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [ + 'enabled' => false, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertFalse($response['body']['authPasswordDictionary']); + + // Step 2: Create user with common password - should succeed + $user1 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => 'dict_off_' . uniqid() . '@localhost.test', + 'password' => $commonPassword, + 'name' => 'Dictionary Off User', + ]); + $this->assertSame(201, $user1['headers']['status-code']); + $this->assertNotEmpty($user1['body']['$id']); + + // Step 3: Enable password dictionary policy + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [ + 'enabled' => true, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['authPasswordDictionary']); + + // Step 4: Creating another user with the common password must fail + $user2 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => 'dict_on_' . uniqid() . '@localhost.test', + 'password' => $commonPassword, + 'name' => 'Dictionary On User', + ]); + $this->assertSame(400, $user2['headers']['status-code']); + + // Cleanup: disable policy + $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [ + 'enabled' => false, + ]); + } +} diff --git a/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php new file mode 100644 index 0000000000..c2dfd7be5e --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php @@ -0,0 +1,152 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // Step 1: Enable password history policy with limit 3 + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $serverHeaders, [ + 'total' => 3, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(3, $response['body']['authPasswordHistory']); + + $firstPassword = 'firstpassword'; + $secondPassword = 'secondpassword'; + $thirdPassword = 'thirdpassword'; + $fourthPassword = 'fourthpassword'; + + // Step 2: Sign up user with firstpassword (policy on, so signup populates history) + $email = 'history_' . uniqid() . '@localhost.test'; + $userId = ID::unique(); + + $account = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'userId' => $userId, + 'email' => $email, + 'password' => $firstPassword, + 'name' => 'History User', + ]); + $this->assertSame(201, $account['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $firstPassword, + ]); + $this->assertSame(201, $session['headers']['status-code']); + $sessionCookie = $session['cookies']['a_session_' . $projectId]; + + $clientHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + + // Change password: first -> second + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $secondPassword, + 'oldPassword' => $firstPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Change password: second -> third + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $thirdPassword, + 'oldPassword' => $secondPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Step 3: Attempt to reuse each of the 3 previous passwords - all should fail + foreach ([$firstPassword, $secondPassword, $thirdPassword] as $reused) { + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $reused, + 'oldPassword' => $thirdPassword, + ]); + $this->assertSame(400, $response['headers']['status-code'], 'Reusing password "' . $reused . '" should be blocked by history policy'); + $this->assertSame('password_recently_used', $response['body']['type']); + } + + // Step 4: Setting fourthpassword succeeds + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $fourthPassword, + 'oldPassword' => $thirdPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Verify the new password works by signing in again + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $fourthPassword, + ]); + $this->assertSame(201, $session['headers']['status-code']); + + // Step 5: Disable password history policy + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $serverHeaders, [ + 'total' => null, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authPasswordHistory']); + + // Step 6: With policy off, reusing any previous password should succeed, as should setting a brand new one. + // oldPassword must match current password, so walk through each previous password sequentially. + $fifthPassword = 'fifthpassword'; + $chain = [ + [$fourthPassword, $firstPassword], + [$firstPassword, $secondPassword], + [$secondPassword, $thirdPassword], + [$thirdPassword, $fourthPassword], + [$fourthPassword, $fifthPassword], + ]; + + foreach ($chain as [$current, $next]) { + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $next, + 'oldPassword' => $current, + ]); + $this->assertSame(200, $response['headers']['status-code'], 'Changing password from "' . $current . '" to "' . $next . '" should succeed with history policy disabled'); + } + + // Verify the final password works by signing in + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $fifthPassword, + ]); + $this->assertSame(201, $session['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php new file mode 100644 index 0000000000..66e45b06a9 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php @@ -0,0 +1,121 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $email = 'session_' . uniqid() . '@localhost.test'; + $password = 'password1234'; + + // Create user (via API key so signup rules don't interfere) + $response = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Session User', + ]); + $this->assertSame(201, $response['headers']['status-code']); + + $login = function () use ($publicHeaders, $email, $password): string { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $response['headers']['status-code']); + return $response['cookies']['a_session_' . $this->getProject()['$id']]; + }; + + $accountHeaders = function (string $sessionCookie) use ($projectId): array { + return [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + }; + + $getAccount = function (string $sessionCookie) use ($accountHeaders): array { + return $this->client->call(Client::METHOD_GET, '/account', $accountHeaders($sessionCookie)); + }; + + $setSessionLimit = function (?int $total) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $serverHeaders, [ + 'total' => $total, + ]); + $this->assertSame(200, $response['headers']['status-code']); + }; + + // Step 1: Session limit = 1 + $setSessionLimit(1); + + $session1 = $login(); + $this->assertEventually(function () use ($getAccount, $session1) { + $response = $getAccount($session1); + $this->assertSame(200, $response['headers']['status-code']); + }, 15_000, 500); + + // New session pushes old one out + $session2 = $login(); + $this->assertEventually(function () use ($getAccount, $session1, $session2) { + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); + }, 15_000, 500); + + // Step 2: Session limit = 2 + $setSessionLimit(2); + + $session3 = $login(); + $this->assertEventually(function () use ($getAccount, $session2, $session3) { + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + $this->assertSame(200, $getAccount($session3)['headers']['status-code']); + }, 15_000, 500); + + // Step 3: 4th session evicts session2 (oldest), session3 and session4 remain + $session4 = $login(); + $this->assertEventually(function () use ($getAccount, $session2, $session3, $session4) { + $this->assertSame(200, $getAccount($session4)['headers']['status-code']); + $this->assertSame(200, $getAccount($session3)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); + }, 15_000, 500); + + // Step 4: Disable session limit, create 5 new sessions, all should remain usable + $setSessionLimit(null); + + $newSessions = []; + for ($i = 0; $i < 5; $i++) { + $newSessions[] = $login(); + } + + $this->assertEventually(function () use ($getAccount, $newSessions) { + foreach ($newSessions as $index => $sessionCookie) { + $this->assertSame(200, $getAccount($sessionCookie)['headers']['status-code'], 'Session #' . ($index + 1) . ' should remain valid when limit is disabled'); + } + }, 15_000, 500); + } +} diff --git a/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php new file mode 100644 index 0000000000..5ddcd8aaa1 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php @@ -0,0 +1,87 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $signupHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $signup = function () use ($signupHeaders): array { + return $this->client->call(Client::METHOD_POST, '/account', $signupHeaders, [ + 'userId' => ID::unique(), + 'email' => 'limit_' . uniqid() . '@localhost.test', + 'password' => 'password1234', + 'name' => 'Limit User', + ]); + }; + + // Step 1: Set user limit to 3 + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [ + 'total' => 3, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(3, $response['body']['authLimit']); + + // Create 3 users - all should succeed + for ($i = 1; $i <= 3; $i++) { + $response = $signup(); + $this->assertSame(201, $response['headers']['status-code'], 'User ' . $i . ' should be created under limit of 3'); + } + + // User 4 should be blocked + $response = $signup(); + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('user_count_exceeded', $response['body']['type']); + + // Step 2: Raise user limit to 4 + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [ + 'total' => 4, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(4, $response['body']['authLimit']); + + // User 4 now succeeds + $response = $signup(); + $this->assertSame(201, $response['headers']['status-code']); + + // User 5 should be blocked + $response = $signup(); + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('user_count_exceeded', $response['body']['type']); + + // Step 3: Remove user limit (null -> stored as 0 -> unlimited) + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [ + 'total' => null, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authLimit']); + + // User 5 now succeeds + $response = $signup(); + $this->assertSame(201, $response['headers']['status-code']); + } +} From eba145ee2d09175cfaa9075991208b05dc3a450b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 15:13:27 +0200 Subject: [PATCH 153/402] More integration tests --- .../Policies/SessionDuration/Update.php | 2 +- tests/e2e/Services/Project/PoliciesBase.php | 4 +- ...iesPasswordPersonalDataIntegrationTest.php | 104 +++++++++++++++ .../PoliciesSessionAlertIntegrationTest.php | 121 ++++++++++++++++++ ...PoliciesSessionDurationIntegrationTest.php | 102 +++++++++++++++ ...ciesSessionInvalidationIntegrationTest.php | 119 +++++++++++++++++ 6 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php index ad2540172c..c58951213d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php @@ -49,7 +49,7 @@ class Update extends Action ) ], )) - ->param('duration', null, new Range(60, 31536000), 'Maximum session length in seconds. Minium allowed value is 60 seconds, and maximum is 1 year, which is 31536000 seconds.') + ->param('duration', null, new Range(5, 31536000), 'Maximum session length in seconds. Minium allowed value is 5 second, and maximum is 1 year, which is 31536000 seconds.') ->inject('response') ->inject('dbForPlatform') ->inject('project') diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php index 666ca55fd6..db3576a523 100644 --- a/tests/e2e/Services/Project/PoliciesBase.php +++ b/tests/e2e/Services/Project/PoliciesBase.php @@ -306,7 +306,7 @@ trait PoliciesBase public function testUpdateSessionDurationPolicyMin(): void { - $response = $this->updateSessionDurationPolicy(60); + $response = $this->updateSessionDurationPolicy(1); $this->assertSame(200, $response['headers']['status-code']); $this->assertSame(60, $response['body']['authDuration']); @@ -325,7 +325,7 @@ trait PoliciesBase public function testUpdateSessionDurationPolicyBelowMin(): void { - $response = $this->updateSessionDurationPolicy(59); + $response = $this->updateSessionDurationPolicy(0); $this->assertSame(400, $response['headers']['status-code']); } diff --git a/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php new file mode 100644 index 0000000000..3284fed16f --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php @@ -0,0 +1,104 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $setPersonalData = function (bool $enabled) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $serverHeaders, [ + 'enabled' => $enabled, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($enabled, $response['body']['authPersonalDataCheck']); + }; + + $buildCases = function (): array { + $suffix = \uniqid(); + $userId = 'personaluser' . $suffix; + $emailLocal = 'personalmail' . $suffix; + $email = $emailLocal . '@localhost.test'; + $name = 'Personalname' . $suffix; + $phone = '+12025550' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT); + + return [ + 'userId' => [ + 'userId' => $userId, + 'email' => 'safe_' . $suffix . '@localhost.test', + 'phone' => '+12025559' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT), + 'name' => 'Safe Name', + 'password' => $userId . 'extra', + ], + 'email' => [ + 'userId' => 'safeid' . $suffix, + 'email' => $email, + 'phone' => '+12025558' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT), + 'name' => 'Safe Name', + 'password' => 'prefix_' . $emailLocal . '_suffix', + ], + 'name' => [ + 'userId' => 'safeid2' . $suffix, + 'email' => 'safename_' . $suffix . '@localhost.test', + 'phone' => '+12025557' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT), + 'name' => $name, + 'password' => 'prefix' . $name . 'xyz', + ], + 'phone' => [ + 'userId' => 'safeid3' . $suffix, + 'email' => 'safephone_' . $suffix . '@localhost.test', + 'phone' => $phone, + 'name' => 'Safe Name', + 'password' => 'prefix' . \str_replace('+', '', $phone) . 'xyz', + ], + ]; + }; + + $createUser = function (array $params) use ($serverHeaders): array { + return $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => $params['userId'], + 'email' => $params['email'], + 'phone' => $params['phone'], + 'password' => $params['password'], + 'name' => $params['name'], + ]); + }; + + // Step 1: Enable password personal data policy + $setPersonalData(true); + + // Step 2: Each of the four personal-data fields in the password must block user creation + foreach ($buildCases() as $field => $params) { + $response = $createUser($params); + $this->assertSame(400, $response['headers']['status-code'], 'Password containing ' . $field . ' should be rejected'); + $this->assertSame('password_personal_data', $response['body']['type']); + } + + // Step 3: Disable password personal data policy + $setPersonalData(false); + + // Step 4: The same categories of passwords should now be accepted (fresh data to avoid uniqueness conflicts) + foreach ($buildCases() as $field => $params) { + $response = $createUser($params); + $this->assertSame(201, $response['headers']['status-code'], 'Password containing ' . $field . ' should be accepted with policy disabled'); + $this->assertSame($params['userId'], $response['body']['$id']); + } + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php new file mode 100644 index 0000000000..1500a1dcfa --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php @@ -0,0 +1,121 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + $password = 'password1234'; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setSessionAlert = function (bool $enabled) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $serverHeaders, [ + 'enabled' => $enabled, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($enabled, $response['body']['authSessionAlerts']); + }; + + $createUser = function (string $email) use ($serverHeaders, $password): void { + $response = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Alert User', + ]); + $this->assertSame(201, $response['headers']['status-code']); + }; + + $createSession = function (string $email) use ($publicHeaders, $password): void { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $response['headers']['status-code']); + }; + + $countEmailsTo = function (string $address): int { + $emails = \json_decode(\file_get_contents('http://maildev:1080/email'), true) ?? []; + $count = 0; + foreach ($emails as $email) { + foreach ($email['to'] ?? [] as $recipient) { + if (($recipient['address'] ?? '') === $address) { + $count++; + } + } + } + return $count; + }; + + $assertEmailCountStays = function (string $address, int $expected, int $seconds) use ($countEmailsTo): void { + $deadline = \microtime(true) + $seconds; + while (\microtime(true) < $deadline) { + $this->assertSame($expected, $countEmailsTo($address), 'Unexpected email count for ' . $address); + \usleep(500_000); + } + }; + + // Step 1: Disable session alerts + $setSessionAlert(false); + + // Step 2: Create user1 and two sessions + $user1Email = 'alert1_' . uniqid() . '@localhost.test'; + $createUser($user1Email); + $createSession($user1Email); + $createSession($user1Email); + + // Step 3: No alert should arrive in the next 10 seconds + $assertEmailCountStays($user1Email, 0, 10); + + // Step 4: Enable session alerts + $setSessionAlert(true); + + // Step 5: Create user2 and one session + $user2Email = 'alert2_' . uniqid() . '@localhost.test'; + $createUser($user2Email); + $createSession($user2Email); + + // Step 6: First session never alerts, so nothing arrives in 10 seconds + $assertEmailCountStays($user2Email, 0, 10); + + // Step 7: Create the second session for user2 + $createSession($user2Email); + + // Step 8: Session alert email should eventually arrive + $this->assertEventually(function () use ($countEmailsTo, $user2Email) { + $this->assertSame(1, $countEmailsTo($user2Email)); + }, 15_000, 500); + + // Step 9: Disable session alerts + $setSessionAlert(false); + + // Step 10: Create the third session for user2 + $createSession($user2Email); + + // Step 11: No additional alert email should arrive in 10 seconds + $assertEmailCountStays($user2Email, 1, 10); + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php new file mode 100644 index 0000000000..b58514a348 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php @@ -0,0 +1,102 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setDuration = function (int $seconds) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $serverHeaders, [ + 'duration' => $seconds, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($seconds, $response['body']['authDuration']); + }; + + // Step 1: Set session duration to 5 seconds + $setDuration(5); + + // Step 2: Create user and a session + $email = 'duration_' . uniqid() . '@localhost.test'; + $password = 'password1234'; + + $user = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Duration User', + ]); + $this->assertSame(201, $user['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $session['headers']['status-code']); + $sessionCookie = $session['cookies']['a_session_' . $projectId]; + + $accountHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(200, $response['headers']['status-code']); + + // Step 3: Poll until the 5s TTL elapses - session should expire + $this->assertEventually(function () use ($accountHeaders) { + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); + }, 15_000, 500); + + // Step 4: Raise duration to 10s - same session should be usable again + $setDuration(10); + + $this->assertEventually(function () use ($accountHeaders) { + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(200, $response['headers']['status-code']); + }, 15_000, 500); + + // Step 5: Poll until the 10s TTL elapses - session should expire again + $this->assertEventually(function () use ($accountHeaders) { + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); + }, 20_000, 500); + + // Step 6: Set duration to 1 year + $setDuration(31536000); + + // Step 7: Same session should be usable again + $this->assertEventually(function () use ($accountHeaders) { + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(200, $response['headers']['status-code']); + }, 15_000, 500); + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php new file mode 100644 index 0000000000..c9de2be9a5 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php @@ -0,0 +1,119 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setInvalidation = function (bool $enabled) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $serverHeaders, [ + 'enabled' => $enabled, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($enabled, $response['body']['authInvalidateSessions']); + }; + + $accountHeaders = function (string $sessionCookie) use ($projectId): array { + return [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + }; + + $getAccount = function (string $sessionCookie) use ($accountHeaders): array { + return $this->client->call(Client::METHOD_GET, '/account', $accountHeaders($sessionCookie)); + }; + + // Step 1: Disable session invalidation + $setInvalidation(false); + + // Step 2: Create user and two sessions + $email = 'invalidation_' . uniqid() . '@localhost.test'; + $firstPassword = 'firstpassword'; + + $user = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $firstPassword, + 'name' => 'Invalidation User', + ]); + $this->assertSame(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + + $login = function (string $password) use ($publicHeaders, $email, $projectId): string { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $response['headers']['status-code']); + return $response['cookies']['a_session_' . $projectId]; + }; + + $session1 = $login($firstPassword); + $session2 = $login($firstPassword); + + $this->assertSame(200, $getAccount($session1)['headers']['status-code']); + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + + // Step 3: Change password while invalidation is disabled - both sessions survive + $secondPassword = 'secondpassword'; + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/password', $serverHeaders, [ + 'password' => $secondPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + $this->assertEventually(function () use ($getAccount, $session1, $session2) { + $this->assertSame(200, $getAccount($session1)['headers']['status-code']); + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + }, 15_000, 500); + + // Step 4: Enable session invalidation + $setInvalidation(true); + + // Step 5: Change password - both sessions should be invalidated + $thirdPassword = 'thirdpassword'; + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/password', $serverHeaders, [ + 'password' => $thirdPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + $this->assertEventually(function () use ($getAccount, $session1, $session2) { + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); + }, 15_000, 500); + + // Step 6: Disable session invalidation again + $setInvalidation(false); + + // Step 7: Previously-invalidated sessions stay dead + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); + } +} From 0be94a7aff270b3c9246a8a3d83a659fb7e74797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 15:57:30 +0200 Subject: [PATCH 154/402] Fix tests --- .../Policies/MembershipPrivacy/Update.php | 1 - .../Modules/Teams/Http/Memberships/Get.php | 2 + .../Modules/Teams/Http/Memberships/XList.php | 2 + tests/e2e/Services/Project/PoliciesBase.php | 105 ++++++++++++++++-- ...liciesMembershipPrivacyIntegrationTest.php | 2 +- 5 files changed, 99 insertions(+), 13 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php index fcaf44aff4..c56e65308e 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php @@ -95,7 +95,6 @@ class Update extends Action ]); $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php index 9cd784e5d0..d146684a20 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php @@ -117,6 +117,8 @@ class Get extends Action if ($membershipsPrivacy['userId']) { $membership->setAttribute('userId', $memberUser->getId()); + } else { + $membership->removeAttribute('userId'); } if ($membershipsPrivacy['userPhone']) { diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php index ca18ed4920..70b78e02c6 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php @@ -171,6 +171,8 @@ class XList extends Action if ($membershipsPrivacy['userId']) { $membership->setAttribute('userId', $memberUser->getId()); + } else { + $membership->removeAttribute('userId'); } if ($membershipsPrivacy['userPhone']) { diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php index db3576a523..84f5938d3e 100644 --- a/tests/e2e/Services/Project/PoliciesBase.php +++ b/tests/e2e/Services/Project/PoliciesBase.php @@ -306,10 +306,10 @@ trait PoliciesBase public function testUpdateSessionDurationPolicyMin(): void { - $response = $this->updateSessionDurationPolicy(1); + $response = $this->updateSessionDurationPolicy(5); $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame(60, $response['body']['authDuration']); + $this->assertSame(5, $response['body']['authDuration']); // Cleanup $this->updateSessionDurationPolicy(31536000); @@ -325,7 +325,7 @@ trait PoliciesBase public function testUpdateSessionDurationPolicyBelowMin(): void { - $response = $this->updateSessionDurationPolicy(0); + $response = $this->updateSessionDurationPolicy(4); $this->assertSame(400, $response['headers']['status-code']); } @@ -693,27 +693,110 @@ trait PoliciesBase ]); } - public function testUpdateMembershipPrivacyPolicyMissingParam(): void + public function testUpdateMembershipPrivacyPolicyIndividualFields(): void { - // Missing userMFA - $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders(), [ + // Start from a known baseline where every field is enabled + $this->updateMembershipPrivacyPolicy([ 'userId' => true, 'userEmail' => true, 'userPhone' => true, 'userName' => true, + 'userMFA' => true, ]); - $this->assertSame(400, $response['headers']['status-code']); + $fields = [ + 'userId' => 'authMembershipsUserId', + 'userEmail' => 'authMembershipsUserEmail', + 'userPhone' => 'authMembershipsUserPhone', + 'userName' => 'authMembershipsUserName', + 'userMFA' => 'authMembershipsMfa', + ]; + + // Each field can be toggled individually without clobbering the others + foreach ($fields as $param => $attribute) { + $response = $this->updateMembershipPrivacyPolicy([$param => false]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body'][$attribute]); + + foreach ($fields as $otherParam => $otherAttribute) { + if ($otherParam === $param) { + continue; + } + $this->assertSame(true, $response['body'][$otherAttribute], $otherAttribute . ' should be untouched while only ' . $param . ' was updated'); + } + + // Restore the field before the next iteration + $restore = $this->updateMembershipPrivacyPolicy([$param => true]); + $this->assertSame(200, $restore['headers']['status-code']); + $this->assertSame(true, $restore['body'][$attribute]); + } + } + + public function testUpdateMembershipPrivacyPolicyMultipleFields(): void + { + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userPhone' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserPhone']); + $this->assertSame(true, $response['body']['authMembershipsUserEmail']); + $this->assertSame(true, $response['body']['authMembershipsUserName']); + $this->assertSame(true, $response['body']['authMembershipsMfa']); + + // Cleanup + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyEmptyBody(): void + { + // PATCH with no fields should be a no-op, leaving state unchanged + $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + + $response = $this->updateMembershipPrivacyPolicy([]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserEmail']); + $this->assertSame(false, $response['body']['authMembershipsUserPhone']); + $this->assertSame(false, $response['body']['authMembershipsUserName']); + $this->assertSame(false, $response['body']['authMembershipsMfa']); + + // Cleanup + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); } public function testUpdateMembershipPrivacyPolicyInvalidType(): void { $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders(), [ 'userId' => 'not-a-boolean', - 'userEmail' => true, - 'userPhone' => true, - 'userName' => true, - 'userMFA' => true, ]); $this->assertSame(400, $response['headers']['status-code']); diff --git a/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php b/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php index 378cd1800d..2acdb9715d 100644 --- a/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php +++ b/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php @@ -119,7 +119,7 @@ class PoliciesMembershipPrivacyIntegrationTest extends Scope 'x-appwrite-project' => $projectId, 'cookie' => 'a_session_' . $projectId . '=' . $user1Session, ]; - + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamId . '/memberships', $clientHeaders); $this->assertSame(200, $response['headers']['status-code']); $this->assertSame(2, $response['body']['total']); From 648ffcdcfa9f7f7e1439e3f131d0be2a1e754c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 16:07:52 +0200 Subject: [PATCH 155/402] Fix event labels --- .../Project/Policies/MembershipPrivacy/Update.php | 12 ++++++++++-- .../Project/Policies/PasswordDictionary/Update.php | 11 +++++++++-- .../Http/Project/Policies/PasswordHistory/Update.php | 11 +++++++++-- .../Project/Policies/PasswordPersonalData/Update.php | 11 +++++++++-- .../Http/Project/Policies/SessionAlert/Update.php | 11 +++++++++-- .../Http/Project/Policies/SessionDuration/Update.php | 11 +++++++++-- .../Project/Policies/SessionInvalidation/Update.php | 11 +++++++++-- .../Http/Project/Policies/SessionLimit/Update.php | 11 +++++++++-- .../Http/Project/Policies/UserLimit/Update.php | 11 +++++++++-- 9 files changed, 82 insertions(+), 18 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php index c56e65308e..c947ff225a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\MembershipPrivacy; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update membership privacy policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.membership-privacy.update') - ->label('audits.event', 'policies.membership-privacy.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -58,6 +59,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -71,6 +73,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); @@ -95,6 +98,11 @@ class Update extends Action ]); $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'membership-privacy'); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php index 6218165daf..e2c678abb6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordDictionary; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update password dictionary policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.password-dictionary.update') - ->label('audits.event', 'policies.password-dictionary.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -54,6 +55,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -63,6 +65,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); $auths['passwordDictionary'] = $enabled; @@ -73,6 +76,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'password-dictionary'); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php index a1aea6b0a4..a8ae81caff 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordHistory; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -32,8 +33,8 @@ class Update extends Action ->desc('Update password history policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.password-history.update') - ->label('audits.event', 'policies.password-history.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -57,6 +58,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -66,6 +68,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); @@ -81,6 +84,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'password-history'); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php index 4ba90045c8..9db7cf0549 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordPersonalData; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update password personal data policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.password-personal-data.update') - ->label('audits.event', 'policies.password-personal-data.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -55,6 +56,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -64,6 +66,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); $auths['personalDataCheck'] = $enabled; @@ -74,6 +77,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'password-personal-data'); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php index fe9a0dac0b..22b7a44b04 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionAlert; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update session alert policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.session-alert.update') - ->label('audits.event', 'policies.session-alert.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -54,6 +55,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -63,6 +65,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); $auths['sessionAlerts'] = $enabled; @@ -73,6 +76,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-alert'); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php index c58951213d..ba72c93a6f 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionDuration; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update session duration policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.session-duration.update') - ->label('audits.event', 'policies.session-duration.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -54,6 +55,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -63,6 +65,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); $auths['duration'] = $duration; @@ -73,6 +76,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-duration'); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php index 0963d7eb56..8f8a959959 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionInvalidation; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update session invalidation policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.session-invalidation.update') - ->label('audits.event', 'policies.session-invalidation.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -54,6 +55,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -63,6 +65,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); $auths['invalidateSessions'] = $enabled; @@ -73,6 +76,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-invalidation'); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php index 407e7e43a6..382ed6f0d9 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionLimit; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -32,8 +33,8 @@ class Update extends Action ->desc('Update session limit policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.session-limit.update') - ->label('audits.event', 'policies.session-limit.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -55,6 +56,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -64,6 +66,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); @@ -79,6 +82,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-limit'); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php index 6b614fdedc..9129b81250 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\UserLimit; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -32,8 +33,8 @@ class Update extends Action ->desc('Update user limit policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.user-limit.update') - ->label('audits.event', 'policies.user-limit.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -55,6 +56,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -64,6 +66,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); @@ -79,6 +82,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'user-limit'); + $response->dynamic($project, Response::MODEL_PROJECT); } } From 6c89a05a608d13fcc87626abc03682e8593b8f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 16:38:53 +0200 Subject: [PATCH 156/402] Fix 0 session to mean unlimited --- app/controllers/shared/api.php | 5 +++++ .../Project/PoliciesMembershipPrivacyIntegrationTest.php | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index bba00bede1..6519e1f28f 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -758,6 +758,11 @@ Http::shutdown() ->inject('dbForProject') ->action(function (Http $utopia, Request $request, Response $response, Document $project, Database $dbForProject) { $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT; + + if ($sessionLimit === 0) { + return; + } + $session = $response->getPayload(); $userId = $session['userId'] ?? ''; if (empty($userId)) { diff --git a/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php b/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php index 2acdb9715d..378cd1800d 100644 --- a/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php +++ b/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php @@ -119,7 +119,7 @@ class PoliciesMembershipPrivacyIntegrationTest extends Scope 'x-appwrite-project' => $projectId, 'cookie' => 'a_session_' . $projectId . '=' . $user1Session, ]; - + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamId . '/memberships', $clientHeaders); $this->assertSame(200, $response['headers']['status-code']); $this->assertSame(2, $response['body']['total']); From 06eb550e9893d740a38a4eb0c752a03e0a818ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 16:56:00 +0200 Subject: [PATCH 157/402] Finalize tests --- .github/workflows/ci.yml | 2 -- app/controllers/api/account.php | 2 +- ...PoliciesSessionDurationIntegrationTest.php | 24 ++++++------------- 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b02d021f1a..ac30bd64ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -692,9 +692,7 @@ jobs: - name: Installing latest version run: | - rm docker-compose.yml rm .env - curl https://appwrite.io/install/compose -o docker-compose.yml curl https://appwrite.io/install/env -o .env sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env docker compose up -d diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index ffe2b54c5b..761ea33806 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -3274,7 +3274,7 @@ Http::patch('/v1/account/password') } $history[] = $newPassword; - $history = array_slice($history, (count($history) - $historyLimit), $historyLimit); + $history = array_slice($history, -$historyLimit); } if ($project->getAttribute('auths', [])['personalDataCheck'] ?? false) { diff --git a/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php index b58514a348..71562f52a5 100644 --- a/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php +++ b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php @@ -76,27 +76,17 @@ class PoliciesSessionDurationIntegrationTest extends Scope $this->assertSame(401, $response['headers']['status-code']); }, 15_000, 500); - // Step 4: Raise duration to 10s - same session should be usable again + // Step 4: Raise duration to 10s - same session should still not be usable $setDuration(10); - $this->assertEventually(function () use ($accountHeaders) { - $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); - $this->assertSame(200, $response['headers']['status-code']); - }, 15_000, 500); + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); - // Step 5: Poll until the 10s TTL elapses - session should expire again - $this->assertEventually(function () use ($accountHeaders) { - $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); - $this->assertSame(401, $response['headers']['status-code']); - }, 20_000, 500); - - // Step 6: Set duration to 1 year + // Step 5: Set duration to 1 year $setDuration(31536000); - // Step 7: Same session should be usable again - $this->assertEventually(function () use ($accountHeaders) { - $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); - $this->assertSame(200, $response['headers']['status-code']); - }, 15_000, 500); + // Step 6: Same session should still not be usable + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); } } From c8a1746119341c72aba116fc0c557169f85253f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 17:34:44 +0200 Subject: [PATCH 158/402] Fix existing tests --- src/Appwrite/Utopia/Request/Filters/V23.php | 33 +++++++++++++++++ .../Account/AccountCustomClientTest.php | 1 + .../PoliciesSessionLimitIntegrationTest.php | 37 ++++++++++--------- tests/e2e/Services/Projects/ProjectsBase.php | 1 + .../Projects/ProjectsConsoleClientTest.php | 19 ++++++++++ tests/e2e/Services/Teams/TeamsBaseClient.php | 2 +- 6 files changed, 74 insertions(+), 19 deletions(-) diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php index 8e92f9c13a..acd238a0fe 100644 --- a/src/Appwrite/Utopia/Request/Filters/V23.php +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -13,6 +13,14 @@ class V23 extends Filter case 'project.updateMembershipPrivacyPolicy': $content = $this->parseUpdateMembershipPrivacyPolicy($content); break; + case 'project.updateSessionAlertPolicy': + $content = $this->parseUpdateSessionAlertPolicy($content); + break; + case 'project.updateUserLimitPolicy': + case 'project.updatePasswordHistoryPolicy': + case 'project.updateSessionLimitPolicy': + $content = $this->parseLimitToTotal($content); + break; } return $content; @@ -23,6 +31,31 @@ class V23 extends Filter $content['userId'] = false; $content['userPhone'] = false; + if (isset($content['mfa'])) { + $content['userMFA'] = $content['mfa']; + unset($content['mfa']); + } + + return $content; + } + + protected function parseUpdateSessionAlertPolicy(array $content): array + { + if (isset($content['alerts'])) { + $content['enabled'] = $content['alerts']; + unset($content['alerts']); + } + + return $content; + } + + protected function parseLimitToTotal(array $content): array + { + if (isset($content['limit'])) { + $content['total'] = $content['limit']; + unset($content['limit']); + } + return $content; } } diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 49f0c4c245..1ad42750e7 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -2050,6 +2050,7 @@ class AccountCustomClientTest extends Scope 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', + 'x-appwrite-response-format' => '1.9.1', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'alerts' => true, diff --git a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php index 66e45b06a9..df8a22a151 100644 --- a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php +++ b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php @@ -82,27 +82,30 @@ class PoliciesSessionLimitIntegrationTest extends Scope // New session pushes old one out $session2 = $login(); - $this->assertEventually(function () use ($getAccount, $session1, $session2) { - $this->assertSame(200, $getAccount($session2)['headers']['status-code']); - $this->assertSame(401, $getAccount($session1)['headers']['status-code']); - }, 15_000, 500); + + \sleep(3); // Giving ::shutdown() hooks some time + + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); // Step 2: Session limit = 2 $setSessionLimit(2); $session3 = $login(); - $this->assertEventually(function () use ($getAccount, $session2, $session3) { - $this->assertSame(200, $getAccount($session2)['headers']['status-code']); - $this->assertSame(200, $getAccount($session3)['headers']['status-code']); - }, 15_000, 500); + + \sleep(3); // Giving ::shutdown() hooks some time + + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + $this->assertSame(200, $getAccount($session3)['headers']['status-code']); // Step 3: 4th session evicts session2 (oldest), session3 and session4 remain $session4 = $login(); - $this->assertEventually(function () use ($getAccount, $session2, $session3, $session4) { - $this->assertSame(200, $getAccount($session4)['headers']['status-code']); - $this->assertSame(200, $getAccount($session3)['headers']['status-code']); - $this->assertSame(401, $getAccount($session2)['headers']['status-code']); - }, 15_000, 500); + + \sleep(3); // Giving ::shutdown() hooks some time + + $this->assertSame(200, $getAccount($session4)['headers']['status-code']); + $this->assertSame(200, $getAccount($session3)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); // Step 4: Disable session limit, create 5 new sessions, all should remain usable $setSessionLimit(null); @@ -112,10 +115,8 @@ class PoliciesSessionLimitIntegrationTest extends Scope $newSessions[] = $login(); } - $this->assertEventually(function () use ($getAccount, $newSessions) { - foreach ($newSessions as $index => $sessionCookie) { - $this->assertSame(200, $getAccount($sessionCookie)['headers']['status-code'], 'Session #' . ($index + 1) . ' should remain valid when limit is disabled'); - } - }, 15_000, 500); + foreach ($newSessions as $index => $sessionCookie) { + $this->assertSame(200, $getAccount($sessionCookie)['headers']['status-code'], 'Session #' . ($index + 1) . ' should remain valid when limit is disabled'); + } } } diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index 122104e3d9..ef83e65d95 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -331,6 +331,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 59ff5e353c..df49ef5993 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1255,6 +1255,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/auth/session-alerts', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'alerts' => true, ]); @@ -1397,6 +1398,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => 10, // Set session duration to 10 seconds ]); @@ -1464,6 +1466,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => 600, // seconds ]); @@ -1484,6 +1487,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, ]); @@ -1540,6 +1544,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); @@ -1556,6 +1561,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -1874,6 +1880,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 1, ]); @@ -1952,6 +1959,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -1989,6 +1997,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -2001,6 +2010,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 1, ]); @@ -2072,6 +2082,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 10, ]); @@ -2090,6 +2101,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 25, ]); @@ -2103,6 +2115,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 1, ]); @@ -2176,6 +2189,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -2436,6 +2450,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-dictionary', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -2493,6 +2508,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -2506,6 +2522,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-dictionary', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); @@ -2525,6 +2542,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/personal-data', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -2637,6 +2655,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/personal-data', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); diff --git a/tests/e2e/Services/Teams/TeamsBaseClient.php b/tests/e2e/Services/Teams/TeamsBaseClient.php index 80d73b3bc0..397bc42c3b 100644 --- a/tests/e2e/Services/Teams/TeamsBaseClient.php +++ b/tests/e2e/Services/Teams/TeamsBaseClient.php @@ -254,7 +254,7 @@ trait TeamsBaseClient $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertFalse($response['body']['mfa']); - $this->assertNotEmpty($response['body']['userId']); + $this->assertArrayHasKey($response['body']['userId']); $this->assertArrayHasKey('userName', $response['body']); $this->assertArrayHasKey('userEmail', $response['body']); $this->assertNotEmpty($response['body']['teamId']); From 70cb5ca68a665a3133c8a284c1fc7e8f5ce8f2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 17:34:59 +0200 Subject: [PATCH 159/402] linter fix --- .../Project/PoliciesSessionLimitIntegrationTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php index df8a22a151..295418a974 100644 --- a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php +++ b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php @@ -82,9 +82,9 @@ class PoliciesSessionLimitIntegrationTest extends Scope // New session pushes old one out $session2 = $login(); - + \sleep(3); // Giving ::shutdown() hooks some time - + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); $this->assertSame(401, $getAccount($session1)['headers']['status-code']); @@ -92,17 +92,17 @@ class PoliciesSessionLimitIntegrationTest extends Scope $setSessionLimit(2); $session3 = $login(); - + \sleep(3); // Giving ::shutdown() hooks some time - + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); $this->assertSame(200, $getAccount($session3)['headers']['status-code']); // Step 3: 4th session evicts session2 (oldest), session3 and session4 remain $session4 = $login(); - + \sleep(3); // Giving ::shutdown() hooks some time - + $this->assertSame(200, $getAccount($session4)['headers']['status-code']); $this->assertSame(200, $getAccount($session3)['headers']['status-code']); $this->assertSame(401, $getAccount($session2)['headers']['status-code']); From 193891a7c94d22ceb7e91c5a70a3fd20d3831473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 17:38:32 +0200 Subject: [PATCH 160/402] Fix analyzer --- tests/e2e/Services/Teams/TeamsBaseClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Teams/TeamsBaseClient.php b/tests/e2e/Services/Teams/TeamsBaseClient.php index 397bc42c3b..5b04108f71 100644 --- a/tests/e2e/Services/Teams/TeamsBaseClient.php +++ b/tests/e2e/Services/Teams/TeamsBaseClient.php @@ -254,7 +254,7 @@ trait TeamsBaseClient $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertFalse($response['body']['mfa']); - $this->assertArrayHasKey($response['body']['userId']); + $this->assertArrayHasKey('userId', $response['body']); $this->assertArrayHasKey('userName', $response['body']); $this->assertArrayHasKey('userEmail', $response['body']); $this->assertNotEmpty($response['body']['teamId']); From d9d5a8133753bc34d116684ae410dfeb8b40cda6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 17:51:31 +0200 Subject: [PATCH 161/402] Fix 0->null backwrds compatibility --- src/Appwrite/Utopia/Request/Filters/V23.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php index acd238a0fe..a8a52a7ea0 100644 --- a/src/Appwrite/Utopia/Request/Filters/V23.php +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -52,7 +52,7 @@ class V23 extends Filter protected function parseLimitToTotal(array $content): array { if (isset($content['limit'])) { - $content['total'] = $content['limit']; + $content['total'] = $content['limit'] === 0 ? null : $content['limit']; unset($content['limit']); } From 7e963e84394a878397a5f662535e0b8b82a86d1f Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:55:56 +0100 Subject: [PATCH 162/402] fix: honor _APP_WORKERS_NUM in realtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Realtime was ignoring _APP_WORKERS_NUM and always computing workers as CPU × _APP_WORKER_PER_CORE, making it impossible to cap the worker count without also changing the per-core multiplier. Prefer _APP_WORKERS_NUM when set, falling back to the CPU × per-core calculation. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/realtime.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 552823336f..3461ca83e5 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -262,7 +262,9 @@ $stats->create(); $containerId = uniqid(); $statsDocument = null; -$workerNumber = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6)); + +$workerNumber = intval(System::getEnv('_APP_WORKERS_NUM', 0)) + ?: intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6)); $adapter = new Adapter\Swoole(port: System::getEnv('PORT', 80)); $adapter From 39af9c544d21bc4e54ab0b67ba98e9e691c50513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 18:22:44 +0200 Subject: [PATCH 163/402] Remove unnessessary backwards compatibility --- .../Projects/ProjectsConsoleClientTest.php | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index df49ef5993..ff18eab938 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1991,19 +1991,6 @@ class ProjectsConsoleClientTest extends Scope 'region' => System::getEnv('_APP_REGION', 'default') ]); - /** - * Test for failure - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()), [ - 'limit' => 0, - ]); - - $this->assertEquals(400, $response['headers']['status-code']); - /** * Test for SUCCESS */ @@ -2095,20 +2082,6 @@ class ProjectsConsoleClientTest extends Scope $data = $this->setupProjectWithAuthLimit(); $id = $data['projectId']; - /** - * Test for Failure - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()), [ - 'limit' => 25, - ]); - - $this->assertEquals(400, $response['headers']['status-code']); - - /** * Test for Success */ From 9ca84a56c9bff1c03194bf98026eba48e05604ba Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 08:51:51 +0530 Subject: [PATCH 164/402] Switch HTTP benchmark back to k6 --- .github/workflows/ci.yml | 202 +++--- tests/benchmarks/http.js | 1017 ++++++++++++++++++++++++++++ tests/benchmarks/http.php | 1331 ------------------------------------- 3 files changed, 1104 insertions(+), 1446 deletions(-) create mode 100644 tests/benchmarks/http.js delete mode 100644 tests/benchmarks/http.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e1a79cb3d..327f32daa8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ concurrency: env: COMPOSE_FILE: docker-compose.yml IMAGE: appwrite-dev + K6_IMAGE: grafana/k6:0.53.0 on: pull_request: @@ -712,13 +713,13 @@ jobs: continue-on-error: true run: | rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before.txt benchmark.txt - docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts \ + docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts ${{ env.K6_IMAGE }} run --quiet \ -e APPWRITE_ENDPOINT=http://localhost/v1 \ -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ -e APPWRITE_BENCHMARK_ITERATIONS=1 \ - -e APPWRITE_BENCHMARK_RUNS=1 \ + -e APPWRITE_BENCHMARK_VUS=1 \ -e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-before-summary.json \ - ${{ env.IMAGE }}:before php tests/benchmarks/http.php | tee benchmark-before.txt + tests/benchmarks/http.js | tee benchmark-before.txt - name: Stop before Appwrite if: always() @@ -736,14 +737,14 @@ jobs: - name: Benchmark after run: | - docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts \ + docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts ${{ env.K6_IMAGE }} run --quiet \ -e APPWRITE_ENDPOINT=http://localhost/v1 \ -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ -e APPWRITE_BENCHMARK_ITERATIONS=1 \ - -e APPWRITE_BENCHMARK_RUNS=1 \ + -e APPWRITE_BENCHMARK_VUS=1 \ -e APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH=benchmark-before-summary.json \ -e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-after-summary.json \ - ${{ env.IMAGE }}:after php tests/benchmarks/http.php | tee benchmark.txt + tests/benchmarks/http.js | tee benchmark.txt - name: Stop after Appwrite if: always() @@ -754,140 +755,111 @@ jobs: BENCHMARK_BASE_REF: ${{ github.event.pull_request.base.ref }} BENCHMARK_HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | - docker run --rm -i -v "$PWD:/scripts" -w /scripts \ - -e BENCHMARK_BASE_REF \ - -e BENCHMARK_HEAD_REF \ - ${{ env.IMAGE }}:after php <<'PHP' > benchmark-comment.txt - benchmark-comment.txt + const fs = require('fs'); - function env_value(string $name, string $default): string - { - $value = getenv($name); - return $value === false || $value === '' ? $default : $value; - } - - function markdown_text(string $value): string - { - return htmlspecialchars(str_replace(["\r", "\n"], ' ', $value), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); - } - - function fail_summary(string $message, bool $required): void - { - fwrite(STDERR, $message . "\n"); - if ($required) { - exit(1); + function readSummary(path, required = true) { + if (!fs.existsSync(path)) { + if (required) { + throw new Error(`Missing benchmark summary: ${path}`); } + return null; + } + + try { + return JSON.parse(fs.readFileSync(path, 'utf8')); + } catch (error) { + throw new Error(`Invalid benchmark summary ${path}: ${error.message}`); + } } - function read_summary(string $path, bool $required = true): ?array - { - if (!is_file($path)) { - fail_summary("Missing benchmark summary: {$path}", $required); - return null; - } - - try { - $summary = json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException $error) { - fail_summary("Invalid benchmark summary {$path}: {$error->getMessage()}", $required); - return null; - } - - if (!is_array($summary)) { - fail_summary("Invalid benchmark summary {$path}: expected JSON object", $required); - return null; - } - - return $summary; + function markdownText(value) { + return String(value || '').replace(/[\r\n]/g, ' ').replace(/[&<>"']/g, (char) => { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]; + }); } - $before = read_summary('benchmark-before-summary.json', false); - $after = read_summary('benchmark-after-summary.json'); - $baseRef = markdown_text(env_value('BENCHMARK_BASE_REF', 'base')); - $headRef = markdown_text(env_value('BENCHMARK_HEAD_REF', 'head')); - - function metric_value(?array $data, string $metric, string $stat): mixed - { - return $data['metrics'][$metric]['values'][$stat] ?? null; + function metricValue(data, metric, stat) { + return data?.metrics?.[metric]?.values?.[stat] ?? null; } - function format_number(mixed $value): string - { - $value = round((float) $value, 2); - return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); + function formatNumber(value) { + return Number(value).toFixed(2).replace(/\.?0+$/, ''); } - function format_value(mixed $value, string $suffix = ''): string - { - return $value === null ? 'n/a' : format_number($value) . $suffix; + function formatValue(value, suffix = '') { + return value === null ? 'n/a' : `${formatNumber(value)}${suffix}`; } - function delta(mixed $beforeValue, mixed $afterValue, string $suffix = ''): string - { - if ($beforeValue === null || $afterValue === null) { - return 'n/a'; - } + function delta(beforeValue, afterValue, suffix = '') { + if (beforeValue === null || afterValue === null) { + return 'n/a'; + } - $difference = round((float) $afterValue - (float) $beforeValue, 2); - return ($difference > 0 ? '+' : '') . format_number($difference) . $suffix; + const difference = Number((afterValue - beforeValue).toFixed(2)); + return `${difference > 0 ? '+' : ''}${formatNumber(difference)}${suffix}`; } - function row(string $label, mixed $beforeValue, mixed $afterValue, string $suffix = ''): string - { - return '| ' . $label . ' | ' . format_value($beforeValue, $suffix) . ' | ' . format_value($afterValue, $suffix) . ' | ' . delta($beforeValue, $afterValue, $suffix) . ' |'; + function row(label, beforeValue, afterValue, suffix = '') { + return `| ${label} | ${formatValue(beforeValue, suffix)} | ${formatValue(afterValue, suffix)} | ${delta(beforeValue, afterValue, suffix)} |`; } - function format_detail_value(mixed $value, string $suffix = ''): string - { - return $value === null ? 'n/a' : number_format((float) $value, 2, '.', '') . $suffix; + function detailValue(value, suffix = '') { + return value === null ? 'n/a' : `${Number(value).toFixed(2)}${suffix}`; } - function detail_row(array $after, string $label, string $metric, string $suffix = 'ms'): string - { - $values = $after['metrics'][$metric]['values'] ?? null; - if (!is_array($values)) { - return '| ' . $label . ' | n/a | n/a | n/a | n/a |'; - } + function detailRow(after, label, metric, suffix = 'ms') { + const values = after.metrics?.[metric]?.values; + if (!values) { + return `| ${label} | n/a | n/a | n/a | n/a |`; + } - return '| ' . $label - . ' | ' . format_detail_value($values['avg'] ?? null, $suffix) - . ' | ' . format_detail_value($values['p(90)'] ?? null, $suffix) - . ' | ' . format_detail_value($values['p(95)'] ?? null, $suffix) - . ' | ' . format_detail_value($values['max'] ?? null, $suffix) - . ' |'; + return `| ${label} | ${detailValue(values.avg ?? null, suffix)} | ${detailValue(values['p(90)'] ?? null, suffix)} | ${detailValue(values['p(95)'] ?? null, suffix)} | ${detailValue(values.max ?? null, suffix)} |`; } - $rows = [ - row('HTTP total p95', metric_value($before, 'http_req_duration', 'p(95)'), metric_value($after, 'http_req_duration', 'p(95)'), 'ms'), - row('API endpoints p95', metric_value($before, 'appwrite_api_duration', 'p(95)'), metric_value($after, 'appwrite_api_duration', 'p(95)'), 'ms'), - row('Database worker p95', metric_value($before, 'appwrite_worker_database_duration', 'p(95)'), metric_value($after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'), - row('TablesDB worker p95', metric_value($before, 'appwrite_worker_tables_duration', 'p(95)'), metric_value($after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'), - row('Mail worker p95', metric_value($before, 'appwrite_worker_mails_duration', 'p(95)'), metric_value($after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'), - row('Messaging worker p95', metric_value($before, 'appwrite_worker_messaging_duration', 'p(95)'), metric_value($after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'), + const before = readSummary('benchmark-before-summary.json', false); + const after = readSummary('benchmark-after-summary.json'); + const baseRef = markdownText(process.env.BENCHMARK_BASE_REF || 'base'); + const headRef = markdownText(process.env.BENCHMARK_HEAD_REF || 'head'); + + const rows = [ + row('HTTP total p95', metricValue(before, 'appwrite_http_duration', 'p(95)'), metricValue(after, 'appwrite_http_duration', 'p(95)'), 'ms'), + row('API endpoints p95', metricValue(before, 'appwrite_api_duration', 'p(95)'), metricValue(after, 'appwrite_api_duration', 'p(95)'), 'ms'), + row('Database worker p95', metricValue(before, 'appwrite_worker_database_duration', 'p(95)'), metricValue(after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'), + row('TablesDB worker p95', metricValue(before, 'appwrite_worker_tables_duration', 'p(95)'), metricValue(after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'), + row('Mail worker p95', metricValue(before, 'appwrite_worker_mails_duration', 'p(95)'), metricValue(after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'), + row('Messaging worker p95', metricValue(before, 'appwrite_worker_messaging_duration', 'p(95)'), metricValue(after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'), ]; - echo "\n"; - echo "## :sparkles: Benchmark results\n\n"; - echo "Comparing {$baseRef} (before) to {$headRef} (after).\n\n"; - if ($before === null) { - echo "> Before benchmark did not complete; showing current branch metrics only.\n\n"; + console.log(''); + console.log('## :sparkles: Benchmark results'); + console.log(); + console.log(`Comparing ${baseRef} (before) to ${headRef} (after).`); + console.log(); + if (before === null) { + console.log('> Before benchmark did not complete; showing current branch metrics only.'); + console.log(); } - echo "| Metric | Before | After | Delta |\n"; - echo "| --- | ---: | ---: | ---: |\n"; - echo implode("\n", $rows) . "\n\n"; - echo "
\n"; - echo "Current run details\n\n"; - echo "
\n\n"; - echo "| Scenario | Avg | P90 | P95 | Max |\n"; - echo "| --- | ---: | ---: | ---: | ---: |\n"; - echo detail_row($after, 'HTTP total', 'http_req_duration') . "\n"; - echo detail_row($after, 'API endpoints', 'appwrite_api_duration') . "\n"; - echo detail_row($after, 'Database worker schema jobs', 'appwrite_worker_database_duration') . "\n"; - echo detail_row($after, 'TablesDB worker schema jobs', 'appwrite_worker_tables_duration') . "\n"; - echo detail_row($after, 'Mail worker delivery', 'appwrite_worker_mails_duration') . "\n"; - echo detail_row($after, 'Messaging worker delivery', 'appwrite_worker_messaging_duration') . "\n\n"; - echo "
\n"; - PHP + console.log('| Metric | Before | After | Delta |'); + console.log('| --- | ---: | ---: | ---: |'); + console.log(rows.join('\n')); + console.log(); + console.log('
'); + console.log('Current run details'); + console.log(); + console.log('
'); + console.log(); + console.log('| Scenario | Avg | P90 | P95 | Max |'); + console.log('| --- | ---: | ---: | ---: | ---: |'); + console.log(detailRow(after, 'HTTP total', 'appwrite_http_duration')); + console.log(detailRow(after, 'API endpoints', 'appwrite_api_duration')); + console.log(detailRow(after, 'Database worker schema jobs', 'appwrite_worker_database_duration')); + console.log(detailRow(after, 'TablesDB worker schema jobs', 'appwrite_worker_tables_duration')); + console.log(detailRow(after, 'Mail worker delivery', 'appwrite_worker_mails_duration')); + console.log(detailRow(after, 'Messaging worker delivery', 'appwrite_worker_messaging_duration')); + console.log(); + console.log('
'); + NODE - name: Save results uses: actions/upload-artifact@v7 diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js new file mode 100644 index 0000000000..e852794e3b --- /dev/null +++ b/tests/benchmarks/http.js @@ -0,0 +1,1017 @@ +import http from 'k6/http'; +import { check, group, sleep } from 'k6'; +import encoding from 'k6/encoding'; +import { Counter, Trend } from 'k6/metrics'; + +const ENDPOINT = (__ENV.APPWRITE_ENDPOINT || 'http://localhost/v1').replace(/\/+$/, ''); +const MAILDEV_ENDPOINT = __ENV.APPWRITE_MAILDEV_ENDPOINT || 'http://localhost:9503/email'; +const CONSOLE_PROJECT = __ENV.APPWRITE_CONSOLE_PROJECT || 'console'; +const REGION = __ENV.APPWRITE_REGION || 'default'; +const REDIRECT_URL = __ENV.APPWRITE_BENCHMARK_REDIRECT_URL || 'http://localhost'; +const PASSWORD = __ENV.APPWRITE_BENCHMARK_PASSWORD || 'Password123!'; +const MAIL_TIMEOUT_MS = Number(__ENV.APPWRITE_MAIL_TIMEOUT_MS || 20000); +const WORKER_TIMEOUT_MS = Number(__ENV.APPWRITE_WORKER_TIMEOUT_MS || 60000); +const ITERATIONS = Number(__ENV.APPWRITE_BENCHMARK_ITERATIONS || 1); +const VUS = Number(__ENV.APPWRITE_BENCHMARK_VUS || 1); +const SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_SUMMARY_PATH || 'tests/benchmarks/http-summary.json'; +const PREVIOUS_SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH || SUMMARY_PATH; +const PREVIOUS_SUMMARY = loadPreviousSummary(); + +export const httpDuration = new Trend('appwrite_http_duration', true); +export const apiDuration = new Trend('appwrite_api_duration', true); +export const databaseWorkerDuration = new Trend('appwrite_worker_database_duration', true); +export const tablesWorkerDuration = new Trend('appwrite_worker_tables_duration', true); +export const mailsWorkerDuration = new Trend('appwrite_worker_mails_duration', true); +export const messagingWorkerDuration = new Trend('appwrite_worker_messaging_duration', true); +export const flowFailures = new Counter('appwrite_benchmark_flow_failures'); + +export const options = { + scenarios: { + curated_flows: { + executor: 'shared-iterations', + exec: 'curatedFlows', + vus: VUS, + iterations: ITERATIONS, + maxDuration: __ENV.APPWRITE_BENCHMARK_MAX_DURATION || '30m', + }, + }, + thresholds: { + http_req_failed: ['rate<0.05'], + appwrite_api_duration: ['p(95)<2000'], + appwrite_benchmark_flow_failures: ['count<1'], + }, +}; + +const API_SCOPES = [ + 'sessions.write', + 'users.read', + 'users.write', + 'teams.read', + 'teams.write', + 'databases.read', + 'databases.write', + 'collections.read', + 'collections.write', + 'tables.read', + 'tables.write', + 'attributes.read', + 'attributes.write', + 'columns.read', + 'columns.write', + 'indexes.read', + 'indexes.write', + 'documents.read', + 'documents.write', + 'rows.read', + 'rows.write', + 'files.read', + 'files.write', + 'buckets.read', + 'buckets.write', + 'functions.read', + 'functions.write', + 'sites.read', + 'sites.write', + 'log.read', + 'log.write', + 'execution.read', + 'execution.write', + 'locale.read', + 'avatars.read', + 'health.read', + 'providers.read', + 'providers.write', + 'messages.read', + 'messages.write', + 'topics.read', + 'topics.write', + 'subscribers.read', + 'subscribers.write', + 'targets.read', + 'targets.write', + 'rules.read', + 'rules.write', + 'migrations.read', + 'migrations.write', + 'vcs.read', + 'vcs.write', + 'assistant.read', + 'tokens.read', + 'tokens.write', + 'platforms.read', + 'platforms.write', +]; + +const BASE_PERMISSIONS = [ + 'read("any")', + 'create("any")', + 'update("any")', + 'delete("any")', +]; + +const ITEM_PERMISSIONS = [ + 'read("any")', + 'update("any")', + 'delete("any")', +]; + +export function setup() { + const runId = unique('run'); + const consoleEmail = __ENV.APPWRITE_ADMIN_EMAIL || `bench-admin-${runId}@example.com`; + const consolePassword = __ENV.APPWRITE_ADMIN_PASSWORD || PASSWORD; + + const consoleHeaders = { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': CONSOLE_PROJECT, + }; + + const account = rawRequest('POST', '/account', { + userId: unique('admin'), + email: consoleEmail, + password: consolePassword, + name: 'Benchmark Admin', + }, consoleHeaders, 'setup.account.create'); + + if (![201, 409].includes(account.status)) { + failResponse(account, 'Unable to create or reuse the benchmark console account'); + } + + const session = rawRequest('POST', '/account/sessions/email', { + email: consoleEmail, + password: consolePassword, + }, consoleHeaders, 'setup.account.session'); + + assertStatus(session, [201], 'console session created'); + + const consoleSessionHeaders = { + ...consoleHeaders, + Cookie: cookieHeader(session), + }; + + const team = api('POST', '/teams', { + teamId: unique('team'), + name: `Benchmark Team ${runId}`, + }, consoleSessionHeaders, [201], 'setup.teams.create'); + + const teamId = team.json('$id'); + const project = api('POST', '/projects', { + projectId: unique('project'), + name: `Benchmark Project ${runId}`, + teamId, + region: REGION, + }, consoleSessionHeaders, [201], 'setup.projects.create'); + + const projectId = project.json('$id'); + const key = api('POST', `/projects/${projectId}/keys`, { + keyId: unique('key'), + name: 'Benchmark API key', + scopes: API_SCOPES, + }, consoleSessionHeaders, [201], 'setup.projects.keys.create'); + + const apiHeaders = { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': projectId, + 'X-Appwrite-Key': key.json('secret'), + }; + + const platform = api('POST', '/project/platforms/web', { + platformId: unique('web'), + name: 'Benchmark web', + hostname: hostnameFromUrl(REDIRECT_URL), + }, apiHeaders, [201, 409], 'setup.project.platforms.web.create'); + + const smtp = rawRequest('PATCH', `/projects/${projectId}/smtp`, { + enabled: true, + senderName: 'Benchmark', + senderEmail: 'benchmark@appwrite.io', + replyTo: 'benchmark@appwrite.io', + host: __ENV.APPWRITE_SMTP_HOST || 'maildev', + port: Number(__ENV.APPWRITE_SMTP_PORT || 1025), + username: __ENV.APPWRITE_SMTP_USERNAME || 'user', + password: __ENV.APPWRITE_SMTP_PASSWORD || 'password', + ...(String(__ENV.APPWRITE_SMTP_SECURE || '') !== '' ? { secure: __ENV.APPWRITE_SMTP_SECURE } : {}), + }, consoleSessionHeaders, 'setup.projects.smtp.update'); + + if (smtp.status !== 200) { + console.warn(`Custom SMTP was not enabled (${smtp.status}). Mail worker timings may be unavailable.`); + } + + return { + runId, + teamId, + projectId, + consoleSessionHeaders, + apiHeaders, + platformStatus: platform.status, + }; +} + +export function curatedFlows(data) { + const ctx = { ...data }; + + try { + group('account and mail worker', () => accountFlow(ctx)); + group('databases documents flow', () => databasesFlow(ctx)); + group('tablesdb rows flow', () => tablesDbFlow(ctx)); + group('storage files and tokens flow', () => storageFlow(ctx)); + group('messaging worker flow', () => messagingFlow(ctx)); + group('functions and sites control-plane flow', () => computeFlow(ctx)); + group('health and queue probes', () => healthFlow(ctx)); + } catch (error) { + flowFailures.add(1); + throw error; + } +} + +export function teardown(data) { + if (data && data.projectId && data.consoleSessionHeaders) { + rawRequest('DELETE', `/projects/${data.projectId}`, null, data.consoleSessionHeaders, 'teardown.projects.delete'); + } + + if (data && data.teamId && data.consoleSessionHeaders) { + rawRequest('DELETE', `/teams/${data.teamId}`, null, data.consoleSessionHeaders, 'teardown.teams.delete'); + } +} + +function accountFlow(ctx) { + const userId = unique('user'); + const email = `bench-user-${unique('mail')}@example.com`; + const headers = projectHeaders(ctx.projectId); + + api('POST', '/account', { + userId, + email, + password: PASSWORD, + name: 'Benchmark User', + }, headers, [201], 'account.create'); + + const session = api('POST', '/account/sessions/email', { + email, + password: PASSWORD, + }, headers, [201], 'account.sessions.email.create'); + + const sessionHeaders = { + ...headers, + Cookie: cookieHeader(session), + }; + + ctx.userId = userId; + ctx.userEmail = email; + ctx.sessionHeaders = sessionHeaders; + + const jwt = api('POST', '/account/jwts', null, sessionHeaders, [201], 'account.jwts.create'); + ctx.jwtHeaders = { + ...headers, + 'X-Appwrite-JWT': jwt.json('jwt'), + }; + + api('GET', '/account', null, sessionHeaders, [200], 'account.get'); + api('GET', '/account/logs', null, sessionHeaders, [200], 'account.logs.list'); + api('PATCH', '/account/prefs', { prefs: { benchmark: true, runId: ctx.runId } }, sessionHeaders, [200], 'account.prefs.update'); + api('PATCH', '/account/name', { name: 'Benchmark User Updated' }, sessionHeaders, [200], 'account.name.update'); + api('PATCH', '/account/password', { password: `${PASSWORD}2`, oldPassword: PASSWORD }, sessionHeaders, [200], 'account.password.update'); + + const verificationStarted = Date.now(); + api('POST', '/account/verifications/email', { url: REDIRECT_URL }, sessionHeaders, [201], 'account.emailVerification.create'); + const verificationEmail = waitForEmail(email, (message) => { + return includes(message.subject, 'verify') + || includes(message.subject, 'verification') + || includes(message.html, 'verify') + || includes(message.html, 'verification') + || includes(message.text, 'verify') + || includes(message.text, 'verification'); + }, MAIL_TIMEOUT_MS); + mailsWorkerDuration.add(Date.now() - verificationStarted, { job: 'email_verification' }); + + const verification = extractQueryParams(verificationEmail); + if (verification.userId && verification.secret) { + api('PUT', '/account/verifications/email', { + userId: verification.userId, + secret: verification.secret, + }, sessionHeaders, [200], 'account.emailVerification.update'); + } + + const recoveryStarted = Date.now(); + api('POST', '/account/recovery', { email, url: REDIRECT_URL }, headers, [201], 'account.recovery.create'); + const recoveryEmail = waitForEmail(email, (message) => { + return includes(message.subject, 'recovery') + || includes(message.subject, 'recover') + || includes(message.subject, 'reset') + || includes(message.html, 'recovery') + || includes(message.html, 'recover') + || includes(message.html, 'reset') + || includes(message.text, 'recovery') + || includes(message.text, 'recover') + || includes(message.text, 'reset'); + }, MAIL_TIMEOUT_MS); + mailsWorkerDuration.add(Date.now() - recoveryStarted, { job: 'password_recovery' }); + + const recovery = extractQueryParams(recoveryEmail); + if (recovery.userId && recovery.secret) { + api('DELETE', '/account/sessions/current', null, sessionHeaders, [204], 'account.sessions.current.delete'); + + api('PUT', '/account/recovery', { + userId: recovery.userId, + secret: recovery.secret, + password: `${PASSWORD}3`, + }, headers, [200], 'account.recovery.update'); + + const recoveredSession = api('POST', '/account/sessions/email', { + email, + password: `${PASSWORD}3`, + }, headers, [201], 'account.sessions.email.recovered'); + + ctx.sessionHeaders = { + ...headers, + Cookie: cookieHeader(recoveredSession), + }; + + const recoveredJwt = api('POST', '/account/jwts', null, ctx.sessionHeaders, [201], 'account.jwts.recovered'); + ctx.jwtHeaders = { + ...headers, + 'X-Appwrite-JWT': recoveredJwt.json('jwt'), + }; + } +} + +function databasesFlow(ctx) { + const databaseId = unique('db'); + const collectionId = unique('col'); + const documentId = unique('doc'); + const indexKey = unique('idx'); + + api('POST', '/databases', { databaseId, name: 'Benchmark DB' }, ctx.apiHeaders, [201], 'databases.create'); + api('POST', `/databases/${databaseId}/collections`, { + collectionId, + name: 'Benchmark Collection', + permissions: BASE_PERMISSIONS, + documentSecurity: false, + }, ctx.apiHeaders, [201], 'databases.collections.create'); + + const attributes = [ + ['string', 'title', { size: 128 }], + ['integer', 'count', { min: 0, max: 100000 }], + ['email', 'email', {}], + ['boolean', 'active', {}], + ['datetime', 'publishedAt', {}], + ['float', 'score', { min: 0, max: 1000 }], + ['url', 'url', {}], + ['ip', 'ip', {}], + ]; + + for (const [type, key, extra] of attributes) { + const started = Date.now(); + api('POST', `/databases/${databaseId}/collections/${collectionId}/attributes/${type}`, { + key, + required: false, + array: false, + ...extra, + }, ctx.apiHeaders, [202], `databases.attributes.${type}.create`); + waitForStatus(`/databases/${databaseId}/collections/${collectionId}/attributes/${key}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); + databaseWorkerDuration.add(Date.now() - started, { job: `attribute_${type}` }); + } + + const indexStarted = Date.now(); + api('POST', `/databases/${databaseId}/collections/${collectionId}/indexes`, { + key: indexKey, + type: 'key', + attributes: ['title'], + orders: ['asc'], + }, ctx.apiHeaders, [202], 'databases.indexes.create'); + waitForStatus(`/databases/${databaseId}/collections/${collectionId}/indexes/${indexKey}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); + databaseWorkerDuration.add(Date.now() - indexStarted, { job: 'index' }); + + api('POST', `/databases/${databaseId}/collections/${collectionId}/documents`, { + documentId, + data: documentPayload(), + permissions: ITEM_PERMISSIONS, + }, ctx.apiHeaders, [201], 'databases.documents.create'); + api('GET', `/databases/${databaseId}/collections/${collectionId}/documents`, null, ctx.apiHeaders, [200], 'databases.documents.list'); + api('GET', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, null, ctx.apiHeaders, [200], 'databases.documents.get'); + api('PATCH', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, { + data: { title: 'Benchmark Document Updated' }, + }, ctx.apiHeaders, [200], 'databases.documents.update'); + api('PATCH', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}/count/increment`, { + value: 1, + }, ctx.apiHeaders, [200], 'databases.documents.increment'); + api('PATCH', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}/count/decrement`, { + value: 1, + }, ctx.apiHeaders, [200], 'databases.documents.decrement'); + api('DELETE', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, null, ctx.apiHeaders, [204], 'databases.documents.delete'); + api('DELETE', `/databases/${databaseId}`, null, ctx.apiHeaders, [204], 'databases.delete'); +} + +function tablesDbFlow(ctx) { + requireSession(ctx, 'tablesDbFlow'); + + const databaseId = unique('tdb'); + const tableId = unique('tbl'); + const rowId = unique('row'); + const indexKey = unique('tidx'); + + api('POST', '/tablesdb', { databaseId, name: 'Benchmark TablesDB' }, ctx.apiHeaders, [201], 'tablesdb.create'); + api('POST', `/tablesdb/${databaseId}/tables`, { + tableId, + name: 'Benchmark Table', + permissions: BASE_PERMISSIONS, + rowSecurity: false, + }, ctx.apiHeaders, [201], 'tablesdb.tables.create'); + + const columns = [ + ['string', 'title', { size: 128 }], + ['integer', 'count', { min: 0, max: 100000 }], + ['email', 'email', {}], + ['boolean', 'active', {}], + ]; + + for (const [type, key, extra] of columns) { + const started = Date.now(); + api('POST', `/tablesdb/${databaseId}/tables/${tableId}/columns/${type}`, { + key, + required: false, + array: false, + ...extra, + }, ctx.apiHeaders, [202], `tablesdb.columns.${type}.create`); + waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); + tablesWorkerDuration.add(Date.now() - started, { job: `column_${type}` }); + } + + const indexStarted = Date.now(); + api('POST', `/tablesdb/${databaseId}/tables/${tableId}/indexes`, { + key: indexKey, + type: 'key', + columns: ['title'], + orders: ['asc'], + }, ctx.apiHeaders, [202], 'tablesdb.indexes.create'); + waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/indexes/${indexKey}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); + tablesWorkerDuration.add(Date.now() - indexStarted, { job: 'index' }); + + api('POST', `/tablesdb/${databaseId}/tables/${tableId}/rows`, { + rowId, + data: tablePayload(), + permissions: ITEM_PERMISSIONS, + }, ctx.sessionHeaders, [201], 'tablesdb.rows.create'); + api('GET', `/tablesdb/${databaseId}/tables/${tableId}/rows`, null, ctx.sessionHeaders, [200], 'tablesdb.rows.list'); + api('GET', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, null, ctx.sessionHeaders, [200], 'tablesdb.rows.get'); + api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, { + data: { title: 'Benchmark Row Updated' }, + }, ctx.sessionHeaders, [200], 'tablesdb.rows.update'); + api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/count/increment`, { + value: 1, + }, ctx.sessionHeaders, [200], 'tablesdb.rows.increment'); + api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/count/decrement`, { + value: 1, + }, ctx.sessionHeaders, [200], 'tablesdb.rows.decrement'); + api('DELETE', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, null, ctx.sessionHeaders, [204], 'tablesdb.rows.delete'); + api('DELETE', `/tablesdb/${databaseId}`, null, ctx.apiHeaders, [204], 'tablesdb.delete'); +} + +function storageFlow(ctx) { + requireSession(ctx, 'storageFlow'); + + const bucketId = unique('bucket'); + const fileId = unique('file'); + + api('POST', '/storage/buckets', { + bucketId, + name: 'Benchmark Bucket', + permissions: BASE_PERMISSIONS, + fileSecurity: false, + enabled: true, + maximumFileSize: 30000000, + allowedFileExtensions: [], + compression: 'none', + encryption: false, + antivirus: false, + }, ctx.apiHeaders, [201], 'storage.buckets.create'); + + const multipartHeaders = { ...ctx.sessionHeaders }; + delete multipartHeaders['Content-Type']; + + const upload = http.post(`${ENDPOINT}/storage/buckets/${bucketId}/files`, { + fileId, + file: http.file(onePixelPng(), 'benchmark.png', 'image/png'), + ...flattenMultipartArray('permissions', ITEM_PERMISSIONS), + }, { + headers: multipartHeaders, + tags: { name: 'storage.files.create' }, + }); + + httpDuration.add(upload.timings.duration, { name: 'storage.files.create' }); + apiDuration.add(upload.timings.duration, { name: 'storage.files.create' }); + assertStatus(upload, [201], 'storage file created'); + + api('GET', `/storage/buckets/${bucketId}/files`, null, ctx.sessionHeaders, [200], 'storage.files.list'); + api('GET', `/storage/buckets/${bucketId}/files/${fileId}`, null, ctx.sessionHeaders, [200], 'storage.files.get'); + api('GET', `/storage/buckets/${bucketId}/files/${fileId}/view`, null, ctx.sessionHeaders, [200], 'storage.files.view'); + api('GET', `/storage/buckets/${bucketId}/files/${fileId}/download`, null, ctx.sessionHeaders, [200], 'storage.files.download'); + api('GET', `/storage/buckets/${bucketId}/files/${fileId}/preview`, null, ctx.sessionHeaders, [200], 'storage.files.preview'); + api('PUT', `/storage/buckets/${bucketId}/files/${fileId}`, { + name: 'benchmark-renamed.png', + permissions: ITEM_PERMISSIONS, + }, ctx.sessionHeaders, [200], 'storage.files.update'); + + const token = api('POST', `/tokens/buckets/${bucketId}/files/${fileId}`, {}, ctx.apiHeaders, [201], 'tokens.files.create'); + api('GET', `/tokens/buckets/${bucketId}/files/${fileId}`, null, ctx.apiHeaders, [200], 'tokens.files.list'); + api('GET', `/tokens/${token.json('$id')}`, null, ctx.apiHeaders, [200], 'tokens.get'); + api('PATCH', `/tokens/${token.json('$id')}`, { expire: null }, ctx.apiHeaders, [200], 'tokens.update'); + api('DELETE', `/tokens/${token.json('$id')}`, null, ctx.apiHeaders, [204], 'tokens.delete'); + + api('DELETE', `/storage/buckets/${bucketId}/files/${fileId}`, null, ctx.sessionHeaders, [204], 'storage.files.delete'); + api('DELETE', `/storage/buckets/${bucketId}`, null, ctx.apiHeaders, [204], 'storage.buckets.delete'); +} + +function messagingFlow(ctx) { + requireSession(ctx, 'messagingFlow'); + if (!ctx.userId || !ctx.userEmail) { + throw new Error('accountFlow must run before messagingFlow'); + } + + const providerId = unique('smtp'); + let targetId = unique('target'); + const topicId = unique('topic'); + const subscriberId = unique('sub'); + const messageId = unique('msg'); + + api('POST', '/messaging/providers/smtp', { + providerId, + name: 'Benchmark SMTP', + host: __ENV.APPWRITE_SMTP_HOST || 'maildev', + port: Number(__ENV.APPWRITE_SMTP_PORT || 1025), + username: __ENV.APPWRITE_SMTP_USERNAME || 'user', + password: __ENV.APPWRITE_SMTP_PASSWORD || 'password', + encryption: __ENV.APPWRITE_SMTP_ENCRYPTION || 'none', + autoTLS: false, + fromName: 'Benchmark', + fromEmail: 'benchmark@appwrite.io', + replyToName: 'Benchmark', + replyToEmail: 'benchmark@appwrite.io', + enabled: true, + }, ctx.apiHeaders, [201], 'messaging.providers.smtp.create'); + + const targets = api('GET', `/users/${ctx.userId}/targets`, null, ctx.apiHeaders, [200], 'users.targets.list'); + const existingTarget = (targets.json('targets') || []).find((target) => { + return target.providerType === 'email' && target.identifier === ctx.userEmail; + }); + + if (existingTarget) { + targetId = existingTarget.$id; + api('PATCH', `/users/${ctx.userId}/targets/${targetId}`, { + providerId, + name: 'Benchmark email target', + }, ctx.apiHeaders, [200], 'users.targets.update'); + } else { + api('POST', `/users/${ctx.userId}/targets`, { + targetId, + providerType: 'email', + identifier: ctx.userEmail, + providerId, + name: 'Benchmark email target', + }, ctx.apiHeaders, [201], 'users.targets.create'); + } + + api('POST', '/messaging/topics', { + topicId, + name: 'Benchmark Topic', + subscribe: ['users'], + }, ctx.apiHeaders, [201], 'messaging.topics.create'); + + api('POST', `/messaging/topics/${topicId}/subscribers`, { + subscriberId, + targetId, + }, ctx.sessionHeaders, [201], 'messaging.subscribers.create'); + + const started = Date.now(); + api('POST', '/messaging/messages/email', { + messageId, + subject: `Benchmark message ${ctx.runId}`, + content: `Benchmark messaging worker probe ${ctx.runId}`, + targets: [targetId], + draft: false, + html: false, + }, ctx.apiHeaders, [201], 'messaging.messages.email.create'); + + waitForMessage(messageId, ctx.apiHeaders, WORKER_TIMEOUT_MS); + waitForEmail(ctx.userEmail, (message) => includes(message.subject, `Benchmark message ${ctx.runId}`), MAIL_TIMEOUT_MS, true); + messagingWorkerDuration.add(Date.now() - started, { job: 'email_message' }); + + api('GET', '/messaging/messages', null, ctx.apiHeaders, [200], 'messaging.messages.list'); + api('GET', `/messaging/messages/${messageId}/logs`, null, ctx.apiHeaders, [200], 'messaging.messages.logs.list'); + api('GET', `/messaging/messages/${messageId}/targets`, null, ctx.apiHeaders, [200], 'messaging.messages.targets.list'); + api('GET', `/messaging/providers/${providerId}/logs`, null, ctx.apiHeaders, [200], 'messaging.providers.logs.list'); + api('GET', `/messaging/topics/${topicId}/logs`, null, ctx.apiHeaders, [200], 'messaging.topics.logs.list'); + api('GET', `/messaging/subscribers/${subscriberId}/logs`, null, ctx.apiHeaders, [200], 'messaging.subscribers.logs.list'); + api('DELETE', `/messaging/topics/${topicId}/subscribers/${subscriberId}`, null, ctx.sessionHeaders, [204], 'messaging.subscribers.delete'); + api('DELETE', `/messaging/topics/${topicId}`, null, ctx.apiHeaders, [204], 'messaging.topics.delete'); + api('DELETE', `/messaging/messages/${messageId}`, null, ctx.apiHeaders, [204], 'messaging.messages.delete'); + api('DELETE', `/messaging/providers/${providerId}`, null, ctx.apiHeaders, [204], 'messaging.providers.delete'); +} + +function computeFlow(ctx) { + requireSession(ctx, 'computeFlow'); + + const functionId = unique('fn'); + let functionVariableId; + const siteId = unique('site'); + let siteVariableId; + + api('POST', '/functions', { + functionId, + name: 'Benchmark Function', + runtime: __ENV.APPWRITE_BENCHMARK_RUNTIME || 'node-22', + execute: ['any'], + events: [], + schedule: '', + timeout: 15, + enabled: true, + logging: true, + entrypoint: 'index.js', + commands: 'npm install', + scopes: ['users.read'], + }, ctx.apiHeaders, [201], 'functions.create'); + api('GET', '/functions/runtimes', null, ctx.sessionHeaders, [200], 'functions.runtimes.list'); + api('GET', '/functions/specifications', null, ctx.apiHeaders, [200], 'functions.specifications.list'); + const functionVariable = api('POST', `/functions/${functionId}/variables`, { + key: 'BENCHMARK', + value: 'true', + secret: false, + }, ctx.apiHeaders, [201], 'functions.variables.create'); + functionVariableId = functionVariable.json('$id'); + + api('PUT', `/functions/${functionId}/variables/${functionVariableId}`, { + key: 'BENCHMARK', + value: 'updated', + secret: false, + }, ctx.apiHeaders, [200], 'functions.variables.update'); + api('GET', `/functions/${functionId}/variables/${functionVariableId}`, null, ctx.apiHeaders, [200], 'functions.variables.get'); + api('DELETE', `/functions/${functionId}/variables/${functionVariableId}`, null, ctx.apiHeaders, [204], 'functions.variables.delete'); + api('DELETE', `/functions/${functionId}`, null, ctx.apiHeaders, [204], 'functions.delete'); + + api('POST', '/sites', { + siteId, + name: 'Benchmark Site', + framework: 'other', + adapter: 'static', + buildRuntime: __ENV.APPWRITE_BENCHMARK_RUNTIME || 'node-22', + buildCommand: '', + outputDirectory: '.', + installCommand: '', + fallbackFile: 'index.html', + providerRootDirectory: '.', + specification: '', + }, ctx.apiHeaders, [201], 'sites.create'); + api('GET', '/sites/frameworks', null, ctx.sessionHeaders, [200], 'sites.frameworks.list'); + api('GET', '/sites/specifications', null, ctx.apiHeaders, [200], 'sites.specifications.list'); + const siteVariable = api('POST', `/sites/${siteId}/variables`, { + key: 'BENCHMARK', + value: 'true', + secret: false, + }, ctx.apiHeaders, [201], 'sites.variables.create'); + siteVariableId = siteVariable.json('$id'); + + api('PUT', `/sites/${siteId}/variables/${siteVariableId}`, { + key: 'BENCHMARK', + value: 'updated', + secret: false, + }, ctx.apiHeaders, [200], 'sites.variables.update'); + api('GET', `/sites/${siteId}/variables/${siteVariableId}`, null, ctx.apiHeaders, [200], 'sites.variables.get'); + api('DELETE', `/sites/${siteId}/variables/${siteVariableId}`, null, ctx.apiHeaders, [204], 'sites.variables.delete'); + api('DELETE', `/sites/${siteId}`, null, ctx.apiHeaders, [204], 'sites.delete'); +} + +function healthFlow(ctx) { + const probes = [ + '/health', + '/health/db', + '/health/cache', + '/health/pubsub', + '/health/storage', + '/health/storage/local', + '/health/time', + '/health/queue/databases', + '/health/queue/mails', + '/health/queue/messaging', + '/health/queue/functions', + '/health/queue/builds', + '/health/queue/deletes', + '/health/queue/webhooks', + '/health/queue/stats-resources', + '/health/queue/stats-usage', + '/health/queue/failed/v1-mails', + ]; + + for (const path of probes) { + api('GET', path, null, ctx.apiHeaders, [200], `health${path.replace(/\//g, '.')}`); + } +} + +function api(method, path, body, headers, expected, name) { + const response = rawRequest(method, path, body, headers, name); + apiDuration.add(response.timings.duration, { name }); + assertStatus(response, expected, name); + return response; +} + +function rawRequest(method, path, body, headers, name) { + const params = { + headers, + tags: { name }, + }; + const payload = body === null || body === undefined ? null : JSON.stringify(body); + const response = http.request(method, `${ENDPOINT}${path}`, payload, params); + httpDuration.add(response.timings.duration, { name }); + + return response; +} + +function waitForStatus(path, headers, wantedStatus, timeoutMs) { + const started = Date.now(); + + while (Date.now() - started < timeoutMs) { + const response = rawRequest('GET', path, null, headers, `wait${path}`); + if (response.status === 200) { + const status = response.json('status'); + if (status === wantedStatus) { + return response; + } + if (status === 'failed') { + throw new Error(`${path} failed while waiting for ${wantedStatus}`); + } + } + sleep(0.5); + } + + throw new Error(`Timed out waiting for ${path} to become ${wantedStatus}`); +} + +function waitForMessage(messageId, headers, timeoutMs) { + const started = Date.now(); + + while (Date.now() - started < timeoutMs) { + const response = rawRequest('GET', `/messaging/messages/${messageId}`, null, headers, 'messaging.messages.poll'); + const status = response.status === 200 ? response.json('status') : null; + + if (['sent', 'failed'].includes(status)) { + if (status === 'failed') { + throw new Error(`Messaging worker marked message ${messageId} as failed`); + } + return response; + } + + sleep(0.5); + } + + throw new Error(`Timed out waiting for messaging worker to send message ${messageId}`); +} + +function waitForEmail(address, predicate, timeoutMs, allowMissingRecipient = false) { + const started = Date.now(); + + while (Date.now() - started < timeoutMs) { + const response = http.get(MAILDEV_ENDPOINT, { tags: { name: 'maildev.email.list' } }); + if (response.status === 200) { + const emails = response.json(); + for (let i = emails.length - 1; i >= 0; i--) { + const message = emails[i]; + if ((emailMatches(message, address) || (allowMissingRecipient && emailRecipientMissing(message))) && predicate(message)) { + return message; + } + } + } + sleep(0.5); + } + + throw new Error(`Timed out waiting for email to ${address}`); +} + +function emailMatches(message, address) { + const recipients = message.to || []; + return recipients.some((recipient) => recipient.address === address); +} + +function emailRecipientMissing(message) { + const recipients = message.to || []; + return recipients.length === 0 || recipients.every((recipient) => !recipient.address); +} + +function extractQueryParams(message) { + const content = `${message.html || ''}\n${message.text || ''}`; + const links = []; + const hrefPattern = /href="([^"]+)"/g; + let hrefMatch = hrefPattern.exec(content); + + while (hrefMatch !== null) { + links.push(hrefMatch[1]); + hrefMatch = hrefPattern.exec(content); + } + + if (links.length === 0) { + links.push(content); + } + + for (const link of links) { + const queryStart = link.indexOf('?'); + if (queryStart === -1) { + continue; + } + + const query = link.slice(queryStart + 1).split('#')[0].replace(/&/g, '&'); + const params = {}; + + for (const pair of query.split('&')) { + const [key, value] = pair.split('='); + params[decodeURIComponent(key)] = decodeURIComponent(value || ''); + } + + if (params.userId && params.secret) { + return params; + } + } + + return {}; +} + +function assertStatus(response, expected, name) { + const ok = check(response, { + [`${name} status ${expected.join('|')}`]: (r) => expected.includes(r.status), + }); + + if (!ok) { + failResponse(response, `${name} returned an unexpected status`); + } +} + +function failResponse(response, message) { + throw new Error(`${message}. Status: ${response.status}. Body: ${response.body}`); +} + +function cookieHeader(response) { + return response.headers['Set-Cookie'] || response.headers['set-cookie'] || ''; +} + +function projectHeaders(projectId) { + return { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': projectId, + }; +} + +function requireSession(ctx, flow) { + if (!ctx.sessionHeaders || typeof ctx.sessionHeaders !== 'object') { + throw new Error(`accountFlow must run before ${flow}`); + } +} + +function documentPayload() { + return { + title: 'Benchmark Document', + count: 1, + email: 'document@example.com', + active: true, + publishedAt: new Date().toISOString(), + score: 10.5, + url: 'https://appwrite.io', + ip: '127.0.0.1', + }; +} + +function tablePayload() { + return { + title: 'Benchmark Row', + count: 1, + email: 'row@example.com', + active: true, + }; +} + +function onePixelPng() { + return encoding.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'std', 'b'); +} + +function flattenMultipartArray(key, values) { + const output = {}; + values.forEach((value, index) => { + output[`${key}[${index}]`] = value; + }); + return output; +} + +function unique(prefix) { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .slice(0, 36); +} + +function includes(value, needle) { + return String(value || '').toLowerCase().includes(String(needle).toLowerCase()); +} + +function hostnameFromUrl(value) { + return value.replace(/^https?:\/\//, '').split('/')[0].split(':')[0]; +} + +export function handleSummary(data) { + const lines = [ + 'Appwrite curated benchmark review', + '', + 'Before/after comparison', + '', + comparisonTable(PREVIOUS_SUMMARY, data), + '', + 'Current run details', + '', + detailsTable(data), + '', + ]; + + return { + stdout: `${lines.join('\n')}\n`, + [SUMMARY_PATH]: JSON.stringify(data, null, 2), + }; +} + +function detailsTable(data) { + return [ + '| Scenario | Avg | P90 | P95 | Max |', + '| --- | ---: | ---: | ---: | ---: |', + detailRow(data, 'HTTP total', 'appwrite_http_duration'), + detailRow(data, 'API endpoints', 'appwrite_api_duration'), + detailRow(data, 'Database worker schema jobs', 'appwrite_worker_database_duration'), + detailRow(data, 'TablesDB worker schema jobs', 'appwrite_worker_tables_duration'), + detailRow(data, 'Mail worker delivery', 'appwrite_worker_mails_duration'), + detailRow(data, 'Messaging worker delivery', 'appwrite_worker_messaging_duration'), + ].join('\n'); +} + +function detailRow(data, label, metric, unit = 'ms') { + const values = data.metrics[metric] && data.metrics[metric].values; + if (!values || values.count === 0) { + return `| ${label} | n/a | n/a | n/a | n/a |`; + } + + return `| ${label} | ${formatDetailValue(values.avg, unit)} | ${formatDetailValue(values['p(90)'], unit)} | ${formatDetailValue(values['p(95)'], unit)} | ${formatDetailValue(values.max, unit)} |`; +} + +function loadPreviousSummary() { + try { + return JSON.parse(open(PREVIOUS_SUMMARY_PATH)); + } catch (error) { + return null; + } +} + +function comparisonTable(before, after) { + const rows = [ + ['HTTP total p95', trendMetric(before, 'appwrite_http_duration', 'p(95)'), trendMetric(after, 'appwrite_http_duration', 'p(95)'), 'ms'], + ['API endpoints p95', trendMetric(before, 'appwrite_api_duration', 'p(95)'), trendMetric(after, 'appwrite_api_duration', 'p(95)'), 'ms'], + ['Database worker p95', trendMetric(before, 'appwrite_worker_database_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'], + ['TablesDB worker p95', trendMetric(before, 'appwrite_worker_tables_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'], + ['Mail worker p95', trendMetric(before, 'appwrite_worker_mails_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'], + ['Messaging worker p95', trendMetric(before, 'appwrite_worker_messaging_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'], + ]; + + return [ + '| Metric | Before | After | Delta |', + '| --- | ---: | ---: | ---: |', + ...rows.map(([label, beforeValue, afterValue, unit]) => { + return `| ${label} | ${formatValue(beforeValue, unit)} | ${formatValue(afterValue, unit)} | ${formatDelta(beforeValue, afterValue, unit)} |`; + }), + ].join('\n'); +} + +function trendMetric(data, metric, stat) { + return data && data.metrics[metric] && data.metrics[metric].values + ? data.metrics[metric].values[stat] + : null; +} + +function formatValue(value, unit) { + if (value === null || value === undefined || Number.isNaN(value)) { + return 'n/a'; + } + + return `${round(value)}${unit}`; +} + +function formatDetailValue(value, unit) { + if (value === null || value === undefined || Number.isNaN(value)) { + return 'n/a'; + } + + return `${Number(value).toFixed(2)}${unit}`; +} + +function formatDelta(before, after, unit) { + if (before === null || before === undefined || after === null || after === undefined || Number.isNaN(before) || Number.isNaN(after)) { + return 'n/a'; + } + + const delta = round(after - before); + const sign = delta > 0 ? '+' : ''; + return `${sign}${delta}${unit}`; +} + +function round(value) { + return Math.round((value || 0) * 100) / 100; +} diff --git a/tests/benchmarks/http.php b/tests/benchmarks/http.php deleted file mode 100644 index 9bddf57327..0000000000 --- a/tests/benchmarks/http.php +++ /dev/null @@ -1,1331 +0,0 @@ -jsonParsed) { - $this->json = json_decode($this->body, true); - $this->jsonParsed = true; - } - - if ($key === null) { - return $this->json; - } - - return is_array($this->json) ? ($this->json[$key] ?? null) : null; - } - - public function header(string $name): string - { - $key = strtolower($name); - return isset($this->headers[$key]) ? implode(', ', $this->headers[$key]) : ''; - } - - public function cookieHeader(): string - { - $cookies = []; - - foreach ($this->headers['set-cookie'] ?? [] as $cookie) { - $cookies[] = explode(';', $cookie, 2)[0]; - } - - return implode('; ', $cookies); - } -} - -final class BenchmarkMetrics -{ - private array $trends = []; - private array $counters = [ - 'appwrite_benchmark_flow_failures' => 0, - ]; - private int $checksPassed = 0; - private int $checksFailed = 0; - - public function addTrend(string $name, float $value): void - { - $this->trends[$name] ??= []; - $this->trends[$name][] = $value; - } - - public function addCounter(string $name, int $value = 1): void - { - $this->counters[$name] ??= 0; - $this->counters[$name] += $value; - } - - public function addCheck(bool $passed): void - { - if ($passed) { - $this->checksPassed++; - return; - } - - $this->checksFailed++; - } - - public function summary(): array - { - $metrics = []; - - foreach ($this->trends as $name => $values) { - $metrics[$name] = [ - 'type' => 'trend', - 'contains' => 'time', - 'values' => $this->trendValues($values), - ]; - } - - foreach ($this->counters as $name => $count) { - $metrics[$name] = [ - 'type' => 'counter', - 'contains' => 'default', - 'values' => [ - 'count' => $count, - ], - ]; - } - - $totalChecks = $this->checksPassed + $this->checksFailed; - $metrics['checks'] = [ - 'type' => 'rate', - 'contains' => 'default', - 'values' => [ - 'rate' => $totalChecks > 0 ? $this->checksPassed / $totalChecks : 1, - 'passes' => $this->checksPassed, - 'fails' => $this->checksFailed, - ], - ]; - - return ['metrics' => $metrics]; - } - - public function failedChecks(): int - { - return $this->checksFailed; - } - - public function flowFailures(): int - { - return $this->counters['appwrite_benchmark_flow_failures'] ?? 0; - } - - private function trendValues(array $values): array - { - sort($values, SORT_NUMERIC); - $count = count($values); - - if ($count === 0) { - return [ - 'count' => 0, - 'min' => null, - 'avg' => null, - 'med' => null, - 'max' => null, - 'p(90)' => null, - 'p(95)' => null, - ]; - } - - return [ - 'count' => $count, - 'min' => $values[0], - 'avg' => array_sum($values) / $count, - 'med' => $this->percentile($values, 50), - 'max' => $values[$count - 1], - 'p(90)' => $this->percentile($values, 90), - 'p(95)' => $this->percentile($values, 95), - ]; - } - - private function percentile(array $sortedValues, int $percentile): float - { - $count = count($sortedValues); - - if ($count === 1) { - return (float) $sortedValues[0]; - } - - $rank = ($percentile / 100) * ($count - 1); - $lower = (int) floor($rank); - $upper = (int) ceil($rank); - - if ($lower === $upper) { - return (float) $sortedValues[$lower]; - } - - $weight = $rank - $lower; - return (float) ($sortedValues[$lower] + (($sortedValues[$upper] - $sortedValues[$lower]) * $weight)); - } -} - -final class HttpBenchmark -{ - private const API_SCOPES = [ - 'sessions.write', - 'users.read', - 'users.write', - 'teams.read', - 'teams.write', - 'databases.read', - 'databases.write', - 'collections.read', - 'collections.write', - 'tables.read', - 'tables.write', - 'attributes.read', - 'attributes.write', - 'columns.read', - 'columns.write', - 'indexes.read', - 'indexes.write', - 'documents.read', - 'documents.write', - 'rows.read', - 'rows.write', - 'files.read', - 'files.write', - 'buckets.read', - 'buckets.write', - 'functions.read', - 'functions.write', - 'sites.read', - 'sites.write', - 'log.read', - 'log.write', - 'execution.read', - 'execution.write', - 'locale.read', - 'avatars.read', - 'health.read', - 'providers.read', - 'providers.write', - 'messages.read', - 'messages.write', - 'topics.read', - 'topics.write', - 'subscribers.read', - 'subscribers.write', - 'targets.read', - 'targets.write', - 'rules.read', - 'rules.write', - 'migrations.read', - 'migrations.write', - 'vcs.read', - 'vcs.write', - 'assistant.read', - 'tokens.read', - 'tokens.write', - 'platforms.read', - 'platforms.write', - ]; - - private const BASE_PERMISSIONS = [ - 'read("any")', - 'create("any")', - 'update("any")', - 'delete("any")', - ]; - - private const ITEM_PERMISSIONS = [ - 'read("any")', - 'update("any")', - 'delete("any")', - ]; - - private const PNG_1X1 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII='; - - private BenchmarkMetrics $metrics; - private string $endpoint; - private string $maildevEndpoint; - private string $consoleProject; - private string $region; - private string $redirectUrl; - private string $password; - private int $mailTimeoutMs; - private int $workerTimeoutMs; - private int $iterations; - private int $runs; - private string $summaryPath; - private ?array $previousSummary; - - public function __construct() - { - $this->metrics = new BenchmarkMetrics(); - $this->endpoint = rtrim($this->env('APPWRITE_ENDPOINT', 'http://localhost/v1'), '/'); - $this->maildevEndpoint = $this->env('APPWRITE_MAILDEV_ENDPOINT', 'http://localhost:9503/email'); - $this->consoleProject = $this->env('APPWRITE_CONSOLE_PROJECT', 'console'); - $this->region = $this->env('APPWRITE_REGION', 'default'); - $this->redirectUrl = $this->env('APPWRITE_BENCHMARK_REDIRECT_URL', 'http://localhost'); - $this->password = $this->env('APPWRITE_BENCHMARK_PASSWORD', 'Password123!'); - $this->mailTimeoutMs = (int) $this->env('APPWRITE_MAIL_TIMEOUT_MS', '20000'); - $this->workerTimeoutMs = (int) $this->env('APPWRITE_WORKER_TIMEOUT_MS', '60000'); - $this->iterations = max(1, (int) $this->env('APPWRITE_BENCHMARK_ITERATIONS', '1')); - $this->runs = max(1, (int) $this->env('APPWRITE_BENCHMARK_RUNS', $this->env('APPWRITE_BENCHMARK_VUS', '1'))); - $this->summaryPath = $this->env('APPWRITE_BENCHMARK_SUMMARY_PATH', 'tests/benchmarks/http-summary.json'); - $this->previousSummary = $this->loadPreviousSummary($this->env('APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH', $this->summaryPath)); - } - - public function run(): int - { - $context = null; - $exitCode = 0; - - try { - $context = $this->setup(); - - for ($i = 0; $i < $this->iterations * $this->runs; $i++) { - try { - $this->curatedFlows($context); - } catch (Throwable $error) { - $exitCode = 1; - fwrite(STDERR, 'Iteration ' . ($i + 1) . ' failed: ' . $error->getMessage() . PHP_EOL); - } - } - } catch (Throwable $error) { - $exitCode = 1; - fwrite(STDERR, $error->getMessage() . PHP_EOL); - } finally { - if (is_array($context)) { - try { - $this->teardown($context); - } catch (Throwable $error) { - $exitCode = 1; - fwrite(STDERR, 'Teardown failed: ' . $error->getMessage() . PHP_EOL); - } - } - - $summary = $this->metrics->summary(); - echo $this->renderSummary($summary); - try { - $this->writeSummary($summary); - } catch (Throwable $error) { - $exitCode = 1; - fwrite(STDERR, $error->getMessage() . PHP_EOL); - } - } - - if ($this->metrics->failedChecks() > 0 || $this->metrics->flowFailures() > 0) { - $exitCode = 1; - } - - return $exitCode; - } - - private function setup(): array - { - $runId = $this->unique('run'); - $consoleEmail = $this->env('APPWRITE_ADMIN_EMAIL', "bench-admin-{$runId}@example.com"); - $consolePassword = $this->env('APPWRITE_ADMIN_PASSWORD', $this->password); - $consoleHeaders = [ - 'Content-Type' => 'application/json', - 'X-Appwrite-Project' => $this->consoleProject, - ]; - - $account = $this->rawRequest('POST', '/account', [ - 'userId' => $this->unique('admin'), - 'email' => $consoleEmail, - 'password' => $consolePassword, - 'name' => 'Benchmark Admin', - ], $consoleHeaders, 'setup.account.create'); - - if (!in_array($account->status, [201, 409], true)) { - $this->failResponse($account, 'Unable to create or reuse the benchmark console account'); - } - - $session = $this->rawRequest('POST', '/account/sessions/email', [ - 'email' => $consoleEmail, - 'password' => $consolePassword, - ], $consoleHeaders, 'setup.account.session'); - $this->assertStatus($session, [201], 'console session created'); - - $consoleSessionHeaders = [ - ...$consoleHeaders, - 'Cookie' => $session->cookieHeader(), - ]; - - $team = $this->api('POST', '/teams', [ - 'teamId' => $this->unique('team'), - 'name' => "Benchmark Team {$runId}", - ], $consoleSessionHeaders, [201], 'setup.teams.create'); - - $teamId = (string) $team->json('$id'); - $project = $this->api('POST', '/projects', [ - 'projectId' => $this->unique('project'), - 'name' => "Benchmark Project {$runId}", - 'teamId' => $teamId, - 'region' => $this->region, - ], $consoleSessionHeaders, [201], 'setup.projects.create'); - - $projectId = (string) $project->json('$id'); - $key = $this->api('POST', "/projects/{$projectId}/keys", [ - 'keyId' => $this->unique('key'), - 'name' => 'Benchmark API key', - 'scopes' => self::API_SCOPES, - ], $consoleSessionHeaders, [201], 'setup.projects.keys.create'); - - $apiHeaders = [ - 'Content-Type' => 'application/json', - 'X-Appwrite-Project' => $projectId, - 'X-Appwrite-Key' => (string) $key->json('secret'), - ]; - - $platform = $this->api('POST', '/project/platforms/web', [ - 'platformId' => $this->unique('web'), - 'name' => 'Benchmark web', - 'hostname' => $this->hostnameFromUrl($this->redirectUrl), - ], $apiHeaders, [201, 409], 'setup.project.platforms.web.create'); - - $smtpBody = [ - 'enabled' => true, - 'senderName' => 'Benchmark', - 'senderEmail' => 'benchmark@appwrite.io', - 'replyTo' => 'benchmark@appwrite.io', - 'host' => $this->env('APPWRITE_SMTP_HOST', 'maildev'), - 'port' => (int) $this->env('APPWRITE_SMTP_PORT', '1025'), - 'username' => $this->env('APPWRITE_SMTP_USERNAME', 'user'), - 'password' => $this->env('APPWRITE_SMTP_PASSWORD', 'password'), - ]; - - if ($this->env('APPWRITE_SMTP_SECURE', '') !== '') { - $smtpBody['secure'] = $this->env('APPWRITE_SMTP_SECURE', ''); - } - - $smtp = $this->rawRequest('PATCH', "/projects/{$projectId}/smtp", $smtpBody, $consoleSessionHeaders, 'setup.projects.smtp.update'); - if ($smtp->status !== 200) { - fwrite(STDERR, "Custom SMTP was not enabled ({$smtp->status}). Mail worker timings may be unavailable." . PHP_EOL); - } - - return [ - 'runId' => $runId, - 'teamId' => $teamId, - 'projectId' => $projectId, - 'consoleSessionHeaders' => $consoleSessionHeaders, - 'apiHeaders' => $apiHeaders, - 'platformStatus' => $platform->status, - ]; - } - - private function curatedFlows(array &$context): void - { - try { - $this->accountFlow($context); - $this->databasesFlow($context); - $this->tablesDbFlow($context); - $this->storageFlow($context); - $this->messagingFlow($context); - $this->computeFlow($context); - $this->healthFlow($context); - } catch (Throwable $error) { - $this->metrics->addCounter('appwrite_benchmark_flow_failures'); - throw $error; - } - } - - private function teardown(array $context): void - { - if (($context['projectId'] ?? null) && ($context['consoleSessionHeaders'] ?? null)) { - $this->rawRequest('DELETE', "/projects/{$context['projectId']}", null, $context['consoleSessionHeaders'], 'teardown.projects.delete'); - } - - if (($context['teamId'] ?? null) && ($context['consoleSessionHeaders'] ?? null)) { - $this->rawRequest('DELETE', "/teams/{$context['teamId']}", null, $context['consoleSessionHeaders'], 'teardown.teams.delete'); - } - } - - private function accountFlow(array &$context): void - { - $userId = $this->unique('user'); - $email = 'bench-user-' . $this->unique('mail') . '@example.com'; - $headers = $this->projectHeaders($context['projectId']); - - $this->api('POST', '/account', [ - 'userId' => $userId, - 'email' => $email, - 'password' => $this->password, - 'name' => 'Benchmark User', - ], $headers, [201], 'account.create'); - - $session = $this->api('POST', '/account/sessions/email', [ - 'email' => $email, - 'password' => $this->password, - ], $headers, [201], 'account.sessions.email.create'); - - $sessionHeaders = [ - ...$headers, - 'Cookie' => $session->cookieHeader(), - ]; - - $context['userId'] = $userId; - $context['userEmail'] = $email; - $context['sessionHeaders'] = $sessionHeaders; - - $jwt = $this->api('POST', '/account/jwts', null, $sessionHeaders, [201], 'account.jwts.create'); - $context['jwtHeaders'] = [ - ...$headers, - 'X-Appwrite-JWT' => (string) $jwt->json('jwt'), - ]; - - $this->api('GET', '/account', null, $sessionHeaders, [200], 'account.get'); - $this->api('GET', '/account/logs', null, $sessionHeaders, [200], 'account.logs.list'); - $this->api('PATCH', '/account/prefs', ['prefs' => ['benchmark' => true, 'runId' => $context['runId']]], $sessionHeaders, [200], 'account.prefs.update'); - $this->api('PATCH', '/account/name', ['name' => 'Benchmark User Updated'], $sessionHeaders, [200], 'account.name.update'); - $this->api('PATCH', '/account/password', ['password' => $this->password . '2', 'oldPassword' => $this->password], $sessionHeaders, [200], 'account.password.update'); - - $verificationStarted = $this->nowMs(); - $this->api('POST', '/account/verifications/email', ['url' => $this->redirectUrl], $sessionHeaders, [201], 'account.emailVerification.create'); - $verificationEmail = $this->waitForEmail($email, fn (array $message): bool => $this->messageIncludes($message, ['verify', 'verification']), $this->mailTimeoutMs); - $this->metrics->addTrend('appwrite_worker_mails_duration', $this->nowMs() - $verificationStarted); - - $verification = $this->extractQueryParams($verificationEmail); - if (($verification['userId'] ?? null) && ($verification['secret'] ?? null)) { - $this->api('PUT', '/account/verifications/email', [ - 'userId' => $verification['userId'], - 'secret' => $verification['secret'], - ], $sessionHeaders, [200], 'account.emailVerification.update'); - } - - $recoveryStarted = $this->nowMs(); - $this->api('POST', '/account/recovery', ['email' => $email, 'url' => $this->redirectUrl], $headers, [201], 'account.recovery.create'); - $recoveryEmail = $this->waitForEmail($email, fn (array $message): bool => $this->messageIncludes($message, ['recovery', 'recover', 'reset']), $this->mailTimeoutMs); - $this->metrics->addTrend('appwrite_worker_mails_duration', $this->nowMs() - $recoveryStarted); - - $recovery = $this->extractQueryParams($recoveryEmail); - if (($recovery['userId'] ?? null) && ($recovery['secret'] ?? null)) { - $this->api('DELETE', '/account/sessions/current', null, $sessionHeaders, [204], 'account.sessions.current.delete'); - $this->api('PUT', '/account/recovery', [ - 'userId' => $recovery['userId'], - 'secret' => $recovery['secret'], - 'password' => $this->password . '3', - ], $headers, [200], 'account.recovery.update'); - - $recoveredSession = $this->api('POST', '/account/sessions/email', [ - 'email' => $email, - 'password' => $this->password . '3', - ], $headers, [201], 'account.sessions.email.recovered'); - - $context['sessionHeaders'] = [ - ...$headers, - 'Cookie' => $recoveredSession->cookieHeader(), - ]; - - $recoveredJwt = $this->api('POST', '/account/jwts', null, $context['sessionHeaders'], [201], 'account.jwts.recovered'); - $context['jwtHeaders'] = [ - ...$headers, - 'X-Appwrite-JWT' => (string) $recoveredJwt->json('jwt'), - ]; - } - } - - private function databasesFlow(array $context): void - { - $databaseId = $this->unique('db'); - $collectionId = $this->unique('col'); - $documentId = $this->unique('doc'); - $indexKey = $this->unique('idx'); - - $this->api('POST', '/databases', ['databaseId' => $databaseId, 'name' => 'Benchmark DB'], $context['apiHeaders'], [201], 'databases.create'); - $this->api('POST', "/databases/{$databaseId}/collections", [ - 'collectionId' => $collectionId, - 'name' => 'Benchmark Collection', - 'permissions' => self::BASE_PERMISSIONS, - 'documentSecurity' => false, - ], $context['apiHeaders'], [201], 'databases.collections.create'); - - $attributes = [ - ['string', 'title', ['size' => 128]], - ['integer', 'count', ['min' => 0, 'max' => 100000]], - ['email', 'email', []], - ['boolean', 'active', []], - ['datetime', 'publishedAt', []], - ['float', 'score', ['min' => 0, 'max' => 1000]], - ['url', 'url', []], - ['ip', 'ip', []], - ]; - - foreach ($attributes as [$type, $key, $extra]) { - $started = $this->nowMs(); - $this->api('POST', "/databases/{$databaseId}/collections/{$collectionId}/attributes/{$type}", [ - 'key' => $key, - 'required' => false, - 'array' => false, - ...$extra, - ], $context['apiHeaders'], [202], "databases.attributes.{$type}.create"); - $this->waitForStatus("/databases/{$databaseId}/collections/{$collectionId}/attributes/{$key}", $context['apiHeaders'], 'available', $this->workerTimeoutMs); - $this->metrics->addTrend('appwrite_worker_database_duration', $this->nowMs() - $started); - } - - $indexStarted = $this->nowMs(); - $this->api('POST', "/databases/{$databaseId}/collections/{$collectionId}/indexes", [ - 'key' => $indexKey, - 'type' => 'key', - 'attributes' => ['title'], - 'orders' => ['asc'], - ], $context['apiHeaders'], [202], 'databases.indexes.create'); - $this->waitForStatus("/databases/{$databaseId}/collections/{$collectionId}/indexes/{$indexKey}", $context['apiHeaders'], 'available', $this->workerTimeoutMs); - $this->metrics->addTrend('appwrite_worker_database_duration', $this->nowMs() - $indexStarted); - - $this->api('POST', "/databases/{$databaseId}/collections/{$collectionId}/documents", [ - 'documentId' => $documentId, - 'data' => $this->documentPayload(), - 'permissions' => self::ITEM_PERMISSIONS, - ], $context['apiHeaders'], [201], 'databases.documents.create'); - $this->api('GET', "/databases/{$databaseId}/collections/{$collectionId}/documents", null, $context['apiHeaders'], [200], 'databases.documents.list'); - $this->api('GET', "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", null, $context['apiHeaders'], [200], 'databases.documents.get'); - $this->api('PATCH', "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", ['data' => ['title' => 'Benchmark Document Updated']], $context['apiHeaders'], [200], 'databases.documents.update'); - $this->api('PATCH', "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}/count/increment", ['value' => 1], $context['apiHeaders'], [200], 'databases.documents.increment'); - $this->api('PATCH', "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}/count/decrement", ['value' => 1], $context['apiHeaders'], [200], 'databases.documents.decrement'); - $this->api('DELETE', "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", null, $context['apiHeaders'], [204], 'databases.documents.delete'); - $this->api('DELETE', "/databases/{$databaseId}", null, $context['apiHeaders'], [204], 'databases.delete'); - } - - private function tablesDbFlow(array $context): void - { - if (!isset($context['sessionHeaders']) || !is_array($context['sessionHeaders'])) { - throw new RuntimeException('accountFlow must run before tablesDbFlow'); - } - - $databaseId = $this->unique('tdb'); - $tableId = $this->unique('tbl'); - $rowId = $this->unique('row'); - $indexKey = $this->unique('tidx'); - - $this->api('POST', '/tablesdb', ['databaseId' => $databaseId, 'name' => 'Benchmark TablesDB'], $context['apiHeaders'], [201], 'tablesdb.create'); - $this->api('POST', "/tablesdb/{$databaseId}/tables", [ - 'tableId' => $tableId, - 'name' => 'Benchmark Table', - 'permissions' => self::BASE_PERMISSIONS, - 'rowSecurity' => false, - ], $context['apiHeaders'], [201], 'tablesdb.tables.create'); - - $columns = [ - ['string', 'title', ['size' => 128]], - ['integer', 'count', ['min' => 0, 'max' => 100000]], - ['email', 'email', []], - ['boolean', 'active', []], - ]; - - foreach ($columns as [$type, $key, $extra]) { - $started = $this->nowMs(); - $this->api('POST', "/tablesdb/{$databaseId}/tables/{$tableId}/columns/{$type}", [ - 'key' => $key, - 'required' => false, - 'array' => false, - ...$extra, - ], $context['apiHeaders'], [202], "tablesdb.columns.{$type}.create"); - $this->waitForStatus("/tablesdb/{$databaseId}/tables/{$tableId}/columns/{$key}", $context['apiHeaders'], 'available', $this->workerTimeoutMs); - $this->metrics->addTrend('appwrite_worker_tables_duration', $this->nowMs() - $started); - } - - $indexStarted = $this->nowMs(); - $this->api('POST', "/tablesdb/{$databaseId}/tables/{$tableId}/indexes", [ - 'key' => $indexKey, - 'type' => 'key', - 'columns' => ['title'], - 'orders' => ['asc'], - ], $context['apiHeaders'], [202], 'tablesdb.indexes.create'); - $this->waitForStatus("/tablesdb/{$databaseId}/tables/{$tableId}/indexes/{$indexKey}", $context['apiHeaders'], 'available', $this->workerTimeoutMs); - $this->metrics->addTrend('appwrite_worker_tables_duration', $this->nowMs() - $indexStarted); - - $this->api('POST', "/tablesdb/{$databaseId}/tables/{$tableId}/rows", [ - 'rowId' => $rowId, - 'data' => $this->tablePayload(), - 'permissions' => self::ITEM_PERMISSIONS, - ], $context['sessionHeaders'], [201], 'tablesdb.rows.create'); - $this->api('GET', "/tablesdb/{$databaseId}/tables/{$tableId}/rows", null, $context['sessionHeaders'], [200], 'tablesdb.rows.list'); - $this->api('GET', "/tablesdb/{$databaseId}/tables/{$tableId}/rows/{$rowId}", null, $context['sessionHeaders'], [200], 'tablesdb.rows.get'); - $this->api('PATCH', "/tablesdb/{$databaseId}/tables/{$tableId}/rows/{$rowId}", ['data' => ['title' => 'Benchmark Row Updated']], $context['sessionHeaders'], [200], 'tablesdb.rows.update'); - $this->api('PATCH', "/tablesdb/{$databaseId}/tables/{$tableId}/rows/{$rowId}/count/increment", ['value' => 1], $context['sessionHeaders'], [200], 'tablesdb.rows.increment'); - $this->api('PATCH', "/tablesdb/{$databaseId}/tables/{$tableId}/rows/{$rowId}/count/decrement", ['value' => 1], $context['sessionHeaders'], [200], 'tablesdb.rows.decrement'); - $this->api('DELETE', "/tablesdb/{$databaseId}/tables/{$tableId}/rows/{$rowId}", null, $context['sessionHeaders'], [204], 'tablesdb.rows.delete'); - $this->api('DELETE', "/tablesdb/{$databaseId}", null, $context['apiHeaders'], [204], 'tablesdb.delete'); - } - - private function storageFlow(array $context): void - { - if (!isset($context['sessionHeaders']) || !is_array($context['sessionHeaders'])) { - throw new RuntimeException('accountFlow must run before storageFlow'); - } - - $bucketId = $this->unique('bucket'); - $fileId = $this->unique('file'); - - $this->api('POST', '/storage/buckets', [ - 'bucketId' => $bucketId, - 'name' => 'Benchmark Bucket', - 'permissions' => self::BASE_PERMISSIONS, - 'fileSecurity' => false, - 'enabled' => true, - 'maximumFileSize' => 30000000, - 'allowedFileExtensions' => [], - 'compression' => 'none', - 'encryption' => false, - 'antivirus' => false, - ], $context['apiHeaders'], [201], 'storage.buckets.create'); - - $tmpFile = tempnam(sys_get_temp_dir(), 'appwrite-benchmark-'); - if ($tmpFile === false) { - throw new RuntimeException('Unable to create temporary PNG fixture'); - } - - file_put_contents($tmpFile, base64_decode(self::PNG_1X1, true)); - - try { - $fields = [ - 'fileId' => $fileId, - 'file' => new CURLFile($tmpFile, 'image/png', 'benchmark.png'), - ...$this->flattenMultipartArray('permissions', self::ITEM_PERMISSIONS), - ]; - $multipartHeaders = $context['sessionHeaders']; - unset($multipartHeaders['Content-Type']); - - $upload = $this->rawMultipartRequest('POST', "/storage/buckets/{$bucketId}/files", $fields, $multipartHeaders, 'storage.files.create'); - $this->metrics->addTrend('appwrite_api_duration', $upload->duration); - $this->assertStatus($upload, [201], 'storage file created'); - } finally { - @unlink($tmpFile); - } - - $this->api('GET', "/storage/buckets/{$bucketId}/files", null, $context['sessionHeaders'], [200], 'storage.files.list'); - $this->api('GET', "/storage/buckets/{$bucketId}/files/{$fileId}", null, $context['sessionHeaders'], [200], 'storage.files.get'); - $this->api('GET', "/storage/buckets/{$bucketId}/files/{$fileId}/view", null, $context['sessionHeaders'], [200], 'storage.files.view'); - $this->api('GET', "/storage/buckets/{$bucketId}/files/{$fileId}/download", null, $context['sessionHeaders'], [200], 'storage.files.download'); - $this->api('GET', "/storage/buckets/{$bucketId}/files/{$fileId}/preview", null, $context['sessionHeaders'], [200], 'storage.files.preview'); - $this->api('PUT', "/storage/buckets/{$bucketId}/files/{$fileId}", [ - 'name' => 'benchmark-renamed.png', - 'permissions' => self::ITEM_PERMISSIONS, - ], $context['sessionHeaders'], [200], 'storage.files.update'); - - $token = $this->api('POST', "/tokens/buckets/{$bucketId}/files/{$fileId}", (object) [], $context['apiHeaders'], [201], 'tokens.files.create'); - $tokenId = (string) $token->json('$id'); - $this->api('GET', "/tokens/buckets/{$bucketId}/files/{$fileId}", null, $context['apiHeaders'], [200], 'tokens.files.list'); - $this->api('GET', "/tokens/{$tokenId}", null, $context['apiHeaders'], [200], 'tokens.get'); - $this->api('PATCH', "/tokens/{$tokenId}", ['expire' => null], $context['apiHeaders'], [200], 'tokens.update'); - $this->api('DELETE', "/tokens/{$tokenId}", null, $context['apiHeaders'], [204], 'tokens.delete'); - - $this->api('DELETE', "/storage/buckets/{$bucketId}/files/{$fileId}", null, $context['sessionHeaders'], [204], 'storage.files.delete'); - $this->api('DELETE', "/storage/buckets/{$bucketId}", null, $context['apiHeaders'], [204], 'storage.buckets.delete'); - } - - private function messagingFlow(array $context): void - { - if ( - !isset($context['sessionHeaders']) || !is_array($context['sessionHeaders']) - || !isset($context['userId'], $context['userEmail']) - ) { - throw new RuntimeException('accountFlow must run before messagingFlow'); - } - - $providerId = $this->unique('smtp'); - $targetId = $this->unique('target'); - $existingTarget = false; - $topicId = $this->unique('topic'); - $subscriberId = $this->unique('sub'); - $messageId = $this->unique('msg'); - - $this->api('POST', '/messaging/providers/smtp', [ - 'providerId' => $providerId, - 'name' => 'Benchmark SMTP', - 'host' => $this->env('APPWRITE_SMTP_HOST', 'maildev'), - 'port' => (int) $this->env('APPWRITE_SMTP_PORT', '1025'), - 'username' => $this->env('APPWRITE_SMTP_USERNAME', 'user'), - 'password' => $this->env('APPWRITE_SMTP_PASSWORD', 'password'), - 'encryption' => $this->env('APPWRITE_SMTP_ENCRYPTION', 'none'), - 'autoTLS' => false, - 'fromName' => 'Benchmark', - 'fromEmail' => 'benchmark@appwrite.io', - 'replyToName' => 'Benchmark', - 'replyToEmail' => 'benchmark@appwrite.io', - 'enabled' => true, - ], $context['apiHeaders'], [201], 'messaging.providers.smtp.create'); - - $targets = $this->api('GET', "/users/{$context['userId']}/targets", null, $context['apiHeaders'], [200], 'users.targets.list'); - foreach ($targets->json('targets') ?? [] as $target) { - if (($target['providerType'] ?? '') === 'email' && ($target['identifier'] ?? '') === $context['userEmail']) { - $targetId = (string) $target['$id']; - $existingTarget = true; - break; - } - } - - if ($existingTarget) { - $this->api('PATCH', "/users/{$context['userId']}/targets/{$targetId}", [ - 'providerId' => $providerId, - 'name' => 'Benchmark email target', - ], $context['apiHeaders'], [200], 'users.targets.update'); - } else { - $this->api('POST', "/users/{$context['userId']}/targets", [ - 'targetId' => $targetId, - 'providerType' => 'email', - 'identifier' => $context['userEmail'], - 'providerId' => $providerId, - 'name' => 'Benchmark email target', - ], $context['apiHeaders'], [201], 'users.targets.create'); - } - - $this->api('POST', '/messaging/topics', [ - 'topicId' => $topicId, - 'name' => 'Benchmark Topic', - 'subscribe' => ['users'], - ], $context['apiHeaders'], [201], 'messaging.topics.create'); - - $this->api('POST', "/messaging/topics/{$topicId}/subscribers", [ - 'subscriberId' => $subscriberId, - 'targetId' => $targetId, - ], $context['sessionHeaders'], [201], 'messaging.subscribers.create'); - - $started = $this->nowMs(); - $this->api('POST', '/messaging/messages/email', [ - 'messageId' => $messageId, - 'subject' => "Benchmark message {$context['runId']}", - 'content' => "Benchmark messaging worker probe {$context['runId']}", - 'targets' => [$targetId], - 'draft' => false, - 'html' => false, - ], $context['apiHeaders'], [201], 'messaging.messages.email.create'); - - $this->waitForMessage($messageId, $context['apiHeaders'], $this->workerTimeoutMs); - $this->waitForEmail($context['userEmail'], fn (array $message): bool => $this->includes($message['subject'] ?? '', "Benchmark message {$context['runId']}"), $this->mailTimeoutMs, true); - $this->metrics->addTrend('appwrite_worker_messaging_duration', $this->nowMs() - $started); - - $this->api('GET', '/messaging/messages', null, $context['apiHeaders'], [200], 'messaging.messages.list'); - $this->api('GET', "/messaging/messages/{$messageId}/logs", null, $context['apiHeaders'], [200], 'messaging.messages.logs.list'); - $this->api('GET', "/messaging/messages/{$messageId}/targets", null, $context['apiHeaders'], [200], 'messaging.messages.targets.list'); - $this->api('GET', "/messaging/providers/{$providerId}/logs", null, $context['apiHeaders'], [200], 'messaging.providers.logs.list'); - $this->api('GET', "/messaging/topics/{$topicId}/logs", null, $context['apiHeaders'], [200], 'messaging.topics.logs.list'); - $this->api('GET', "/messaging/subscribers/{$subscriberId}/logs", null, $context['apiHeaders'], [200], 'messaging.subscribers.logs.list'); - $this->api('DELETE', "/messaging/topics/{$topicId}/subscribers/{$subscriberId}", null, $context['sessionHeaders'], [204], 'messaging.subscribers.delete'); - $this->api('DELETE', "/messaging/topics/{$topicId}", null, $context['apiHeaders'], [204], 'messaging.topics.delete'); - $this->api('DELETE', "/messaging/messages/{$messageId}", null, $context['apiHeaders'], [204], 'messaging.messages.delete'); - $this->api('DELETE', "/messaging/providers/{$providerId}", null, $context['apiHeaders'], [204], 'messaging.providers.delete'); - } - - private function computeFlow(array $context): void - { - if (!isset($context['sessionHeaders']) || !is_array($context['sessionHeaders'])) { - throw new RuntimeException('accountFlow must run before computeFlow'); - } - - $functionId = $this->unique('fn'); - $siteId = $this->unique('site'); - $runtime = $this->env('APPWRITE_BENCHMARK_RUNTIME', 'node-22'); - - $this->api('POST', '/functions', [ - 'functionId' => $functionId, - 'name' => 'Benchmark Function', - 'runtime' => $runtime, - 'execute' => ['any'], - 'events' => [], - 'schedule' => '', - 'timeout' => 15, - 'enabled' => true, - 'logging' => true, - 'entrypoint' => 'index.js', - 'commands' => 'npm install', - 'scopes' => ['users.read'], - ], $context['apiHeaders'], [201], 'functions.create'); - $this->api('GET', '/functions/runtimes', null, $context['sessionHeaders'], [200], 'functions.runtimes.list'); - $this->api('GET', '/functions/specifications', null, $context['apiHeaders'], [200], 'functions.specifications.list'); - - $functionVariable = $this->api('POST', "/functions/{$functionId}/variables", [ - 'key' => 'BENCHMARK', - 'value' => 'true', - 'secret' => false, - ], $context['apiHeaders'], [201], 'functions.variables.create'); - $functionVariableId = (string) $functionVariable->json('$id'); - $this->api('PUT', "/functions/{$functionId}/variables/{$functionVariableId}", ['key' => 'BENCHMARK', 'value' => 'updated', 'secret' => false], $context['apiHeaders'], [200], 'functions.variables.update'); - $this->api('GET', "/functions/{$functionId}/variables/{$functionVariableId}", null, $context['apiHeaders'], [200], 'functions.variables.get'); - $this->api('DELETE', "/functions/{$functionId}/variables/{$functionVariableId}", null, $context['apiHeaders'], [204], 'functions.variables.delete'); - $this->api('DELETE', "/functions/{$functionId}", null, $context['apiHeaders'], [204], 'functions.delete'); - - $this->api('POST', '/sites', [ - 'siteId' => $siteId, - 'name' => 'Benchmark Site', - 'framework' => 'other', - 'adapter' => 'static', - 'buildRuntime' => $runtime, - 'buildCommand' => '', - 'outputDirectory' => '.', - 'installCommand' => '', - 'fallbackFile' => 'index.html', - 'providerRootDirectory' => '.', - 'specification' => '', - ], $context['apiHeaders'], [201], 'sites.create'); - $this->api('GET', '/sites/frameworks', null, $context['sessionHeaders'], [200], 'sites.frameworks.list'); - $this->api('GET', '/sites/specifications', null, $context['apiHeaders'], [200], 'sites.specifications.list'); - - $siteVariable = $this->api('POST', "/sites/{$siteId}/variables", ['key' => 'BENCHMARK', 'value' => 'true', 'secret' => false], $context['apiHeaders'], [201], 'sites.variables.create'); - $siteVariableId = (string) $siteVariable->json('$id'); - $this->api('PUT', "/sites/{$siteId}/variables/{$siteVariableId}", ['key' => 'BENCHMARK', 'value' => 'updated', 'secret' => false], $context['apiHeaders'], [200], 'sites.variables.update'); - $this->api('GET', "/sites/{$siteId}/variables/{$siteVariableId}", null, $context['apiHeaders'], [200], 'sites.variables.get'); - $this->api('DELETE', "/sites/{$siteId}/variables/{$siteVariableId}", null, $context['apiHeaders'], [204], 'sites.variables.delete'); - $this->api('DELETE', "/sites/{$siteId}", null, $context['apiHeaders'], [204], 'sites.delete'); - } - - private function healthFlow(array $context): void - { - $probes = [ - '/health', - '/health/db', - '/health/cache', - '/health/pubsub', - '/health/storage', - '/health/storage/local', - '/health/time', - '/health/queue/databases', - '/health/queue/mails', - '/health/queue/messaging', - '/health/queue/functions', - '/health/queue/builds', - '/health/queue/deletes', - '/health/queue/webhooks', - '/health/queue/stats-resources', - '/health/queue/stats-usage', - '/health/queue/failed/v1-mails', - ]; - - foreach ($probes as $path) { - $this->api('GET', $path, null, $context['apiHeaders'], [200], 'health' . str_replace('/', '.', $path)); - } - } - - private function api(string $method, string $path, mixed $body, array $headers, array $expected, string $name): BenchmarkResponse - { - $response = $this->rawRequest($method, $path, $body, $headers, $name); - $this->metrics->addTrend('appwrite_api_duration', $response->duration); - $this->assertStatus($response, $expected, $name); - return $response; - } - - private function rawRequest(string $method, string $path, mixed $body, array $headers, string $name, bool $recordHttpDuration = true): BenchmarkResponse - { - return $this->send($method, str_starts_with($path, 'http') ? $path : $this->endpoint . $path, $body, $headers, $name, false, $recordHttpDuration); - } - - private function rawMultipartRequest(string $method, string $path, array $fields, array $headers, string $name): BenchmarkResponse - { - return $this->send($method, $this->endpoint . $path, $fields, $headers, $name, true, true); - } - - private function send(string $method, string $url, mixed $body, array $headers, string $name, bool $multipart, bool $recordHttpDuration): BenchmarkResponse - { - $handle = curl_init($url); - if ($handle === false) { - throw new RuntimeException("Unable to initialize curl for {$url}"); - } - - $headerLines = []; - foreach ($headers as $key => $value) { - $headerLines[] = "{$key}: {$value}"; - } - - curl_setopt_array($handle, [ - CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => true, - CURLOPT_HTTPHEADER => $headerLines, - CURLOPT_TIMEOUT => 120, - ]); - - if ($body !== null) { - curl_setopt($handle, CURLOPT_POSTFIELDS, $multipart ? $body : json_encode($body, JSON_UNESCAPED_SLASHES)); - } elseif (in_array($method, ['POST', 'PUT', 'PATCH'], true)) { - curl_setopt($handle, CURLOPT_POSTFIELDS, ''); - } - - $started = hrtime(true); - $raw = curl_exec($handle); - $duration = (hrtime(true) - $started) / 1_000_000; - if ($recordHttpDuration) { - $this->metrics->addTrend('http_req_duration', $duration); - } - - if ($raw === false) { - $error = curl_error($handle); - throw new RuntimeException("{$name} curl error: {$error}"); - } - - $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); - $headerSize = (int) curl_getinfo($handle, CURLINFO_HEADER_SIZE); - - return new BenchmarkResponse( - $status, - substr($raw, $headerSize), - $this->parseHeaders(substr($raw, 0, $headerSize)), - $duration, - ); - } - - private function waitForStatus(string $path, array $headers, string $wantedStatus, int $timeoutMs): BenchmarkResponse - { - $started = $this->nowMs(); - - while ($this->nowMs() - $started < $timeoutMs) { - $response = $this->rawRequest('GET', $path, null, $headers, "wait{$path}"); - if ($response->status === 200) { - $status = $response->json('status'); - if ($status === $wantedStatus) { - return $response; - } - - if ($status === 'failed') { - throw new RuntimeException("Resource {$path} failed while waiting for {$wantedStatus}"); - } - } - - usleep(500_000); - } - - throw new RuntimeException("Timed out waiting for {$path} to become {$wantedStatus}"); - } - - private function waitForMessage(string $messageId, array $headers, int $timeoutMs): BenchmarkResponse - { - $started = $this->nowMs(); - - while ($this->nowMs() - $started < $timeoutMs) { - $response = $this->rawRequest('GET', "/messaging/messages/{$messageId}", null, $headers, 'messaging.messages.poll'); - $status = $response->status === 200 ? $response->json('status') : null; - - if (in_array($status, ['sent', 'failed'], true)) { - if ($status === 'failed') { - throw new RuntimeException("Messaging worker marked message {$messageId} as failed"); - } - - return $response; - } - - usleep(500_000); - } - - throw new RuntimeException("Timed out waiting for messaging worker to send message {$messageId}"); - } - - private function waitForEmail(string $address, callable $predicate, int $timeoutMs, bool $allowMissingRecipient = false): array - { - $started = $this->nowMs(); - - while ($this->nowMs() - $started < $timeoutMs) { - $response = $this->rawRequest('GET', $this->maildevEndpoint, null, [], 'maildev.email.list', false); - - if ($response->status === 200) { - $emails = $response->json(); - if (is_array($emails)) { - for ($i = count($emails) - 1; $i >= 0; $i--) { - $message = $emails[$i]; - if (!is_array($message)) { - continue; - } - - if (($this->emailMatches($message, $address) || ($allowMissingRecipient && $this->emailRecipientMissing($message))) && $predicate($message)) { - return $message; - } - } - } - } - - usleep(500_000); - } - - throw new RuntimeException("Timed out waiting for email to {$address}"); - } - - private function assertStatus(BenchmarkResponse $response, array $expected, string $name): void - { - $passed = in_array($response->status, $expected, true); - $this->metrics->addCheck($passed); - - if (!$passed) { - $this->failResponse($response, "{$name} returned an unexpected status"); - } - } - - private function failResponse(BenchmarkResponse $response, string $message): never - { - throw new RuntimeException("{$message}. Status: {$response->status}. Body: {$response->body}"); - } - - private function parseHeaders(string $rawHeaders): array - { - $blocks = preg_split("/\r\n\r\n|\n\n/", trim($rawHeaders)) ?: []; - $headerBlock = end($blocks) ?: ''; - $headers = []; - - foreach (preg_split("/\r\n|\n|\r/", $headerBlock) ?: [] as $line) { - if (!str_contains($line, ':')) { - continue; - } - - [$name, $value] = explode(':', $line, 2); - $headers[strtolower(trim($name))][] = trim($value); - } - - return $headers; - } - - private function emailMatches(array $message, string $address): bool - { - foreach ($message['to'] ?? [] as $recipient) { - if (($recipient['address'] ?? null) === $address) { - return true; - } - } - - return false; - } - - private function emailRecipientMissing(array $message): bool - { - $recipients = $message['to'] ?? []; - if ($recipients === []) { - return true; - } - - foreach ($recipients as $recipient) { - if ($recipient['address'] ?? null) { - return false; - } - } - - return true; - } - - private function extractQueryParams(array $message): array - { - $content = ($message['html'] ?? '') . "\n" . ($message['text'] ?? ''); - preg_match_all('/href="([^"]+)"/', $content, $matches); - $links = $matches[1] ?: [$content]; - - foreach ($links as $link) { - $query = parse_url(html_entity_decode($link), PHP_URL_QUERY); - if (!is_string($query)) { - continue; - } - - parse_str($query, $params); - if (($params['userId'] ?? null) && ($params['secret'] ?? null)) { - return $params; - } - } - - return []; - } - - private function projectHeaders(string $projectId): array - { - return [ - 'Content-Type' => 'application/json', - 'X-Appwrite-Project' => $projectId, - ]; - } - - private function documentPayload(): array - { - return [ - 'title' => 'Benchmark Document', - 'count' => 1, - 'email' => 'document@example.com', - 'active' => true, - 'publishedAt' => gmdate('c'), - 'score' => 10.5, - 'url' => 'https://appwrite.io', - 'ip' => '127.0.0.1', - ]; - } - - private function tablePayload(): array - { - return [ - 'title' => 'Benchmark Row', - 'count' => 1, - 'email' => 'row@example.com', - 'active' => true, - ]; - } - - private function flattenMultipartArray(string $key, array $values): array - { - $output = []; - - foreach (array_values($values) as $index => $value) { - $output["{$key}[{$index}]"] = $value; - } - - return $output; - } - - private function messageIncludes(array $message, array $needles): bool - { - $content = implode("\n", [ - (string) ($message['subject'] ?? ''), - (string) ($message['html'] ?? ''), - (string) ($message['text'] ?? ''), - ]); - - foreach ($needles as $needle) { - if ($this->includes($content, $needle)) { - return true; - } - } - - return false; - } - - private function includes(string $value, string $needle): bool - { - return str_contains(strtolower($value), strtolower($needle)); - } - - private function hostnameFromUrl(string $value): string - { - $host = parse_url($value, PHP_URL_HOST); - if (is_string($host) && $host !== '') { - return $host; - } - - return explode(':', explode('/', preg_replace('/^https?:\/\//', '', $value) ?? '')[0])[0]; - } - - private function unique(string $prefix): string - { - $id = strtolower($prefix . '-' . base_convert((string) ((int) (microtime(true) * 1000)), 10, 36) . '-' . bin2hex(random_bytes(4))); - return substr(preg_replace('/[^a-z0-9-]/', '-', $id) ?? $id, 0, 36); - } - - private function nowMs(): float - { - return hrtime(true) / 1_000_000; - } - - private function env(string $name, string $default): string - { - $value = getenv($name); - return $value === false || $value === '' ? $default : $value; - } - - private function loadPreviousSummary(string $path): ?array - { - if (!is_file($path)) { - return null; - } - - $summary = json_decode((string) file_get_contents($path), true); - return is_array($summary) ? $summary : null; - } - - private function writeSummary(array $summary): void - { - $directory = dirname($this->summaryPath); - if ($directory !== '.' && !is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { - throw new RuntimeException("Unable to create benchmark summary directory: {$directory}"); - } - - $json = json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - if ($json === false) { - throw new RuntimeException('Unable to encode benchmark summary: ' . json_last_error_msg()); - } - - if (file_put_contents($this->summaryPath, $json) === false) { - throw new RuntimeException("Unable to write benchmark summary: {$this->summaryPath}"); - } - } - - private function renderSummary(array $summary): string - { - $lines = [ - 'Appwrite curated benchmark review', - '', - 'Before/after comparison', - '', - $this->comparisonTable($this->previousSummary, $summary), - '', - 'Current run details', - '', - $this->metricLine($summary, 'http_req_duration', 'HTTP total'), - $this->metricLine($summary, 'appwrite_api_duration', 'API endpoints'), - $this->metricLine($summary, 'appwrite_worker_database_duration', 'Database worker schema jobs'), - $this->metricLine($summary, 'appwrite_worker_tables_duration', 'TablesDB worker schema jobs'), - $this->metricLine($summary, 'appwrite_worker_mails_duration', 'Mail worker delivery'), - $this->metricLine($summary, 'appwrite_worker_messaging_duration', 'Messaging worker delivery'), - '', - ]; - - return implode(PHP_EOL, $lines) . PHP_EOL; - } - - private function comparisonTable(?array $before, array $after): string - { - $rows = [ - ['HTTP total p95', $this->trendMetric($before, 'http_req_duration', 'p(95)'), $this->trendMetric($after, 'http_req_duration', 'p(95)'), 'ms'], - ['API endpoints p95', $this->trendMetric($before, 'appwrite_api_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_api_duration', 'p(95)'), 'ms'], - ['Database worker p95', $this->trendMetric($before, 'appwrite_worker_database_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'], - ['TablesDB worker p95', $this->trendMetric($before, 'appwrite_worker_tables_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'], - ['Mail worker p95', $this->trendMetric($before, 'appwrite_worker_mails_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'], - ['Messaging worker p95', $this->trendMetric($before, 'appwrite_worker_messaging_duration', 'p(95)'), $this->trendMetric($after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'], - ]; - - $table = [ - '| Metric | Before | After | Delta |', - '| --- | ---: | ---: | ---: |', - ]; - - foreach ($rows as [$label, $beforeValue, $afterValue, $unit]) { - $table[] = "| {$label} | {$this->formatValue($beforeValue, $unit)} | {$this->formatValue($afterValue, $unit)} | {$this->formatDelta($beforeValue, $afterValue, $unit)} |"; - } - - return implode(PHP_EOL, $table); - } - - private function trendMetric(?array $data, string $metric, string $stat): ?float - { - return $data['metrics'][$metric]['values'][$stat] ?? null; - } - - private function metricLine(array $data, string $metric, string $label): string - { - $values = $data['metrics'][$metric]['values'] ?? null; - if (!is_array($values) || ($values['count'] ?? 0) === 0) { - return "{$label}: no samples"; - } - - return "{$label}: avg={$this->round($values['avg'])}ms p90={$this->round($values['p(90)'])}ms p95={$this->round($values['p(95)'])}ms max={$this->round($values['max'])}ms"; - } - - private function formatValue(?float $value, string $unit): string - { - return $value === null || is_nan($value) ? 'n/a' : $this->round($value) . $unit; - } - - private function formatDelta(?float $before, ?float $after, string $unit): string - { - if ($before === null || $after === null || is_nan($before) || is_nan($after)) { - return 'n/a'; - } - - $delta = $this->round($after - $before); - return ($delta > 0 ? '+' : '') . $delta . $unit; - } - - private function round(float|int|null $value): string - { - $rounded = round((float) ($value ?? 0), 2); - return rtrim(rtrim(number_format($rounded, 2, '.', ''), '0'), '.'); - } -} - -exit((new HttpBenchmark())->run()); From 3cc7b833dbe91b9bff0d8e9fa6188e558d7f2c48 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 09:06:54 +0530 Subject: [PATCH 165/402] Fix k6 benchmark diagnostics --- .github/workflows/ci.yml | 67 +++++++++++++++++++++++++++++++++++++++- tests/benchmarks/http.js | 2 +- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 327f32daa8..22c1f78ddc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -712,8 +712,10 @@ jobs: if: steps.benchmark_before_start.outcome == 'success' continue-on-error: true run: | - rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before.txt benchmark.txt + set -o pipefail + rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before-samples.json benchmark-after-samples.json benchmark-before.txt benchmark.txt docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts ${{ env.K6_IMAGE }} run --quiet \ + --out json=benchmark-before-samples.json \ -e APPWRITE_ENDPOINT=http://localhost/v1 \ -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ -e APPWRITE_BENCHMARK_ITERATIONS=1 \ @@ -736,8 +738,12 @@ jobs: docker compose up -d --wait --no-build - name: Benchmark after + id: benchmark_after + continue-on-error: true run: | + set -o pipefail docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts ${{ env.K6_IMAGE }} run --quiet \ + --out json=benchmark-after-samples.json \ -e APPWRITE_ENDPOINT=http://localhost/v1 \ -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ -e APPWRITE_BENCHMARK_ITERATIONS=1 \ @@ -817,10 +823,58 @@ jobs: return `| ${label} | ${detailValue(values.avg ?? null, suffix)} | ${detailValue(values['p(90)'] ?? null, suffix)} | ${detailValue(values['p(95)'] ?? null, suffix)} | ${detailValue(values.max ?? null, suffix)} |`; } + function readSamples(path) { + if (!fs.existsSync(path)) { + return []; + } + + const contents = fs.readFileSync(path, 'utf8').trim(); + if (contents === '') { + return []; + } + + return contents + .split('\n') + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line)]; + } catch { + return []; + } + }); + } + + function slowestSample(samples, metric) { + return samples.reduce((slowest, sample) => { + if (sample.metric !== metric || typeof sample.data?.value !== 'number') { + return slowest; + } + + const current = { + name: sample.data.tags?.name || 'unknown', + value: sample.data.value, + }; + + return slowest === null || current.value > slowest.value ? current : slowest; + }, null); + } + + function formatSlowest(sample) { + if (sample === null) { + return 'n/a'; + } + + return `${markdownText(sample.name).replace(/\|/g, '\\|')} (${detailValue(sample.value, 'ms')})`; + } + const before = readSummary('benchmark-before-summary.json', false); const after = readSummary('benchmark-after-summary.json'); + const afterSamples = readSamples('benchmark-after-samples.json'); const baseRef = markdownText(process.env.BENCHMARK_BASE_REF || 'base'); const headRef = markdownText(process.env.BENCHMARK_HEAD_REF || 'head'); + const slowestHttp = slowestSample(afterSamples, 'appwrite_http_duration'); + const slowestApi = slowestSample(afterSamples, 'appwrite_api_duration'); const rows = [ row('HTTP total p95', metricValue(before, 'appwrite_http_duration', 'p(95)'), metricValue(after, 'appwrite_http_duration', 'p(95)'), 'ms'), @@ -858,6 +912,11 @@ jobs: console.log(detailRow(after, 'Mail worker delivery', 'appwrite_worker_mails_duration')); console.log(detailRow(after, 'Messaging worker delivery', 'appwrite_worker_messaging_duration')); console.log(); + console.log('| Detail | Value |'); + console.log('| --- | --- |'); + console.log(`| Slowest Appwrite request | ${formatSlowest(slowestHttp)} |`); + console.log(`| Slowest API endpoint | ${formatSlowest(slowestApi)} |`); + console.log(); console.log(''); NODE @@ -871,6 +930,8 @@ jobs: benchmark.txt benchmark-before-summary.json benchmark-after-summary.json + benchmark-before-samples.json + benchmark-after-samples.json retention-days: 7 - name: Find Comment @@ -899,3 +960,7 @@ jobs: issue-number: ${{ github.event.pull_request.number }} body-path: benchmark-comment.txt edit-mode: replace + + - name: Fail benchmark + if: steps.benchmark_after.outcome == 'failure' + run: exit 1 diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index e852794e3b..71a96c69e2 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -885,7 +885,7 @@ function tablePayload() { } function onePixelPng() { - return encoding.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'std', 'b'); + return encoding.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=', 'std', 'b'); } function flattenMultipartArray(key, values) { From cb7f2ec693e8c34473a87602f60572b8161f27b2 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 09:19:26 +0530 Subject: [PATCH 166/402] Show top benchmark request waits --- .github/workflows/ci.yml | 46 +++++++++++++++++++++++----------------- tests/benchmarks/http.js | 2 ++ 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22c1f78ddc..54f43a0a9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -845,27 +845,35 @@ jobs: }); } - function slowestSample(samples, metric) { - return samples.reduce((slowest, sample) => { + function topSamples(samples, metric, limit) { + const byName = samples.reduce((result, sample) => { if (sample.metric !== metric || typeof sample.data?.value !== 'number') { - return slowest; + return result; } - const current = { - name: sample.data.tags?.name || 'unknown', - value: sample.data.value, - }; + const name = sample.data.tags?.name || 'unknown'; + const current = result.get(name); + if (!current || sample.data.value > current.value) { + result.set(name, { name, value: sample.data.value }); + } - return slowest === null || current.value > slowest.value ? current : slowest; - }, null); + return result; + }, new Map()); + + return [...byName.values()] + .sort((left, right) => right.value - left.value) + .slice(0, limit); } - function formatSlowest(sample) { - if (sample === null) { - return 'n/a'; + function topSampleRows(samples) { + if (samples.length === 0) { + return ['| n/a | n/a |']; } - return `${markdownText(sample.name).replace(/\|/g, '\\|')} (${detailValue(sample.value, 'ms')})`; + return samples.map((sample) => { + const name = markdownText(sample.name).replace(/\|/g, '\\|'); + return `| ${name} | ${detailValue(sample.value, 'ms')} |`; + }); } const before = readSummary('benchmark-before-summary.json', false); @@ -873,8 +881,7 @@ jobs: const afterSamples = readSamples('benchmark-after-samples.json'); const baseRef = markdownText(process.env.BENCHMARK_BASE_REF || 'base'); const headRef = markdownText(process.env.BENCHMARK_HEAD_REF || 'head'); - const slowestHttp = slowestSample(afterSamples, 'appwrite_http_duration'); - const slowestApi = slowestSample(afterSamples, 'appwrite_api_duration'); + const topWaits = topSamples(afterSamples, 'appwrite_http_waiting', 3); const rows = [ row('HTTP total p95', metricValue(before, 'appwrite_http_duration', 'p(95)'), metricValue(after, 'appwrite_http_duration', 'p(95)'), 'ms'), @@ -912,10 +919,11 @@ jobs: console.log(detailRow(after, 'Mail worker delivery', 'appwrite_worker_mails_duration')); console.log(detailRow(after, 'Messaging worker delivery', 'appwrite_worker_messaging_duration')); console.log(); - console.log('| Detail | Value |'); - console.log('| --- | --- |'); - console.log(`| Slowest Appwrite request | ${formatSlowest(slowestHttp)} |`); - console.log(`| Slowest API endpoint | ${formatSlowest(slowestApi)} |`); + console.log('**Top 3 request waits**'); + console.log(); + console.log('| Request | Max wait |'); + console.log('| --- | ---: |'); + console.log(topSampleRows(topWaits).join('\n')); console.log(); console.log(''); NODE diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 71a96c69e2..7762b5d8d6 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -18,6 +18,7 @@ const PREVIOUS_SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH || const PREVIOUS_SUMMARY = loadPreviousSummary(); export const httpDuration = new Trend('appwrite_http_duration', true); +export const httpWaiting = new Trend('appwrite_http_waiting', true); export const apiDuration = new Trend('appwrite_api_duration', true); export const databaseWorkerDuration = new Trend('appwrite_worker_database_duration', true); export const tablesWorkerDuration = new Trend('appwrite_worker_tables_duration', true); @@ -720,6 +721,7 @@ function rawRequest(method, path, body, headers, name) { const payload = body === null || body === undefined ? null : JSON.stringify(body); const response = http.request(method, `${ENDPOINT}${path}`, payload, params); httpDuration.add(response.timings.duration, { name }); + httpWaiting.add(response.timings.waiting, { name }); return response; } From 7c486ddcef428c1cf87d4f3b3e3d4d90c9a64b59 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 09:26:36 +0530 Subject: [PATCH 167/402] Keep benchmark comment on missing summary --- .github/workflows/ci.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54f43a0a9d..7e88feda47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -815,7 +815,7 @@ jobs: } function detailRow(after, label, metric, suffix = 'ms') { - const values = after.metrics?.[metric]?.values; + const values = after?.metrics?.[metric]?.values; if (!values) { return `| ${label} | n/a | n/a | n/a | n/a |`; } @@ -877,7 +877,7 @@ jobs: } const before = readSummary('benchmark-before-summary.json', false); - const after = readSummary('benchmark-after-summary.json'); + const after = readSummary('benchmark-after-summary.json', false); const afterSamples = readSamples('benchmark-after-samples.json'); const baseRef = markdownText(process.env.BENCHMARK_BASE_REF || 'base'); const headRef = markdownText(process.env.BENCHMARK_HEAD_REF || 'head'); @@ -901,6 +901,10 @@ jobs: console.log('> Before benchmark did not complete; showing current branch metrics only.'); console.log(); } + if (after === null) { + console.log('> Current branch benchmark did not complete; showing available metrics only.'); + console.log(); + } console.log('| Metric | Before | After | Delta |'); console.log('| --- | ---: | ---: | ---: |'); console.log(rows.join('\n')); From c973ca0a5d91eba9366a5abead65846fd0bf9771 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 09:34:03 +0530 Subject: [PATCH 168/402] Address PHPStan level 4 review feedback --- src/Appwrite/Messaging/Adapter/Realtime.php | 9 +- src/Appwrite/Platform/Tasks/ScheduleBase.php | 4 +- src/Appwrite/SDK/Specification/Format.php | 49 +++++--- .../Database/Validator/Queries/Webhooks.php | 11 +- tests/e2e/Scopes/ApiVectorsDB.php | 110 ++++++++++++++++++ .../Databases/VectorsDBCustomClientTest.php | 2 + 6 files changed, 165 insertions(+), 20 deletions(-) create mode 100644 tests/e2e/Scopes/ApiVectorsDB.php diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 345481e0de..8fe7342ec2 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -452,6 +452,7 @@ class Realtime extends MessagingAdapter * Reserved channel params with expected type * If matched the expected type then skip the query parsing like in project */ + /** @var array $reservedParamExpectedTypes */ $reservedParamExpectedTypes = [ 'project' => 'string', ]; @@ -461,8 +462,14 @@ class Realtime extends MessagingAdapter $params = $getQueryParam($paramKey); if (\array_key_exists($paramKey, $reservedParamExpectedTypes) && $params !== null) { + $expectedType = $reservedParamExpectedTypes[$paramKey]; + $isExpectedType = match ($expectedType) { + 'array' => \is_array($params), + 'string' => \is_string($params), + }; + // If the value matches the expected type dont use it the queries - if (\is_string($params)) { + if ($isExpectedType) { $params = null; } } diff --git a/src/Appwrite/Platform/Tasks/ScheduleBase.php b/src/Appwrite/Platform/Tasks/ScheduleBase.php index 9ecd151474..1213f78924 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleBase.php +++ b/src/Appwrite/Platform/Tasks/ScheduleBase.php @@ -73,7 +73,7 @@ abstract class ScheduleBase extends Action * 2. Create timer that sync all changes from 'schedules' collection to local copy. Only reading changes thanks to 'resourceUpdatedAt' attribute * 3. Create timer that prepares coroutines for soon-to-execute schedules. When it's ready, coroutine sleeps until exact time before sending request to worker. */ - public function action(BrokerPool $publisher, BrokerPool $publisherMigrations, BrokerPool $publisherFunctions, BrokerPool $publisherMessaging, callable $isResourceBlocked, Database $dbForPlatform, callable $getProjectDB, Telemetry $telemetry): void + public function action(BrokerPool $publisher, BrokerPool $publisherMigrations, BrokerPool $publisherFunctions, BrokerPool $publisherMessaging, callable $isResourceBlocked, Database $dbForPlatform, callable $getProjectDB, Telemetry $telemetry): never { Console::title(\ucfirst(static::getSupportedResource()) . ' scheduler V1'); Console::success(APP_NAME . ' ' . \ucfirst(static::getSupportedResource()) . ' scheduler v1 has started'); @@ -102,7 +102,7 @@ abstract class ScheduleBase extends Action $this->collectSchedules($dbForPlatform, $getProjectDB, $lastSyncUpdate, $isResourceBlocked); }); - for (;;) { + while (true) { try { go(fn () => $this->enqueueResources($dbForPlatform, $getProjectDB)); $this->scheduleTelemetryCount->record(count($this->schedules), ['resourceType' => static::getSupportedResource()]); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 4b9ee63205..08f960b2a7 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -40,6 +40,9 @@ abstract class Format 'license.url' => '', ]; + /** + * @var list, parameter: string, excludeKeys?: list, exclude?: bool}> + */ private const array OAUTH_PROVIDER_BLACKLIST = [ [ 'namespace' => 'account', @@ -67,6 +70,9 @@ abstract class Format ], ]; + /** + * @var list, parameter: string, excludeKeys?: list, exclude?: bool}> + */ private const array PROVIDER_USAGE_BLACKLIST = [ [ 'namespace' => 'users', @@ -78,6 +84,9 @@ abstract class Format ], ]; + /** + * @var list, parameter: string, required?: bool, nullable?: bool}> + */ private const array REQUEST_PARAMETER_OVERRIDES = [ [ 'namespace' => 'project', @@ -109,25 +118,20 @@ abstract class Format { $blacklist = []; - foreach (self::OAUTH_PROVIDER_BLACKLIST as $config) { + foreach ([...self::OAUTH_PROVIDER_BLACKLIST, ...self::PROVIDER_USAGE_BLACKLIST] as $config) { foreach ($config['methods'] as $method) { - $blacklist[] = [ + $entry = [ 'namespace' => $config['namespace'], 'method' => $method, 'parameter' => $config['parameter'], - 'excludeKeys' => $config['excludeKeys'], - ]; - } - } - - foreach (self::PROVIDER_USAGE_BLACKLIST as $config) { - foreach ($config['methods'] as $method) { - $blacklist[] = [ - 'namespace' => $config['namespace'], - 'method' => $method, - 'parameter' => $config['parameter'], - 'exclude' => $config['exclude'], ]; + if (isset($config['excludeKeys'])) { + $entry['excludeKeys'] = $config['excludeKeys']; + } + if (isset($config['exclude'])) { + $entry['exclude'] = $config['exclude']; + } + $blacklist[] = $entry; } } @@ -947,7 +951,7 @@ abstract class Format 'nullable' => $nullable, ]; - foreach (self::REQUEST_PARAMETER_OVERRIDES as $override) { + foreach ($this->getRequestParameterOverrides() as $override) { if ( $override['namespace'] !== $service || !\in_array($method, $override['methods'], true) @@ -956,7 +960,12 @@ abstract class Format continue; } - $config['required'] = $override['required']; + if (isset($override['required'])) { + $config['required'] = $override['required']; + } + if (isset($override['nullable'])) { + $config['nullable'] = $override['nullable']; + } break; } @@ -965,6 +974,14 @@ abstract class Format return $config; } + /** + * @return list, parameter: string, required?: bool, nullable?: bool}> + */ + private function getRequestParameterOverrides(): array + { + return self::REQUEST_PARAMETER_OVERRIDES; + } + public function getResponseEnumName(string $model, string $param): ?string { if ($param === 'type' && \str_starts_with($model, 'platform') && $model !== 'platformList') { diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php index 9fbc158ab2..587ad58ea4 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php @@ -51,6 +51,15 @@ class Webhooks extends Base */ public function isValid($value): bool { + return parent::isValid($this->normalizeAliases($value)); + } + + private function normalizeAliases(mixed $value): mixed + { + if (!\is_array($value)) { + return $value; + } + foreach ($value as &$queryString) { if (!\is_string($queryString)) { continue; @@ -61,6 +70,6 @@ class Webhooks extends Base } unset($queryString); - return parent::isValid($value); + return $value; } } diff --git a/tests/e2e/Scopes/ApiVectorsDB.php b/tests/e2e/Scopes/ApiVectorsDB.php new file mode 100644 index 0000000000..09494d3c10 --- /dev/null +++ b/tests/e2e/Scopes/ApiVectorsDB.php @@ -0,0 +1,110 @@ + Date: Wed, 22 Apr 2026 16:14:30 +1200 Subject: [PATCH 169/402] fix: cast cached total to int in listDocuments/listRows Redis stringifies scalars on save, so on a cache hit the `total` field was served as a string. Flutter SDK (and any strictly-typed client) then failed with `TypeError: "37": type 'String' is not a subtype of type 'int'`. The cache-miss path returned an int from `count()`, which is why only repeat requests with `ttl > 0` tripped the bug. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Databases/Http/Databases/Collections/Documents/XList.php | 2 +- tests/e2e/Services/Databases/DatabasesBase.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index c1297b98a0..d2f1915a4f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -182,7 +182,7 @@ class XList extends Action $cachedTotal = null; } if ($cachedTotal !== null && $cachedTotal !== false) { - $total = $cachedTotal; + $total = (int) $cachedTotal; } else { $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); try { diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index f5f1d1864c..b8f2a1742e 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -3368,7 +3368,7 @@ trait DatabasesBase ]); $this->assertEquals(200, $documents2['headers']['status-code']); - $this->assertEquals(3, $documents2['body']['total']); + $this->assertSame(3, $documents2['body']['total']); $this->assertCount(3, $documents2['body'][$this->getRecordResource()]); $this->assertEquals($documents1['body'][$this->getRecordResource()][0]['$id'], $documents2['body'][$this->getRecordResource()][0]['$id']); $this->assertEquals($documents1['body'][$this->getRecordResource()][0]['title'], $documents2['body'][$this->getRecordResource()][0]['title']); From 3b9c604eb8cbceb81c2879b73c2d678f4ec2e20c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 09:45:51 +0530 Subject: [PATCH 170/402] Harden benchmark comparison run --- .github/workflows/ci.yml | 3 +++ tests/benchmarks/http.js | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e88feda47..a52f051fa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -714,12 +714,14 @@ jobs: run: | set -o pipefail rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before-samples.json benchmark-after-samples.json benchmark-before.txt benchmark.txt + # Use the current benchmark script for both images so before/after differ only by the Appwrite image. docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts ${{ env.K6_IMAGE }} run --quiet \ --out json=benchmark-before-samples.json \ -e APPWRITE_ENDPOINT=http://localhost/v1 \ -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ -e APPWRITE_BENCHMARK_ITERATIONS=1 \ -e APPWRITE_BENCHMARK_VUS=1 \ + -e APPWRITE_WORKER_TIMEOUT_MS=120000 \ -e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-before-summary.json \ tests/benchmarks/http.js | tee benchmark-before.txt @@ -748,6 +750,7 @@ jobs: -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ -e APPWRITE_BENCHMARK_ITERATIONS=1 \ -e APPWRITE_BENCHMARK_VUS=1 \ + -e APPWRITE_WORKER_TIMEOUT_MS=120000 \ -e APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH=benchmark-before-summary.json \ -e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-after-summary.json \ tests/benchmarks/http.js | tee benchmark.txt diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 7762b5d8d6..e6b7db58ef 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -956,11 +956,20 @@ function detailRow(data, label, metric, unit = 'ms') { } function loadPreviousSummary() { - try { - return JSON.parse(open(PREVIOUS_SUMMARY_PATH)); - } catch (error) { - return null; + const paths = [PREVIOUS_SUMMARY_PATH]; + if (!PREVIOUS_SUMMARY_PATH.startsWith('/')) { + paths.push(`../../${PREVIOUS_SUMMARY_PATH}`); } + + for (const path of paths) { + try { + return JSON.parse(open(path)); + } catch (error) { + // Try the next path. k6 resolves open() relative to the script file. + } + } + + return null; } function comparisonTable(before, after) { From a0ef5968fb9ea2084f7032cd9dc4fa3fe13b05a8 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 11:58:30 +0530 Subject: [PATCH 171/402] Document local HTTP benchmark command --- tests/benchmarks/http.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index e6b7db58ef..0ec27f01db 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -1,3 +1,28 @@ +/* + * Run locally: + * + * docker compose up -d --force-recreate --build --wait + * + * docker run --rm -i \ + * --network appwrite \ + * -p 127.0.0.1:5665:5665 \ + * -v "$PWD:/scripts:ro" \ + * -v /tmp:/host-tmp \ + * -w /scripts \ + * -e K6_WEB_DASHBOARD=true \ + * -e K6_WEB_DASHBOARD_HOST=0.0.0.0 \ + * -e K6_WEB_DASHBOARD_PORT=5665 \ + * -e K6_WEB_DASHBOARD_EXPORT=/host-tmp/appwrite-k6-report.html \ + * -e APPWRITE_ENDPOINT=http://appwrite/v1 \ + * -e APPWRITE_MAILDEV_ENDPOINT=http://maildev:1080/email \ + * -e APPWRITE_WORKER_TIMEOUT_MS=120000 \ + * -e APPWRITE_BENCHMARK_SUMMARY_PATH=/host-tmp/appwrite-k6-summary.json \ + * grafana/k6:0.53.0 run \ + * --out json=/host-tmp/appwrite-k6-samples.json \ + * tests/benchmarks/http.js + * + * Open http://127.0.0.1:5665 while the benchmark is running. + */ import http from 'k6/http'; import { check, group, sleep } from 'k6'; import encoding from 'k6/encoding'; From a98b9f23195548a7f72e91329ae3c88f04dedbb6 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 12:06:32 +0530 Subject: [PATCH 172/402] Handle malformed optional benchmark summaries --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a52f051fa7..de5fbfc433 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -778,7 +778,11 @@ jobs: try { return JSON.parse(fs.readFileSync(path, 'utf8')); } catch (error) { - throw new Error(`Invalid benchmark summary ${path}: ${error.message}`); + if (required) { + throw new Error(`Invalid benchmark summary ${path}: ${error.message}`); + } + console.error(`Invalid benchmark summary ${path}: ${error.message}`); + return null; } } From 32508e7251c770192135774e3349a9823e06e8d6 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 12:53:13 +0530 Subject: [PATCH 173/402] Avoid reserved TablesDB benchmark column name --- tests/benchmarks/http.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 0ec27f01db..90e6003332 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -445,7 +445,7 @@ function tablesDbFlow(ctx) { const columns = [ ['string', 'title', { size: 128 }], - ['integer', 'count', { min: 0, max: 100000 }], + ['integer', 'quantity', { min: 0, max: 100000 }], ['email', 'email', {}], ['boolean', 'active', {}], ]; @@ -482,10 +482,10 @@ function tablesDbFlow(ctx) { api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, { data: { title: 'Benchmark Row Updated' }, }, ctx.sessionHeaders, [200], 'tablesdb.rows.update'); - api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/count/increment`, { + api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/quantity/increment`, { value: 1, }, ctx.sessionHeaders, [200], 'tablesdb.rows.increment'); - api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/count/decrement`, { + api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/quantity/decrement`, { value: 1, }, ctx.sessionHeaders, [200], 'tablesdb.rows.decrement'); api('DELETE', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, null, ctx.sessionHeaders, [204], 'tablesdb.rows.delete'); @@ -905,7 +905,7 @@ function documentPayload() { function tablePayload() { return { title: 'Benchmark Row', - count: 1, + quantity: 1, email: 'row@example.com', active: true, }; From b3f305f9a805ce065d7114ced5cc3e9561569527 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 13:02:16 +0530 Subject: [PATCH 174/402] Record storage upload wait metric --- tests/benchmarks/http.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 90e6003332..2a3527c95b 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -524,6 +524,7 @@ function storageFlow(ctx) { }); httpDuration.add(upload.timings.duration, { name: 'storage.files.create' }); + httpWaiting.add(upload.timings.waiting, { name: 'storage.files.create' }); apiDuration.add(upload.timings.duration, { name: 'storage.files.create' }); assertStatus(upload, [201], 'storage file created'); From e530bf41f7033fe341689d425334c00191864de2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 09:59:00 +0200 Subject: [PATCH 175/402] Post-merge fix --- app/controllers/api/projects.php | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 84f109b975..bd5d0504cf 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -301,3 +301,30 @@ Http::post('/v1/projects/:projectId/jwts') 'scopes' => $scopes ])]), Response::MODEL_JWT); }); + +// Backwards compatibility +Http::delete('/v1/projects/:projectId/templates/email') + ->alias('/v1/projects/:projectId/templates/email/:type/:locale') + ->desc('Delete custom email template') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) + ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) + ->inject('response') + ->inject('dbForPlatform') + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + + $project = $dbForPlatform->getDocument('projects', $projectId); + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $templates = $project->getAttribute('templates', []); + unset($templates['email.' . $type . '-' . $locale]); + + $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); + + $response->noContent(); + }); From bfa1960d8aa94c63d702d5e36221fd4251c843eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 10:00:10 +0200 Subject: [PATCH 176/402] Remove unneeded const --- app/init/constants.php | 1 - src/Appwrite/Migration/Version/V17.php | 2 +- src/Appwrite/Utopia/Response/Model/Project.php | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/init/constants.php b/app/init/constants.php index ce1b34f574..8eacf2fe12 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -25,7 +25,6 @@ const APP_MODE_ADMIN = 'admin'; const APP_PAGING_LIMIT = 12; const APP_LIMIT_COUNT = 5000; const APP_LIMIT_USERS = 10_000; -const APP_LIMIT_USER_SESSIONS_DEFAULT = 10; const APP_LIMIT_ANTIVIRUS = 20_000_000; //20MB const APP_LIMIT_ENCRYPTION = 20_000_000; //20MB const APP_LIMIT_COMPRESSION = 20_000_000; //20MB diff --git a/src/Appwrite/Migration/Version/V17.php b/src/Appwrite/Migration/Version/V17.php index 3297206ccd..862ab7f26c 100644 --- a/src/Appwrite/Migration/Version/V17.php +++ b/src/Appwrite/Migration/Version/V17.php @@ -262,7 +262,7 @@ class V17 extends Migration * Set default maxSessions */ $document->setAttribute('auths', array_merge($document->getAttribute('auths', []), [ - 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT + 'maxSessions' => 10 ])); break; case 'users': diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index a599d08a04..97b58d8a51 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -482,7 +482,7 @@ class Project extends Model $document->setAttribute('authLimit', $authValues['limit'] ?? 0); $document->setAttribute('authDuration', $authValues['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG); - $document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT); + $document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? 0); $document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0); $document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false); $document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false); From 72bb6378c23f009a045f5d6a83a75063cc777172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 10:00:19 +0200 Subject: [PATCH 177/402] Leftover --- app/controllers/shared/api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 74a05ce4e4..8b8c7ee066 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -753,7 +753,7 @@ Http::shutdown() ->inject('project') ->inject('dbForProject') ->action(function (Http $utopia, Request $request, Response $response, Document $project, Database $dbForProject) { - $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT; + $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? 0; if ($sessionLimit === 0) { return; From 73a77b8dccb041934ee6a5b817e992d69ea68a0a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 13:45:50 +0530 Subject: [PATCH 178/402] Show benchmark throughput --- .github/workflows/ci.yml | 1 + tests/benchmarks/http.js | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de5fbfc433..2bbff0e8c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -891,6 +891,7 @@ jobs: const topWaits = topSamples(afterSamples, 'appwrite_http_waiting', 3); const rows = [ + row('HTTP throughput', metricValue(before, 'http_reqs', 'rate'), metricValue(after, 'http_reqs', 'rate'), ' req/s'), row('HTTP total p95', metricValue(before, 'appwrite_http_duration', 'p(95)'), metricValue(after, 'appwrite_http_duration', 'p(95)'), 'ms'), row('API endpoints p95', metricValue(before, 'appwrite_api_duration', 'p(95)'), metricValue(after, 'appwrite_api_duration', 'p(95)'), 'ms'), row('Database worker p95', metricValue(before, 'appwrite_worker_database_duration', 'p(95)'), metricValue(after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'), diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 2a3527c95b..ef873f6688 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -1000,6 +1000,7 @@ function loadPreviousSummary() { function comparisonTable(before, after) { const rows = [ + ['HTTP throughput', trendMetric(before, 'http_reqs', 'rate'), trendMetric(after, 'http_reqs', 'rate'), ' req/s'], ['HTTP total p95', trendMetric(before, 'appwrite_http_duration', 'p(95)'), trendMetric(after, 'appwrite_http_duration', 'p(95)'), 'ms'], ['API endpoints p95', trendMetric(before, 'appwrite_api_duration', 'p(95)'), trendMetric(after, 'appwrite_api_duration', 'p(95)'), 'ms'], ['Database worker p95', trendMetric(before, 'appwrite_worker_database_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'], From 7d7fcea8c0f6c1b24ad3556b9b817971d0d82bf6 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 14:16:12 +0530 Subject: [PATCH 179/402] Ensure benchmark failures fail CI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bbff0e8c8..c215e209e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -982,5 +982,5 @@ jobs: edit-mode: replace - name: Fail benchmark - if: steps.benchmark_after.outcome == 'failure' + if: always() && steps.benchmark_after.outcome == 'failure' run: exit 1 From dfd39d394604a9054918093fd160362d0d5eff37 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 14:25:59 +0530 Subject: [PATCH 180/402] Tolerate benchmark cleanup failures --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c215e209e8..802648b0ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -757,7 +757,7 @@ jobs: - name: Stop after Appwrite if: always() - run: docker compose down -v + run: docker compose down -v || true - name: Prepare comment env: From affd5876abf11fcc88208f05e5a2cf84d97c5308 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 14:49:35 +0530 Subject: [PATCH 181/402] Add spec enum service overlap validation --- src/Appwrite/Platform/Tasks/Specs.php | 141 ++++++++++++++++++++++++ tests/unit/Platform/Tasks/SpecsTest.php | 122 ++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 tests/unit/Platform/Tasks/SpecsTest.php diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index 2c03ad3108..e9fa15e6c7 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -297,6 +297,146 @@ class Specs extends Action ]; } + protected function verifyParsedSpec(array $spec): void + { + $services = []; + foreach ($spec['tags'] ?? [] as $tag) { + if (!\is_array($tag)) { + continue; + } + + $service = $tag['name'] ?? null; + if (!\is_string($service) || $service === '') { + continue; + } + + $services[$this->normalizeSdkName($service)] = $service; + } + + if (empty($services)) { + return; + } + + $enums = []; + $this->collectSpecEnumNames($spec, $enums); + + if (empty($enums)) { + return; + } + + $overlaps = []; + foreach ($services as $normalized => $service) { + if (!isset($enums[$normalized])) { + continue; + } + + foreach ($enums[$normalized] as $enum) { + $overlaps[] = "{$enum} (service '{$service}', enum '{$enum}')"; + } + } + + if (!empty($overlaps)) { + throw new \RuntimeException( + 'Spec service names must not overlap enum names. Overlaps: ' + . \implode(', ', \array_unique($overlaps)) + ); + } + } + + private function collectSpecEnumNames(array $node, array &$enums, ?string $fallbackName = null): void + { + if (isset($node['enum']) && \is_array($node['enum'])) { + $enumName = $this->getExplicitSpecEnumName($node) + ?? $this->getFallbackSpecEnumName($node, $fallbackName); + + if (!\is_null($enumName)) { + $this->addSpecEnumName($enums, $enumName); + } + } + + if ( + isset($node['items']) + && \is_array($node['items']) + && isset($node['items']['enum']) + && \is_array($node['items']['enum']) + ) { + $enumName = $this->getExplicitSpecEnumName($node['items']) + ?? $this->getExplicitSpecEnumName($node) + ?? $this->getFallbackSpecEnumName($node, $fallbackName); + + if (!\is_null($enumName)) { + $this->addSpecEnumName($enums, $enumName); + } + } + + $explicitEnumName = $this->getExplicitSpecEnumName($node); + if (!\is_null($explicitEnumName)) { + $this->addSpecEnumName($enums, $explicitEnumName); + } + + foreach ($node as $key => $value) { + if (!\is_array($value)) { + continue; + } + + $this->collectSpecEnumNames( + $value, + $enums, + $this->getChildSpecEnumFallbackName($node, $key, $value, $fallbackName) + ); + } + } + + private function addSpecEnumName(array &$enums, string $name): void + { + $enums[$this->normalizeSdkName($name)][] = $this->formatSdkName($name); + } + + private function getExplicitSpecEnumName(array $node): ?string + { + $enumName = $node['x-enum-name'] ?? null; + + return \is_string($enumName) && $enumName !== '' ? $enumName : null; + } + + private function getFallbackSpecEnumName(array $node, ?string $fallbackName): ?string + { + $name = $node['name'] ?? $fallbackName; + + return \is_string($name) && $name !== '' ? $name : null; + } + + private function getChildSpecEnumFallbackName( + array $parent, + int|string $key, + array $child, + ?string $fallbackName + ): ?string { + if (isset($child['name']) && \is_string($child['name']) && $child['name'] !== '') { + return $child['name']; + } + + if ($key === 'schema' || $key === 'items') { + return $this->getFallbackSpecEnumName($parent, $fallbackName); + } + + if (\is_string($key) && !\in_array($key, ['components', 'content', 'definitions', 'parameters', 'paths', 'properties', 'responses'], true)) { + return $key; + } + + return $fallbackName; + } + + private function formatSdkName(string $name): string + { + return \str_replace(' ', '', \ucwords(\str_replace(['-', '_', '/'], ' ', $name))); + } + + private function normalizeSdkName(string $name): string + { + return \strtolower((string) \preg_replace('/[^a-z0-9]/i', '', $name)); + } + public function getSDKPlatformsForRouteSecurity(array $routeSecurity): array { $sdkPlatforms = []; @@ -483,6 +623,7 @@ class Specs extends Action try { $parsedSpecs = $specs->parse(); + $this->verifyParsedSpec($parsedSpecs); } catch (\RuntimeException $e) { throw new \RuntimeException("Spec generation failed for {$platform} ({$format}): " . $e->getMessage(), 0, $e); } diff --git a/tests/unit/Platform/Tasks/SpecsTest.php b/tests/unit/Platform/Tasks/SpecsTest.php new file mode 100644 index 0000000000..a10c66bf81 --- /dev/null +++ b/tests/unit/Platform/Tasks/SpecsTest.php @@ -0,0 +1,122 @@ +verifyParsedSpec($spec); + } +} + +class SpecsTest extends TestCase +{ + private TestSpecs $specs; + + protected function setUp(): void + { + $this->specs = new TestSpecs(); + } + + public function testVerifyParsedSpecFailsOnServiceEnumNameOverlap(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Locale (service \'locale\', enum \'Locale\')'); + + $this->specs->verify([ + 'tags' => [ + [ + 'name' => 'locale', + 'description' => 'Locale APIs', + ], + ], + 'paths' => [ + '/account/sessions/oauth2/{provider}' => [ + 'get' => [ + 'parameters' => [ + [ + 'name' => 'provider', + 'schema' => [ + 'type' => 'string', + 'enum' => ['en'], + 'x-enum-name' => 'Locale', + ], + ], + ], + ], + ], + ], + ]); + } + + public function testVerifyParsedSpecFailsOnDerivedServiceEnumNameOverlap(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Locale (service \'locale\', enum \'Locale\')'); + + $this->specs->verify([ + 'tags' => [ + [ + 'name' => 'locale', + 'description' => 'Locale APIs', + ], + ], + 'paths' => [ + '/projects/{projectId}/templates/email/{type}/{locale}' => [ + 'patch' => [ + 'parameters' => [ + [ + 'name' => 'payload', + 'in' => 'body', + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'locale' => [ + 'type' => 'string', + 'enum' => ['en'], + 'x-enum-name' => null, + ], + ], + ], + ], + ], + ], + ], + ], + ]); + } + + public function testVerifyParsedSpecAllowsDistinctServiceAndEnumNames(): void + { + $this->specs->verify([ + 'tags' => [ + [ + 'name' => 'locale', + 'description' => 'Locale APIs', + ], + ], + 'paths' => [ + '/projects/{projectId}/templates/email/{type}/{locale}' => [ + 'patch' => [ + 'parameters' => [ + [ + 'name' => 'locale', + 'schema' => [ + 'type' => 'string', + 'enum' => ['en'], + 'x-enum-name' => 'EmailTemplateLocale', + ], + ], + ], + ], + ], + ], + ]); + + $this->addToAssertionCount(1); + } +} From 2e42633e1281afbf96c1f11ee86251bf79fd0ec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 11:30:39 +0200 Subject: [PATCH 182/402] Add public mocks API for phones --- app/config/errors.php | 10 ++ app/controllers/api/projects.php | 14 +-- app/init/models.php | 1 + src/Appwrite/Extend/Exception.php | 4 + .../Project/Http/Project/MockPhone/Create.php | 107 ++++++++++++++++++ .../Project/Http/Project/MockPhone/Delete.php | 103 +++++++++++++++++ .../Project/Http/Project/MockPhone/Get.php | 78 +++++++++++++ .../Project/Http/Project/MockPhone/Update.php | 107 ++++++++++++++++++ .../Project/Http/Project/MockPhone/XList.php | 69 +++++++++++ .../Modules/Project/Services/Http.php | 12 ++ src/Appwrite/Utopia/Response.php | 1 + src/Appwrite/Utopia/Response/Filters/V23.php | 14 +++ .../Utopia/Response/Model/MockNumber.php | 14 ++- 13 files changed, 520 insertions(+), 14 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php diff --git a/app/config/errors.php b/app/config/errors.php index 4190c6e277..9a4710cb33 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1408,4 +1408,14 @@ return [ 'description' => 'When using project API key, make sure to pass x-appwrite-project header with your project ID.', 'code' => 403, ], + Exception::MOCK_NUMBER_ALREADY_EXISTS => [ + 'name' => Exception::MOCK_NUMBER_ALREADY_EXISTS, + 'description' => 'Mock number with the requested number already exists. Try again with a different number. or update OTP of existing mock number.', + 'code' => 409, + ], + Exception::MOCK_NUMBER_NOT_FOUND => [ + 'name' => Exception::MOCK_NUMBER_NOT_FOUND, + 'description' => 'Mock number with the requested number could not be found.', + 'code' => 404, + ], ]; diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index bd5d0504cf..9241043209 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -170,23 +170,11 @@ Http::patch('/v1/projects/:projectId/auth/:method') $response->dynamic($project, Response::MODEL_PROJECT); }); +// Backwards compatibility Http::patch('/v1/projects/:projectId/auth/mock-numbers') ->desc('Update the mock numbers for the project') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateMockNumbers', - description: '/docs/references/projects/update-mock-numbers.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('numbers', '', new ArrayList(new MockNumber(), 10), 'An array of mock numbers and their corresponding verification codes (OTPs). Each number should be a valid E.164 formatted phone number. Maximum of 10 numbers are allowed.') ->inject('response') diff --git a/app/init/models.php b/app/init/models.php index f654c10121..4d1ccc2824 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -210,6 +210,7 @@ Response::setModel(new BaseList('Currencies List', Response::MODEL_CURRENCY_LIST Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phones', Response::MODEL_PHONE)); Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false)); Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE)); +Response::setModel(new BaseList('Mock Numbers List', Response::MODEL_MOCK_NUMBER_LIST, 'mockNumbers', Response::MODEL_MOCK_NUMBER)); Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS)); Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE)); Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE)); diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 58a21b5517..b1651fec13 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -384,6 +384,10 @@ class Exception extends \Exception public const string MESSAGE_TARGET_NOT_PUSH = 'message_target_not_push'; public const string MESSAGE_MISSING_SCHEDULE = 'message_missing_schedule'; + /** Mocks */ + public const string MOCK_NUMBER_ALREADY_EXISTS = 'mock_number_already_exists'; + public const string MOCK_NUMBER_NOT_FOUND = 'mock_number_not_found'; + /** Targets */ public const string TARGET_PROVIDER_INVALID_TYPE = 'target_provider_invalid_type'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php new file mode 100644 index 0000000000..8aa2bcf642 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php @@ -0,0 +1,107 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/mock-phones') + ->desc('Create project mock phone') + ->groups(['api', 'project']) + ->label('scope', 'mocks.write') + ->label('event', 'mock-phones.[number].create') + ->label('audits.event', 'project.mock-phone.create') + ->label('audits.resource', 'project.mock-phone/{response.number}') + ->label('sdk', new Method( + namespace: 'project', + group: 'mocks', + name: 'createMockPhone', + description: <<param('number', null, new Phone(), 'Phone number to associate with the mock phone. Must be a valid E.164 formatted phone number.') + ->param('otp', '', new Text(6, 6, Text::NUMBERS), 'One-time password (OTP) to associate with the mock phone. Must be a 6-digit numeric code.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $number, + string $otp, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $auths = $project->getAttribute('auths', []); + + $mockNumbers = $auths['mockNumbers'] ?? []; + + foreach ($mockNumbers as $mockNumber) { + if ($mockNumber['number'] === $number) { + throw new Exception(Exception::MOCK_NUMBER_ALREADY_EXISTS); + } + } + + // Set to now date + $mockNumber = [ + 'number' => $number, + 'otp' => $otp, + '$createdAt' => DateTime::now(), + '$updatedAt' => DateTime::now(), + ]; + + $mockNumbers[] = $mockNumber; + $auths['mockNumbers'] = $mockNumbers; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('number', $number); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic(new Document($mockNumber), Response::MODEL_MOCK_NUMBER); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php new file mode 100644 index 0000000000..af7afae120 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php @@ -0,0 +1,103 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project/mock-phones/:number') + ->desc('Delete project mock phone') + ->groups(['api', 'project']) + ->label('scope', 'mocks.write') + ->label('event', 'mock-phones.[number].delete') + ->label('audits.event', 'project.mock-phone.delete') + ->label('audits.resource', 'project.mock-phone/{request.number}') + ->label('sdk', new Method( + namespace: 'project', + group: 'mocks', + name: 'deleteMockPhone', + description: <<param('number', null, new Phone(), 'Phone number associated with the mock phone. Must be a valid E.164 formatted phone number.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $number, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $auths = $project->getAttribute('auths', []); + + $mockNumbers = $auths['mockNumbers'] ?? []; + + $mockNumberIndex = null; + foreach ($mockNumbers as $index => $mock) { + if ($mock['number'] === $number) { + $mockNumberIndex = $index; + break; + } + } + + if (\is_null($mockNumberIndex)) { + throw new Exception(Exception::MOCK_NUMBER_NOT_FOUND); + } + + unset($mockNumbers[$mockNumberIndex]); + $mockNumbers = array_values($mockNumbers); + + $auths['mockNumbers'] = $mockNumbers; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('number', $number); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php new file mode 100644 index 0000000000..8f799e98d7 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/mock-phones/:number') + ->desc('Get project mock phone') + ->groups(['api', 'project']) + ->label('scope', 'mocks.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'mocks', + name: 'getMockPhone', + description: <<param('number', null, new Phone(), 'Phone number associated with the mock phone. Must be a valid E.164 formatted phone number.') + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $number, + Response $response, + Document $project + ) { + $auths = $project->getAttribute('auths', []); + + $mockNumbers = $auths['mockNumbers'] ?? []; + + $mockNumberIndex = null; + foreach ($mockNumbers as $index => $mock) { + if ($mock['number'] === $number) { + $mockNumberIndex = $index; + break; + } + } + + if (\is_null($mockNumberIndex)) { + throw new Exception(Exception::MOCK_NUMBER_NOT_FOUND); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($mockNumbers[$mockNumberIndex]), Response::MODEL_MOCK_NUMBER); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php new file mode 100644 index 0000000000..4924f53ff8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php @@ -0,0 +1,107 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/mock-phones/:number') + ->desc('Update project mock phone') + ->groups(['api', 'project']) + ->label('scope', 'mocks.write') + ->label('event', 'mock-phones.[number].update') + ->label('audits.event', 'project.mock-phone.update') + ->label('audits.resource', 'project.mock-phone/{response.number}') + ->label('sdk', new Method( + namespace: 'project', + group: 'mocks', + name: 'updateMockPhone', + description: <<param('number', null, new Phone(), 'Phone number associated with the mock phone. Must be a valid E.164 formatted phone number.') + ->param('otp', '', new Text(6, 6, Text::NUMBERS), 'One-time password (OTP) to associate with the mock phone. Must be a 6-digit numeric code.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $number, + string $otp, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $auths = $project->getAttribute('auths', []); + + $mockNumbers = $auths['mockNumbers'] ?? []; + + $mockNumberIndex = null; + foreach ($mockNumbers as $index => $mock) { + if ($mock['number'] === $number) { + $mockNumberIndex = $index; + break; + } + } + + if (\is_null($mockNumberIndex)) { + throw new Exception(Exception::MOCK_NUMBER_NOT_FOUND); + } + + $mockNumbers[$mockNumberIndex]['otp'] = $otp; + $mockNumbers[$mockNumberIndex]['$updatedAt'] = DateTime::now(); + + $auths['mockNumbers'] = $mockNumbers; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('number', $number); + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($mockNumbers[$mockNumberIndex]), Response::MODEL_MOCK_NUMBER); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php new file mode 100644 index 0000000000..a12aa11108 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php @@ -0,0 +1,69 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/mock-phones') + ->desc('List project mock phones') + ->groups(['api', 'project']) + ->label('scope', 'mocks.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'mocks', + name: 'listMockPhones', + description: <<param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + bool $includeTotal, + Response $response, + Document $project, + ) { + $auths = $project->getAttribute('auths', []); + $mockNumbers = $auths['mockNumbers'] ?? []; + + $total = $includeTotal ? \count($mockNumbers) : 0; + + $mockNumbers = \array_map(fn ($mockNumber) => new Document($mockNumber), $mockNumbers); + + $response->dynamic(new Document([ + 'mockNumbers' => $mockNumbers, + 'total' => $total, + ]), Response::MODEL_MOCK_NUMBER_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 331ad9482e..c353c6a4f3 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -9,6 +9,11 @@ use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Update as UpdateKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\XList as ListKeys; use Appwrite\Platform\Modules\Project\Http\Project\Labels\Update as UpdateProjectLabels; +use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Create as CreateMockPhone; +use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Delete as DeleteMockPhone; +use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Get as GetMockPhone; +use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Update as UpdateMockPhone; +use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\XList as ListMockPhones; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Create as CreateAndroidPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Update as UpdateAndroidPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Apple\Create as CreateApplePlatform; @@ -95,6 +100,13 @@ class Http extends Service $this->addAction(GetPlatform::getName(), new GetPlatform()); $this->addAction(ListPlatforms::getName(), new ListPlatforms()); + // Mock Phones + $this->addAction(CreateMockPhone::getName(), new CreateMockPhone()); + $this->addAction(ListMockPhones::getName(), new ListMockPhones()); + $this->addAction(GetMockPhone::getName(), new GetMockPhone()); + $this->addAction(UpdateMockPhone::getName(), new UpdateMockPhone()); + $this->addAction(DeleteMockPhone::getName(), new DeleteMockPhone()); + // Policies $this->addAction(UpdateMembershipPrivacyPolicy::getName(), new UpdateMembershipPrivacyPolicy()); $this->addAction(UpdatePasswordDictionaryPolicy::getName(), new UpdatePasswordDictionaryPolicy()); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index d747373b59..56ba5635b1 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -254,6 +254,7 @@ class Response extends SwooleResponse public const MODEL_DEV_KEY = 'devKey'; public const MODEL_DEV_KEY_LIST = 'devKeyList'; public const MODEL_MOCK_NUMBER = 'mockNumber'; + public const MODEL_MOCK_NUMBER_LIST = 'mockNumberList'; public const MODEL_AUTH_PROVIDER = 'authProvider'; public const MODEL_AUTH_PROVIDER_LIST = 'authProviderList'; public const MODEL_PLATFORM_APPLE = 'platformApple'; diff --git a/src/Appwrite/Utopia/Response/Filters/V23.php b/src/Appwrite/Utopia/Response/Filters/V23.php index 51d223de37..cd8ce44c0a 100644 --- a/src/Appwrite/Utopia/Response/Filters/V23.php +++ b/src/Appwrite/Utopia/Response/Filters/V23.php @@ -16,10 +16,24 @@ class V23 extends Filter Response::MODEL_PROJECT => $this->parseProject($content), Response::MODEL_PROJECT_LIST => $this->handleList($content, 'projects', fn ($item) => $this->parseProject($item)), Response::MODEL_EMAIL_TEMPLATE => $this->parseEmailTemplate($content), + Response::MODEL_MOCK_NUMBER => $this->parseMockNumber($content), default => $content, }; } + private function parseMockNumber(array $content): array + { + unset($content['$createdAt']); + unset($content['$updatedAt']); + + if (isset($content['number'])) { + $content['phone'] = $content['number']; + unset($content['number']); + } + + return $content; + } + private function parseMembership(array $content): array { unset($content['userPhone']); diff --git a/src/Appwrite/Utopia/Response/Model/MockNumber.php b/src/Appwrite/Utopia/Response/Model/MockNumber.php index 14ce747da6..eee788dbab 100644 --- a/src/Appwrite/Utopia/Response/Model/MockNumber.php +++ b/src/Appwrite/Utopia/Response/Model/MockNumber.php @@ -10,7 +10,7 @@ class MockNumber extends Model public function __construct() { $this - ->addRule('phone', [ + ->addRule('number', [ 'type' => self::TYPE_STRING, 'description' => 'Mock phone number for testing phone authentication. Useful for testing phone authentication without sending an SMS.', 'default' => '', @@ -22,6 +22,18 @@ class MockNumber extends Model 'default' => '', 'example' => '123456', ]) + ->addRule('$createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Attribute creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$updatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Attribute update date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]); ; } From eeadba3b592880fa5b05a10b07b19a3d46847cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 11:36:54 +0200 Subject: [PATCH 183/402] Add missing endpoint in email templates --- app/init/models.php | 1 + .../Http/Project/Templates/Email/XList.php | 91 +++++++ .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + tests/e2e/Services/Project/TemplatesBase.php | 246 ++++++++++++++++++ 5 files changed, 341 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php diff --git a/app/init/models.php b/app/init/models.php index 4d1ccc2824..8f569d3252 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -211,6 +211,7 @@ Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phon Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false)); Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE)); Response::setModel(new BaseList('Mock Numbers List', Response::MODEL_MOCK_NUMBER_LIST, 'mockNumbers', Response::MODEL_MOCK_NUMBER)); +Response::setModel(new BaseList('Email Templates List', Response::MODEL_EMAIL_TEMPLATE_LIST, 'templates', Response::MODEL_EMAIL_TEMPLATE)); Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS)); Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE)); Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE)); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php new file mode 100644 index 0000000000..8b13bdb28a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -0,0 +1,91 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/templates/email') + ->desc('List project email templates') + ->groups(['api', 'project']) + ->label('scope', 'templates.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'listEmailTemplates', + description: <<param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + bool $includeTotal, + Response $response, + Document $project, + ) { + $templates = $project->getAttribute('templates', []); + + $emailTemplates = []; + foreach ($templates as $key => $template) { + if (!\str_starts_with($key, 'email.')) { + continue; + } + + $suffix = \substr($key, \strlen('email.')); + $parts = \explode('-', $suffix, 2); + if (\count($parts) !== 2) { + continue; + } + + [$templateId, $locale] = $parts; + + $template['templateId'] = $templateId; + $template['locale'] = $locale; + + // Backwards compatibility + if (!\is_null($template['replyTo'] ?? null)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + } + + $emailTemplates[] = new Document($template); + } + + $total = $includeTotal ? \count($emailTemplates) : 0; + + $response->dynamic(new Document([ + 'templates' => $emailTemplates, + 'total' => $total, + ]), Response::MODEL_EMAIL_TEMPLATE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index c353c6a4f3..86a7b2c055 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -42,6 +42,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSM use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\XList as ListTemplates; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -68,6 +69,7 @@ class Http extends Service $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); // Templates + $this->addAction(ListTemplates::getName(), new ListTemplates()); $this->addAction(GetTemplate::getName(), new GetTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 56ba5635b1..d72b52e4cb 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -267,6 +267,7 @@ class Response extends SwooleResponse public const MODEL_VARIABLE_LIST = 'variableList'; public const MODEL_VCS = 'vcs'; public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; + public const MODEL_EMAIL_TEMPLATE_LIST = 'emailTemplateList'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 72a14210a5..cb7c1bf0b3 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -579,6 +579,233 @@ trait TemplatesBase } } + // List email template tests + + public function testListEmailTemplatesReturnsSeededTemplate(): void + { + $this->ensureSMTPEnabled(); + + $subject = 'List subject ' . \uniqid(); + $seed = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: $subject, + message: 'List body', + ); + $this->assertSame(200, $seed['headers']['status-code']); + + $response = $this->listEmailTemplates(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('templates', $response['body']); + $this->assertArrayHasKey('total', $response['body']); + $this->assertIsArray($response['body']['templates']); + $this->assertIsInt($response['body']['total']); + $this->assertGreaterThanOrEqual(1, $response['body']['total']); + + $found = null; + foreach ($response['body']['templates'] as $template) { + if ( + $template['templateId'] === 'verification' + && $template['locale'] === 'en' + && $template['subject'] === $subject + ) { + $found = $template; + break; + } + } + $this->assertNotNull($found, 'seeded verification/en template must appear in the list'); + } + + public function testListEmailTemplatesResponseModel(): void + { + $this->ensureSMTPEnabled(); + + $seed = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'en', + subject: 'Shape subject ' . \uniqid(), + message: 'Shape body', + senderName: 'Shape Sender', + senderEmail: 'shape@appwrite.io', + replyToEmail: 'shape-reply@appwrite.io', + replyToName: 'Shape Reply', + ); + $this->assertSame(200, $seed['headers']['status-code']); + + $response = $this->listEmailTemplates(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['templates']); + + foreach ($response['body']['templates'] as $template) { + $this->assertArrayHasKey('templateId', $template); + $this->assertArrayHasKey('locale', $template); + $this->assertArrayHasKey('subject', $template); + $this->assertArrayHasKey('message', $template); + $this->assertArrayHasKey('senderName', $template); + $this->assertArrayHasKey('senderEmail', $template); + $this->assertArrayHasKey('replyToEmail', $template); + $this->assertArrayHasKey('replyToName', $template); + } + } + + public function testListEmailTemplatesSeparatesLocales(): void + { + $this->ensureSMTPEnabled(); + + $runId = \uniqid(); + $enSubject = "Multi-locale EN {$runId}"; + $frSubject = "Multi-locale FR {$runId}"; + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'en', + subject: $enSubject, + message: 'EN body', + )['headers']['status-code']); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'fr', + subject: $frSubject, + message: 'FR body', + )['headers']['status-code']); + + $response = $this->listEmailTemplates(); + $this->assertSame(200, $response['headers']['status-code']); + + $foundEn = false; + $foundFr = false; + foreach ($response['body']['templates'] as $template) { + if ($template['templateId'] === 'recovery' && $template['locale'] === 'en' && $template['subject'] === $enSubject) { + $foundEn = true; + } + if ($template['templateId'] === 'recovery' && $template['locale'] === 'fr' && $template['subject'] === $frSubject) { + $foundFr = true; + } + } + + $this->assertTrue($foundEn, 'recovery/en must appear in the list'); + $this->assertTrue($foundFr, 'recovery/fr must appear in the list'); + } + + public function testListEmailTemplatesUpdateDoesNotDuplicate(): void + { + $this->ensureSMTPEnabled(); + + $runId = \uniqid(); + $firstSubject = "First {$runId}"; + $secondSubject = "Second {$runId}"; + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'mfaChallenge', + locale: 'en', + subject: $firstSubject, + message: 'Body', + )['headers']['status-code']); + + $before = $this->listEmailTemplates(); + $this->assertSame(200, $before['headers']['status-code']); + $beforeTotal = $before['body']['total']; + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'mfaChallenge', + locale: 'en', + subject: $secondSubject, + message: 'Body', + )['headers']['status-code']); + + $after = $this->listEmailTemplates(); + $this->assertSame(200, $after['headers']['status-code']); + + // Same templateId/locale must remain a single entry, not accumulate. + $this->assertSame($beforeTotal, $after['body']['total']); + + $matches = \array_values(\array_filter( + $after['body']['templates'], + fn ($t) => $t['templateId'] === 'mfaChallenge' && $t['locale'] === 'en', + )); + $this->assertCount(1, $matches); + $this->assertSame($secondSubject, $matches[0]['subject']); + } + + public function testListEmailTemplatesTotalFalse(): void + { + $this->ensureSMTPEnabled(); + + // Ensure at least one template exists so `templates` is non-empty. + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Total-false subject', + message: 'Body', + )['headers']['status-code']); + + $response = $this->listEmailTemplates(total: false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['total']); + $this->assertSame(0, $response['body']['total']); + $this->assertNotEmpty($response['body']['templates']); + } + + public function testListEmailTemplatesTotalMatchesCount(): void + { + $this->ensureSMTPEnabled(); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Match subject', + message: 'Body', + )['headers']['status-code']); + + $response = $this->listEmailTemplates(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(\count($response['body']['templates']), $response['body']['total']); + } + + public function testListEmailTemplatesOnlyReturnsCustomizedTemplates(): void + { + $this->ensureSMTPEnabled(); + + // Seed exactly one template so we have a stable marker to count against. + $marker = 'Customized-only ' . \uniqid(); + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'otpSession', + locale: 'en', + subject: $marker, + message: 'Body', + )['headers']['status-code']); + + $response = $this->listEmailTemplates(); + $this->assertSame(200, $response['headers']['status-code']); + + // Every returned entry must be a real stored template (has templateId+locale set, + // not a synthesized default row for every possible type). + foreach ($response['body']['templates'] as $template) { + $this->assertNotEmpty($template['templateId']); + $this->assertNotEmpty($template['locale']); + } + + // A `(templateId, locale)` pair that has never been customized in this test + // run must NOT show up. 'otpSession'/'pt-br' has no writer anywhere in the file. + $uncustomized = \array_filter( + $response['body']['templates'], + fn ($t) => $t['templateId'] === 'otpSession' && $t['locale'] === 'pt-br', + ); + $this->assertEmpty($uncustomized, 'uncustomized (templateId, locale) pairs must not appear'); + } + + public function testListEmailTemplatesWithoutAuthentication(): void + { + $response = $this->listEmailTemplates(authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + // Backwards compatibility (x-appwrite-response-format: 1.9.1) public function testGetEmailTemplateLegacyResponseFormat(): void @@ -804,6 +1031,25 @@ trait TemplatesBase return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $templateId, $headers, $params); } + protected function listEmailTemplates(?bool $total = null, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($total !== null) { + $params['total'] = $total; + } + + return $this->client->call(Client::METHOD_GET, '/project/templates/email', $headers, $params); + } + protected function updateEmailTemplate( string $templateId, ?string $locale = null, From f770277ea5fe143f4b8d3b66553c197f05e96a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 11:42:39 +0200 Subject: [PATCH 184/402] New mock phones tests --- tests/e2e/Services/Project/MockPhonesBase.php | 500 ++++++++++++++++++ .../MockPhonesSessionIntegrationTest.php | 152 ++++++ 2 files changed, 652 insertions(+) create mode 100644 tests/e2e/Services/Project/MockPhonesBase.php create mode 100644 tests/e2e/Services/Project/MockPhonesSessionIntegrationTest.php diff --git a/tests/e2e/Services/Project/MockPhonesBase.php b/tests/e2e/Services/Project/MockPhonesBase.php new file mode 100644 index 0000000000..10ddf8aa0c --- /dev/null +++ b/tests/e2e/Services/Project/MockPhonesBase.php @@ -0,0 +1,500 @@ +uniquePhoneNumber(); + + $response = $this->createMockPhone($number, '123456'); + + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame($number, $response['body']['number']); + $this->assertSame('123456', $response['body']['otp']); + + $dateValidator = new DatetimeValidator(); + $this->assertTrue($dateValidator->isValid($response['body']['$createdAt'])); + $this->assertTrue($dateValidator->isValid($response['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getMockPhone($number); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($number, $get['body']['number']); + $this->assertSame('123456', $get['body']['otp']); + + // Verify via LIST + $list = $this->listMockPhones(); + $this->assertSame(200, $list['headers']['status-code']); + $numbers = \array_column($list['body']['mockNumbers'], 'number'); + $this->assertContains($number, $numbers); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testCreateMockPhoneAlreadyExists(): void + { + $number = $this->uniquePhoneNumber(); + + $first = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $first['headers']['status-code']); + + $duplicate = $this->createMockPhone($number, '654321'); + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('mock_number_already_exists', $duplicate['body']['type']); + + // Original OTP must remain unchanged + $get = $this->getMockPhone($number); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('123456', $get['body']['otp']); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testCreateMockPhoneInvalidNumber(): void + { + // Missing `+` prefix — Phone validator rejects. + $response = $this->createMockPhone('16555551234', '123456'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneNumberTooLong(): void + { + // 16 digits exceeds the E.164 15-digit maximum. + $response = $this->createMockPhone('+1234567890987654', '123456'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneInvalidOtpTooShort(): void + { + $response = $this->createMockPhone($this->uniquePhoneNumber(), '123'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneInvalidOtpTooLong(): void + { + $response = $this->createMockPhone($this->uniquePhoneNumber(), '1234567'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneInvalidOtpNonNumeric(): void + { + $response = $this->createMockPhone($this->uniquePhoneNumber(), 'abc123'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneMissingNumber(): void + { + $response = $this->createMockPhone(null, '123456'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneMissingOtp(): void + { + $response = $this->createMockPhone($this->uniquePhoneNumber(), null); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneWithoutAuthentication(): void + { + $response = $this->createMockPhone($this->uniquePhoneNumber(), '123456', authenticated: false); + $this->assertSame(401, $response['headers']['status-code']); + } + + // Get mock phone tests + + public function testGetMockPhone(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '987654'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->getMockPhone($number); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($number, $response['body']['number']); + $this->assertSame('987654', $response['body']['otp']); + + $dateValidator = new DatetimeValidator(); + $this->assertTrue($dateValidator->isValid($response['body']['$createdAt'])); + $this->assertTrue($dateValidator->isValid($response['body']['$updatedAt'])); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testGetMockPhoneNotFound(): void + { + $response = $this->getMockPhone($this->uniquePhoneNumber()); + + $this->assertSame(404, $response['headers']['status-code']); + $this->assertSame('mock_number_not_found', $response['body']['type']); + } + + public function testGetMockPhoneInvalidNumber(): void + { + // Path param is still validated with the Phone validator. + $response = $this->getMockPhone('not-a-phone'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testGetMockPhoneWithoutAuthentication(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->getMockPhone($number, authenticated: false); + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteMockPhone($number); + } + + // Update mock phone tests + + public function testUpdateMockPhone(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '111111'); + $this->assertSame(201, $create['headers']['status-code']); + + $createdAt = $create['body']['$createdAt']; + + // Sleep a bit so $updatedAt shifts noticeably — makes the assertion below meaningful. + \sleep(1); + + $update = $this->updateMockPhone($number, '222222'); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertSame($number, $update['body']['number']); + $this->assertSame('222222', $update['body']['otp']); + $this->assertSame($createdAt, $update['body']['$createdAt']); + $this->assertNotSame($createdAt, $update['body']['$updatedAt']); + + // Verify persistence via GET + $get = $this->getMockPhone($number); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('222222', $get['body']['otp']); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testUpdateMockPhoneNotFound(): void + { + $response = $this->updateMockPhone($this->uniquePhoneNumber(), '123456'); + + $this->assertSame(404, $response['headers']['status-code']); + $this->assertSame('mock_number_not_found', $response['body']['type']); + } + + public function testUpdateMockPhoneInvalidOtp(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->updateMockPhone($number, 'abc123'); + $this->assertSame(400, $response['headers']['status-code']); + + // Original OTP must remain unchanged + $get = $this->getMockPhone($number); + $this->assertSame('123456', $get['body']['otp']); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testUpdateMockPhoneMissingOtp(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->updateMockPhone($number, null); + $this->assertSame(400, $response['headers']['status-code']); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testUpdateMockPhoneWithoutAuthentication(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->updateMockPhone($number, '654321', authenticated: false); + $this->assertSame(401, $response['headers']['status-code']); + + // Verify it's unchanged + $get = $this->getMockPhone($number); + $this->assertSame('123456', $get['body']['otp']); + + // Cleanup + $this->deleteMockPhone($number); + } + + // List mock phones tests + + public function testListMockPhones(): void + { + $number1 = $this->uniquePhoneNumber(); + $number2 = $this->uniquePhoneNumber(); + $number3 = $this->uniquePhoneNumber(); + + $this->assertSame(201, $this->createMockPhone($number1, '111111')['headers']['status-code']); + $this->assertSame(201, $this->createMockPhone($number2, '222222')['headers']['status-code']); + $this->assertSame(201, $this->createMockPhone($number3, '333333')['headers']['status-code']); + + $response = $this->listMockPhones(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('mockNumbers', $response['body']); + $this->assertArrayHasKey('total', $response['body']); + $this->assertIsArray($response['body']['mockNumbers']); + $this->assertIsInt($response['body']['total']); + $this->assertGreaterThanOrEqual(3, $response['body']['total']); + $this->assertGreaterThanOrEqual(3, \count($response['body']['mockNumbers'])); + + // Verify shape of each entry + foreach ($response['body']['mockNumbers'] as $entry) { + $this->assertArrayHasKey('number', $entry); + $this->assertArrayHasKey('otp', $entry); + $this->assertArrayHasKey('$createdAt', $entry); + $this->assertArrayHasKey('$updatedAt', $entry); + } + + // All three seeded phones must be in the list + $numbers = \array_column($response['body']['mockNumbers'], 'number'); + $this->assertContains($number1, $numbers); + $this->assertContains($number2, $numbers); + $this->assertContains($number3, $numbers); + + // Cleanup + $this->deleteMockPhone($number1); + $this->deleteMockPhone($number2); + $this->deleteMockPhone($number3); + } + + public function testListMockPhonesTotalFalse(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->listMockPhones(total: false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($response['body']['mockNumbers'])); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testListMockPhonesTotalMatchesCount(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->listMockPhones(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(\count($response['body']['mockNumbers']), $response['body']['total']); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testListMockPhonesWithoutAuthentication(): void + { + $response = $this->listMockPhones(authenticated: false); + $this->assertSame(401, $response['headers']['status-code']); + } + + // Delete mock phone tests + + public function testDeleteMockPhone(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + // Confirm it exists + $this->assertSame(200, $this->getMockPhone($number)['headers']['status-code']); + + $response = $this->deleteMockPhone($number); + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Confirm it is gone + $get = $this->getMockPhone($number); + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('mock_number_not_found', $get['body']['type']); + } + + public function testDeleteMockPhoneNotFound(): void + { + $response = $this->deleteMockPhone($this->uniquePhoneNumber()); + + $this->assertSame(404, $response['headers']['status-code']); + $this->assertSame('mock_number_not_found', $response['body']['type']); + } + + public function testDeleteMockPhoneDoubleDelete(): void + { + $number = $this->uniquePhoneNumber(); + $this->assertSame(201, $this->createMockPhone($number, '123456')['headers']['status-code']); + + $first = $this->deleteMockPhone($number); + $this->assertSame(204, $first['headers']['status-code']); + + $second = $this->deleteMockPhone($number); + $this->assertSame(404, $second['headers']['status-code']); + $this->assertSame('mock_number_not_found', $second['body']['type']); + } + + public function testDeleteMockPhoneRemovedFromList(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $before = $this->listMockPhones(); + $this->assertSame(200, $before['headers']['status-code']); + $this->assertContains($number, \array_column($before['body']['mockNumbers'], 'number')); + $countBefore = $before['body']['total']; + + $delete = $this->deleteMockPhone($number); + $this->assertSame(204, $delete['headers']['status-code']); + + $after = $this->listMockPhones(); + $this->assertSame(200, $after['headers']['status-code']); + $this->assertSame($countBefore - 1, $after['body']['total']); + $this->assertNotContains($number, \array_column($after['body']['mockNumbers'], 'number')); + } + + public function testDeleteMockPhoneWithoutAuthentication(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->deleteMockPhone($number, authenticated: false); + $this->assertSame(401, $response['headers']['status-code']); + + // Still present + $this->assertSame(200, $this->getMockPhone($number)['headers']['status-code']); + + // Cleanup + $this->deleteMockPhone($number); + } + + // Helpers + + protected function createMockPhone(?string $number, ?string $otp, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($number !== null) { + $params['number'] = $number; + } + if ($otp !== null) { + $params['otp'] = $otp; + } + + return $this->client->call(Client::METHOD_POST, '/project/mock-phones', $headers, $params); + } + + protected function getMockPhone(string $number, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/project/mock-phones/' . \urlencode($number), $headers); + } + + protected function updateMockPhone(string $number, ?string $otp, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($otp !== null) { + $params['otp'] = $otp; + } + + return $this->client->call(Client::METHOD_PUT, '/project/mock-phones/' . \urlencode($number), $headers, $params); + } + + protected function listMockPhones(?bool $total = null, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($total !== null) { + $params['total'] = $total; + } + + return $this->client->call(Client::METHOD_GET, '/project/mock-phones', $headers, $params); + } + + protected function deleteMockPhone(string $number, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_DELETE, '/project/mock-phones/' . \urlencode($number), $headers); + } + + protected function uniquePhoneNumber(): string + { + // E.164: leading '+', first digit 1-9, 10 more digits. Randomised to avoid + // collisions between interleaved tests that all live in the same project. + return '+1' . \random_int(2000000000, 9999999999); + } +} diff --git a/tests/e2e/Services/Project/MockPhonesSessionIntegrationTest.php b/tests/e2e/Services/Project/MockPhonesSessionIntegrationTest.php new file mode 100644 index 0000000000..8ff65e3fd1 --- /dev/null +++ b/tests/e2e/Services/Project/MockPhonesSessionIntegrationTest.php @@ -0,0 +1,152 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $clientHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + // Step 1: Configure two mock phones with distinct OTPs. + $phoneA = '+1' . \random_int(2000000000, 9999999999); + $phoneB = '+1' . \random_int(2000000000, 9999999999); + $otpA = '111111'; + $otpB = '222222'; + + $mockA = $this->client->call(Client::METHOD_POST, '/project/mock-phones', $serverHeaders, [ + 'number' => $phoneA, + 'otp' => $otpA, + ]); + $this->assertSame(201, $mockA['headers']['status-code']); + $this->assertSame($phoneA, $mockA['body']['number']); + $this->assertSame($otpA, $mockA['body']['otp']); + + $mockB = $this->client->call(Client::METHOD_POST, '/project/mock-phones', $serverHeaders, [ + 'number' => $phoneB, + 'otp' => $otpB, + ]); + $this->assertSame(201, $mockB['headers']['status-code']); + $this->assertSame($phoneB, $mockB['body']['number']); + $this->assertSame($otpB, $mockB['body']['otp']); + + // Step 2 (Phone A): sign-in flow that also creates the user (userId = unique()). + $tokenA = $this->client->call(Client::METHOD_POST, '/account/tokens/phone', $clientHeaders, [ + 'userId' => ID::unique(), + 'phone' => $phoneA, + ]); + $this->assertSame(201, $tokenA['headers']['status-code']); + $userIdA = $tokenA['body']['userId']; + $this->assertNotEmpty($userIdA); + + // Arbitrary wrong OTP must be rejected. + $wrongA = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdA, + 'secret' => '999999', + ]); + $this->assertSame(401, $wrongA['headers']['status-code']); + + // Phone B's OTP must not unlock Phone A's user — proves OTPs are scoped to the mock record. + $crossA = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdA, + 'secret' => $otpB, + ]); + $this->assertSame(401, $crossA['headers']['status-code']); + + // Correct mock OTP establishes the session. + $sessionA = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdA, + 'secret' => $otpA, + ]); + $this->assertSame(201, $sessionA['headers']['status-code']); + $this->assertNotEmpty($sessionA['cookies']['a_session_' . $projectId] ?? null); + $cookieA = $sessionA['cookies']['a_session_' . $projectId]; + + // GET /account using the session confirms identity. + $accountA = $this->client->call(Client::METHOD_GET, '/account', \array_merge($clientHeaders, [ + 'cookie' => 'a_session_' . $projectId . '=' . $cookieA, + ])); + $this->assertSame(200, $accountA['headers']['status-code']); + $this->assertSame($userIdA, $accountA['body']['$id']); + $this->assertSame($phoneA, $accountA['body']['phone']); + $this->assertTrue($accountA['body']['phoneVerification']); + + // Step 3 (Phone B): pre-create the user server-side, then sign in with the mock OTP. + $precreated = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'phone' => $phoneB, + ]); + $this->assertSame(201, $precreated['headers']['status-code']); + $userIdB = $precreated['body']['$id']; + $this->assertSame($phoneB, $precreated['body']['phone']); + + $tokenB = $this->client->call(Client::METHOD_POST, '/account/tokens/phone', $clientHeaders, [ + 'userId' => $userIdB, + 'phone' => $phoneB, + ]); + $this->assertSame(201, $tokenB['headers']['status-code']); + $this->assertSame($userIdB, $tokenB['body']['userId']); + + // Arbitrary wrong OTP must be rejected. + $wrongB = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdB, + 'secret' => '000000', + ]); + $this->assertSame(401, $wrongB['headers']['status-code']); + + // Phone A's OTP must not unlock Phone B's user. + $crossB = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdB, + 'secret' => $otpA, + ]); + $this->assertSame(401, $crossB['headers']['status-code']); + + // Correct mock OTP establishes the session. + $sessionB = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdB, + 'secret' => $otpB, + ]); + $this->assertSame(201, $sessionB['headers']['status-code']); + $this->assertNotEmpty($sessionB['cookies']['a_session_' . $projectId] ?? null); + $cookieB = $sessionB['cookies']['a_session_' . $projectId]; + + // GET /account using the session confirms identity. + $accountB = $this->client->call(Client::METHOD_GET, '/account', \array_merge($clientHeaders, [ + 'cookie' => 'a_session_' . $projectId . '=' . $cookieB, + ])); + $this->assertSame(200, $accountB['headers']['status-code']); + $this->assertSame($userIdB, $accountB['body']['$id']); + $this->assertSame($phoneB, $accountB['body']['phone']); + $this->assertTrue($accountB['body']['phoneVerification']); + + // Cross-check: the two flows produced distinct users. + $this->assertNotSame($userIdA, $userIdB); + $this->assertNotSame($accountA['body']['phone'], $accountB['body']['phone']); + + // Cleanup mock phone config to avoid polluting project state for later tests. + $this->client->call(Client::METHOD_DELETE, '/project/mock-phones/' . \urlencode($phoneA), $serverHeaders); + $this->client->call(Client::METHOD_DELETE, '/project/mock-phones/' . \urlencode($phoneB), $serverHeaders); + } +} From 4f74394e8fb24eee10ebeedadfa6f58b661967aa Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:21:41 +0530 Subject: [PATCH 185/402] Fix spec enum name validation --- src/Appwrite/Platform/Tasks/Specs.php | 6 +- src/Appwrite/SDK/Specification/Format.php | 9 +++ tests/unit/Platform/Tasks/SpecsTest.php | 67 +++++++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index e9fa15e6c7..31fb69460f 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -370,12 +370,12 @@ class Specs extends Action } $explicitEnumName = $this->getExplicitSpecEnumName($node); - if (!\is_null($explicitEnumName)) { + if (!\is_null($explicitEnumName) && !isset($node['enum'])) { $this->addSpecEnumName($enums, $explicitEnumName); } foreach ($node as $key => $value) { - if (!\is_array($value)) { + if ($key === 'items' || !\is_array($value)) { continue; } @@ -420,7 +420,7 @@ class Specs extends Action return $this->getFallbackSpecEnumName($parent, $fallbackName); } - if (\is_string($key) && !\in_array($key, ['components', 'content', 'definitions', 'parameters', 'paths', 'properties', 'responses'], true)) { + if (\is_string($key) && !\in_array($key, ['components', 'content', 'definitions', 'delete', 'get', 'head', 'options', 'parameters', 'patch', 'paths', 'post', 'properties', 'put', 'responses'], true)) { return $key; } diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index e68e9438ca..0c28384db1 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -751,6 +751,15 @@ abstract class Format break; case 'project': switch ($method) { + case 'getEmailTemplate': + case 'updateEmailTemplate': + switch ($param) { + case 'templateId': + return 'EmailTemplateType'; + case 'locale': + return 'EmailTemplateLocale'; + } + break; case 'getUsage': switch ($param) { case 'period': diff --git a/tests/unit/Platform/Tasks/SpecsTest.php b/tests/unit/Platform/Tasks/SpecsTest.php index a10c66bf81..6cb11b310e 100644 --- a/tests/unit/Platform/Tasks/SpecsTest.php +++ b/tests/unit/Platform/Tasks/SpecsTest.php @@ -11,6 +11,18 @@ class TestSpecs extends Specs { $this->verifyParsedSpec($spec); } + + public function collectEnums(array $spec): array + { + $collect = \Closure::bind(function (array $spec): array { + $enums = []; + $this->collectSpecEnumNames($spec, $enums); + + return $enums; + }, $this, Specs::class); + + return $collect($spec); + } } class SpecsTest extends TestCase @@ -119,4 +131,59 @@ class SpecsTest extends TestCase $this->addToAssertionCount(1); } + + public function testCollectSpecEnumNamesDoesNotDoubleRegisterExplicitNames(): void + { + $enums = $this->specs->collectEnums([ + 'schema' => [ + 'type' => 'string', + 'enum' => ['en'], + 'x-enum-name' => 'Locale', + ], + ]); + + $this->assertSame(['Locale'], $enums['locale']); + } + + public function testCollectSpecEnumNamesDoesNotDoubleRegisterItemsEnums(): void + { + $enums = $this->specs->collectEnums([ + 'name' => 'Locale', + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'enum' => ['en'], + ], + ]); + + $this->assertSame(['Locale'], $enums['locale']); + } + + public function testVerifyParsedSpecIgnoresHttpMethodFallbackNames(): void + { + $this->specs->verify([ + 'tags' => [ + [ + 'name' => 'patch', + 'description' => 'Patch APIs', + ], + ], + 'paths' => [ + '/example' => [ + 'patch' => [ + 'parameters' => [ + [ + 'schema' => [ + 'type' => 'string', + 'enum' => ['enabled'], + ], + ], + ], + ], + ], + ], + ]); + + $this->addToAssertionCount(1); + } } From f75a7269c9a1e7f70a56cba87bb48f4353266f1e Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:23:35 +0530 Subject: [PATCH 186/402] Address benchmark review simplifications --- .github/workflows/benchmark-comment.js | 355 +++++++++++++++++++++++++ .github/workflows/ci.yml | 288 ++++---------------- tests/benchmarks/http.js | 327 ++++++----------------- 3 files changed, 486 insertions(+), 484 deletions(-) create mode 100644 .github/workflows/benchmark-comment.js diff --git a/.github/workflows/benchmark-comment.js b/.github/workflows/benchmark-comment.js new file mode 100644 index 0000000000..2294611318 --- /dev/null +++ b/.github/workflows/benchmark-comment.js @@ -0,0 +1,355 @@ +const fs = require('fs'); + +const marker = ''; +const serviceLabels = ['Account', 'TablesDB', 'Storage', 'Functions', 'Sites', 'Health']; + +module.exports = async ({ github, context, core }) => { + const body = buildComment(core); + fs.writeFileSync('benchmark-comment.txt', body); + + const pullRequest = context.payload.pull_request; + if (!pullRequest || pullRequest.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) { + return; + } + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + per_page: 100, + }); + + const existing = comments.find((comment) => { + return comment.user?.type === 'Bot' && comment.body?.includes(marker); + }) || comments.find((comment) => { + return comment.user?.type === 'Bot' && comment.body?.includes('Benchmark results'); + }); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + return; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + body, + }); +}; + +function buildComment(core) { + const before = readSummary('benchmark-before-summary.json', core); + const after = readSummary('benchmark-after-summary.json', core); + const beforeSamples = readSamples('benchmark-before-samples.json', core); + const afterSamples = readSamples('benchmark-after-samples.json', core); + const baseRef = markdownText(process.env.BENCHMARK_BASE_REF || 'base'); + const headRef = markdownText(process.env.BENCHMARK_HEAD_REF || 'head'); + const rows = benchmarkRows(before, after, beforeSamples, afterSamples); + const topWaits = topSamples(afterSamples, 'appwrite_http_waiting', 3); + const lines = [ + marker, + '## :sparkles: Benchmark results', + '', + `Comparing ${baseRef} (before) to ${headRef} (after).`, + '', + ]; + + if (before === null) { + lines.push('> Before benchmark did not complete; showing current branch metrics only.', ''); + } + if (after === null) { + lines.push('> Current branch benchmark did not complete; showing available metrics only.', ''); + } + + lines.push( + '| Scenario | Before P50 (ms) | Before P95 (ms) | After P50 (ms) | After P95 (ms) | Delta P95 (ms) | After iterations | After RPS |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', + ...rows.map(comparisonRow), + '', + '
', + 'Current run details', + '', + '
', + '', + '| Scenario | P50 (ms) | P95 (ms) | Iterations | RPS |', + '| --- | ---: | ---: | ---: | ---: |', + ...rows.map(detailRow), + '', + '**Top 3 request waits**', + '', + '| Request | Max wait (ms) |', + '| --- | ---: |', + ...topWaitRows(topWaits), + '', + '
', + ); + + return `${lines.join('\n')}\n`; +} + +function readSummary(path, core) { + if (!fs.existsSync(path)) { + return null; + } + + try { + return JSON.parse(fs.readFileSync(path, 'utf8')); + } catch (error) { + core?.warning(`Invalid benchmark summary ${path}: ${error.message}`); + return null; + } +} + +function readSamples(path, core) { + if (!fs.existsSync(path)) { + return []; + } + + const contents = fs.readFileSync(path, 'utf8').trim(); + if (contents === '') { + return []; + } + + return contents + .split('\n') + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line)]; + } catch (error) { + core?.warning(`Invalid benchmark sample in ${path}: ${error.message}`); + return []; + } + }); +} + +function benchmarkRows(before, after, beforeSamples, afterSamples) { + const beforeServices = serviceStats(beforeSamples); + const afterServices = serviceStats(afterSamples); + return [ + { + label: 'Load test', + before: summaryStats(before, 'appwrite_http_duration', 'iterations', 'http_reqs'), + after: summaryStats(after, 'appwrite_http_duration', 'iterations', 'http_reqs'), + }, + { + label: 'API total', + before: apiSampleStats(beforeSamples) || summaryStats(before, 'appwrite_api_duration'), + after: apiSampleStats(afterSamples) || summaryStats(after, 'appwrite_api_duration'), + }, + ...serviceLabels.map((label) => ({ + label, + before: beforeServices.get(label) || null, + after: afterServices.get(label) || null, + })), + { + label: 'TablesDB schema', + before: summaryStats(before, 'appwrite_worker_tables_duration'), + after: summaryStats(after, 'appwrite_worker_tables_duration'), + }, + { + label: 'Mail delivery', + before: summaryStats(before, 'appwrite_worker_mails_duration'), + after: summaryStats(after, 'appwrite_worker_mails_duration'), + }, + ]; +} + +function summaryStats(summary, durationMetric, iterationsMetric = null, rpsMetric = null) { + const values = metricValues(summary, durationMetric); + if (!values) { + return null; + } + + return { + p50: values.med ?? null, + p95: values['p(95)'] ?? null, + iterations: iterationsMetric ? metricValue(summary, iterationsMetric, 'count') : values.count ?? null, + rps: rpsMetric ? metricValue(summary, rpsMetric, 'rate') : null, + }; +} + +function serviceStats(samples) { + const apiSamples = samples.filter((sample) => { + return sample.metric === 'appwrite_api_duration' && typeof sample.data?.value === 'number'; + }); + const durationSeconds = sampleWindowSeconds(apiSamples); + const groups = new Map(); + + for (const sample of apiSamples) { + const service = serviceFromName(sample.data.tags?.name || ''); + if (!service) { + continue; + } + + const values = groups.get(service) || []; + values.push(sample.data.value); + groups.set(service, values); + } + + return new Map([...groups.entries()].map(([service, values]) => { + return [service, { + p50: percentile(values, 50), + p95: percentile(values, 95), + iterations: values.length, + rps: durationSeconds ? values.length / durationSeconds : null, + }]; + })); +} + +function apiSampleStats(samples) { + const values = samples + .filter((sample) => sample.metric === 'appwrite_api_duration' && typeof sample.data?.value === 'number') + .map((sample) => sample.data.value); + if (values.length === 0) { + return null; + } + + const durationSeconds = sampleWindowSeconds(samples); + return { + p50: percentile(values, 50), + p95: percentile(values, 95), + iterations: values.length, + rps: durationSeconds ? values.length / durationSeconds : null, + }; +} + +function serviceFromName(name) { + if (name.startsWith('account.')) { + return 'Account'; + } + if (name.startsWith('tablesdb.')) { + return 'TablesDB'; + } + if (name.startsWith('storage.') || name.startsWith('tokens.')) { + return 'Storage'; + } + if (name.startsWith('functions.')) { + return 'Functions'; + } + if (name.startsWith('sites.')) { + return 'Sites'; + } + if (name.startsWith('health.')) { + return 'Health'; + } + return null; +} + +function sampleWindowSeconds(samples) { + const times = samples + .map((sample) => Date.parse(sample.data?.time)) + .filter((value) => !Number.isNaN(value)); + if (times.length < 2) { + return null; + } + + return Math.max((Math.max(...times) - Math.min(...times)) / 1000, 1); +} + +function percentile(values, percentileValue) { + if (values.length === 0) { + return null; + } + + const sorted = [...values].sort((left, right) => left - right); + const index = Math.ceil((percentileValue / 100) * sorted.length) - 1; + return sorted[Math.max(0, Math.min(index, sorted.length - 1))]; +} + +function metricValues(data, metric) { + return data?.metrics?.[metric]?.values ?? null; +} + +function metricValue(data, metric, stat) { + return metricValues(data, metric)?.[stat] ?? null; +} + +function comparisonRow(row) { + return `| ${row.label} | ${formatMs(row.before?.p50)} | ${formatMs(row.before?.p95)} | ${formatMs(row.after?.p50)} | ${formatMs(row.after?.p95)} | ${formatDelta(row.before?.p95, row.after?.p95)} | ${formatCount(row.after?.iterations)} | ${formatRate(row.after?.rps)} |`; +} + +function detailRow(row) { + return `| ${row.label} | ${formatMs(row.after?.p50)} | ${formatMs(row.after?.p95)} | ${formatCount(row.after?.iterations)} | ${formatRate(row.after?.rps)} |`; +} + +function topSamples(samples, metric, limit) { + const byName = samples.reduce((result, sample) => { + if (sample.metric !== metric || typeof sample.data?.value !== 'number') { + return result; + } + + const name = sample.data.tags?.name || 'unknown'; + const current = result.get(name); + if (!current || sample.data.value > current.value) { + result.set(name, { name, value: sample.data.value }); + } + + return result; + }, new Map()); + + return [...byName.values()] + .sort((left, right) => right.value - left.value) + .slice(0, limit); +} + +function topWaitRows(samples) { + if (samples.length === 0) { + return ['| n/a | n/a |']; + } + + return samples.map((sample) => { + return `| ${markdownText(sample.name).replace(/\|/g, '\\|')} | ${formatMs(sample.value)} |`; + }); +} + +function markdownText(value) { + return String(value || '').replace(/[\r\n]/g, ' ').replace(/[&<>"']/g, (char) => { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]; + }); +} + +function formatMs(value) { + return formatNumber(value, 2); +} + +function formatRate(value) { + return formatNumber(value, 2); +} + +function formatCount(value) { + if (value === null || value === undefined || Number.isNaN(value)) { + return 'n/a'; + } + + return `${Math.round(value)}`; +} + +function formatDelta(before, after) { + if (before === null || before === undefined || after === null || after === undefined || Number.isNaN(before) || Number.isNaN(after)) { + return 'n/a'; + } + + const difference = Number((after - before).toFixed(2)); + return `${difference > 0 ? '+' : ''}${trimNumber(difference)}`; +} + +function formatNumber(value, decimals) { + if (value === null || value === undefined || Number.isNaN(value)) { + return 'n/a'; + } + + return trimNumber(Number(value).toFixed(decimals)); +} + +function trimNumber(value) { + const text = String(value); + const trimmed = text.includes('.') ? text.replace(/\.?0+$/, '') : text; + return trimmed === '' ? '0' : trimmed; +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 802648b0ea..b5f5fc79b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ concurrency: env: COMPOSE_FILE: docker-compose.yml IMAGE: appwrite-dev - K6_IMAGE: grafana/k6:0.53.0 + K6_VERSION: '0.53.0' on: pull_request: @@ -683,6 +683,11 @@ jobs: docker load --input /tmp/${{ env.IMAGE }}.tar docker tag ${{ env.IMAGE }} ${{ env.IMAGE }}:after + - name: Setup k6 + uses: grafana/setup-k6-action@v1 + with: + k6-version: ${{ env.K6_VERSION }} + - name: Prepare benchmark before id: benchmark_before_prepare continue-on-error: true @@ -703,27 +708,34 @@ jobs: if: steps.benchmark_before_prepare.outcome == 'success' continue-on-error: true working-directory: /tmp/appwrite-benchmark-before + env: + _APP_DOMAIN: localhost + _APP_CONSOLE_DOMAIN: localhost + _APP_DOMAIN_FUNCTIONS: functions.localhost + _APP_DOMAIN_SITES: sites.localhost run: | docker tag ${{ env.IMAGE }}:before ${{ env.IMAGE }} - sed -i 's/traefik/localhost/g' .env docker compose up -d --wait --no-build + - name: Prepare benchmark files + run: rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before-samples.json benchmark-after-samples.json + - name: Benchmark before if: steps.benchmark_before_start.outcome == 'success' continue-on-error: true - run: | - set -o pipefail - rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before-samples.json benchmark-after-samples.json benchmark-before.txt benchmark.txt - # Use the current benchmark script for both images so before/after differ only by the Appwrite image. - docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts ${{ env.K6_IMAGE }} run --quiet \ - --out json=benchmark-before-samples.json \ - -e APPWRITE_ENDPOINT=http://localhost/v1 \ - -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ - -e APPWRITE_BENCHMARK_ITERATIONS=1 \ - -e APPWRITE_BENCHMARK_VUS=1 \ - -e APPWRITE_WORKER_TIMEOUT_MS=120000 \ - -e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-before-summary.json \ - tests/benchmarks/http.js | tee benchmark-before.txt + uses: grafana/run-k6-action@v1 + env: + APPWRITE_ENDPOINT: 'http://localhost/v1' + APPWRITE_MAILDEV_ENDPOINT: 'http://localhost:9503/email' + APPWRITE_BENCHMARK_ITERATIONS: '1' + APPWRITE_BENCHMARK_VUS: '1' + APPWRITE_WORKER_TIMEOUT_MS: '120000' + APPWRITE_BENCHMARK_SUMMARY_PATH: 'benchmark-before-summary.json' + with: + path: tests/benchmarks/http.js + flags: --quiet --out json=benchmark-before-samples.json + cloud-comment-on-pr: false + debug: true - name: Stop before Appwrite if: always() @@ -734,211 +746,47 @@ jobs: fi - name: Start after Appwrite + env: + _APP_DOMAIN: localhost + _APP_CONSOLE_DOMAIN: localhost + _APP_DOMAIN_FUNCTIONS: functions.localhost + _APP_DOMAIN_SITES: sites.localhost run: | docker tag ${{ env.IMAGE }}:after ${{ env.IMAGE }} - sed -i 's/traefik/localhost/g' .env docker compose up -d --wait --no-build - name: Benchmark after id: benchmark_after continue-on-error: true - run: | - set -o pipefail - docker run --rm -i --network host --user "$(id -u):$(id -g)" -v "$PWD:/scripts" -w /scripts ${{ env.K6_IMAGE }} run --quiet \ - --out json=benchmark-after-samples.json \ - -e APPWRITE_ENDPOINT=http://localhost/v1 \ - -e APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ - -e APPWRITE_BENCHMARK_ITERATIONS=1 \ - -e APPWRITE_BENCHMARK_VUS=1 \ - -e APPWRITE_WORKER_TIMEOUT_MS=120000 \ - -e APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH=benchmark-before-summary.json \ - -e APPWRITE_BENCHMARK_SUMMARY_PATH=benchmark-after-summary.json \ - tests/benchmarks/http.js | tee benchmark.txt + uses: grafana/run-k6-action@v1 + env: + APPWRITE_ENDPOINT: 'http://localhost/v1' + APPWRITE_MAILDEV_ENDPOINT: 'http://localhost:9503/email' + APPWRITE_BENCHMARK_ITERATIONS: '1' + APPWRITE_BENCHMARK_VUS: '1' + APPWRITE_WORKER_TIMEOUT_MS: '120000' + APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH: 'benchmark-before-summary.json' + APPWRITE_BENCHMARK_SUMMARY_PATH: 'benchmark-after-summary.json' + with: + path: tests/benchmarks/http.js + flags: --quiet --out json=benchmark-after-samples.json + cloud-comment-on-pr: false + debug: true - name: Stop after Appwrite if: always() run: docker compose down -v || true - - name: Prepare comment + - name: Comment on PR + if: always() + uses: actions/github-script@v8 env: BENCHMARK_BASE_REF: ${{ github.event.pull_request.base.ref }} BENCHMARK_HEAD_REF: ${{ github.event.pull_request.head.ref }} - run: | - node <<'NODE' > benchmark-comment.txt - const fs = require('fs'); - - function readSummary(path, required = true) { - if (!fs.existsSync(path)) { - if (required) { - throw new Error(`Missing benchmark summary: ${path}`); - } - return null; - } - - try { - return JSON.parse(fs.readFileSync(path, 'utf8')); - } catch (error) { - if (required) { - throw new Error(`Invalid benchmark summary ${path}: ${error.message}`); - } - console.error(`Invalid benchmark summary ${path}: ${error.message}`); - return null; - } - } - - function markdownText(value) { - return String(value || '').replace(/[\r\n]/g, ' ').replace(/[&<>"']/g, (char) => { - return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]; - }); - } - - function metricValue(data, metric, stat) { - return data?.metrics?.[metric]?.values?.[stat] ?? null; - } - - function formatNumber(value) { - return Number(value).toFixed(2).replace(/\.?0+$/, ''); - } - - function formatValue(value, suffix = '') { - return value === null ? 'n/a' : `${formatNumber(value)}${suffix}`; - } - - function delta(beforeValue, afterValue, suffix = '') { - if (beforeValue === null || afterValue === null) { - return 'n/a'; - } - - const difference = Number((afterValue - beforeValue).toFixed(2)); - return `${difference > 0 ? '+' : ''}${formatNumber(difference)}${suffix}`; - } - - function row(label, beforeValue, afterValue, suffix = '') { - return `| ${label} | ${formatValue(beforeValue, suffix)} | ${formatValue(afterValue, suffix)} | ${delta(beforeValue, afterValue, suffix)} |`; - } - - function detailValue(value, suffix = '') { - return value === null ? 'n/a' : `${Number(value).toFixed(2)}${suffix}`; - } - - function detailRow(after, label, metric, suffix = 'ms') { - const values = after?.metrics?.[metric]?.values; - if (!values) { - return `| ${label} | n/a | n/a | n/a | n/a |`; - } - - return `| ${label} | ${detailValue(values.avg ?? null, suffix)} | ${detailValue(values['p(90)'] ?? null, suffix)} | ${detailValue(values['p(95)'] ?? null, suffix)} | ${detailValue(values.max ?? null, suffix)} |`; - } - - function readSamples(path) { - if (!fs.existsSync(path)) { - return []; - } - - const contents = fs.readFileSync(path, 'utf8').trim(); - if (contents === '') { - return []; - } - - return contents - .split('\n') - .filter(Boolean) - .flatMap((line) => { - try { - return [JSON.parse(line)]; - } catch { - return []; - } - }); - } - - function topSamples(samples, metric, limit) { - const byName = samples.reduce((result, sample) => { - if (sample.metric !== metric || typeof sample.data?.value !== 'number') { - return result; - } - - const name = sample.data.tags?.name || 'unknown'; - const current = result.get(name); - if (!current || sample.data.value > current.value) { - result.set(name, { name, value: sample.data.value }); - } - - return result; - }, new Map()); - - return [...byName.values()] - .sort((left, right) => right.value - left.value) - .slice(0, limit); - } - - function topSampleRows(samples) { - if (samples.length === 0) { - return ['| n/a | n/a |']; - } - - return samples.map((sample) => { - const name = markdownText(sample.name).replace(/\|/g, '\\|'); - return `| ${name} | ${detailValue(sample.value, 'ms')} |`; - }); - } - - const before = readSummary('benchmark-before-summary.json', false); - const after = readSummary('benchmark-after-summary.json', false); - const afterSamples = readSamples('benchmark-after-samples.json'); - const baseRef = markdownText(process.env.BENCHMARK_BASE_REF || 'base'); - const headRef = markdownText(process.env.BENCHMARK_HEAD_REF || 'head'); - const topWaits = topSamples(afterSamples, 'appwrite_http_waiting', 3); - - const rows = [ - row('HTTP throughput', metricValue(before, 'http_reqs', 'rate'), metricValue(after, 'http_reqs', 'rate'), ' req/s'), - row('HTTP total p95', metricValue(before, 'appwrite_http_duration', 'p(95)'), metricValue(after, 'appwrite_http_duration', 'p(95)'), 'ms'), - row('API endpoints p95', metricValue(before, 'appwrite_api_duration', 'p(95)'), metricValue(after, 'appwrite_api_duration', 'p(95)'), 'ms'), - row('Database worker p95', metricValue(before, 'appwrite_worker_database_duration', 'p(95)'), metricValue(after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'), - row('TablesDB worker p95', metricValue(before, 'appwrite_worker_tables_duration', 'p(95)'), metricValue(after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'), - row('Mail worker p95', metricValue(before, 'appwrite_worker_mails_duration', 'p(95)'), metricValue(after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'), - row('Messaging worker p95', metricValue(before, 'appwrite_worker_messaging_duration', 'p(95)'), metricValue(after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'), - ]; - - console.log(''); - console.log('## :sparkles: Benchmark results'); - console.log(); - console.log(`Comparing ${baseRef} (before) to ${headRef} (after).`); - console.log(); - if (before === null) { - console.log('> Before benchmark did not complete; showing current branch metrics only.'); - console.log(); - } - if (after === null) { - console.log('> Current branch benchmark did not complete; showing available metrics only.'); - console.log(); - } - console.log('| Metric | Before | After | Delta |'); - console.log('| --- | ---: | ---: | ---: |'); - console.log(rows.join('\n')); - console.log(); - console.log('
'); - console.log('Current run details'); - console.log(); - console.log('
'); - console.log(); - console.log('| Scenario | Avg | P90 | P95 | Max |'); - console.log('| --- | ---: | ---: | ---: | ---: |'); - console.log(detailRow(after, 'HTTP total', 'appwrite_http_duration')); - console.log(detailRow(after, 'API endpoints', 'appwrite_api_duration')); - console.log(detailRow(after, 'Database worker schema jobs', 'appwrite_worker_database_duration')); - console.log(detailRow(after, 'TablesDB worker schema jobs', 'appwrite_worker_tables_duration')); - console.log(detailRow(after, 'Mail worker delivery', 'appwrite_worker_mails_duration')); - console.log(detailRow(after, 'Messaging worker delivery', 'appwrite_worker_messaging_duration')); - console.log(); - console.log('**Top 3 request waits**'); - console.log(); - console.log('| Request | Max wait |'); - console.log('| --- | ---: |'); - console.log(topSampleRows(topWaits).join('\n')); - console.log(); - console.log('
'); - NODE + with: + script: | + const comment = require('./.github/workflows/benchmark-comment.js'); + await comment({ github, context, core }); - name: Save results uses: actions/upload-artifact@v7 @@ -946,41 +794,13 @@ jobs: with: name: benchmark-results path: | - benchmark-before.txt - benchmark.txt + benchmark-comment.txt benchmark-before-summary.json benchmark-after-summary.json benchmark-before-samples.json benchmark-after-samples.json retention-days: 7 - - name: Find Comment - if: github.event.pull_request.head.repo.full_name == github.repository - uses: peter-evans/find-comment@v3 - id: fc - with: - issue-number: ${{ github.event.pull_request.number }} - comment-author: 'github-actions[bot]' - body-includes: appwrite-benchmark-results - - - name: Find Legacy Comment - if: github.event.pull_request.head.repo.full_name == github.repository && steps.fc.outputs.comment-id == '' - uses: peter-evans/find-comment@v3 - id: legacy_fc - with: - issue-number: ${{ github.event.pull_request.number }} - comment-author: 'github-actions[bot]' - body-includes: Benchmark results - - - name: Comment on PR - if: github.event.pull_request.head.repo.full_name == github.repository - uses: peter-evans/create-or-update-comment@v4 - with: - comment-id: ${{ steps.fc.outputs.comment-id || steps.legacy_fc.outputs.comment-id }} - issue-number: ${{ github.event.pull_request.number }} - body-path: benchmark-comment.txt - edit-mode: replace - - name: Fail benchmark if: always() && steps.benchmark_after.outcome == 'failure' run: exit 1 diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index ef873f6688..c6248dd3b0 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -3,22 +3,16 @@ * * docker compose up -d --force-recreate --build --wait * - * docker run --rm -i \ - * --network appwrite \ - * -p 127.0.0.1:5665:5665 \ - * -v "$PWD:/scripts:ro" \ - * -v /tmp:/host-tmp \ - * -w /scripts \ - * -e K6_WEB_DASHBOARD=true \ - * -e K6_WEB_DASHBOARD_HOST=0.0.0.0 \ - * -e K6_WEB_DASHBOARD_PORT=5665 \ - * -e K6_WEB_DASHBOARD_EXPORT=/host-tmp/appwrite-k6-report.html \ - * -e APPWRITE_ENDPOINT=http://appwrite/v1 \ - * -e APPWRITE_MAILDEV_ENDPOINT=http://maildev:1080/email \ - * -e APPWRITE_WORKER_TIMEOUT_MS=120000 \ - * -e APPWRITE_BENCHMARK_SUMMARY_PATH=/host-tmp/appwrite-k6-summary.json \ - * grafana/k6:0.53.0 run \ - * --out json=/host-tmp/appwrite-k6-samples.json \ + * K6_WEB_DASHBOARD=true \ + * K6_WEB_DASHBOARD_HOST=127.0.0.1 \ + * K6_WEB_DASHBOARD_PORT=5665 \ + * K6_WEB_DASHBOARD_EXPORT=/tmp/appwrite-k6-report.html \ + * APPWRITE_ENDPOINT=http://localhost/v1 \ + * APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ + * APPWRITE_WORKER_TIMEOUT_MS=120000 \ + * APPWRITE_BENCHMARK_SUMMARY_PATH=/tmp/appwrite-k6-summary.json \ + * k6 run \ + * --out json=/tmp/appwrite-k6-samples.json \ * tests/benchmarks/http.js * * Open http://127.0.0.1:5665 while the benchmark is running. @@ -45,10 +39,8 @@ const PREVIOUS_SUMMARY = loadPreviousSummary(); export const httpDuration = new Trend('appwrite_http_duration', true); export const httpWaiting = new Trend('appwrite_http_waiting', true); export const apiDuration = new Trend('appwrite_api_duration', true); -export const databaseWorkerDuration = new Trend('appwrite_worker_database_duration', true); export const tablesWorkerDuration = new Trend('appwrite_worker_tables_duration', true); export const mailsWorkerDuration = new Trend('appwrite_worker_mails_duration', true); -export const messagingWorkerDuration = new Trend('appwrite_worker_messaging_duration', true); export const flowFailures = new Counter('appwrite_benchmark_flow_failures'); export const options = { @@ -105,16 +97,6 @@ const API_SCOPES = [ 'locale.read', 'avatars.read', 'health.read', - 'providers.read', - 'providers.write', - 'messages.read', - 'messages.write', - 'topics.read', - 'topics.write', - 'subscribers.read', - 'subscribers.write', - 'targets.read', - 'targets.write', 'rules.read', 'rules.write', 'migrations.read', @@ -174,13 +156,13 @@ export function setup() { Cookie: cookieHeader(session), }; - const team = api('POST', '/teams', { + const team = setupApi('POST', '/teams', { teamId: unique('team'), name: `Benchmark Team ${runId}`, }, consoleSessionHeaders, [201], 'setup.teams.create'); const teamId = team.json('$id'); - const project = api('POST', '/projects', { + const project = setupApi('POST', '/projects', { projectId: unique('project'), name: `Benchmark Project ${runId}`, teamId, @@ -188,7 +170,7 @@ export function setup() { }, consoleSessionHeaders, [201], 'setup.projects.create'); const projectId = project.json('$id'); - const key = api('POST', `/projects/${projectId}/keys`, { + const key = setupApi('POST', `/projects/${projectId}/keys`, { keyId: unique('key'), name: 'Benchmark API key', scopes: API_SCOPES, @@ -200,7 +182,7 @@ export function setup() { 'X-Appwrite-Key': key.json('secret'), }; - const platform = api('POST', '/project/platforms/web', { + const platform = setupApi('POST', '/project/platforms/web', { platformId: unique('web'), name: 'Benchmark web', hostname: hostnameFromUrl(REDIRECT_URL), @@ -236,11 +218,9 @@ export function curatedFlows(data) { const ctx = { ...data }; try { - group('account and mail worker', () => accountFlow(ctx)); - group('databases documents flow', () => databasesFlow(ctx)); + group('account and mail flow', () => accountFlow(ctx)); group('tablesdb rows flow', () => tablesDbFlow(ctx)); group('storage files and tokens flow', () => storageFlow(ctx)); - group('messaging worker flow', () => messagingFlow(ctx)); group('functions and sites control-plane flow', () => computeFlow(ctx)); group('health and queue probes', () => healthFlow(ctx)); } catch (error) { @@ -360,73 +340,6 @@ function accountFlow(ctx) { } } -function databasesFlow(ctx) { - const databaseId = unique('db'); - const collectionId = unique('col'); - const documentId = unique('doc'); - const indexKey = unique('idx'); - - api('POST', '/databases', { databaseId, name: 'Benchmark DB' }, ctx.apiHeaders, [201], 'databases.create'); - api('POST', `/databases/${databaseId}/collections`, { - collectionId, - name: 'Benchmark Collection', - permissions: BASE_PERMISSIONS, - documentSecurity: false, - }, ctx.apiHeaders, [201], 'databases.collections.create'); - - const attributes = [ - ['string', 'title', { size: 128 }], - ['integer', 'count', { min: 0, max: 100000 }], - ['email', 'email', {}], - ['boolean', 'active', {}], - ['datetime', 'publishedAt', {}], - ['float', 'score', { min: 0, max: 1000 }], - ['url', 'url', {}], - ['ip', 'ip', {}], - ]; - - for (const [type, key, extra] of attributes) { - const started = Date.now(); - api('POST', `/databases/${databaseId}/collections/${collectionId}/attributes/${type}`, { - key, - required: false, - array: false, - ...extra, - }, ctx.apiHeaders, [202], `databases.attributes.${type}.create`); - waitForStatus(`/databases/${databaseId}/collections/${collectionId}/attributes/${key}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); - databaseWorkerDuration.add(Date.now() - started, { job: `attribute_${type}` }); - } - - const indexStarted = Date.now(); - api('POST', `/databases/${databaseId}/collections/${collectionId}/indexes`, { - key: indexKey, - type: 'key', - attributes: ['title'], - orders: ['asc'], - }, ctx.apiHeaders, [202], 'databases.indexes.create'); - waitForStatus(`/databases/${databaseId}/collections/${collectionId}/indexes/${indexKey}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); - databaseWorkerDuration.add(Date.now() - indexStarted, { job: 'index' }); - - api('POST', `/databases/${databaseId}/collections/${collectionId}/documents`, { - documentId, - data: documentPayload(), - permissions: ITEM_PERMISSIONS, - }, ctx.apiHeaders, [201], 'databases.documents.create'); - api('GET', `/databases/${databaseId}/collections/${collectionId}/documents`, null, ctx.apiHeaders, [200], 'databases.documents.list'); - api('GET', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, null, ctx.apiHeaders, [200], 'databases.documents.get'); - api('PATCH', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, { - data: { title: 'Benchmark Document Updated' }, - }, ctx.apiHeaders, [200], 'databases.documents.update'); - api('PATCH', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}/count/increment`, { - value: 1, - }, ctx.apiHeaders, [200], 'databases.documents.increment'); - api('PATCH', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}/count/decrement`, { - value: 1, - }, ctx.apiHeaders, [200], 'databases.documents.decrement'); - api('DELETE', `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, null, ctx.apiHeaders, [204], 'databases.documents.delete'); - api('DELETE', `/databases/${databaseId}`, null, ctx.apiHeaders, [204], 'databases.delete'); -} - function tablesDbFlow(ctx) { requireSession(ctx, 'tablesDbFlow'); @@ -548,92 +461,6 @@ function storageFlow(ctx) { api('DELETE', `/storage/buckets/${bucketId}`, null, ctx.apiHeaders, [204], 'storage.buckets.delete'); } -function messagingFlow(ctx) { - requireSession(ctx, 'messagingFlow'); - if (!ctx.userId || !ctx.userEmail) { - throw new Error('accountFlow must run before messagingFlow'); - } - - const providerId = unique('smtp'); - let targetId = unique('target'); - const topicId = unique('topic'); - const subscriberId = unique('sub'); - const messageId = unique('msg'); - - api('POST', '/messaging/providers/smtp', { - providerId, - name: 'Benchmark SMTP', - host: __ENV.APPWRITE_SMTP_HOST || 'maildev', - port: Number(__ENV.APPWRITE_SMTP_PORT || 1025), - username: __ENV.APPWRITE_SMTP_USERNAME || 'user', - password: __ENV.APPWRITE_SMTP_PASSWORD || 'password', - encryption: __ENV.APPWRITE_SMTP_ENCRYPTION || 'none', - autoTLS: false, - fromName: 'Benchmark', - fromEmail: 'benchmark@appwrite.io', - replyToName: 'Benchmark', - replyToEmail: 'benchmark@appwrite.io', - enabled: true, - }, ctx.apiHeaders, [201], 'messaging.providers.smtp.create'); - - const targets = api('GET', `/users/${ctx.userId}/targets`, null, ctx.apiHeaders, [200], 'users.targets.list'); - const existingTarget = (targets.json('targets') || []).find((target) => { - return target.providerType === 'email' && target.identifier === ctx.userEmail; - }); - - if (existingTarget) { - targetId = existingTarget.$id; - api('PATCH', `/users/${ctx.userId}/targets/${targetId}`, { - providerId, - name: 'Benchmark email target', - }, ctx.apiHeaders, [200], 'users.targets.update'); - } else { - api('POST', `/users/${ctx.userId}/targets`, { - targetId, - providerType: 'email', - identifier: ctx.userEmail, - providerId, - name: 'Benchmark email target', - }, ctx.apiHeaders, [201], 'users.targets.create'); - } - - api('POST', '/messaging/topics', { - topicId, - name: 'Benchmark Topic', - subscribe: ['users'], - }, ctx.apiHeaders, [201], 'messaging.topics.create'); - - api('POST', `/messaging/topics/${topicId}/subscribers`, { - subscriberId, - targetId, - }, ctx.sessionHeaders, [201], 'messaging.subscribers.create'); - - const started = Date.now(); - api('POST', '/messaging/messages/email', { - messageId, - subject: `Benchmark message ${ctx.runId}`, - content: `Benchmark messaging worker probe ${ctx.runId}`, - targets: [targetId], - draft: false, - html: false, - }, ctx.apiHeaders, [201], 'messaging.messages.email.create'); - - waitForMessage(messageId, ctx.apiHeaders, WORKER_TIMEOUT_MS); - waitForEmail(ctx.userEmail, (message) => includes(message.subject, `Benchmark message ${ctx.runId}`), MAIL_TIMEOUT_MS, true); - messagingWorkerDuration.add(Date.now() - started, { job: 'email_message' }); - - api('GET', '/messaging/messages', null, ctx.apiHeaders, [200], 'messaging.messages.list'); - api('GET', `/messaging/messages/${messageId}/logs`, null, ctx.apiHeaders, [200], 'messaging.messages.logs.list'); - api('GET', `/messaging/messages/${messageId}/targets`, null, ctx.apiHeaders, [200], 'messaging.messages.targets.list'); - api('GET', `/messaging/providers/${providerId}/logs`, null, ctx.apiHeaders, [200], 'messaging.providers.logs.list'); - api('GET', `/messaging/topics/${topicId}/logs`, null, ctx.apiHeaders, [200], 'messaging.topics.logs.list'); - api('GET', `/messaging/subscribers/${subscriberId}/logs`, null, ctx.apiHeaders, [200], 'messaging.subscribers.logs.list'); - api('DELETE', `/messaging/topics/${topicId}/subscribers/${subscriberId}`, null, ctx.sessionHeaders, [204], 'messaging.subscribers.delete'); - api('DELETE', `/messaging/topics/${topicId}`, null, ctx.apiHeaders, [204], 'messaging.topics.delete'); - api('DELETE', `/messaging/messages/${messageId}`, null, ctx.apiHeaders, [204], 'messaging.messages.delete'); - api('DELETE', `/messaging/providers/${providerId}`, null, ctx.apiHeaders, [204], 'messaging.providers.delete'); -} - function computeFlow(ctx) { requireSession(ctx, 'computeFlow'); @@ -715,9 +542,7 @@ function healthFlow(ctx) { '/health/storage', '/health/storage/local', '/health/time', - '/health/queue/databases', '/health/queue/mails', - '/health/queue/messaging', '/health/queue/functions', '/health/queue/builds', '/health/queue/deletes', @@ -739,6 +564,12 @@ function api(method, path, body, headers, expected, name) { return response; } +function setupApi(method, path, body, headers, expected, name) { + const response = rawRequest(method, path, body, headers, name); + assertStatus(response, expected, name); + return response; +} + function rawRequest(method, path, body, headers, name) { const params = { headers, @@ -772,26 +603,6 @@ function waitForStatus(path, headers, wantedStatus, timeoutMs) { throw new Error(`Timed out waiting for ${path} to become ${wantedStatus}`); } -function waitForMessage(messageId, headers, timeoutMs) { - const started = Date.now(); - - while (Date.now() - started < timeoutMs) { - const response = rawRequest('GET', `/messaging/messages/${messageId}`, null, headers, 'messaging.messages.poll'); - const status = response.status === 200 ? response.json('status') : null; - - if (['sent', 'failed'].includes(status)) { - if (status === 'failed') { - throw new Error(`Messaging worker marked message ${messageId} as failed`); - } - return response; - } - - sleep(0.5); - } - - throw new Error(`Timed out waiting for messaging worker to send message ${messageId}`); -} - function waitForEmail(address, predicate, timeoutMs, allowMissingRecipient = false) { const started = Date.now(); @@ -890,19 +701,6 @@ function requireSession(ctx, flow) { } } -function documentPayload() { - return { - title: 'Benchmark Document', - count: 1, - email: 'document@example.com', - active: true, - publishedAt: new Date().toISOString(), - score: 10.5, - url: 'https://appwrite.io', - ip: '127.0.0.1', - }; -} - function tablePayload() { return { title: 'Benchmark Row', @@ -961,24 +759,27 @@ export function handleSummary(data) { function detailsTable(data) { return [ - '| Scenario | Avg | P90 | P95 | Max |', + '| Scenario | P50 (ms) | P95 (ms) | Iterations | RPS |', '| --- | ---: | ---: | ---: | ---: |', - detailRow(data, 'HTTP total', 'appwrite_http_duration'), - detailRow(data, 'API endpoints', 'appwrite_api_duration'), - detailRow(data, 'Database worker schema jobs', 'appwrite_worker_database_duration'), - detailRow(data, 'TablesDB worker schema jobs', 'appwrite_worker_tables_duration'), - detailRow(data, 'Mail worker delivery', 'appwrite_worker_mails_duration'), - detailRow(data, 'Messaging worker delivery', 'appwrite_worker_messaging_duration'), + detailRow(data, 'Load test', 'appwrite_http_duration', 'iterations', 'http_reqs'), + detailRow(data, 'API total', 'appwrite_api_duration'), + detailRow(data, 'TablesDB schema', 'appwrite_worker_tables_duration'), + detailRow(data, 'Mail delivery', 'appwrite_worker_mails_duration'), ].join('\n'); } -function detailRow(data, label, metric, unit = 'ms') { +function detailRow(data, label, metric, iterationsMetric = null, rpsMetric = null) { const values = data.metrics[metric] && data.metrics[metric].values; if (!values || values.count === 0) { return `| ${label} | n/a | n/a | n/a | n/a |`; } - return `| ${label} | ${formatDetailValue(values.avg, unit)} | ${formatDetailValue(values['p(90)'], unit)} | ${formatDetailValue(values['p(95)'], unit)} | ${formatDetailValue(values.max, unit)} |`; + const iterations = iterationsMetric + ? trendMetric(data, iterationsMetric, 'count') + : values.count; + const rps = rpsMetric ? trendMetric(data, rpsMetric, 'rate') : null; + + return `| ${label} | ${formatDetailValue(values.med)} | ${formatDetailValue(values['p(95)'])} | ${formatCount(iterations)} | ${formatRate(rps)} |`; } function loadPreviousSummary() { @@ -988,10 +789,19 @@ function loadPreviousSummary() { } for (const path of paths) { + let contents; try { - return JSON.parse(open(path)); + contents = open(path); } catch (error) { // Try the next path. k6 resolves open() relative to the script file. + continue; + } + + try { + return JSON.parse(contents); + } catch (error) { + console.warn(`Invalid benchmark summary at ${path}: ${error.message}`); + return null; } } @@ -1000,20 +810,21 @@ function loadPreviousSummary() { function comparisonTable(before, after) { const rows = [ - ['HTTP throughput', trendMetric(before, 'http_reqs', 'rate'), trendMetric(after, 'http_reqs', 'rate'), ' req/s'], - ['HTTP total p95', trendMetric(before, 'appwrite_http_duration', 'p(95)'), trendMetric(after, 'appwrite_http_duration', 'p(95)'), 'ms'], - ['API endpoints p95', trendMetric(before, 'appwrite_api_duration', 'p(95)'), trendMetric(after, 'appwrite_api_duration', 'p(95)'), 'ms'], - ['Database worker p95', trendMetric(before, 'appwrite_worker_database_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_database_duration', 'p(95)'), 'ms'], - ['TablesDB worker p95', trendMetric(before, 'appwrite_worker_tables_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_tables_duration', 'p(95)'), 'ms'], - ['Mail worker p95', trendMetric(before, 'appwrite_worker_mails_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_mails_duration', 'p(95)'), 'ms'], - ['Messaging worker p95', trendMetric(before, 'appwrite_worker_messaging_duration', 'p(95)'), trendMetric(after, 'appwrite_worker_messaging_duration', 'p(95)'), 'ms'], + ['Load test', 'appwrite_http_duration'], + ['API total', 'appwrite_api_duration'], + ['TablesDB schema', 'appwrite_worker_tables_duration'], + ['Mail delivery', 'appwrite_worker_mails_duration'], ]; return [ - '| Metric | Before | After | Delta |', - '| --- | ---: | ---: | ---: |', - ...rows.map(([label, beforeValue, afterValue, unit]) => { - return `| ${label} | ${formatValue(beforeValue, unit)} | ${formatValue(afterValue, unit)} | ${formatDelta(beforeValue, afterValue, unit)} |`; + '| Scenario | Before P50 (ms) | Before P95 (ms) | After P50 (ms) | After P95 (ms) | Delta P95 (ms) |', + '| --- | ---: | ---: | ---: | ---: | ---: |', + ...rows.map(([label, metric]) => { + const beforeP50 = trendMetric(before, metric, 'med'); + const beforeP95 = trendMetric(before, metric, 'p(95)'); + const afterP50 = trendMetric(after, metric, 'med'); + const afterP95 = trendMetric(after, metric, 'p(95)'); + return `| ${label} | ${formatValue(beforeP50)} | ${formatValue(beforeP95)} | ${formatValue(afterP50)} | ${formatValue(afterP95)} | ${formatDelta(beforeP95, afterP95)} |`; }), ].join('\n'); } @@ -1024,30 +835,46 @@ function trendMetric(data, metric, stat) { : null; } -function formatValue(value, unit) { +function formatValue(value) { if (value === null || value === undefined || Number.isNaN(value)) { return 'n/a'; } - return `${round(value)}${unit}`; + return `${round(value)}`; } -function formatDetailValue(value, unit) { +function formatDetailValue(value) { if (value === null || value === undefined || Number.isNaN(value)) { return 'n/a'; } - return `${Number(value).toFixed(2)}${unit}`; + return `${Number(value).toFixed(2)}`; } -function formatDelta(before, after, unit) { +function formatDelta(before, after) { if (before === null || before === undefined || after === null || after === undefined || Number.isNaN(before) || Number.isNaN(after)) { return 'n/a'; } const delta = round(after - before); const sign = delta > 0 ? '+' : ''; - return `${sign}${delta}${unit}`; + return `${sign}${delta}`; +} + +function formatCount(value) { + if (value === null || value === undefined || Number.isNaN(value)) { + return 'n/a'; + } + + return `${Math.round(value)}`; +} + +function formatRate(value) { + if (value === null || value === undefined || Number.isNaN(value)) { + return 'n/a'; + } + + return `${Number(value).toFixed(2)}`; } function round(value) { From 2390d40731dd54ff517e34d51f01065aca739ddc Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:24:13 +0530 Subject: [PATCH 187/402] Remove specs task unit test --- tests/unit/Platform/Tasks/SpecsTest.php | 189 ------------------------ 1 file changed, 189 deletions(-) delete mode 100644 tests/unit/Platform/Tasks/SpecsTest.php diff --git a/tests/unit/Platform/Tasks/SpecsTest.php b/tests/unit/Platform/Tasks/SpecsTest.php deleted file mode 100644 index 6cb11b310e..0000000000 --- a/tests/unit/Platform/Tasks/SpecsTest.php +++ /dev/null @@ -1,189 +0,0 @@ -verifyParsedSpec($spec); - } - - public function collectEnums(array $spec): array - { - $collect = \Closure::bind(function (array $spec): array { - $enums = []; - $this->collectSpecEnumNames($spec, $enums); - - return $enums; - }, $this, Specs::class); - - return $collect($spec); - } -} - -class SpecsTest extends TestCase -{ - private TestSpecs $specs; - - protected function setUp(): void - { - $this->specs = new TestSpecs(); - } - - public function testVerifyParsedSpecFailsOnServiceEnumNameOverlap(): void - { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Locale (service \'locale\', enum \'Locale\')'); - - $this->specs->verify([ - 'tags' => [ - [ - 'name' => 'locale', - 'description' => 'Locale APIs', - ], - ], - 'paths' => [ - '/account/sessions/oauth2/{provider}' => [ - 'get' => [ - 'parameters' => [ - [ - 'name' => 'provider', - 'schema' => [ - 'type' => 'string', - 'enum' => ['en'], - 'x-enum-name' => 'Locale', - ], - ], - ], - ], - ], - ], - ]); - } - - public function testVerifyParsedSpecFailsOnDerivedServiceEnumNameOverlap(): void - { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Locale (service \'locale\', enum \'Locale\')'); - - $this->specs->verify([ - 'tags' => [ - [ - 'name' => 'locale', - 'description' => 'Locale APIs', - ], - ], - 'paths' => [ - '/projects/{projectId}/templates/email/{type}/{locale}' => [ - 'patch' => [ - 'parameters' => [ - [ - 'name' => 'payload', - 'in' => 'body', - 'schema' => [ - 'type' => 'object', - 'properties' => [ - 'locale' => [ - 'type' => 'string', - 'enum' => ['en'], - 'x-enum-name' => null, - ], - ], - ], - ], - ], - ], - ], - ], - ]); - } - - public function testVerifyParsedSpecAllowsDistinctServiceAndEnumNames(): void - { - $this->specs->verify([ - 'tags' => [ - [ - 'name' => 'locale', - 'description' => 'Locale APIs', - ], - ], - 'paths' => [ - '/projects/{projectId}/templates/email/{type}/{locale}' => [ - 'patch' => [ - 'parameters' => [ - [ - 'name' => 'locale', - 'schema' => [ - 'type' => 'string', - 'enum' => ['en'], - 'x-enum-name' => 'EmailTemplateLocale', - ], - ], - ], - ], - ], - ], - ]); - - $this->addToAssertionCount(1); - } - - public function testCollectSpecEnumNamesDoesNotDoubleRegisterExplicitNames(): void - { - $enums = $this->specs->collectEnums([ - 'schema' => [ - 'type' => 'string', - 'enum' => ['en'], - 'x-enum-name' => 'Locale', - ], - ]); - - $this->assertSame(['Locale'], $enums['locale']); - } - - public function testCollectSpecEnumNamesDoesNotDoubleRegisterItemsEnums(): void - { - $enums = $this->specs->collectEnums([ - 'name' => 'Locale', - 'type' => 'array', - 'items' => [ - 'type' => 'string', - 'enum' => ['en'], - ], - ]); - - $this->assertSame(['Locale'], $enums['locale']); - } - - public function testVerifyParsedSpecIgnoresHttpMethodFallbackNames(): void - { - $this->specs->verify([ - 'tags' => [ - [ - 'name' => 'patch', - 'description' => 'Patch APIs', - ], - ], - 'paths' => [ - '/example' => [ - 'patch' => [ - 'parameters' => [ - [ - 'schema' => [ - 'type' => 'string', - 'enum' => ['enabled'], - ], - ], - ], - ], - ], - ], - ]); - - $this->addToAssertionCount(1); - } -} From 240cdf43e5cacc2f4f46de516280bbe9b45ca73f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:24:47 +0530 Subject: [PATCH 188/402] Simplify local benchmark command --- tests/benchmarks/http.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index c6248dd3b0..7442a26220 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -1,7 +1,6 @@ /* * Run locally: - * - * docker compose up -d --force-recreate --build --wait + * Requires k6 and a running Appwrite instance. * * K6_WEB_DASHBOARD=true \ * K6_WEB_DASHBOARD_HOST=127.0.0.1 \ From 7578b5644cf029872dae385d34cc246190197b2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 12:00:15 +0200 Subject: [PATCH 189/402] AI review fixes --- app/config/errors.php | 5 +++++ src/Appwrite/Extend/Exception.php | 1 + .../Modules/Project/Http/Project/MockPhone/Create.php | 8 ++++++-- .../Modules/Project/Http/Project/MockPhone/Delete.php | 2 +- .../Modules/Project/Http/Project/MockPhone/Get.php | 2 +- .../Modules/Project/Http/Project/MockPhone/Update.php | 2 +- src/Appwrite/Utopia/Response/Model/MockNumber.php | 11 +++++++++++ 7 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/config/errors.php b/app/config/errors.php index 9a4710cb33..07b0cd59ed 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1418,4 +1418,9 @@ return [ 'description' => 'Mock number with the requested number could not be found.', 'code' => 404, ], + Exception::MOCK_NUMBER_LIMIT_EXCEEDED => [ + 'name' => Exception::MOCK_NUMBER_LIMIT_EXCEEDED, + 'description' => 'The maximum number of mock phones for this project has been reached.', + 'code' => 400, + ], ]; diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index b1651fec13..6fc3e88635 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -387,6 +387,7 @@ class Exception extends \Exception /** Mocks */ public const string MOCK_NUMBER_ALREADY_EXISTS = 'mock_number_already_exists'; public const string MOCK_NUMBER_NOT_FOUND = 'mock_number_not_found'; + public const string MOCK_NUMBER_LIMIT_EXCEEDED = 'mock_number_limit_exceeded'; /** Targets */ public const string TARGET_PROVIDER_INVALID_TYPE = 'target_provider_invalid_type'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php index 8aa2bcf642..f4002c60ef 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php @@ -75,15 +75,19 @@ class Create extends Action $mockNumbers = $auths['mockNumbers'] ?? []; + if (\count($mockNumbers) >= APP_LIMIT_COUNT) { + throw new Exception(Exception::MOCK_NUMBER_LIMIT_EXCEEDED); + } + foreach ($mockNumbers as $mockNumber) { - if ($mockNumber['number'] === $number) { + if ($mockNumber['phone'] === $number) { throw new Exception(Exception::MOCK_NUMBER_ALREADY_EXISTS); } } // Set to now date $mockNumber = [ - 'number' => $number, + 'phone' => $number, 'otp' => $otp, '$createdAt' => DateTime::now(), '$updatedAt' => DateTime::now(), diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php index af7afae120..0fb23e1764 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php @@ -75,7 +75,7 @@ class Delete extends Action $mockNumberIndex = null; foreach ($mockNumbers as $index => $mock) { - if ($mock['number'] === $number) { + if ($mock['phone'] === $number) { $mockNumberIndex = $index; break; } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php index 8f799e98d7..a51095b368 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php @@ -61,7 +61,7 @@ class Get extends Action $mockNumberIndex = null; foreach ($mockNumbers as $index => $mock) { - if ($mock['number'] === $number) { + if ($mock['phone'] === $number) { $mockNumberIndex = $index; break; } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php index 4924f53ff8..48b90a1b97 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php @@ -77,7 +77,7 @@ class Update extends Action $mockNumberIndex = null; foreach ($mockNumbers as $index => $mock) { - if ($mock['number'] === $number) { + if ($mock['phone'] === $number) { $mockNumberIndex = $index; break; } diff --git a/src/Appwrite/Utopia/Response/Model/MockNumber.php b/src/Appwrite/Utopia/Response/Model/MockNumber.php index eee788dbab..507700bc5b 100644 --- a/src/Appwrite/Utopia/Response/Model/MockNumber.php +++ b/src/Appwrite/Utopia/Response/Model/MockNumber.php @@ -4,6 +4,7 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; +use Utopia\Database\Document; class MockNumber extends Model { @@ -37,6 +38,16 @@ class MockNumber extends Model ; } + public function filter(Document $document): Document + { + if ($document->isSet('phone')) { + $document->setAttribute('number', $document->getAttribute('phone')); + $document->removeAttribute('phone'); + } + + return $document; + } + /** * Get Name * From 355d4323fc9bb5eebfb2a307340955da998e1a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 12:01:42 +0200 Subject: [PATCH 190/402] Fix tests not running --- .../Project/MockPhonesConsoleClientTest.php | 14 ++++++++++++++ .../Project/MockPhonesCustomServerTest.php | 14 ++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 tests/e2e/Services/Project/MockPhonesConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/MockPhonesCustomServerTest.php diff --git a/tests/e2e/Services/Project/MockPhonesConsoleClientTest.php b/tests/e2e/Services/Project/MockPhonesConsoleClientTest.php new file mode 100644 index 0000000000..c4819774bf --- /dev/null +++ b/tests/e2e/Services/Project/MockPhonesConsoleClientTest.php @@ -0,0 +1,14 @@ + Date: Wed, 22 Apr 2026 15:33:18 +0530 Subject: [PATCH 191/402] Preserve nested items enum validation --- src/Appwrite/Platform/Tasks/Specs.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index 31fb69460f..ce1d78799d 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -343,9 +343,9 @@ class Specs extends Action } } - private function collectSpecEnumNames(array $node, array &$enums, ?string $fallbackName = null): void + private function collectSpecEnumNames(array $node, array &$enums, ?string $fallbackName = null, bool $skipCurrentEnum = false): void { - if (isset($node['enum']) && \is_array($node['enum'])) { + if (!$skipCurrentEnum && isset($node['enum']) && \is_array($node['enum'])) { $enumName = $this->getExplicitSpecEnumName($node) ?? $this->getFallbackSpecEnumName($node, $fallbackName); @@ -354,6 +354,7 @@ class Specs extends Action } } + $itemsEnumHandled = false; if ( isset($node['items']) && \is_array($node['items']) @@ -367,6 +368,8 @@ class Specs extends Action if (!\is_null($enumName)) { $this->addSpecEnumName($enums, $enumName); } + + $itemsEnumHandled = true; } $explicitEnumName = $this->getExplicitSpecEnumName($node); @@ -375,14 +378,15 @@ class Specs extends Action } foreach ($node as $key => $value) { - if ($key === 'items' || !\is_array($value)) { + if (!\is_array($value)) { continue; } $this->collectSpecEnumNames( $value, $enums, - $this->getChildSpecEnumFallbackName($node, $key, $value, $fallbackName) + $this->getChildSpecEnumFallbackName($node, $key, $value, $fallbackName), + $key === 'items' && $itemsEnumHandled ); } } From 205c2839355b4ff441bf7881e52637e1c755169a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:33:52 +0530 Subject: [PATCH 192/402] Remove unused JWT benchmark setup --- tests/benchmarks/http.js | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 7442a26220..970ceff4a5 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -264,12 +264,6 @@ function accountFlow(ctx) { ctx.userEmail = email; ctx.sessionHeaders = sessionHeaders; - const jwt = api('POST', '/account/jwts', null, sessionHeaders, [201], 'account.jwts.create'); - ctx.jwtHeaders = { - ...headers, - 'X-Appwrite-JWT': jwt.json('jwt'), - }; - api('GET', '/account', null, sessionHeaders, [200], 'account.get'); api('GET', '/account/logs', null, sessionHeaders, [200], 'account.logs.list'); api('PATCH', '/account/prefs', { prefs: { benchmark: true, runId: ctx.runId } }, sessionHeaders, [200], 'account.prefs.update'); @@ -331,11 +325,6 @@ function accountFlow(ctx) { Cookie: cookieHeader(recoveredSession), }; - const recoveredJwt = api('POST', '/account/jwts', null, ctx.sessionHeaders, [201], 'account.jwts.recovered'); - ctx.jwtHeaders = { - ...headers, - 'X-Appwrite-JWT': recoveredJwt.json('jwt'), - }; } } From e0fec8f550b098d77192758419bef031cf62ceb1 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 15:42:17 +0530 Subject: [PATCH 193/402] updated --- app/realtime.php | 68 +++++++++++++++++++++++ src/Appwrite/SDK/Specification/Format.php | 7 +-- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 3461ca83e5..13ebb609a8 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -38,6 +38,7 @@ use Utopia\DSN\DSN; use Utopia\Logger\Log; use Utopia\Pools\Group; use Utopia\Registry\Registry; +use Utopia\Span\Span; use Utopia\System\System; use Utopia\Telemetry\Adapter\None as NoTelemetry; use Utopia\WebSocket\Adapter; @@ -326,11 +327,32 @@ if (!function_exists('logError')) { } } +if (!function_exists('traceOperationalEvent')) { + function traceOperationalEvent(string $action, string $message, array $context = []): void + { + Span::init($action); + Span::add('realtime.action', $action); + Span::add('realtime.message', $message); + Span::add('realtime.timestamp', DateTime::formatTz(DateTime::now())); + + foreach ($context as $key => $value) { + if (\is_scalar($value) || $value === null) { + Span::add('realtime.' . $key, ($value === null || $value === '') ? 'n/a' : $value); + } + } + + Span::current()?->finish(); + } +} + $server->error(logError(...)); $server->onStart(function () use ($stats, $containerId, &$statsDocument) { sleep(5); // wait for the initial database schema to be ready Console::success('Server started successfully'); + traceOperationalEvent('realtime.server.started', 'Realtime server started', [ + 'container' => $containerId, + ]); /** * Create document for this worker to share stats across Containers. @@ -394,6 +416,9 @@ $server->onStart(function () use ($stats, $containerId, &$statsDocument) { $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime) { Console::success('Worker ' . $workerId . ' started successfully'); + traceOperationalEvent('realtime.worker.started', 'Realtime worker started', [ + 'workerId' => $workerId, + ]); $telemetry = getTelemetry($workerId); $realtimeDelayBuckets = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]; @@ -526,6 +551,9 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, if ($pubsub->ping(true)) { $attempts = 0; Console::success('Pub/sub connection established (worker: ' . $workerId . ')'); + traceOperationalEvent('realtime.pubsub.connected', 'Realtime pubsub connected', [ + 'workerId' => $workerId, + ]); } else { Console::error('Pub/sub failed (worker: ' . $workerId . ')'); } @@ -858,6 +886,14 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $server->send([$connection], $connectedPayloadJson); $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); + traceOperationalEvent('realtime.connection.opened', 'Realtime connection established', [ + 'connectionId' => $connection, + 'projectId' => $project->getId(), + 'teamId' => $project->getAttribute('teamId'), + 'userId' => $logUser?->getId() ?: null, + 'channelCount' => \count($names), + 'subscriptionCount' => \count($mapping), + ]); } catch (Throwable $th) { @@ -1053,6 +1089,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $authResponsePayloadJson); + traceOperationalEvent('realtime.authentication.succeeded', 'Realtime authentication succeeded', [ + 'connectionId' => $connection, + 'projectId' => $projectId, + 'userId' => $user['$id'] ?? null, + 'subscriptionDelta' => $subscriptionDelta, + ]); if ($project !== null && !$project->isEmpty()) { $authOutboundBytes = \strlen($authResponsePayloadJson); @@ -1149,6 +1191,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $responsePayload); + traceOperationalEvent('realtime.subscribe.updated', 'Realtime subscriptions updated', [ + 'connectionId' => $connection, + 'projectId' => $projectId, + 'subscriptionCount' => \count($parsedPayloads), + 'subscriptionDelta' => $subscriptionDelta, + ]); if ($project !== null && !$project->isEmpty()) { $subscribeOutboundBytes = \strlen($responsePayload); @@ -1208,6 +1256,13 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $unsubscribeResponsePayload); + traceOperationalEvent('realtime.unsubscribe.updated', 'Realtime subscriptions removed', [ + 'connectionId' => $connection, + 'projectId' => $projectId, + 'requestedCount' => \count($validatedIds), + 'removedCount' => \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed'] ?? false)), + 'subscriptionDelta' => $subscriptionDelta, + ]); if ($project !== null && !$project->isEmpty()) { $unsubscribeOutboundBytes = \strlen($unsubscribeResponsePayload); @@ -1255,6 +1310,14 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re }); $server->onClose(function (int $connection) use ($realtime, $stats, $register) { + $projectId = null; + $userId = null; + + if (array_key_exists($connection, $realtime->connections)) { + $projectId = $realtime->connections[$connection]['projectId'] ?? null; + $userId = $realtime->connections[$connection]['userId'] ?? null; + } + try { if (array_key_exists($connection, $realtime->connections)) { $stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal'); @@ -1278,6 +1341,11 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { } $realtime->unsubscribe($connection); + traceOperationalEvent('realtime.connection.closed', 'Realtime connection closed', [ + 'connectionId' => $connection, + 'projectId' => $projectId, + 'userId' => $userId, + ]); Console::info('Connection close: ' . $connection); }); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 08f960b2a7..d48a4b8f3f 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -742,6 +742,7 @@ abstract class Format } break; case 'project': + case 'projects': switch ($method) { case 'getUsage': switch ($param) { @@ -749,10 +750,6 @@ abstract class Format return 'ProjectUsageRange'; } break; - } - break; - case 'projects': - switch ($method) { case 'getEmailTemplate': case 'updateEmailTemplate': case 'deleteEmailTemplate': @@ -770,7 +767,9 @@ abstract class Format } break; case 'createSmtpTest': + case 'createSMTPTest': case 'updateSmtp': + case 'updateSMTP': switch ($param) { case 'secure': return 'SMTPSecure'; From 9065d9ada49774ab3613e3f4147ef239483ac0fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 12:13:10 +0200 Subject: [PATCH 194/402] Add mocks scopes --- app/config/roles.php | 2 ++ app/config/scopes/project.php | 8 ++++++++ src/Appwrite/Platform/Workers/Migrations.php | 2 ++ tests/e2e/Scopes/ProjectCustom.php | 2 ++ 4 files changed, 14 insertions(+) diff --git a/app/config/roles.php b/app/config/roles.php index 50b0cb3dfc..62efb4d809 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -55,6 +55,8 @@ $admins = [ 'tables.write', 'platforms.read', 'platforms.write', + 'mocks.read', + 'mocks.write', 'policies.write', 'templates.read', 'templates.write', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index c5fba3ed2b..2c78cb921c 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -204,6 +204,14 @@ return [ // List of publicly visible scopes "description" => "Access to create, update, and delete project\'s platforms", ], + "mocks.read" => [ + "description" => + "Access to read project\'s mocks", + ], + "mocks.write" => [ + "description" => + "Access to create, update, and delete project\'s mocks", + ], "policies.write" => [ "description" => "Access to update project\'s policies", diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 52ad64d975..0225983d2f 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -391,6 +391,8 @@ class Migrations extends Action 'keys.write', 'platforms.read', 'platforms.write', + 'mocks.read', + 'mocks.write', 'policies.write', 'templates.read', 'templates.write', diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 86d7de8849..e5a86c07fd 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -169,6 +169,8 @@ trait ProjectCustom 'keys.write', 'platforms.read', 'platforms.write', + 'mocks.read', + 'mocks.write', 'policies.write', 'templates.read', 'templates.write', From 00512df4caebe7d735f718cb72023c8752bc301a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:45:16 +0530 Subject: [PATCH 195/402] Clean up spec enum validation reporting --- src/Appwrite/Platform/Tasks/Specs.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index ce1d78799d..82020b05b1 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -331,7 +331,7 @@ class Specs extends Action } foreach ($enums[$normalized] as $enum) { - $overlaps[] = "{$enum} (service '{$service}', enum '{$enum}')"; + $overlaps[] = "service '{$service}' with enum '{$enum}'"; } } @@ -373,7 +373,7 @@ class Specs extends Action } $explicitEnumName = $this->getExplicitSpecEnumName($node); - if (!\is_null($explicitEnumName) && !isset($node['enum'])) { + if (!\is_null($explicitEnumName) && !isset($node['enum']) && !$itemsEnumHandled) { $this->addSpecEnumName($enums, $explicitEnumName); } From 0f64f542219978d4a586d82ccf92c07e45c28cb7 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:49:27 +0530 Subject: [PATCH 196/402] Harden benchmark rerun metrics --- .github/workflows/benchmark-comment.js | 11 ++++++----- .github/workflows/ci.yml | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/benchmark-comment.js b/.github/workflows/benchmark-comment.js index 2294611318..1ccf1f69e8 100644 --- a/.github/workflows/benchmark-comment.js +++ b/.github/workflows/benchmark-comment.js @@ -179,7 +179,6 @@ function serviceStats(samples) { const apiSamples = samples.filter((sample) => { return sample.metric === 'appwrite_api_duration' && typeof sample.data?.value === 'number'; }); - const durationSeconds = sampleWindowSeconds(apiSamples); const groups = new Map(); for (const sample of apiSamples) { @@ -188,12 +187,14 @@ function serviceStats(samples) { continue; } - const values = groups.get(service) || []; - values.push(sample.data.value); - groups.set(service, values); + const serviceSamples = groups.get(service) || []; + serviceSamples.push(sample); + groups.set(service, serviceSamples); } - return new Map([...groups.entries()].map(([service, values]) => { + return new Map([...groups.entries()].map(([service, serviceSamples]) => { + const values = serviceSamples.map((sample) => sample.data.value); + const durationSeconds = sampleWindowSeconds(serviceSamples); return [service, { p50: percentile(values, 50), p95: percentile(values, 95), diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5f5fc79b2..a8e5cb38fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -745,6 +745,24 @@ jobs: docker compose down -v || true fi + - name: Wait for benchmark ports + if: always() + run: | + for port in 80 443 8080 9503; do + for attempt in $(seq 1 30); do + if ! ss -ltn | awk '{print $4}' | grep -Eq "[:.]${port}$"; then + break + fi + sleep 1 + done + + if ss -ltn | awk '{print $4}' | grep -Eq "[:.]${port}$"; then + echo "Port ${port} is still in use after stopping the before stack" + ss -ltn + exit 1 + fi + done + - name: Start after Appwrite env: _APP_DOMAIN: localhost From d1ade3872e9b0c94d8e6cb07abe959583bf5dc88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 12:22:00 +0200 Subject: [PATCH 197/402] Fix failing tests --- tests/e2e/Services/Project/MockPhonesBase.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/Services/Project/MockPhonesBase.php b/tests/e2e/Services/Project/MockPhonesBase.php index 10ddf8aa0c..02ddcd73bc 100644 --- a/tests/e2e/Services/Project/MockPhonesBase.php +++ b/tests/e2e/Services/Project/MockPhonesBase.php @@ -436,7 +436,7 @@ trait MockPhonesBase $headers = \array_merge($headers, $this->getHeaders()); } - return $this->client->call(Client::METHOD_GET, '/project/mock-phones/' . \urlencode($number), $headers); + return $this->client->call(Client::METHOD_GET, '/project/mock-phones/' . $number, $headers); } protected function updateMockPhone(string $number, ?string $otp, bool $authenticated = true): mixed @@ -455,7 +455,7 @@ trait MockPhonesBase $params['otp'] = $otp; } - return $this->client->call(Client::METHOD_PUT, '/project/mock-phones/' . \urlencode($number), $headers, $params); + return $this->client->call(Client::METHOD_PUT, '/project/mock-phones/' . $number, $headers, $params); } protected function listMockPhones(?bool $total = null, bool $authenticated = true): mixed @@ -488,7 +488,7 @@ trait MockPhonesBase $headers = \array_merge($headers, $this->getHeaders()); } - return $this->client->call(Client::METHOD_DELETE, '/project/mock-phones/' . \urlencode($number), $headers); + return $this->client->call(Client::METHOD_DELETE, '/project/mock-phones/' . $number, $headers); } protected function uniquePhoneNumber(): string From d106e1d5bb06c027b435cde4909b546323dd6f5c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:57:32 +0530 Subject: [PATCH 198/402] Fix session alert test payload --- tests/e2e/Services/Project/TemplatesBase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 72a14210a5..d415c3267d 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -685,7 +685,7 @@ trait TemplatesBase 'x-appwrite-project' => 'console', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ], - ['alerts' => true], + ['enabled' => true], ); $this->assertSame(200, $alertsResponse['headers']['status-code'], 'failed to enable session alerts'); From e7d9ef74c4aa8c650cb9d5a290f44f6af47cd680 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 16:05:12 +0530 Subject: [PATCH 199/402] Fix deployment single chunk content range Fixes CLOUD-3JN6 --- .../Functions/Http/Deployments/Create.php | 6 +++ .../Modules/Sites/Http/Deployments/Create.php | 6 +++ .../Functions/FunctionsCustomServerTest.php | 38 ++++++++++++++++++ .../Services/Sites/SitesCustomServerTest.php | 40 +++++++++++++++++++ 4 files changed, 90 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index 65b6ffd5bb..11736c8ca5 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -206,6 +206,12 @@ class Create extends Action if ($chunk === -1) { $chunk = $chunks; } + } else { + // Guard against manually setting range header for single chunk upload + if ($chunks === -1) { + $chunks = 1; + $chunk = 1; + } } $chunksUploaded = $deviceForFunctions->upload($fileTmpName, $path, $chunk, $chunks, $metadata); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index 8a6964209f..0b8ca24aaa 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -208,6 +208,12 @@ class Create extends Action if ($chunk === -1) { $chunk = $chunks; } + } else { + // Guard against manually setting range header for single chunk upload + if ($chunks === -1) { + $chunks = 1; + $chunk = 1; + } } $chunksUploaded = $deviceForSites->upload($fileTmpName, $path, $chunk, $chunks, $metadata); diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index ba518ee0b6..4255774f18 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -567,6 +567,44 @@ class FunctionsCustomServerTest extends Scope }, 120000, 500); } + public function testCreateDeploymentWithSingleContentRangeChunk(): void + { + $functionId = $this->setupFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test Single Chunk Range', + 'execute' => [Role::user($this->getUser()['$id'])->toString()], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + + $code = $this->packageFunction('basic'); + $size = \filesize($code->getFilename()); + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes 0-' . ($size - 1) . '/' . $size, + ], $this->getHeaders()), [ + 'code' => $code, + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertNotEmpty($deployment['body']['$id']); + + $deploymentId = $deployment['body']['$id']; + + $this->assertEventually(function () use ($functionId, $deploymentId) { + $deployment = $this->getDeployment($functionId, $deploymentId); + + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('ready', $deployment['body']['status']); + }, 120000, 500); + + $this->cleanupFunction($functionId); + } + public function testCreateFunctionAndDeploymentFromTemplate() { diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 69dbd7fdf0..645ad031f5 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -868,6 +868,46 @@ class SitesCustomServerTest extends Scope // // TODO: Implement testCreateDeploymentFromCLI() later // } + public function testCreateDeploymentWithSingleContentRangeChunk(): void + { + $siteId = $this->setupSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test Site Single Chunk Range', + 'outputDirectory' => './', + 'providerBranch' => 'main', + 'providerRootDirectory' => './', + 'siteId' => ID::unique() + ]); + + $code = $this->packageSite('static-single-file'); + $size = \filesize($code->getFilename()); + + $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes 0-' . ($size - 1) . '/' . $size, + ], $this->getHeaders()), [ + 'code' => $code, + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertNotEmpty($deployment['body']['$id']); + + $deploymentId = $deployment['body']['$id']; + + $this->assertEventually(function () use ($siteId, $deploymentId) { + $deployment = $this->getDeployment($siteId, $deploymentId); + + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('ready', $deployment['body']['status']); + }, 120000, 500); + + $this->cleanupSite($siteId); + } + public function testCreateDeployment() { $siteId = $this->setupSite([ From f934259c31d31c016e7aff14b5127c95fff55f21 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 16:22:23 +0530 Subject: [PATCH 200/402] Skip preview rule when no deployment exists on function create --- .../Platform/Modules/Functions/Http/Functions/Create.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 8d4ad5d403..cd2ba6e451 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -375,7 +375,7 @@ class Create extends Base } $functionsDomain = $platform['functionsDomain']; - if (!empty($functionsDomain)) { + if (!empty($functionsDomain) && isset($deployment) && !$deployment->isEmpty()) { $routeSubdomain = ID::unique(); $domain = "{$routeSubdomain}.{$functionsDomain}"; // TODO: (@Meldiron) Remove after 1.7.x migration From f50ca0281b2ba768d6941c9135b6a4a6cffa4b9d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 16:43:28 +0530 Subject: [PATCH 201/402] Drop dead ternary guards now that outer check ensures deployment --- .../Platform/Modules/Functions/Http/Functions/Create.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index cd2ba6e451..7b294f3f90 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -391,8 +391,8 @@ class Create extends Base 'status' => 'verified', 'type' => 'deployment', 'trigger' => 'manual', - 'deploymentId' => !isset($deployment) || $deployment->isEmpty() ? '' : $deployment->getId(), - 'deploymentInternalId' => !isset($deployment) || $deployment->isEmpty() ? '' : $deployment->getSequence(), + 'deploymentId' => $deployment->getId(), + 'deploymentInternalId' => $deployment->getSequence(), 'deploymentResourceType' => 'function', 'deploymentResourceId' => $function->getId(), 'deploymentResourceInternalId' => $function->getSequence(), From ca1cf1982f5a8664a39b8a9752b046c8578aee94 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 16:44:20 +0530 Subject: [PATCH 202/402] added wide events inside the structured one logging per message instead of a discrete logs --- app/realtime.php | 105 +++++++++++++++++++---------------------------- 1 file changed, 43 insertions(+), 62 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 13ebb609a8..835ddf932f 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -327,32 +327,11 @@ if (!function_exists('logError')) { } } -if (!function_exists('traceOperationalEvent')) { - function traceOperationalEvent(string $action, string $message, array $context = []): void - { - Span::init($action); - Span::add('realtime.action', $action); - Span::add('realtime.message', $message); - Span::add('realtime.timestamp', DateTime::formatTz(DateTime::now())); - - foreach ($context as $key => $value) { - if (\is_scalar($value) || $value === null) { - Span::add('realtime.' . $key, ($value === null || $value === '') ? 'n/a' : $value); - } - } - - Span::current()?->finish(); - } -} - $server->error(logError(...)); $server->onStart(function () use ($stats, $containerId, &$statsDocument) { sleep(5); // wait for the initial database schema to be ready Console::success('Server started successfully'); - traceOperationalEvent('realtime.server.started', 'Realtime server started', [ - 'container' => $containerId, - ]); /** * Create document for this worker to share stats across Containers. @@ -416,9 +395,6 @@ $server->onStart(function () use ($stats, $containerId, &$statsDocument) { $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime) { Console::success('Worker ' . $workerId . ' started successfully'); - traceOperationalEvent('realtime.worker.started', 'Realtime worker started', [ - 'workerId' => $workerId, - ]); $telemetry = getTelemetry($workerId); $realtimeDelayBuckets = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]; @@ -551,9 +527,6 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, if ($pubsub->ping(true)) { $attempts = 0; Console::success('Pub/sub connection established (worker: ' . $workerId . ')'); - traceOperationalEvent('realtime.pubsub.connected', 'Realtime pubsub connected', [ - 'workerId' => $workerId, - ]); } else { Console::error('Pub/sub failed (worker: ' . $workerId . ')'); } @@ -886,14 +859,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $server->send([$connection], $connectedPayloadJson); $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); - traceOperationalEvent('realtime.connection.opened', 'Realtime connection established', [ - 'connectionId' => $connection, - 'projectId' => $project->getId(), - 'teamId' => $project->getAttribute('teamId'), - 'userId' => $logUser?->getId() ?: null, - 'channelCount' => \count($names), - 'subscriptionCount' => \count($mapping), - ]); } catch (Throwable $th) { @@ -935,10 +900,24 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId, $register) { $project = null; $authorization = null; + $projectId = $realtime->connections[$connection]['projectId'] ?? null; + $rawSize = \strlen($message); + $messageType = 'invalid'; + $subscriptionDelta = 0; + $subscriptionsRequested = 0; + $subscriptionsRemoved = 0; + $outboundBytes = 0; + $responseCode = 200; + $success = false; + + Span::init('realtime.message'); + Span::add('realtime.connection_id', $connection); + Span::add('realtime.project_id', $projectId ?: 'n/a'); + Span::add('realtime.inbound_bytes', $rawSize); + Span::add('realtime.container_id', $containerId); + try { - $rawSize = \strlen($message); $response = new Response(new SwooleResponse()); - $projectId = $realtime->connections[$connection]['projectId'] ?? null; // Get authorization from connection (stored during onOpen) $authorization = $realtime->connections[$connection]['authorization'] ?? null; @@ -983,6 +962,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } $message = json_decode($message, true); + $messageType = $message['type'] ?? 'invalid'; + Span::add('realtime.message_type', $messageType); if (is_null($message) || (!array_key_exists('type', $message) && !array_key_exists('data', $message))) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.'); @@ -1000,6 +981,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $pongPayloadJson); + $outboundBytes += \strlen($pongPayloadJson); if ($project !== null && !$project->isEmpty()) { $pongOutboundBytes = \strlen($pongPayloadJson); @@ -1089,12 +1071,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $authResponsePayloadJson); - traceOperationalEvent('realtime.authentication.succeeded', 'Realtime authentication succeeded', [ - 'connectionId' => $connection, - 'projectId' => $projectId, - 'userId' => $user['$id'] ?? null, - 'subscriptionDelta' => $subscriptionDelta, - ]); + $outboundBytes += \strlen($authResponsePayloadJson); + Span::add('realtime.user_id', $user['$id'] ?? 'n/a'); if ($project !== null && !$project->isEmpty()) { $authOutboundBytes = \strlen($authResponsePayloadJson); @@ -1171,6 +1149,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + $subscriptionsRequested = \count($parsedPayloads); if ($subscriptionDelta !== 0) { $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); } @@ -1191,12 +1170,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $responsePayload); - traceOperationalEvent('realtime.subscribe.updated', 'Realtime subscriptions updated', [ - 'connectionId' => $connection, - 'projectId' => $projectId, - 'subscriptionCount' => \count($parsedPayloads), - 'subscriptionDelta' => $subscriptionDelta, - ]); + $outboundBytes += \strlen($responsePayload); if ($project !== null && !$project->isEmpty()) { $subscribeOutboundBytes = \strlen($responsePayload); @@ -1242,6 +1216,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; + $subscriptionsRequested = \count($validatedIds); + $subscriptionsRemoved = \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed'] ?? false)); if ($subscriptionDelta !== 0) { $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); } @@ -1256,13 +1232,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ]); $server->send([$connection], $unsubscribeResponsePayload); - traceOperationalEvent('realtime.unsubscribe.updated', 'Realtime subscriptions removed', [ - 'connectionId' => $connection, - 'projectId' => $projectId, - 'requestedCount' => \count($validatedIds), - 'removedCount' => \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed'] ?? false)), - 'subscriptionDelta' => $subscriptionDelta, - ]); + $outboundBytes += \strlen($unsubscribeResponsePayload); if ($project !== null && !$project->isEmpty()) { $unsubscribeOutboundBytes = \strlen($unsubscribeResponsePayload); @@ -1279,12 +1249,14 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re default: throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } + $success = true; } catch (Throwable $th) { logError($th, 'realtimeMessage', project: $project, authorization: $authorization); $code = $th->getCode(); if (!is_int($code)) { $code = 500; } + $responseCode = $code; $message = $th->getMessage(); @@ -1301,11 +1273,25 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re ] ]; - $server->send([$connection], json_encode($response)); + $responsePayloadJson = json_encode($response); + $server->send([$connection], $responsePayloadJson); + $outboundBytes += \strlen($responsePayloadJson); if ($th->getCode() === 1008) { $server->close($connection, $th->getCode()); } + Span::error($th); + } finally { + Span::add('realtime.success', $success); + Span::add('realtime.response_code', $responseCode); + Span::add('realtime.subscription_delta', $subscriptionDelta); + Span::add('realtime.subscriptions_requested', $subscriptionsRequested); + Span::add('realtime.subscriptions_removed', $subscriptionsRemoved); + Span::add('realtime.outbound_bytes', $outboundBytes); + Span::add('realtime.project_id', $project?->getId() ?: $projectId ?: 'n/a'); + Span::add('realtime.user_id', $realtime->connections[$connection]['userId'] ?? 'n/a'); + Span::add('realtime.message_type', $messageType); + Span::current()?->finish(); } }); @@ -1341,11 +1327,6 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { } $realtime->unsubscribe($connection); - traceOperationalEvent('realtime.connection.closed', 'Realtime connection closed', [ - 'connectionId' => $connection, - 'projectId' => $projectId, - 'userId' => $userId, - ]); Console::info('Connection close: ' . $connection); }); From 6648a1987bd8f79d15abd5032bcb21034ecaa842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 13:14:43 +0200 Subject: [PATCH 203/402] Fix tests --- tests/e2e/Services/Project/SMTPBase.php | 20 ++++++++++++++++++++ tests/e2e/Services/Project/TemplatesBase.php | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 4bdf073e19..748fb3502b 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -2,11 +2,31 @@ namespace Tests\E2E\Services\Project; +use PHPUnit\Framework\Attributes\Before; use Tests\E2E\Client; use Utopia\Database\Helpers\ID; trait SMTPBase { + // The ProjectCustom trait reuses the same project across tests in a class. + // Since the SMTP PATCH endpoint is additive (unset fields are preserved), + // state leaks across tests. Reset to a known-good, maildev-compatible + // configuration before each test so tests that don't specify credentials + // still connect cleanly. + #[Before(priority: -1)] + protected function resetProjectSMTP(): void + { + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: 'user', + password: 'password', + enabled: false, + ); + } + // Update SMTP status tests public function testUpdateSMTPStatusEnable(): void diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index cb7c1bf0b3..b57a20a8d9 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -912,7 +912,7 @@ trait TemplatesBase 'x-appwrite-project' => 'console', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ], - ['alerts' => true], + ['enabled' => true], ); $this->assertSame(200, $alertsResponse['headers']['status-code'], 'failed to enable session alerts'); From d1962dbc624e43a223d4c470318c03abb0d46376 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 16:45:16 +0530 Subject: [PATCH 204/402] Shorten local benchmark command --- .github/workflows/benchmark-comment.js | 8 +++--- tests/benchmarks/http-local.sh | 17 +++++++++++ tests/benchmarks/http.js | 40 ++++++++++++++------------ 3 files changed, 42 insertions(+), 23 deletions(-) create mode 100755 tests/benchmarks/http-local.sh diff --git a/.github/workflows/benchmark-comment.js b/.github/workflows/benchmark-comment.js index 1ccf1f69e8..4a9c920365 100644 --- a/.github/workflows/benchmark-comment.js +++ b/.github/workflows/benchmark-comment.js @@ -150,13 +150,13 @@ function benchmarkRows(before, after, beforeSamples, afterSamples) { })), { label: 'TablesDB schema', - before: summaryStats(before, 'appwrite_worker_tables_duration'), - after: summaryStats(after, 'appwrite_worker_tables_duration'), + before: summaryStats(before, 'appwrite_worker_tables_duration', 'appwrite_worker_tables_samples', 'appwrite_worker_tables_samples'), + after: summaryStats(after, 'appwrite_worker_tables_duration', 'appwrite_worker_tables_samples', 'appwrite_worker_tables_samples'), }, { label: 'Mail delivery', - before: summaryStats(before, 'appwrite_worker_mails_duration'), - after: summaryStats(after, 'appwrite_worker_mails_duration'), + before: summaryStats(before, 'appwrite_worker_mails_duration', 'appwrite_worker_mails_samples', 'appwrite_worker_mails_samples'), + after: summaryStats(after, 'appwrite_worker_mails_duration', 'appwrite_worker_mails_samples', 'appwrite_worker_mails_samples'), }, ]; } diff --git a/tests/benchmarks/http-local.sh b/tests/benchmarks/http-local.sh new file mode 100755 index 0000000000..acb8a07058 --- /dev/null +++ b/tests/benchmarks/http-local.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +export K6_WEB_DASHBOARD="${K6_WEB_DASHBOARD:-true}" +export K6_WEB_DASHBOARD_HOST="${K6_WEB_DASHBOARD_HOST:-127.0.0.1}" +export K6_WEB_DASHBOARD_PORT="${K6_WEB_DASHBOARD_PORT:-5665}" +export K6_WEB_DASHBOARD_EXPORT="${K6_WEB_DASHBOARD_EXPORT:-/tmp/appwrite-k6-report.html}" +export APPWRITE_ENDPOINT="${APPWRITE_ENDPOINT:-http://localhost/v1}" +export APPWRITE_MAILDEV_ENDPOINT="${APPWRITE_MAILDEV_ENDPOINT:-http://localhost:9503/email}" +export APPWRITE_WORKER_TIMEOUT_MS="${APPWRITE_WORKER_TIMEOUT_MS:-120000}" +export APPWRITE_BENCHMARK_SUMMARY_PATH="${APPWRITE_BENCHMARK_SUMMARY_PATH:-/tmp/appwrite-k6-summary.json}" + +samples_path="${APPWRITE_BENCHMARK_SAMPLES_PATH:-/tmp/appwrite-k6-samples.json}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../.." && pwd)" + +exec k6 run --out "json=${samples_path}" "$@" "${repo_root}/tests/benchmarks/http.js" diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 970ceff4a5..3d541d074c 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -2,17 +2,7 @@ * Run locally: * Requires k6 and a running Appwrite instance. * - * K6_WEB_DASHBOARD=true \ - * K6_WEB_DASHBOARD_HOST=127.0.0.1 \ - * K6_WEB_DASHBOARD_PORT=5665 \ - * K6_WEB_DASHBOARD_EXPORT=/tmp/appwrite-k6-report.html \ - * APPWRITE_ENDPOINT=http://localhost/v1 \ - * APPWRITE_MAILDEV_ENDPOINT=http://localhost:9503/email \ - * APPWRITE_WORKER_TIMEOUT_MS=120000 \ - * APPWRITE_BENCHMARK_SUMMARY_PATH=/tmp/appwrite-k6-summary.json \ - * k6 run \ - * --out json=/tmp/appwrite-k6-samples.json \ - * tests/benchmarks/http.js + * tests/benchmarks/http-local.sh * * Open http://127.0.0.1:5665 while the benchmark is running. */ @@ -28,10 +18,10 @@ const REGION = __ENV.APPWRITE_REGION || 'default'; const REDIRECT_URL = __ENV.APPWRITE_BENCHMARK_REDIRECT_URL || 'http://localhost'; const PASSWORD = __ENV.APPWRITE_BENCHMARK_PASSWORD || 'Password123!'; const MAIL_TIMEOUT_MS = Number(__ENV.APPWRITE_MAIL_TIMEOUT_MS || 20000); -const WORKER_TIMEOUT_MS = Number(__ENV.APPWRITE_WORKER_TIMEOUT_MS || 60000); +const WORKER_TIMEOUT_MS = Number(__ENV.APPWRITE_WORKER_TIMEOUT_MS || 120000); const ITERATIONS = Number(__ENV.APPWRITE_BENCHMARK_ITERATIONS || 1); const VUS = Number(__ENV.APPWRITE_BENCHMARK_VUS || 1); -const SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_SUMMARY_PATH || 'tests/benchmarks/http-summary.json'; +const SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_SUMMARY_PATH || '/tmp/appwrite-k6-summary.json'; const PREVIOUS_SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH || SUMMARY_PATH; const PREVIOUS_SUMMARY = loadPreviousSummary(); @@ -40,6 +30,8 @@ export const httpWaiting = new Trend('appwrite_http_waiting', true); export const apiDuration = new Trend('appwrite_api_duration', true); export const tablesWorkerDuration = new Trend('appwrite_worker_tables_duration', true); export const mailsWorkerDuration = new Trend('appwrite_worker_mails_duration', true); +export const tablesWorkerSamples = new Counter('appwrite_worker_tables_samples'); +export const mailsWorkerSamples = new Counter('appwrite_worker_mails_samples'); export const flowFailures = new Counter('appwrite_benchmark_flow_failures'); export const options = { @@ -238,6 +230,16 @@ export function teardown(data) { } } +function recordTablesWorkerDuration(duration, tags) { + tablesWorkerDuration.add(duration, tags); + tablesWorkerSamples.add(1, tags); +} + +function recordMailsWorkerDuration(duration, tags) { + mailsWorkerDuration.add(duration, tags); + mailsWorkerSamples.add(1, tags); +} + function accountFlow(ctx) { const userId = unique('user'); const email = `bench-user-${unique('mail')}@example.com`; @@ -280,7 +282,7 @@ function accountFlow(ctx) { || includes(message.text, 'verify') || includes(message.text, 'verification'); }, MAIL_TIMEOUT_MS); - mailsWorkerDuration.add(Date.now() - verificationStarted, { job: 'email_verification' }); + recordMailsWorkerDuration(Date.now() - verificationStarted, { job: 'email_verification' }); const verification = extractQueryParams(verificationEmail); if (verification.userId && verification.secret) { @@ -303,7 +305,7 @@ function accountFlow(ctx) { || includes(message.text, 'recover') || includes(message.text, 'reset'); }, MAIL_TIMEOUT_MS); - mailsWorkerDuration.add(Date.now() - recoveryStarted, { job: 'password_recovery' }); + recordMailsWorkerDuration(Date.now() - recoveryStarted, { job: 'password_recovery' }); const recovery = extractQueryParams(recoveryEmail); if (recovery.userId && recovery.secret) { @@ -360,7 +362,7 @@ function tablesDbFlow(ctx) { ...extra, }, ctx.apiHeaders, [202], `tablesdb.columns.${type}.create`); waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); - tablesWorkerDuration.add(Date.now() - started, { job: `column_${type}` }); + recordTablesWorkerDuration(Date.now() - started, { job: `column_${type}` }); } const indexStarted = Date.now(); @@ -371,7 +373,7 @@ function tablesDbFlow(ctx) { orders: ['asc'], }, ctx.apiHeaders, [202], 'tablesdb.indexes.create'); waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/indexes/${indexKey}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); - tablesWorkerDuration.add(Date.now() - indexStarted, { job: 'index' }); + recordTablesWorkerDuration(Date.now() - indexStarted, { job: 'index' }); api('POST', `/tablesdb/${databaseId}/tables/${tableId}/rows`, { rowId, @@ -751,8 +753,8 @@ function detailsTable(data) { '| --- | ---: | ---: | ---: | ---: |', detailRow(data, 'Load test', 'appwrite_http_duration', 'iterations', 'http_reqs'), detailRow(data, 'API total', 'appwrite_api_duration'), - detailRow(data, 'TablesDB schema', 'appwrite_worker_tables_duration'), - detailRow(data, 'Mail delivery', 'appwrite_worker_mails_duration'), + detailRow(data, 'TablesDB schema', 'appwrite_worker_tables_duration', 'appwrite_worker_tables_samples', 'appwrite_worker_tables_samples'), + detailRow(data, 'Mail delivery', 'appwrite_worker_mails_duration', 'appwrite_worker_mails_samples', 'appwrite_worker_mails_samples'), ].join('\n'); } From 57d777f80a21bdd22ae5da1e5fca39eef1ff2113 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 16:46:18 +0530 Subject: [PATCH 205/402] revert format --- src/Appwrite/SDK/Specification/Format.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 243517445e..30df5acf52 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -742,7 +742,6 @@ abstract class Format } break; case 'project': - case 'projects': switch ($method) { case 'getEmailTemplate': case 'updateEmailTemplate': @@ -759,6 +758,10 @@ abstract class Format return 'ProjectUsageRange'; } break; + } + break; + case 'projects': + switch ($method) { case 'getEmailTemplate': case 'updateEmailTemplate': switch ($param) { @@ -775,9 +778,7 @@ abstract class Format } break; case 'createSmtpTest': - case 'createSMTPTest': case 'updateSmtp': - case 'updateSMTP': switch ($param) { case 'secure': return 'SMTPSecure'; From b2ad7237abf61152b4c5b84c5b826d599b90823b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 16:52:54 +0530 Subject: [PATCH 206/402] Add detailed telemetry logging for realtime connection events - Introduced span logging for connection open and close events, capturing metrics such as inbound and outbound bytes, subscription counts, and response codes. - Enhanced error handling with logging of exceptions during connection lifecycle. - Updated the structure of the telemetry data to include project and user IDs for better traceability. --- app/realtime.php | 54 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 835ddf932f..8402dda1f0 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -702,11 +702,24 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $project = null; $logUser = null; $authorization = null; + $rawSize = $request->getSize(); + $channelCount = 0; + $subscriptionCount = 0; + $outboundBytes = 0; + $responseCode = 200; + $subscriptionMode = 'message'; + $success = false; + + Span::init('realtime.open'); + Span::add('realtime.connection_id', $connection); + Span::add('realtime.inbound_bytes', $rawSize); + Span::add('realtime.origin', $request->getOrigin() ?: 'n/a'); try { /** @var Document $project */ $project = $connectionContainer->get('project'); $authorization = $connectionContainer->get('authorization'); + Span::add('realtime.project_id', $project->getId() ?: 'n/a'); /* * Project Check @@ -751,8 +764,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, throw new Exception(Exception::REALTIME_TOO_MANY_MESSAGES, 'Too many requests'); } - $rawSize = $request->getSize(); - triggerStats([ METRIC_REALTIME_INBOUND => $rawSize, ], $project->getId()); @@ -770,8 +781,10 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, } $roles = $user->getRoles($authorization); + Span::add('realtime.user_id', $user->getId() ?: 'n/a'); $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); + $channelCount = \count($channels); $updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void { $register->get('telemetry.connectionCounter')->add(1); @@ -809,11 +822,15 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId()); $realtime->connections[$connection]['authorization'] = $authorization; $server->send([$connection], $connectedPayloadJson); + $outboundBytes += \strlen($connectedPayloadJson); $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); + $subscriptionMode = 'message'; + $success = true; return; } $names = array_keys($channels); + $subscriptionMode = 'url'; try { $subscriptions = Realtime::constructSubscriptions( @@ -840,6 +857,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $mapping[$index] = $subscriptionId; } + $subscriptionCount = \count($subscriptions); if (!empty($subscriptions)) { $register->get('telemetry.workerSubscriptionCounter')->add(\count($subscriptions), $register->get('telemetry.workerAttributes')); } @@ -858,8 +876,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ]); $server->send([$connection], $connectedPayloadJson); + $outboundBytes += \strlen($connectedPayloadJson); $updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson); - + $success = true; } catch (Throwable $th) { logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization); @@ -869,6 +888,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, if (!\is_int($code)) { $code = 500; } + $responseCode = $code; $message = $th->getMessage(); @@ -886,7 +906,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, ] ]; - $server->send([$connection], json_encode($response)); + $responsePayloadJson = json_encode($response); + $server->send([$connection], $responsePayloadJson); + $outboundBytes += \strlen($responsePayloadJson); $server->close($connection, $code); if (System::getEnv('_APP_ENV', 'production') === 'development') { @@ -894,6 +916,17 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, Console::error('[Error] Code: ' . $response['data']['code']); Console::error('[Error] Message: ' . $response['data']['message']); } + Span::error($th); + } finally { + Span::add('realtime.success', $success); + Span::add('realtime.response_code', $responseCode); + Span::add('realtime.subscription_mode', $subscriptionMode); + Span::add('realtime.channel_count', $channelCount); + Span::add('realtime.subscription_count', $subscriptionCount); + Span::add('realtime.outbound_bytes', $outboundBytes); + Span::add('realtime.project_id', $project?->getId() ?: 'n/a'); + Span::add('realtime.user_id', $logUser?->getId() ?: 'n/a'); + Span::current()?->finish(); } }); @@ -1298,6 +1331,11 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $server->onClose(function (int $connection) use ($realtime, $stats, $register) { $projectId = null; $userId = null; + $subscriptionsBeforeClose = 0; + $success = false; + + Span::init('realtime.close'); + Span::add('realtime.connection_id', $connection); if (array_key_exists($connection, $realtime->connections)) { $projectId = $realtime->connections[$connection]['projectId'] ?? null; @@ -1320,12 +1358,20 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { METRIC_REALTIME_CONNECTIONS => -1, ], $projectId); } + $success = true; } catch (\Throwable $th) { // Log only; do not rethrow. If we let this bubble, Swoole dumps full coroutine // backtraces and unsubscribe() below would never run (connection cleanup would fail). Console::error('Realtime onClose error: ' . $th->getMessage()); + Span::error($th); + } finally { + Span::add('realtime.success', $success); + Span::add('realtime.project_id', $projectId ?: 'n/a'); + Span::add('realtime.user_id', $userId ?: 'n/a'); + Span::add('realtime.subscriptions_before_close', $subscriptionsBeforeClose); } $realtime->unsubscribe($connection); + Span::current()?->finish(); Console::info('Connection close: ' . $connection); }); From c97435d95c29ea846806cbc82ac1ff928a7fd3e4 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 16:53:35 +0530 Subject: [PATCH 207/402] Stabilize benchmark wait metric tags --- tests/benchmarks/http.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 3d541d074c..3592d0248e 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -361,7 +361,7 @@ function tablesDbFlow(ctx) { array: false, ...extra, }, ctx.apiHeaders, [202], `tablesdb.columns.${type}.create`); - waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); + waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS, `tablesdb.columns.${type}.wait`); recordTablesWorkerDuration(Date.now() - started, { job: `column_${type}` }); } @@ -372,7 +372,7 @@ function tablesDbFlow(ctx) { columns: ['title'], orders: ['asc'], }, ctx.apiHeaders, [202], 'tablesdb.indexes.create'); - waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/indexes/${indexKey}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS); + waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/indexes/${indexKey}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS, 'tablesdb.indexes.wait'); recordTablesWorkerDuration(Date.now() - indexStarted, { job: 'index' }); api('POST', `/tablesdb/${databaseId}/tables/${tableId}/rows`, { @@ -573,11 +573,11 @@ function rawRequest(method, path, body, headers, name) { return response; } -function waitForStatus(path, headers, wantedStatus, timeoutMs) { +function waitForStatus(path, headers, wantedStatus, timeoutMs, name) { const started = Date.now(); while (Date.now() - started < timeoutMs) { - const response = rawRequest('GET', path, null, headers, `wait${path}`); + const response = rawRequest('GET', path, null, headers, name); if (response.status === 200) { const status = response.json('status'); if (status === wantedStatus) { From 17e3d03b40e1a5792b80104b998c3f7275022e4e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 16:55:52 +0530 Subject: [PATCH 208/402] Add telemetry logging for subscribed channels and queries in realtime events - Introduced new arrays to capture subscribed channels and passed queries during connection and message events. - Enhanced span logging to include details about channels and queries for better monitoring and analysis. - Updated telemetry data structure to reflect the new metrics, improving traceability of realtime interactions. --- app/realtime.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/realtime.php b/app/realtime.php index 8402dda1f0..16aba56250 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -705,6 +705,8 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $rawSize = $request->getSize(); $channelCount = 0; $subscriptionCount = 0; + $urlSubscribedChannels = []; + $urlPassedQueries = []; $outboundBytes = 0; $responseCode = 200; $subscriptionMode = 'message'; @@ -785,6 +787,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); $channelCount = \count($channels); + $urlSubscribedChannels = \array_values(\array_keys($channels)); $updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void { $register->get('telemetry.connectionCounter')->add(1); @@ -844,6 +847,10 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $mapping = []; foreach ($subscriptions as $index => $subscription) { $subscriptionId = ID::unique(); + $urlPassedQueries[$index] = \array_map( + fn ($query) => $query instanceof Query ? $query->toString() : (string) $query, + $subscription['queries'] ?? [] + ); $realtime->subscribe( $project->getId(), @@ -923,6 +930,8 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, Span::add('realtime.subscription_mode', $subscriptionMode); Span::add('realtime.channel_count', $channelCount); Span::add('realtime.subscription_count', $subscriptionCount); + Span::add('realtime.channels_subscribed', json_encode($urlSubscribedChannels)); + Span::add('realtime.queries_passed', json_encode($urlPassedQueries)); Span::add('realtime.outbound_bytes', $outboundBytes); Span::add('realtime.project_id', $project?->getId() ?: 'n/a'); Span::add('realtime.user_id', $logUser?->getId() ?: 'n/a'); @@ -939,6 +948,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $subscriptionDelta = 0; $subscriptionsRequested = 0; $subscriptionsRemoved = 0; + $subscribeChannelsPassed = []; + $subscribeQueriesPassed = []; $outboundBytes = 0; $responseCode = 200; $success = false; @@ -1172,6 +1183,9 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re 'channels' => $payload['channels'], 'queries' => $convertedQueries, ]; + + $subscribeChannelsPassed[] = $payload['channels']; + $subscribeQueriesPassed[] = $payload['queries']; } foreach ($parsedPayloads as $parsedPayload) { @@ -1320,6 +1334,9 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re Span::add('realtime.subscription_delta', $subscriptionDelta); Span::add('realtime.subscriptions_requested', $subscriptionsRequested); Span::add('realtime.subscriptions_removed', $subscriptionsRemoved); + Span::add('realtime.subscribe.channels_passed', json_encode($subscribeChannelsPassed)); + Span::add('realtime.subscribe.queries_passed', json_encode($subscribeQueriesPassed)); + Span::add('realtime.subscribe.subscriptions_count', \count($subscribeChannelsPassed)); Span::add('realtime.outbound_bytes', $outboundBytes); Span::add('realtime.project_id', $project?->getId() ?: $projectId ?: 'n/a'); Span::add('realtime.user_id', $realtime->connections[$connection]['userId'] ?? 'n/a'); From 0f81bc2da9fd312e6a9060fb85cdda5d586376f3 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 16:57:51 +0530 Subject: [PATCH 209/402] Refactor telemetry logging in realtime events for consistency and clarity - Updated span logging keys to use camelCase for uniformity across connection and message events. - Added checks to ensure project and user IDs are only logged if they are not empty, enhancing data integrity. - Improved error handling and logging structure to maintain consistency in telemetry data. --- app/realtime.php | 92 ++++++++++++++++++++++++++++++------------------ 1 file changed, 57 insertions(+), 35 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 16aba56250..91d392b77c 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -713,15 +713,19 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $success = false; Span::init('realtime.open'); - Span::add('realtime.connection_id', $connection); - Span::add('realtime.inbound_bytes', $rawSize); - Span::add('realtime.origin', $request->getOrigin() ?: 'n/a'); + Span::add('realtime.connectionId', $connection); + Span::add('realtime.inboundBytes', $rawSize); + if (!empty($request->getOrigin())) { + Span::add('realtime.origin', $request->getOrigin()); + } try { /** @var Document $project */ $project = $connectionContainer->get('project'); $authorization = $connectionContainer->get('authorization'); - Span::add('realtime.project_id', $project->getId() ?: 'n/a'); + if (!empty($project->getId())) { + Span::add('realtime.projectId', $project->getId()); + } /* * Project Check @@ -783,7 +787,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, } $roles = $user->getRoles($authorization); - Span::add('realtime.user_id', $user->getId() ?: 'n/a'); + Span::add('realtime.userId', $user->getId()); $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); $channelCount = \count($channels); @@ -926,15 +930,19 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, Span::error($th); } finally { Span::add('realtime.success', $success); - Span::add('realtime.response_code', $responseCode); - Span::add('realtime.subscription_mode', $subscriptionMode); - Span::add('realtime.channel_count', $channelCount); - Span::add('realtime.subscription_count', $subscriptionCount); - Span::add('realtime.channels_subscribed', json_encode($urlSubscribedChannels)); - Span::add('realtime.queries_passed', json_encode($urlPassedQueries)); - Span::add('realtime.outbound_bytes', $outboundBytes); - Span::add('realtime.project_id', $project?->getId() ?: 'n/a'); - Span::add('realtime.user_id', $logUser?->getId() ?: 'n/a'); + Span::add('realtime.responseCode', $responseCode); + Span::add('realtime.subscriptionMode', $subscriptionMode); + Span::add('realtime.channelCount', $channelCount); + Span::add('realtime.subscriptionCount', $subscriptionCount); + Span::add('realtime.channelsSubscribed', json_encode($urlSubscribedChannels)); + Span::add('realtime.queriesPassed', json_encode($urlPassedQueries)); + Span::add('realtime.outboundBytes', $outboundBytes); + if (!empty($project?->getId())) { + Span::add('realtime.projectId', $project->getId()); + } + if (!empty($logUser?->getId())) { + Span::add('realtime.userId', $logUser->getId()); + } Span::current()?->finish(); } }); @@ -955,10 +963,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $success = false; Span::init('realtime.message'); - Span::add('realtime.connection_id', $connection); - Span::add('realtime.project_id', $projectId ?: 'n/a'); - Span::add('realtime.inbound_bytes', $rawSize); - Span::add('realtime.container_id', $containerId); + Span::add('realtime.connectionId', $connection); + if (!empty($projectId)) { + Span::add('realtime.projectId', $projectId); + } + Span::add('realtime.inboundBytes', $rawSize); + Span::add('realtime.containerId', $containerId); try { $response = new Response(new SwooleResponse()); @@ -1007,7 +1017,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $message = json_decode($message, true); $messageType = $message['type'] ?? 'invalid'; - Span::add('realtime.message_type', $messageType); + Span::add('realtime.messageType', $messageType); if (is_null($message) || (!array_key_exists('type', $message) && !array_key_exists('data', $message))) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.'); @@ -1116,7 +1126,9 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $server->send([$connection], $authResponsePayloadJson); $outboundBytes += \strlen($authResponsePayloadJson); - Span::add('realtime.user_id', $user['$id'] ?? 'n/a'); + if (!empty($user['$id'] ?? null)) { + Span::add('realtime.userId', $user['$id']); + } if ($project !== null && !$project->isEmpty()) { $authOutboundBytes = \strlen($authResponsePayloadJson); @@ -1330,17 +1342,23 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re Span::error($th); } finally { Span::add('realtime.success', $success); - Span::add('realtime.response_code', $responseCode); - Span::add('realtime.subscription_delta', $subscriptionDelta); - Span::add('realtime.subscriptions_requested', $subscriptionsRequested); - Span::add('realtime.subscriptions_removed', $subscriptionsRemoved); - Span::add('realtime.subscribe.channels_passed', json_encode($subscribeChannelsPassed)); - Span::add('realtime.subscribe.queries_passed', json_encode($subscribeQueriesPassed)); - Span::add('realtime.subscribe.subscriptions_count', \count($subscribeChannelsPassed)); - Span::add('realtime.outbound_bytes', $outboundBytes); - Span::add('realtime.project_id', $project?->getId() ?: $projectId ?: 'n/a'); - Span::add('realtime.user_id', $realtime->connections[$connection]['userId'] ?? 'n/a'); - Span::add('realtime.message_type', $messageType); + Span::add('realtime.responseCode', $responseCode); + Span::add('realtime.subscriptionDelta', $subscriptionDelta); + Span::add('realtime.subscriptionsRequested', $subscriptionsRequested); + Span::add('realtime.subscriptionsRemoved', $subscriptionsRemoved); + Span::add('realtime.subscribe.channelsPassed', json_encode($subscribeChannelsPassed)); + Span::add('realtime.subscribe.queriesPassed', json_encode($subscribeQueriesPassed)); + Span::add('realtime.subscribe.subscriptionsCount', \count($subscribeChannelsPassed)); + Span::add('realtime.outboundBytes', $outboundBytes); + if (!empty($project?->getId())) { + Span::add('realtime.projectId', $project->getId()); + } elseif (!empty($projectId)) { + Span::add('realtime.projectId', $projectId); + } + if (!empty($realtime->connections[$connection]['userId'] ?? null)) { + Span::add('realtime.userId', $realtime->connections[$connection]['userId']); + } + Span::add('realtime.messageType', $messageType); Span::current()?->finish(); } }); @@ -1352,7 +1370,7 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { $success = false; Span::init('realtime.close'); - Span::add('realtime.connection_id', $connection); + Span::add('realtime.connectionId', $connection); if (array_key_exists($connection, $realtime->connections)) { $projectId = $realtime->connections[$connection]['projectId'] ?? null; @@ -1383,9 +1401,13 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { Span::error($th); } finally { Span::add('realtime.success', $success); - Span::add('realtime.project_id', $projectId ?: 'n/a'); - Span::add('realtime.user_id', $userId ?: 'n/a'); - Span::add('realtime.subscriptions_before_close', $subscriptionsBeforeClose); + if (!empty($projectId)) { + Span::add('realtime.projectId', $projectId); + } + if (!empty($userId)) { + Span::add('realtime.userId', $userId); + } + Span::add('realtime.subscriptionsBeforeClose', $subscriptionsBeforeClose); } $realtime->unsubscribe($connection); Span::current()?->finish(); From 59e0383264950063a4d65fd085a29b0505f144f8 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 17:01:08 +0530 Subject: [PATCH 210/402] updated --- app/realtime.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 91d392b77c..e161f08545 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -1017,12 +1017,17 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $message = json_decode($message, true); $messageType = $message['type'] ?? 'invalid'; - Span::add('realtime.messageType', $messageType); if (is_null($message) || (!array_key_exists('type', $message) && !array_key_exists('data', $message))) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.'); } + if (!\is_scalar($messageType) && $messageType !== null) { + throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); + } + + Span::add('realtime.messageType', $messageType); + // Ping does not require project context; other messages do (e.g. after unsubscribe during auth) if (empty($projectId) && ($message['type'] ?? '') !== 'ping') { throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing project context. Reconnect to the project first.'); From fd9fe5d9ce79c4bc04428409aef5f37d14cf25f8 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 17:07:53 +0530 Subject: [PATCH 211/402] corrected the position --- app/realtime.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index e161f08545..fcca7c9c42 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -1195,9 +1195,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Invalid query: ' . $e->getMessage()); } + $convertedChannels = \array_keys(Realtime::convertChannels($payload['channels'], $userId)); + $parsedPayloads[] = [ 'subscriptionId' => $subscriptionId, 'channels' => $payload['channels'], + 'convertedChannels' => $convertedChannels, 'queries' => $convertedQueries, ]; @@ -1207,7 +1210,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re foreach ($parsedPayloads as $parsedPayload) { $subscriptionId = $parsedPayload['subscriptionId']; - $channels = \array_keys(Realtime::convertChannels($parsedPayload['channels'], $userId)); + $channels = $parsedPayload['convertedChannels']; $queries = $parsedPayload['queries']; $realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries); } @@ -1226,7 +1229,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re 'subscriptions' => \array_map(function (array $parsedPayload) { return [ 'subscriptionId' => $parsedPayload['subscriptionId'], - 'channels' => $parsedPayload['channels'], + 'channels' => $parsedPayload['convertedChannels'], 'queries' => \array_map(fn ($q) => $q->toString(), $parsedPayload['queries']), ]; }, $parsedPayloads), @@ -1413,9 +1416,9 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { Span::add('realtime.userId', $userId); } Span::add('realtime.subscriptionsBeforeClose', $subscriptionsBeforeClose); + Span::current()?->finish(); } $realtime->unsubscribe($connection); - Span::current()?->finish(); Console::info('Connection close: ' . $connection); }); From 46e778ea90269414eed652f6d1b7a98f25b13a31 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 17:13:35 +0530 Subject: [PATCH 212/402] updated --- app/realtime.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index fcca7c9c42..521a80a82f 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -1016,12 +1016,13 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re } $message = json_decode($message, true); - $messageType = $message['type'] ?? 'invalid'; if (is_null($message) || (!array_key_exists('type', $message) && !array_key_exists('data', $message))) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.'); } + $messageType = $message['type'] ?? 'invalid'; + if (!\is_scalar($messageType) && $messageType !== null) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } @@ -1408,6 +1409,13 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { Console::error('Realtime onClose error: ' . $th->getMessage()); Span::error($th); } finally { + try { + $realtime->unsubscribe($connection); + } catch (\Throwable $th) { + Console::error('Realtime onClose unsubscribe error: ' . $th->getMessage()); + Span::error($th); + } + Span::add('realtime.success', $success); if (!empty($projectId)) { Span::add('realtime.projectId', $projectId); @@ -1418,7 +1426,6 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) { Span::add('realtime.subscriptionsBeforeClose', $subscriptionsBeforeClose); Span::current()?->finish(); } - $realtime->unsubscribe($connection); Console::info('Connection close: ' . $connection); }); From f0f1e1c412bad80f33dbe3591370a400feb5fa7b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 17:44:18 +0530 Subject: [PATCH 213/402] updated --- app/realtime.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 521a80a82f..eeaa56d30a 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -1023,7 +1023,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $messageType = $message['type'] ?? 'invalid'; - if (!\is_scalar($messageType) && $messageType !== null) { + if (!\is_scalar($messageType)) { throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } @@ -1285,7 +1285,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection)); $subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore; $subscriptionsRequested = \count($validatedIds); - $subscriptionsRemoved = \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed'] ?? false)); + $subscriptionsRemoved = \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed'])); if ($subscriptionDelta !== 0) { $register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes')); } From 6d1def7716c1265c3df3fa3630a8a86fa1a1cac9 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 17:45:58 +0530 Subject: [PATCH 214/402] removed redundant span attributes --- app/realtime.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index eeaa56d30a..572256eec6 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -723,9 +723,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, /** @var Document $project */ $project = $connectionContainer->get('project'); $authorization = $connectionContainer->get('authorization'); - if (!empty($project->getId())) { - Span::add('realtime.projectId', $project->getId()); - } /* * Project Check @@ -787,7 +784,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, } $roles = $user->getRoles($authorization); - Span::add('realtime.userId', $user->getId()); $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); $channelCount = \count($channels); @@ -1027,8 +1023,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.'); } - Span::add('realtime.messageType', $messageType); - // Ping does not require project context; other messages do (e.g. after unsubscribe during auth) if (empty($projectId) && ($message['type'] ?? '') !== 'ping') { throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing project context. Reconnect to the project first.'); From a85c5e582c6e561723fe6ebabf49e81ae1fac47f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 14:19:04 +0200 Subject: [PATCH 215/402] Add auth method APIs (public) --- app/controllers/api/projects.php | 40 --------- .../Http/Project/AuthMethods/Update.php | 89 +++++++++++++++++++ .../Modules/Project/Services/Http.php | 4 + src/Appwrite/Utopia/Request/Filters/V23.php | 18 ++++ 4 files changed, 111 insertions(+), 40 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index bd5d0504cf..66d3cf7487 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -130,46 +130,6 @@ Http::patch('/v1/projects/:projectId/oauth2') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/auth/:method') - ->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthStatus', - description: '/docs/references/projects/update-auth-status.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('method', '', new WhiteList(\array_keys(Config::getParam('auth')), true), 'Auth Method. Possible values: ' . implode(',', \array_keys(Config::getParam('auth'))), false) - ->param('status', false, new Boolean(true), 'Set the status of this auth method.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $method, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - $auth = Config::getParam('auth')[$method] ?? []; - $authKey = $auth['key'] ?? ''; - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths[$authKey] = $status; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::patch('/v1/projects/:projectId/auth/mock-numbers') ->desc('Update the mock numbers for the project') ->groups(['api', 'projects']) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php new file mode 100644 index 0000000000..b01a977ee9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php @@ -0,0 +1,89 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/auth-methods/:methodId') + ->httpAlias('/v1/projects/:projectId/auth/:methodId') + ->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'authMethod.[methodId].update') + ->label('audits.event', 'project.authMethods.[methodId].update') + ->label('audits.resource', 'project.authMethods/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: null, + name: 'updateAuthMethod', + description: <<param('methodId', '', new WhiteList(\array_keys(Config::getParam('auth')), true), 'Auth Method ID. Possible values: ' . implode(',', \array_keys(Config::getParam('auth'))), false) + ->param('enabled', null, new Boolean(), 'Auth method status.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $methodId, + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents + ): void { + $auth = Config::getParam('auth')[$methodId] ?? []; + $authKey = $auth['key'] ?? ''; + + $auths = $project->getAttribute('auths', []); + $auths[$authKey] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'auths' => $auths, + ]))); + + $queueForEvents->setParam('methodId', $methodId); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 331ad9482e..a59eca16af 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Project\Services; use Appwrite\Platform\Modules\Project\Http\Init; +use Appwrite\Platform\Modules\Project\Http\Project\AuthMethods\Update as UpdateAuthMethod; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey; @@ -105,5 +106,8 @@ class Http extends Service $this->addAction(UpdateSessionInvalidationPolicy::getName(), new UpdateSessionInvalidationPolicy()); $this->addAction(UpdateSessionLimitPolicy::getName(), new UpdateSessionLimitPolicy()); $this->addAction(UpdateUserLimitPolicy::getName(), new UpdateUserLimitPolicy()); + + // Auth Methods + $this->addAction(UpdateAuthMethod::getName(), new UpdateAuthMethod()); } } diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php index b10c26c449..e509900417 100644 --- a/src/Appwrite/Utopia/Request/Filters/V23.php +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -32,6 +32,9 @@ class V23 extends Filter case 'project.updateSessionLimitPolicy': $content = $this->parseLimitToTotal($content); break; + case 'project.updateAuthMethod': + $content = $this->parseUpdateAuthMethod($content); + break; } return $content; @@ -60,6 +63,21 @@ class V23 extends Filter return $content; } + protected function parseUpdateAuthMethod(array $content): array + { + if (isset($content['status'])) { + $content['enabled'] = $content['status']; + unset($content['status']); + } + + if (isset($content['method'])) { + $content['methodId'] = $content['method']; + unset($content['method']); + } + + return $content; + } + protected function parseLimitToTotal(array $content): array { if (isset($content['limit'])) { From b006858d0c34e3df26623324723a9c113e5436bd Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 17:52:45 +0530 Subject: [PATCH 216/402] dedupe --- app/realtime.php | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 572256eec6..d1177ac07b 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -960,9 +960,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re Span::init('realtime.message'); Span::add('realtime.connectionId', $connection); - if (!empty($projectId)) { - Span::add('realtime.projectId', $projectId); - } Span::add('realtime.inboundBytes', $rawSize); Span::add('realtime.containerId', $containerId); @@ -1126,9 +1123,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $server->send([$connection], $authResponsePayloadJson); $outboundBytes += \strlen($authResponsePayloadJson); - if (!empty($user['$id'] ?? null)) { - Span::add('realtime.userId', $user['$id']); - } if ($project !== null && !$project->isEmpty()) { $authOutboundBytes = \strlen($authResponsePayloadJson); @@ -1353,14 +1347,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re Span::add('realtime.subscribe.queriesPassed', json_encode($subscribeQueriesPassed)); Span::add('realtime.subscribe.subscriptionsCount', \count($subscribeChannelsPassed)); Span::add('realtime.outboundBytes', $outboundBytes); - if (!empty($project?->getId())) { - Span::add('realtime.projectId', $project->getId()); - } elseif (!empty($projectId)) { - Span::add('realtime.projectId', $projectId); - } - if (!empty($realtime->connections[$connection]['userId'] ?? null)) { - Span::add('realtime.userId', $realtime->connections[$connection]['userId']); - } + Span::add('realtime.projectId', $project?->getId() ?? $projectId); + Span::add('realtime.userId', $realtime->connections[$connection]['userId'] ?? null); Span::add('realtime.messageType', $messageType); Span::current()?->finish(); } From b2d24080b9beda3ee7ae4e410444c90b5cb96943 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 17:24:49 +0530 Subject: [PATCH 217/402] Stabilize database e2e CI retries --- .github/workflows/ci.yml | 15 +++++++++++++-- tests/e2e/Services/Migrations/MigrationsBase.php | 4 +++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e01839ac6..e7307d3c3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -429,6 +429,8 @@ jobs: include: - service: Databases runner: blacksmith-4vcpu-ubuntu-2404 + paratest_processes: 3 + timeout_minutes: 30 - service: Sites runner: blacksmith-4vcpu-ubuntu-2404 - service: Functions @@ -439,6 +441,10 @@ jobs: runner: blacksmith-4vcpu-ubuntu-2404 - service: TablesDB runner: blacksmith-4vcpu-ubuntu-2404 + paratest_processes: 3 + timeout_minutes: 30 + - service: Migrations + paratest_processes: 1 steps: - name: Checkout repository uses: actions/checkout@v6 @@ -499,7 +505,7 @@ jobs: with: max_attempts: 2 retry_wait_seconds: 60 - timeout_minutes: 20 + timeout_minutes: ${{ matrix.timeout_minutes || 20 }} job_id: ${{ job.check_run_id }} github_token: ${{ secrets.GITHUB_TOKEN }} test_dir: tests/e2e/Services/${{ matrix.service }} @@ -512,9 +518,14 @@ jobs: Databases|TablesDB|Functions|Realtime|GraphQL|ProjectWebhooks) FUNCTIONAL_FLAG="" ;; esac + PARATEST_PROCESSES="${{ matrix.paratest_processes }}" + if [ -z "$PARATEST_PROCESSES" ]; then + PARATEST_PROCESSES="$(nproc)" + fi + docker compose exec -T \ -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ - appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml + appwrite vendor/bin/paratest --processes "$PARATEST_PROCESSES" $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml - name: Failure Logs if: failure() diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 9e9ce2fbcd..069dc9cfbb 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -4207,7 +4207,9 @@ trait MigrationsBase }, 30_000, 500); // Check that email was sent with download link - $lastEmail = $this->getLastEmail(); + $lastEmail = $this->getLastEmail(probe: function ($email) { + $this->assertEquals('Your JSON export is ready', $email['subject']); + }); $this->assertNotEmpty($lastEmail); $this->assertEquals('Your JSON export is ready', $lastEmail['subject']); $this->assertStringContainsStringIgnoringCase('Your data export has been completed successfully', $lastEmail['text']); From c2e5bbe0f738ac4a16a1a9924656f29afd1fa19f Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 22 Apr 2026 18:11:32 +0530 Subject: [PATCH 218/402] updated --- app/realtime.php | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index d1177ac07b..71aa251069 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -705,8 +705,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $rawSize = $request->getSize(); $channelCount = 0; $subscriptionCount = 0; - $urlSubscribedChannels = []; - $urlPassedQueries = []; $outboundBytes = 0; $responseCode = 200; $subscriptionMode = 'message'; @@ -787,7 +785,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); $channelCount = \count($channels); - $urlSubscribedChannels = \array_values(\array_keys($channels)); $updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void { $register->get('telemetry.connectionCounter')->add(1); @@ -847,10 +844,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $mapping = []; foreach ($subscriptions as $index => $subscription) { $subscriptionId = ID::unique(); - $urlPassedQueries[$index] = \array_map( - fn ($query) => $query instanceof Query ? $query->toString() : (string) $query, - $subscription['queries'] ?? [] - ); $realtime->subscribe( $project->getId(), @@ -930,8 +923,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, Span::add('realtime.subscriptionMode', $subscriptionMode); Span::add('realtime.channelCount', $channelCount); Span::add('realtime.subscriptionCount', $subscriptionCount); - Span::add('realtime.channelsSubscribed', json_encode($urlSubscribedChannels)); - Span::add('realtime.queriesPassed', json_encode($urlPassedQueries)); Span::add('realtime.outboundBytes', $outboundBytes); if (!empty($project?->getId())) { Span::add('realtime.projectId', $project->getId()); @@ -952,8 +943,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $subscriptionDelta = 0; $subscriptionsRequested = 0; $subscriptionsRemoved = 0; - $subscribeChannelsPassed = []; - $subscribeQueriesPassed = []; $outboundBytes = 0; $responseCode = 200; $success = false; @@ -1192,9 +1181,6 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re 'convertedChannels' => $convertedChannels, 'queries' => $convertedQueries, ]; - - $subscribeChannelsPassed[] = $payload['channels']; - $subscribeQueriesPassed[] = $payload['queries']; } foreach ($parsedPayloads as $parsedPayload) { @@ -1343,9 +1329,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re Span::add('realtime.subscriptionDelta', $subscriptionDelta); Span::add('realtime.subscriptionsRequested', $subscriptionsRequested); Span::add('realtime.subscriptionsRemoved', $subscriptionsRemoved); - Span::add('realtime.subscribe.channelsPassed', json_encode($subscribeChannelsPassed)); - Span::add('realtime.subscribe.queriesPassed', json_encode($subscribeQueriesPassed)); - Span::add('realtime.subscribe.subscriptionsCount', \count($subscribeChannelsPassed)); + Span::add('realtime.subscribe.subscriptionsCount', $subscriptionsRequested); Span::add('realtime.outboundBytes', $outboundBytes); Span::add('realtime.projectId', $project?->getId() ?? $projectId); Span::add('realtime.userId', $realtime->connections[$connection]['userId'] ?? null); From bb4fdefee7954349f94f60473f2246b4d2d63d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 14:51:10 +0200 Subject: [PATCH 219/402] New tests for auth methods (base + integration) --- .../e2e/Services/Project/AuthMethodsBase.php | 337 ++++++++++++++++++ .../Project/AuthMethodsConsoleClientTest.php | 14 + .../Project/AuthMethodsCustomServerTest.php | 14 + .../Project/AuthMethodsIntegrationTest.php | 184 ++++++++++ 4 files changed, 549 insertions(+) create mode 100644 tests/e2e/Services/Project/AuthMethodsBase.php create mode 100644 tests/e2e/Services/Project/AuthMethodsConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/AuthMethodsCustomServerTest.php create mode 100644 tests/e2e/Services/Project/AuthMethodsIntegrationTest.php diff --git a/tests/e2e/Services/Project/AuthMethodsBase.php b/tests/e2e/Services/Project/AuthMethodsBase.php new file mode 100644 index 0000000000..afa58a3640 --- /dev/null +++ b/tests/e2e/Services/Project/AuthMethodsBase.php @@ -0,0 +1,337 @@ + response field name exposed by the Project model. + */ + protected static array $authMethods = [ + 'email-password' => 'authEmailPassword', + 'magic-url' => 'authUsersAuthMagicURL', + 'email-otp' => 'authEmailOtp', + 'anonymous' => 'authAnonymous', + 'invites' => 'authInvites', + 'jwt' => 'authJWT', + 'phone' => 'authPhone', + ]; + + // Success flow + + public function testDisableAuthMethod(): void + { + foreach (self::$authMethods as $methodId => $responseKey) { + $response = $this->updateAuthMethod($methodId, false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body'][$responseKey]); + } + + // Cleanup + foreach (self::$authMethods as $methodId => $responseKey) { + $this->updateAuthMethod($methodId, true); + } + } + + public function testEnableAuthMethod(): void + { + // Disable first + foreach (self::$authMethods as $methodId => $responseKey) { + $this->updateAuthMethod($methodId, false); + } + + // Re-enable + foreach (self::$authMethods as $methodId => $responseKey) { + $response = $this->updateAuthMethod($methodId, true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body'][$responseKey]); + } + } + + public function testDisableAuthMethodIdempotent(): void + { + $first = $this->updateAuthMethod('email-password', false); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['authEmailPassword']); + + $second = $this->updateAuthMethod('email-password', false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['authEmailPassword']); + + // Cleanup + $this->updateAuthMethod('email-password', true); + } + + public function testEnableAuthMethodIdempotent(): void + { + $first = $this->updateAuthMethod('email-password', true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['authEmailPassword']); + + $second = $this->updateAuthMethod('email-password', true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['authEmailPassword']); + } + + public function testDisableOneMethodDoesNotAffectOther(): void + { + // Ensure both start enabled + $this->updateAuthMethod('email-password', true); + $this->updateAuthMethod('magic-url', true); + + $response = $this->updateAuthMethod('email-password', false); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authEmailPassword']); + $this->assertSame(true, $response['body']['authUsersAuthMagicURL']); + + // Cleanup + $this->updateAuthMethod('email-password', true); + } + + public function testDisabledEmailPasswordBlocksSessionCreation(): void + { + $this->updateAuthMethod('email-password', false); + + // Unauthenticated account creation would normally be permitted; with the + // method disabled we expect the shared auth filter to reject it. + $response = $this->client->call(Client::METHOD_POST, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => 'unique()', + 'email' => 'disabled-method-' . \uniqid() . '@appwrite.io', + 'password' => 'password123', + ]); + + $this->assertSame(501, $response['headers']['status-code']); + $this->assertSame('user_auth_method_unsupported', $response['body']['type']); + + // Cleanup + $this->updateAuthMethod('email-password', true); + } + + public function testEnabledEmailPasswordAllowsSessionCreation(): void + { + $this->updateAuthMethod('email-password', false); + $this->updateAuthMethod('email-password', true); + + $response = $this->client->call(Client::METHOD_POST, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => 'unique()', + 'email' => 'enabled-method-' . \uniqid() . '@appwrite.io', + 'password' => 'password123', + ]); + + $this->assertNotSame(501, $response['headers']['status-code']); + $this->assertNotSame('user_auth_method_unsupported', $response['body']['type'] ?? ''); + } + + public function testDisabledAnonymousBlocksSessionCreation(): void + { + $this->updateAuthMethod('anonymous', false); + + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(501, $response['headers']['status-code']); + $this->assertSame('user_auth_method_unsupported', $response['body']['type']); + + // Cleanup + $this->updateAuthMethod('anonymous', true); + } + + public function testResponseModel(): void + { + $response = $this->updateAuthMethod('email-password', false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + foreach (self::$authMethods as $methodId => $responseKey) { + $this->assertArrayHasKey($responseKey, $response['body']); + } + + // Cleanup + $this->updateAuthMethod('email-password', true); + } + + // Failure flow + + public function testUpdateAuthMethodWithoutAuthentication(): void + { + $response = $this->updateAuthMethod('email-password', false, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateAuthMethodInvalidMethodId(): void + { + $response = $this->updateAuthMethod('invalid-method', false); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateAuthMethodEmptyMethodId(): void + { + $response = $this->updateAuthMethod('', false); + + $this->assertSame(404, $response['headers']['status-code']); + } + + public function testUpdateAuthMethodMissingEnabled(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/auth-methods/email-password', + $headers, + [] + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // Backwards compatibility + + public function testUpdateAuthMethodLegacyAliasPath(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + + $projectId = $this->getProject()['$id']; + + // Disable via the legacy `/v1/projects/:projectId/auth/:methodId` alias + $response = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $projectId . '/auth/email-password', + $headers, + [ + 'enabled' => false, + ] + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['authEmailPassword']); + + // Re-enable via the legacy alias + $response = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $projectId . '/auth/email-password', + $headers, + [ + 'enabled' => true, + ] + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['authEmailPassword']); + } + + public function testUpdateAuthMethodLegacyStatusParam(): void + { + // Old SDK passed `status` in the body. The V23 request filter (triggered + // via `x-appwrite-response-format: 1.9.1`) must rename it to `enabled`. + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $projectId = $this->getProject()['$id']; + + $response = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $projectId . '/auth/email-password', + $headers, + [ + 'status' => false, + ] + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authEmailPassword']); + + $response = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $projectId . '/auth/email-password', + $headers, + [ + 'status' => true, + ] + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['authEmailPassword']); + } + + public function testUpdateAuthMethodLegacyMethodParam(): void + { + // Old SDK also had `method` as a path identifier; the V23 filter renames + // a stray `method` body field to `methodId`. The URL path parameter of + // the alias already binds to `:methodId`, so supplying `method` in the + // body is tolerated. + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $projectId = $this->getProject()['$id']; + + $response = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $projectId . '/auth/email-password', + $headers, + [ + 'method' => 'email-password', + 'status' => false, + ] + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authEmailPassword']); + + // Cleanup + $this->updateAuthMethod('email-password', true); + } + + // Helpers + + protected function updateAuthMethod(string $methodId, bool $enabled, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call( + Client::METHOD_PATCH, + '/project/auth-methods/' . $methodId, + $headers, + [ + 'enabled' => $enabled, + ] + ); + } +} diff --git a/tests/e2e/Services/Project/AuthMethodsConsoleClientTest.php b/tests/e2e/Services/Project/AuthMethodsConsoleClientTest.php new file mode 100644 index 0000000000..e1ae5de357 --- /dev/null +++ b/tests/e2e/Services/Project/AuthMethodsConsoleClientTest.php @@ -0,0 +1,14 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // Public headers carry no session / api key — this forces the shared + // auth init to actually evaluate the auth-method gate (it is bypassed + // for privileged / app users). + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setAuthMethod = function (string $methodId, bool $enabled) use ($serverHeaders): void { + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/auth-methods/' . $methodId, + $serverHeaders, + ['enabled' => $enabled] + ); + $this->assertSame(200, $response['headers']['status-code'], 'Failed to toggle ' . $methodId); + }; + + $methods = ['email-password', 'magic-url', 'email-otp', 'anonymous', 'invites', 'jwt', 'phone']; + + // Step 1 — Disable every auth method up front. + foreach ($methods as $methodId) { + $setAuthMethod($methodId, false); + } + + $assertBlocked = function (array $response, string $context): void { + $this->assertSame(501, $response['headers']['status-code'], $context . ' should be blocked with 501'); + $this->assertSame('user_auth_method_unsupported', $response['body']['type'] ?? '', $context . ' should return user_auth_method_unsupported'); + }; + + $assertNotBlocked = function (array $response, string $context): void { + $this->assertNotSame(501, $response['headers']['status-code'], $context . ' should not be blocked after enabling'); + $this->assertNotSame('user_auth_method_unsupported', $response['body']['type'] ?? '', $context . ' should not return user_auth_method_unsupported after enabling'); + }; + + $email = 'auth_methods_' . \uniqid() . '@localhost.test'; + $password = 'password1234'; + + // Step 2 — anonymous session creation. + $anonymousAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', $publicHeaders); + + $assertBlocked($anonymousAttempt(), 'Anonymous session (disabled)'); + $setAuthMethod('anonymous', true); + $response = $anonymousAttempt(); + $assertNotBlocked($response, 'Anonymous session (enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + + // Step 3 — email/password account creation. + $createAccount = fn () => $this->client->call(Client::METHOD_POST, '/account', $publicHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Auth Methods User', + ]); + + $assertBlocked($createAccount(), 'Account creation (email-password disabled)'); + $setAuthMethod('email-password', true); + $response = $createAccount(); + $assertNotBlocked($response, 'Account creation (email-password enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + $userId = $response['body']['$id']; + + // Step 4 — email/password session creation (still gated by email-password). + // Disable momentarily to prove the session endpoint is gated too. + $setAuthMethod('email-password', false); + $emailSessionAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + + $assertBlocked($emailSessionAttempt(), 'Email/password session (disabled)'); + $setAuthMethod('email-password', true); + $response = $emailSessionAttempt(); + $assertNotBlocked($response, 'Email/password session (enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + $sessionSecret = $response['cookies']['a_session_' . $projectId] ?? ''; + $this->assertNotEmpty($sessionSecret, 'Expected a session cookie after email/password login'); + + // Step 5 — email OTP token. + $emailOtpAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/email', $publicHeaders, [ + 'userId' => $userId, + 'email' => $email, + ]); + + $assertBlocked($emailOtpAttempt(), 'Email OTP (disabled)'); + $setAuthMethod('email-otp', true); + $response = $emailOtpAttempt(); + $assertNotBlocked($response, 'Email OTP (enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + + // Step 6 — magic URL token. + $magicUrlAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', $publicHeaders, [ + 'userId' => ID::unique(), + 'email' => 'magic_' . \uniqid() . '@localhost.test', + ]); + + $assertBlocked($magicUrlAttempt(), 'Magic URL (disabled)'); + $setAuthMethod('magic-url', true); + $response = $magicUrlAttempt(); + $assertNotBlocked($response, 'Magic URL (enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + + // Step 7 — phone token. After enabling the auth method the endpoint may + // still fail for provider reasons — we only assert that the auth-method + // gate stops fighting us. + $phoneAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/phone', $publicHeaders, [ + 'userId' => ID::unique(), + 'phone' => '+14155550199', + ]); + + $assertBlocked($phoneAttempt(), 'Phone token (disabled)'); + $setAuthMethod('phone', true); + $assertNotBlocked($phoneAttempt(), 'Phone token (enabled)'); + + // Step 8 — team invites. Needs an existing team; the session user + // isn't a team owner, so we don't assert on 201 here — the gate itself + // is what's under test and any non-501 proves it was lifted. + $teamResponse = $this->client->call(Client::METHOD_POST, '/teams', $serverHeaders, [ + 'teamId' => ID::unique(), + 'name' => 'Auth Methods Team', + ]); + $this->assertSame(201, $teamResponse['headers']['status-code']); + $teamId = $teamResponse['body']['$id']; + + $inviteHeaders = \array_merge($publicHeaders, [ + 'cookie' => 'a_session_' . $projectId . '=' . $sessionSecret, + ]); + $inviteAttempt = fn () => $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $inviteHeaders, [ + 'email' => 'invitee_' . \uniqid() . '@localhost.test', + 'roles' => ['developer'], + 'url' => 'http://localhost/join', + ]); + + $assertBlocked($inviteAttempt(), 'Team invite (disabled)'); + $setAuthMethod('invites', true); + $assertNotBlocked($inviteAttempt(), 'Team invite (enabled)'); + + // Step 9 — JWT creation. Requires an active session. + $sessionHeaders = \array_merge($publicHeaders, [ + 'cookie' => 'a_session_' . $projectId . '=' . $sessionSecret, + ]); + $jwtAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/jwts', $sessionHeaders); + + $assertBlocked($jwtAttempt(), 'JWT (disabled)'); + $setAuthMethod('jwt', true); + $response = $jwtAttempt(); + $assertNotBlocked($response, 'JWT (enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + + // Step 10 — End goal: GET /v1/account returns 200 using the session we + // built via the (now enabled) email-password flow. + $response = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($userId, $response['body']['$id']); + $this->assertSame($email, $response['body']['email']); + } +} From a0274a7b6ff50d1d71c2e09110ec68727134b43e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 15:12:58 +0200 Subject: [PATCH 220/402] Fix failing tests --- .../Modules/Project/Http/Project/AuthMethods/Update.php | 4 ++-- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php index b01a977ee9..0d1cd83203 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php @@ -52,7 +52,7 @@ class Update extends Action ) ], )) - + ->param('methodId', '', new WhiteList(\array_keys(Config::getParam('auth')), true), 'Auth Method ID. Possible values: ' . implode(',', \array_keys(Config::getParam('auth'))), false) ->param('enabled', null, new Boolean(), 'Auth method status.') ->inject('response') @@ -74,7 +74,7 @@ class Update extends Action ): void { $auth = Config::getParam('auth')[$methodId] ?? []; $authKey = $auth['key'] ?? ''; - + $auths = $project->getAttribute('auths', []); $auths[$authKey] = $enabled; diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index ed72d9375c..1de3f3786c 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1764,6 +1764,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/' . $index, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'status' => false, ]); @@ -1860,6 +1861,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/' . $index, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'status' => true, ]); From 3283b0bec0bcdeb41ea42ebf4d30667dfe1c30a5 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:31:17 +0100 Subject: [PATCH 221/402] perf: memoize request filter chain and V20 schema lookups A phpspy profile of a production databases worker showed the V20 backwards-compat request filter accounting for ~40% of in-request samples on `databases.listDocuments` traffic. Two compounding causes: 1. `Request::getParams()` re-ran the entire filter chain on every invocation. The framework and app call `getParams()` several times per request (route param binding, `cacheIdentifier()`, action injection, logging), so V20's recursive schema walk executed N times with identical inputs. 2. Inside `V20::getRelatedCollectionKeys`, the `databases/$databaseId` document was fetched at every recursion frame (up to RELATION_MAX_DEPTH = 3), and sibling relationships pointing at the same related collection each did their own `getDocument` call. This commit: - Memoizes the post-filter params on `Request`. The cache is invalidated by `addFilter`, `resetFilters`, and `setRoute`. `Request` is constructed per HTTP request (app/http.php), so the memo is naturally request-scoped. Helps every request filter version, not just V20. - Splits V20's walk into an entry point that resolves the database namespace once and a pure recursive helper. - Caches the collection `attributes` array per `(databaseNamespace, collectionId)` on the filter instance, so shared related collections collapse to one `getDocument` call. Missing or errored lookups are cached as `null` to avoid retry storms. --- src/Appwrite/Utopia/Request.php | 10 ++ src/Appwrite/Utopia/Request/Filters/V20.php | 121 ++++++++++++++------ 2 files changed, 94 insertions(+), 37 deletions(-) diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 32f0fa89a9..66ac4ca932 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -18,6 +18,7 @@ class Request extends UtopiaRequest */ private array $filters = []; private ?Route $route = null; + private ?array $filteredParams = null; public function __construct(SwooleRequest $request) { @@ -32,6 +33,10 @@ class Request extends UtopiaRequest */ public function getParams(): array { + if ($this->filteredParams !== null) { + return $this->filteredParams; + } + $parameters = parent::getParams(); if (!$this->hasFilters() || !$this->hasRoute()) { @@ -49,6 +54,7 @@ class Request extends UtopiaRequest foreach ($this->getFilters() as $filter) { $parameters = $filter->parse($parameters, $id); } + $this->filteredParams = $parameters; return $parameters; } @@ -79,6 +85,7 @@ class Request extends UtopiaRequest $parameters = $filter->parse($parameters, $id); } + $this->filteredParams = $parameters; return $parameters; } @@ -92,6 +99,7 @@ class Request extends UtopiaRequest public function addFilter(Filter $filter): void { $this->filters[] = $filter; + $this->filteredParams = null; } /** @@ -112,6 +120,7 @@ class Request extends UtopiaRequest public function resetFilters(): void { $this->filters = []; + $this->filteredParams = null; } /** @@ -134,6 +143,7 @@ class Request extends UtopiaRequest public function setRoute(?Route $route): void { $this->route = $route; + $this->filteredParams = null; } /** diff --git a/src/Appwrite/Utopia/Request/Filters/V20.php b/src/Appwrite/Utopia/Request/Filters/V20.php index a290656b6e..6b1da2709a 100644 --- a/src/Appwrite/Utopia/Request/Filters/V20.php +++ b/src/Appwrite/Utopia/Request/Filters/V20.php @@ -10,6 +10,18 @@ use Utopia\Database\Query; class V20 extends Filter { + /** + * Per-instance (request-scoped) memo of the `attributes` array for a given + * `(databaseNamespace, collectionId)`. Avoids re-fetching the same collection + * document when multiple relationships in the same schema point at it, and + * when `parse()` is re-entered before `Request::getParams()` memoization warms. + * + * A `null` value means we already tried and the collection was missing or errored. + * + * @var array>|null> + */ + private array $collectionAttributesCache = []; + // Convert 1.7 params to 1.8 public function parse(array $content, string $model): array { @@ -106,36 +118,21 @@ class V20 extends Filter * Recursively includes nested relationships up to 3 levels deep. * Prevents infinite loops by tracking all visited collections in the current path. */ - private function getRelatedCollectionKeys( - ?string $databaseId = null, - ?string $collectionId = null, - ?string $prefix = null, - int $depth = 1, - array $visited = [] - ): array { - $databaseId ??= $this->getParamValue('databaseId'); - $collectionId ??= $this->getParamValue('collectionId'); + private function getRelatedCollectionKeys(): array + { + $databaseId = $this->getParamValue('databaseId'); + $collectionId = $this->getParamValue('collectionId'); - if ( - empty($databaseId) || - empty($collectionId) || - $depth > Database::RELATION_MAX_DEPTH - ) { + if (empty($databaseId) || empty($collectionId)) { return []; } - // Check if we've already visited this collection in the current path to prevent cycles - if (in_array($collectionId, $visited)) { - return []; - } - - $visited[] = $collectionId; - $dbForProject = $this->getDbForProject(); if ($dbForProject === null) { return []; } + // Resolve the database namespace once, outside the recursion. try { $database = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument( 'databases', @@ -148,19 +145,42 @@ class V20 extends Filter return []; } - try { - $collection = $database = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument( - 'database_' . $database->getSequence(), - $collectionId - )); - if ($collection->isEmpty()) { - return []; - } - } catch (\Throwable) { + $databaseNamespace = 'database_' . $database->getSequence(); + + return $this->walkRelatedCollectionKeys( + $dbForProject, + $databaseNamespace, + $collectionId, + null, + 1, + [] + ); + } + + private function walkRelatedCollectionKeys( + Database $dbForProject, + string $databaseNamespace, + string $collectionId, + ?string $prefix, + int $depth, + array $visited + ): array { + if ($depth > Database::RELATION_MAX_DEPTH) { return []; } - $attributes = $collection->getAttribute('attributes', []); + // Check if we've already visited this collection in the current path to prevent cycles + if (in_array($collectionId, $visited, true)) { + return []; + } + + $attributes = $this->getCollectionAttributes($dbForProject, $databaseNamespace, $collectionId); + if ($attributes === null) { + return []; + } + + $visited[] = $collectionId; + $relationshipKeys = []; foreach ($attributes as $attr) { @@ -176,27 +196,54 @@ class V20 extends Filter $relatedCollectionId = $attr['relatedCollection'] ?? null; // Skip this relationship entirely if it points to an already visited collection - if ($relatedCollectionId && in_array($relatedCollectionId, $visited)) { + if ($relatedCollectionId && in_array($relatedCollectionId, $visited, true)) { continue; } - // Add the wildcard select for this relationship $relationshipKeys[] = $fullKey . '.*'; - // Continue recursively if we have a related collection if ($relatedCollectionId) { - $nestedKeys = $this->getRelatedCollectionKeys( - $databaseId, + $nestedKeys = $this->walkRelatedCollectionKeys( + $dbForProject, + $databaseNamespace, $relatedCollectionId, $fullKey, $depth + 1, $visited ); - $relationshipKeys = \array_merge($relationshipKeys, $nestedKeys); } } return \array_values(\array_unique($relationshipKeys)); } + + /** + * @return array>|null + */ + private function getCollectionAttributes( + Database $dbForProject, + string $databaseNamespace, + string $collectionId + ): ?array { + $cacheKey = $databaseNamespace . ':' . $collectionId; + if (\array_key_exists($cacheKey, $this->collectionAttributesCache)) { + return $this->collectionAttributesCache[$cacheKey]; + } + + try { + $collection = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument( + $databaseNamespace, + $collectionId + )); + } catch (\Throwable) { + return $this->collectionAttributesCache[$cacheKey] = null; + } + + if ($collection->isEmpty()) { + return $this->collectionAttributesCache[$cacheKey] = null; + } + + return $this->collectionAttributesCache[$cacheKey] = $collection->getAttribute('attributes', []); + } } From 7b25d778d4ce6713c42c308cbda102edf88fa72f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 19:21:51 +0530 Subject: [PATCH 222/402] Trim benchmark scenarios --- .github/workflows/benchmark-comment.js | 77 +++--- .github/workflows/ci.yml | 4 - tests/benchmarks/http-local.sh | 1 - tests/benchmarks/http.js | 360 +++++-------------------- 4 files changed, 96 insertions(+), 346 deletions(-) diff --git a/.github/workflows/benchmark-comment.js b/.github/workflows/benchmark-comment.js index 4a9c920365..fa0fea87f4 100644 --- a/.github/workflows/benchmark-comment.js +++ b/.github/workflows/benchmark-comment.js @@ -1,7 +1,7 @@ const fs = require('fs'); const marker = ''; -const serviceLabels = ['Account', 'TablesDB', 'Storage', 'Functions', 'Sites', 'Health']; +const serviceLabels = ['Account', 'TablesDB', 'Storage', 'Functions']; module.exports = async ({ github, context, core }) => { const body = buildComment(core); @@ -51,7 +51,7 @@ function buildComment(core) { const baseRef = markdownText(process.env.BENCHMARK_BASE_REF || 'base'); const headRef = markdownText(process.env.BENCHMARK_HEAD_REF || 'head'); const rows = benchmarkRows(before, after, beforeSamples, afterSamples); - const topWaits = topSamples(afterSamples, 'appwrite_http_waiting', 3); + const topWaits = topSamples(afterSamples, 'appwrite_api_waiting', 3); const lines = [ marker, '## :sparkles: Benchmark results', @@ -68,22 +68,26 @@ function buildComment(core) { } lines.push( - '| Scenario | Before P50 (ms) | Before P95 (ms) | After P50 (ms) | After P95 (ms) | Delta P95 (ms) | After iterations | After RPS |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', - ...rows.map(comparisonRow), + '**Before**', + '', + metricTable(rows, 'before'), + '', + '**After**', + '', + metricTable(rows, 'after'), + '', + '**Delta**', + '', + '| Scenario | P95 delta (ms) |', + '| --- | ---: |', + ...rows.map(deltaRow), '', '
', - 'Current run details', + 'Top API waits', '', '
', '', - '| Scenario | P50 (ms) | P95 (ms) | Iterations | RPS |', - '| --- | ---: | ---: | ---: | ---: |', - ...rows.map(detailRow), - '', - '**Top 3 request waits**', - '', - '| Request | Max wait (ms) |', + '| API request | Max wait (ms) |', '| --- | ---: |', ...topWaitRows(topWaits), '', @@ -133,11 +137,6 @@ function benchmarkRows(before, after, beforeSamples, afterSamples) { const beforeServices = serviceStats(beforeSamples); const afterServices = serviceStats(afterSamples); return [ - { - label: 'Load test', - before: summaryStats(before, 'appwrite_http_duration', 'iterations', 'http_reqs'), - after: summaryStats(after, 'appwrite_http_duration', 'iterations', 'http_reqs'), - }, { label: 'API total', before: apiSampleStats(beforeSamples) || summaryStats(before, 'appwrite_api_duration'), @@ -148,16 +147,6 @@ function benchmarkRows(before, after, beforeSamples, afterSamples) { before: beforeServices.get(label) || null, after: afterServices.get(label) || null, })), - { - label: 'TablesDB schema', - before: summaryStats(before, 'appwrite_worker_tables_duration', 'appwrite_worker_tables_samples', 'appwrite_worker_tables_samples'), - after: summaryStats(after, 'appwrite_worker_tables_duration', 'appwrite_worker_tables_samples', 'appwrite_worker_tables_samples'), - }, - { - label: 'Mail delivery', - before: summaryStats(before, 'appwrite_worker_mails_duration', 'appwrite_worker_mails_samples', 'appwrite_worker_mails_samples'), - after: summaryStats(after, 'appwrite_worker_mails_duration', 'appwrite_worker_mails_samples', 'appwrite_worker_mails_samples'), - }, ]; } @@ -205,14 +194,15 @@ function serviceStats(samples) { } function apiSampleStats(samples) { - const values = samples - .filter((sample) => sample.metric === 'appwrite_api_duration' && typeof sample.data?.value === 'number') - .map((sample) => sample.data.value); + const apiSamples = samples.filter((sample) => { + return sample.metric === 'appwrite_api_duration' && typeof sample.data?.value === 'number'; + }); + const values = apiSamples.map((sample) => sample.data.value); if (values.length === 0) { return null; } - const durationSeconds = sampleWindowSeconds(samples); + const durationSeconds = sampleWindowSeconds(apiSamples); return { p50: percentile(values, 50), p95: percentile(values, 95), @@ -234,12 +224,6 @@ function serviceFromName(name) { if (name.startsWith('functions.')) { return 'Functions'; } - if (name.startsWith('sites.')) { - return 'Sites'; - } - if (name.startsWith('health.')) { - return 'Health'; - } return null; } @@ -272,12 +256,21 @@ function metricValue(data, metric, stat) { return metricValues(data, metric)?.[stat] ?? null; } -function comparisonRow(row) { - return `| ${row.label} | ${formatMs(row.before?.p50)} | ${formatMs(row.before?.p95)} | ${formatMs(row.after?.p50)} | ${formatMs(row.after?.p95)} | ${formatDelta(row.before?.p95, row.after?.p95)} | ${formatCount(row.after?.iterations)} | ${formatRate(row.after?.rps)} |`; +function metricTable(rows, side) { + return [ + '| Scenario | P50 (ms) | P95 (ms) | Iterations | RPS |', + '| --- | ---: | ---: | ---: | ---: |', + ...rows.map((row) => metricRow(row, side)), + ].join('\n'); } -function detailRow(row) { - return `| ${row.label} | ${formatMs(row.after?.p50)} | ${formatMs(row.after?.p95)} | ${formatCount(row.after?.iterations)} | ${formatRate(row.after?.rps)} |`; +function metricRow(row, side) { + const values = row[side]; + return `| ${row.label} | ${formatMs(values?.p50)} | ${formatMs(values?.p95)} | ${formatCount(values?.iterations)} | ${formatRate(values?.rps)} |`; +} + +function deltaRow(row) { + return `| ${row.label} | ${formatDelta(row.before?.p95, row.after?.p95)} |`; } function topSamples(samples, metric, limit) { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8e5cb38fb..97ca5546d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -712,7 +712,6 @@ jobs: _APP_DOMAIN: localhost _APP_CONSOLE_DOMAIN: localhost _APP_DOMAIN_FUNCTIONS: functions.localhost - _APP_DOMAIN_SITES: sites.localhost run: | docker tag ${{ env.IMAGE }}:before ${{ env.IMAGE }} docker compose up -d --wait --no-build @@ -726,7 +725,6 @@ jobs: uses: grafana/run-k6-action@v1 env: APPWRITE_ENDPOINT: 'http://localhost/v1' - APPWRITE_MAILDEV_ENDPOINT: 'http://localhost:9503/email' APPWRITE_BENCHMARK_ITERATIONS: '1' APPWRITE_BENCHMARK_VUS: '1' APPWRITE_WORKER_TIMEOUT_MS: '120000' @@ -768,7 +766,6 @@ jobs: _APP_DOMAIN: localhost _APP_CONSOLE_DOMAIN: localhost _APP_DOMAIN_FUNCTIONS: functions.localhost - _APP_DOMAIN_SITES: sites.localhost run: | docker tag ${{ env.IMAGE }}:after ${{ env.IMAGE }} docker compose up -d --wait --no-build @@ -779,7 +776,6 @@ jobs: uses: grafana/run-k6-action@v1 env: APPWRITE_ENDPOINT: 'http://localhost/v1' - APPWRITE_MAILDEV_ENDPOINT: 'http://localhost:9503/email' APPWRITE_BENCHMARK_ITERATIONS: '1' APPWRITE_BENCHMARK_VUS: '1' APPWRITE_WORKER_TIMEOUT_MS: '120000' diff --git a/tests/benchmarks/http-local.sh b/tests/benchmarks/http-local.sh index acb8a07058..734c825fda 100755 --- a/tests/benchmarks/http-local.sh +++ b/tests/benchmarks/http-local.sh @@ -6,7 +6,6 @@ export K6_WEB_DASHBOARD_HOST="${K6_WEB_DASHBOARD_HOST:-127.0.0.1}" export K6_WEB_DASHBOARD_PORT="${K6_WEB_DASHBOARD_PORT:-5665}" export K6_WEB_DASHBOARD_EXPORT="${K6_WEB_DASHBOARD_EXPORT:-/tmp/appwrite-k6-report.html}" export APPWRITE_ENDPOINT="${APPWRITE_ENDPOINT:-http://localhost/v1}" -export APPWRITE_MAILDEV_ENDPOINT="${APPWRITE_MAILDEV_ENDPOINT:-http://localhost:9503/email}" export APPWRITE_WORKER_TIMEOUT_MS="${APPWRITE_WORKER_TIMEOUT_MS:-120000}" export APPWRITE_BENCHMARK_SUMMARY_PATH="${APPWRITE_BENCHMARK_SUMMARY_PATH:-/tmp/appwrite-k6-summary.json}" diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 3592d0248e..ccab88c011 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -12,12 +12,10 @@ import encoding from 'k6/encoding'; import { Counter, Trend } from 'k6/metrics'; const ENDPOINT = (__ENV.APPWRITE_ENDPOINT || 'http://localhost/v1').replace(/\/+$/, ''); -const MAILDEV_ENDPOINT = __ENV.APPWRITE_MAILDEV_ENDPOINT || 'http://localhost:9503/email'; const CONSOLE_PROJECT = __ENV.APPWRITE_CONSOLE_PROJECT || 'console'; const REGION = __ENV.APPWRITE_REGION || 'default'; const REDIRECT_URL = __ENV.APPWRITE_BENCHMARK_REDIRECT_URL || 'http://localhost'; const PASSWORD = __ENV.APPWRITE_BENCHMARK_PASSWORD || 'Password123!'; -const MAIL_TIMEOUT_MS = Number(__ENV.APPWRITE_MAIL_TIMEOUT_MS || 20000); const WORKER_TIMEOUT_MS = Number(__ENV.APPWRITE_WORKER_TIMEOUT_MS || 120000); const ITERATIONS = Number(__ENV.APPWRITE_BENCHMARK_ITERATIONS || 1); const VUS = Number(__ENV.APPWRITE_BENCHMARK_VUS || 1); @@ -25,13 +23,9 @@ const SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_SUMMARY_PATH || '/tmp/appwrite-k6- const PREVIOUS_SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH || SUMMARY_PATH; const PREVIOUS_SUMMARY = loadPreviousSummary(); -export const httpDuration = new Trend('appwrite_http_duration', true); export const httpWaiting = new Trend('appwrite_http_waiting', true); export const apiDuration = new Trend('appwrite_api_duration', true); -export const tablesWorkerDuration = new Trend('appwrite_worker_tables_duration', true); -export const mailsWorkerDuration = new Trend('appwrite_worker_mails_duration', true); -export const tablesWorkerSamples = new Counter('appwrite_worker_tables_samples'); -export const mailsWorkerSamples = new Counter('appwrite_worker_mails_samples'); +export const apiWaiting = new Trend('appwrite_api_waiting', true); export const flowFailures = new Counter('appwrite_benchmark_flow_failures'); export const options = { @@ -79,15 +73,12 @@ const API_SCOPES = [ 'buckets.write', 'functions.read', 'functions.write', - 'sites.read', - 'sites.write', 'log.read', 'log.write', 'execution.read', 'execution.write', 'locale.read', 'avatars.read', - 'health.read', 'rules.read', 'rules.write', 'migrations.read', @@ -179,41 +170,60 @@ export function setup() { hostname: hostnameFromUrl(REDIRECT_URL), }, apiHeaders, [201, 409], 'setup.project.platforms.web.create'); - const smtp = rawRequest('PATCH', `/projects/${projectId}/smtp`, { - enabled: true, - senderName: 'Benchmark', - senderEmail: 'benchmark@appwrite.io', - replyTo: 'benchmark@appwrite.io', - host: __ENV.APPWRITE_SMTP_HOST || 'maildev', - port: Number(__ENV.APPWRITE_SMTP_PORT || 1025), - username: __ENV.APPWRITE_SMTP_USERNAME || 'user', - password: __ENV.APPWRITE_SMTP_PASSWORD || 'password', - ...(String(__ENV.APPWRITE_SMTP_SECURE || '') !== '' ? { secure: __ENV.APPWRITE_SMTP_SECURE } : {}), - }, consoleSessionHeaders, 'setup.projects.smtp.update'); - - if (smtp.status !== 200) { - console.warn(`Custom SMTP was not enabled (${smtp.status}). Mail worker timings may be unavailable.`); - } + const tablesDb = setupTablesDb(apiHeaders); return { runId, teamId, projectId, + databaseId: tablesDb.databaseId, + tableId: tablesDb.tableId, consoleSessionHeaders, apiHeaders, platformStatus: platform.status, }; } +function setupTablesDb(apiHeaders) { + const databaseId = unique('tdb'); + const tableId = unique('tbl'); + + setupApi('POST', '/tablesdb', { databaseId, name: 'Benchmark TablesDB' }, apiHeaders, [201], 'setup.tablesdb.create'); + setupApi('POST', `/tablesdb/${databaseId}/tables`, { + tableId, + name: 'Benchmark Table', + permissions: BASE_PERMISSIONS, + rowSecurity: false, + }, apiHeaders, [201], 'setup.tablesdb.tables.create'); + + const columns = [ + ['string', 'title', { size: 128 }], + ['integer', 'quantity', { min: 0, max: 100000 }], + ['email', 'email', {}], + ['boolean', 'active', {}], + ]; + + for (const [type, key, extra] of columns) { + setupApi('POST', `/tablesdb/${databaseId}/tables/${tableId}/columns/${type}`, { + key, + required: false, + array: false, + ...extra, + }, apiHeaders, [202], `setup.tablesdb.columns.${type}.create`); + waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, apiHeaders, 'available', WORKER_TIMEOUT_MS, `setup.tablesdb.columns.${type}.wait`); + } + + return { databaseId, tableId }; +} + export function curatedFlows(data) { const ctx = { ...data }; try { - group('account and mail flow', () => accountFlow(ctx)); + group('account flow', () => accountFlow(ctx)); group('tablesdb rows flow', () => tablesDbFlow(ctx)); group('storage files and tokens flow', () => storageFlow(ctx)); - group('functions and sites control-plane flow', () => computeFlow(ctx)); - group('health and queue probes', () => healthFlow(ctx)); + group('functions control-plane flow', () => computeFlow(ctx)); } catch (error) { flowFailures.add(1); throw error; @@ -230,16 +240,6 @@ export function teardown(data) { } } -function recordTablesWorkerDuration(duration, tags) { - tablesWorkerDuration.add(duration, tags); - tablesWorkerSamples.add(1, tags); -} - -function recordMailsWorkerDuration(duration, tags) { - mailsWorkerDuration.add(duration, tags); - mailsWorkerSamples.add(1, tags); -} - function accountFlow(ctx) { const userId = unique('user'); const email = `bench-user-${unique('mail')}@example.com`; @@ -271,109 +271,14 @@ function accountFlow(ctx) { api('PATCH', '/account/prefs', { prefs: { benchmark: true, runId: ctx.runId } }, sessionHeaders, [200], 'account.prefs.update'); api('PATCH', '/account/name', { name: 'Benchmark User Updated' }, sessionHeaders, [200], 'account.name.update'); api('PATCH', '/account/password', { password: `${PASSWORD}2`, oldPassword: PASSWORD }, sessionHeaders, [200], 'account.password.update'); - - const verificationStarted = Date.now(); - api('POST', '/account/verifications/email', { url: REDIRECT_URL }, sessionHeaders, [201], 'account.emailVerification.create'); - const verificationEmail = waitForEmail(email, (message) => { - return includes(message.subject, 'verify') - || includes(message.subject, 'verification') - || includes(message.html, 'verify') - || includes(message.html, 'verification') - || includes(message.text, 'verify') - || includes(message.text, 'verification'); - }, MAIL_TIMEOUT_MS); - recordMailsWorkerDuration(Date.now() - verificationStarted, { job: 'email_verification' }); - - const verification = extractQueryParams(verificationEmail); - if (verification.userId && verification.secret) { - api('PUT', '/account/verifications/email', { - userId: verification.userId, - secret: verification.secret, - }, sessionHeaders, [200], 'account.emailVerification.update'); - } - - const recoveryStarted = Date.now(); - api('POST', '/account/recovery', { email, url: REDIRECT_URL }, headers, [201], 'account.recovery.create'); - const recoveryEmail = waitForEmail(email, (message) => { - return includes(message.subject, 'recovery') - || includes(message.subject, 'recover') - || includes(message.subject, 'reset') - || includes(message.html, 'recovery') - || includes(message.html, 'recover') - || includes(message.html, 'reset') - || includes(message.text, 'recovery') - || includes(message.text, 'recover') - || includes(message.text, 'reset'); - }, MAIL_TIMEOUT_MS); - recordMailsWorkerDuration(Date.now() - recoveryStarted, { job: 'password_recovery' }); - - const recovery = extractQueryParams(recoveryEmail); - if (recovery.userId && recovery.secret) { - api('DELETE', '/account/sessions/current', null, sessionHeaders, [204], 'account.sessions.current.delete'); - - api('PUT', '/account/recovery', { - userId: recovery.userId, - secret: recovery.secret, - password: `${PASSWORD}3`, - }, headers, [200], 'account.recovery.update'); - - const recoveredSession = api('POST', '/account/sessions/email', { - email, - password: `${PASSWORD}3`, - }, headers, [201], 'account.sessions.email.recovered'); - - ctx.sessionHeaders = { - ...headers, - Cookie: cookieHeader(recoveredSession), - }; - - } } function tablesDbFlow(ctx) { requireSession(ctx, 'tablesDbFlow'); - const databaseId = unique('tdb'); - const tableId = unique('tbl'); + const databaseId = ctx.databaseId; + const tableId = ctx.tableId; const rowId = unique('row'); - const indexKey = unique('tidx'); - - api('POST', '/tablesdb', { databaseId, name: 'Benchmark TablesDB' }, ctx.apiHeaders, [201], 'tablesdb.create'); - api('POST', `/tablesdb/${databaseId}/tables`, { - tableId, - name: 'Benchmark Table', - permissions: BASE_PERMISSIONS, - rowSecurity: false, - }, ctx.apiHeaders, [201], 'tablesdb.tables.create'); - - const columns = [ - ['string', 'title', { size: 128 }], - ['integer', 'quantity', { min: 0, max: 100000 }], - ['email', 'email', {}], - ['boolean', 'active', {}], - ]; - - for (const [type, key, extra] of columns) { - const started = Date.now(); - api('POST', `/tablesdb/${databaseId}/tables/${tableId}/columns/${type}`, { - key, - required: false, - array: false, - ...extra, - }, ctx.apiHeaders, [202], `tablesdb.columns.${type}.create`); - waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS, `tablesdb.columns.${type}.wait`); - recordTablesWorkerDuration(Date.now() - started, { job: `column_${type}` }); - } - - const indexStarted = Date.now(); - api('POST', `/tablesdb/${databaseId}/tables/${tableId}/indexes`, { - key: indexKey, - type: 'key', - columns: ['title'], - orders: ['asc'], - }, ctx.apiHeaders, [202], 'tablesdb.indexes.create'); - waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/indexes/${indexKey}`, ctx.apiHeaders, 'available', WORKER_TIMEOUT_MS, 'tablesdb.indexes.wait'); - recordTablesWorkerDuration(Date.now() - indexStarted, { job: 'index' }); api('POST', `/tablesdb/${databaseId}/tables/${tableId}/rows`, { rowId, @@ -392,7 +297,6 @@ function tablesDbFlow(ctx) { value: 1, }, ctx.sessionHeaders, [200], 'tablesdb.rows.decrement'); api('DELETE', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, null, ctx.sessionHeaders, [204], 'tablesdb.rows.delete'); - api('DELETE', `/tablesdb/${databaseId}`, null, ctx.apiHeaders, [204], 'tablesdb.delete'); } function storageFlow(ctx) { @@ -426,9 +330,9 @@ function storageFlow(ctx) { tags: { name: 'storage.files.create' }, }); - httpDuration.add(upload.timings.duration, { name: 'storage.files.create' }); httpWaiting.add(upload.timings.waiting, { name: 'storage.files.create' }); apiDuration.add(upload.timings.duration, { name: 'storage.files.create' }); + apiWaiting.add(upload.timings.waiting, { name: 'storage.files.create' }); assertStatus(upload, [201], 'storage file created'); api('GET', `/storage/buckets/${bucketId}/files`, null, ctx.sessionHeaders, [200], 'storage.files.list'); @@ -456,8 +360,6 @@ function computeFlow(ctx) { const functionId = unique('fn'); let functionVariableId; - const siteId = unique('site'); - let siteVariableId; api('POST', '/functions', { functionId, @@ -490,66 +392,12 @@ function computeFlow(ctx) { api('GET', `/functions/${functionId}/variables/${functionVariableId}`, null, ctx.apiHeaders, [200], 'functions.variables.get'); api('DELETE', `/functions/${functionId}/variables/${functionVariableId}`, null, ctx.apiHeaders, [204], 'functions.variables.delete'); api('DELETE', `/functions/${functionId}`, null, ctx.apiHeaders, [204], 'functions.delete'); - - api('POST', '/sites', { - siteId, - name: 'Benchmark Site', - framework: 'other', - adapter: 'static', - buildRuntime: __ENV.APPWRITE_BENCHMARK_RUNTIME || 'node-22', - buildCommand: '', - outputDirectory: '.', - installCommand: '', - fallbackFile: 'index.html', - providerRootDirectory: '.', - specification: '', - }, ctx.apiHeaders, [201], 'sites.create'); - api('GET', '/sites/frameworks', null, ctx.sessionHeaders, [200], 'sites.frameworks.list'); - api('GET', '/sites/specifications', null, ctx.apiHeaders, [200], 'sites.specifications.list'); - const siteVariable = api('POST', `/sites/${siteId}/variables`, { - key: 'BENCHMARK', - value: 'true', - secret: false, - }, ctx.apiHeaders, [201], 'sites.variables.create'); - siteVariableId = siteVariable.json('$id'); - - api('PUT', `/sites/${siteId}/variables/${siteVariableId}`, { - key: 'BENCHMARK', - value: 'updated', - secret: false, - }, ctx.apiHeaders, [200], 'sites.variables.update'); - api('GET', `/sites/${siteId}/variables/${siteVariableId}`, null, ctx.apiHeaders, [200], 'sites.variables.get'); - api('DELETE', `/sites/${siteId}/variables/${siteVariableId}`, null, ctx.apiHeaders, [204], 'sites.variables.delete'); - api('DELETE', `/sites/${siteId}`, null, ctx.apiHeaders, [204], 'sites.delete'); -} - -function healthFlow(ctx) { - const probes = [ - '/health', - '/health/db', - '/health/cache', - '/health/pubsub', - '/health/storage', - '/health/storage/local', - '/health/time', - '/health/queue/mails', - '/health/queue/functions', - '/health/queue/builds', - '/health/queue/deletes', - '/health/queue/webhooks', - '/health/queue/stats-resources', - '/health/queue/stats-usage', - '/health/queue/failed/v1-mails', - ]; - - for (const path of probes) { - api('GET', path, null, ctx.apiHeaders, [200], `health${path.replace(/\//g, '.')}`); - } } function api(method, path, body, headers, expected, name) { const response = rawRequest(method, path, body, headers, name); apiDuration.add(response.timings.duration, { name }); + apiWaiting.add(response.timings.waiting, { name }); assertStatus(response, expected, name); return response; } @@ -567,7 +415,6 @@ function rawRequest(method, path, body, headers, name) { }; const payload = body === null || body === undefined ? null : JSON.stringify(body); const response = http.request(method, `${ENDPOINT}${path}`, payload, params); - httpDuration.add(response.timings.duration, { name }); httpWaiting.add(response.timings.waiting, { name }); return response; @@ -593,73 +440,6 @@ function waitForStatus(path, headers, wantedStatus, timeoutMs, name) { throw new Error(`Timed out waiting for ${path} to become ${wantedStatus}`); } -function waitForEmail(address, predicate, timeoutMs, allowMissingRecipient = false) { - const started = Date.now(); - - while (Date.now() - started < timeoutMs) { - const response = http.get(MAILDEV_ENDPOINT, { tags: { name: 'maildev.email.list' } }); - if (response.status === 200) { - const emails = response.json(); - for (let i = emails.length - 1; i >= 0; i--) { - const message = emails[i]; - if ((emailMatches(message, address) || (allowMissingRecipient && emailRecipientMissing(message))) && predicate(message)) { - return message; - } - } - } - sleep(0.5); - } - - throw new Error(`Timed out waiting for email to ${address}`); -} - -function emailMatches(message, address) { - const recipients = message.to || []; - return recipients.some((recipient) => recipient.address === address); -} - -function emailRecipientMissing(message) { - const recipients = message.to || []; - return recipients.length === 0 || recipients.every((recipient) => !recipient.address); -} - -function extractQueryParams(message) { - const content = `${message.html || ''}\n${message.text || ''}`; - const links = []; - const hrefPattern = /href="([^"]+)"/g; - let hrefMatch = hrefPattern.exec(content); - - while (hrefMatch !== null) { - links.push(hrefMatch[1]); - hrefMatch = hrefPattern.exec(content); - } - - if (links.length === 0) { - links.push(content); - } - - for (const link of links) { - const queryStart = link.indexOf('?'); - if (queryStart === -1) { - continue; - } - - const query = link.slice(queryStart + 1).split('#')[0].replace(/&/g, '&'); - const params = {}; - - for (const pair of query.split('&')) { - const [key, value] = pair.split('='); - params[decodeURIComponent(key)] = decodeURIComponent(value || ''); - } - - if (params.userId && params.secret) { - return params; - } - } - - return {}; -} - function assertStatus(response, expected, name) { const ok = check(response, { [`${name} status ${expected.join('|')}`]: (r) => expected.includes(r.status), @@ -719,10 +499,6 @@ function unique(prefix) { .slice(0, 36); } -function includes(value, needle) { - return String(value || '').toLowerCase().includes(String(needle).toLowerCase()); -} - function hostnameFromUrl(value) { return value.replace(/^https?:\/\//, '').split('/')[0].split(':')[0]; } @@ -731,13 +507,17 @@ export function handleSummary(data) { const lines = [ 'Appwrite curated benchmark review', '', - 'Before/after comparison', + 'Before', '', - comparisonTable(PREVIOUS_SUMMARY, data), + summaryTable(PREVIOUS_SUMMARY), '', - 'Current run details', + 'After', '', - detailsTable(data), + summaryTable(data), + '', + 'Delta', + '', + deltaTable(PREVIOUS_SUMMARY, data), '', ]; @@ -747,19 +527,16 @@ export function handleSummary(data) { }; } -function detailsTable(data) { +function summaryTable(data) { return [ '| Scenario | P50 (ms) | P95 (ms) | Iterations | RPS |', '| --- | ---: | ---: | ---: | ---: |', - detailRow(data, 'Load test', 'appwrite_http_duration', 'iterations', 'http_reqs'), - detailRow(data, 'API total', 'appwrite_api_duration'), - detailRow(data, 'TablesDB schema', 'appwrite_worker_tables_duration', 'appwrite_worker_tables_samples', 'appwrite_worker_tables_samples'), - detailRow(data, 'Mail delivery', 'appwrite_worker_mails_duration', 'appwrite_worker_mails_samples', 'appwrite_worker_mails_samples'), + summaryRow(data, 'API total', 'appwrite_api_duration'), ].join('\n'); } -function detailRow(data, label, metric, iterationsMetric = null, rpsMetric = null) { - const values = data.metrics[metric] && data.metrics[metric].values; +function summaryRow(data, label, metric, iterationsMetric = null, rpsMetric = null) { + const values = data && data.metrics[metric] && data.metrics[metric].values; if (!values || values.count === 0) { return `| ${label} | n/a | n/a | n/a | n/a |`; } @@ -798,23 +575,16 @@ function loadPreviousSummary() { return null; } -function comparisonTable(before, after) { - const rows = [ - ['Load test', 'appwrite_http_duration'], - ['API total', 'appwrite_api_duration'], - ['TablesDB schema', 'appwrite_worker_tables_duration'], - ['Mail delivery', 'appwrite_worker_mails_duration'], - ]; - +function deltaTable(before, after) { return [ - '| Scenario | Before P50 (ms) | Before P95 (ms) | After P50 (ms) | After P95 (ms) | Delta P95 (ms) |', - '| --- | ---: | ---: | ---: | ---: | ---: |', - ...rows.map(([label, metric]) => { - const beforeP50 = trendMetric(before, metric, 'med'); + '| Scenario | P95 delta (ms) |', + '| --- | ---: |', + ...[ + ['API total', 'appwrite_api_duration'], + ].map(([label, metric]) => { const beforeP95 = trendMetric(before, metric, 'p(95)'); - const afterP50 = trendMetric(after, metric, 'med'); const afterP95 = trendMetric(after, metric, 'p(95)'); - return `| ${label} | ${formatValue(beforeP50)} | ${formatValue(beforeP95)} | ${formatValue(afterP50)} | ${formatValue(afterP95)} | ${formatDelta(beforeP95, afterP95)} |`; + return `| ${label} | ${formatDelta(beforeP95, afterP95)} |`; }), ].join('\n'); } @@ -825,14 +595,6 @@ function trendMetric(data, metric, stat) { : null; } -function formatValue(value) { - if (value === null || value === undefined || Number.isNaN(value)) { - return 'n/a'; - } - - return `${round(value)}`; -} - function formatDetailValue(value) { if (value === null || value === undefined || Number.isNaN(value)) { return 'n/a'; From c15e8d0126a236c5197eba27134cf28b6b5f2ea6 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 19:30:01 +0530 Subject: [PATCH 223/402] Harden benchmark failure guard --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97ca5546d4..5aac048161 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -712,6 +712,7 @@ jobs: _APP_DOMAIN: localhost _APP_CONSOLE_DOMAIN: localhost _APP_DOMAIN_FUNCTIONS: functions.localhost + _APP_OPTIONS_ABUSE: disabled run: | docker tag ${{ env.IMAGE }}:before ${{ env.IMAGE }} docker compose up -d --wait --no-build @@ -766,6 +767,7 @@ jobs: _APP_DOMAIN: localhost _APP_CONSOLE_DOMAIN: localhost _APP_DOMAIN_FUNCTIONS: functions.localhost + _APP_OPTIONS_ABUSE: disabled run: | docker tag ${{ env.IMAGE }}:after ${{ env.IMAGE }} docker compose up -d --wait --no-build @@ -816,5 +818,5 @@ jobs: retention-days: 7 - name: Fail benchmark - if: always() && steps.benchmark_after.outcome == 'failure' + if: always() && steps.benchmark_after.outcome != 'success' run: exit 1 From 9a6a5977106c769958dc8039b53aa2ee1de24ed8 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 19:38:48 +0530 Subject: [PATCH 224/402] Address benchmark hardening review --- .github/workflows/ci.yml | 8 ++++---- tests/benchmarks/http.js | 37 ++++++++++++++----------------------- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5aac048161..c5486c739a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -684,7 +684,7 @@ jobs: docker tag ${{ env.IMAGE }} ${{ env.IMAGE }}:after - name: Setup k6 - uses: grafana/setup-k6-action@v1 + uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 with: k6-version: ${{ env.K6_VERSION }} @@ -723,7 +723,7 @@ jobs: - name: Benchmark before if: steps.benchmark_before_start.outcome == 'success' continue-on-error: true - uses: grafana/run-k6-action@v1 + uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d env: APPWRITE_ENDPOINT: 'http://localhost/v1' APPWRITE_BENCHMARK_ITERATIONS: '1' @@ -775,13 +775,13 @@ jobs: - name: Benchmark after id: benchmark_after continue-on-error: true - uses: grafana/run-k6-action@v1 + uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d env: APPWRITE_ENDPOINT: 'http://localhost/v1' APPWRITE_BENCHMARK_ITERATIONS: '1' APPWRITE_BENCHMARK_VUS: '1' APPWRITE_WORKER_TIMEOUT_MS: '120000' - APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH: 'benchmark-before-summary.json' + APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH: '../../benchmark-before-summary.json' APPWRITE_BENCHMARK_SUMMARY_PATH: 'benchmark-after-summary.json' with: path: tests/benchmarks/http.js diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index ccab88c011..2f3a73c41f 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -20,8 +20,8 @@ const WORKER_TIMEOUT_MS = Number(__ENV.APPWRITE_WORKER_TIMEOUT_MS || 120000); const ITERATIONS = Number(__ENV.APPWRITE_BENCHMARK_ITERATIONS || 1); const VUS = Number(__ENV.APPWRITE_BENCHMARK_VUS || 1); const SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_SUMMARY_PATH || '/tmp/appwrite-k6-summary.json'; -const PREVIOUS_SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH || SUMMARY_PATH; -const PREVIOUS_SUMMARY = loadPreviousSummary(); +const PREVIOUS_SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH || ''; +const PREVIOUS_SUMMARY = PREVIOUS_SUMMARY_PATH ? loadPreviousSummary(PREVIOUS_SUMMARY_PATH) : null; export const httpWaiting = new Trend('appwrite_http_waiting', true); export const apiDuration = new Trend('appwrite_api_duration', true); @@ -549,30 +549,21 @@ function summaryRow(data, label, metric, iterationsMetric = null, rpsMetric = nu return `| ${label} | ${formatDetailValue(values.med)} | ${formatDetailValue(values['p(95)'])} | ${formatCount(iterations)} | ${formatRate(rps)} |`; } -function loadPreviousSummary() { - const paths = [PREVIOUS_SUMMARY_PATH]; - if (!PREVIOUS_SUMMARY_PATH.startsWith('/')) { - paths.push(`../../${PREVIOUS_SUMMARY_PATH}`); +function loadPreviousSummary(path) { + let contents; + try { + contents = open(path); + } catch (error) { + console.warn(`Missing benchmark summary at ${path}: ${error.message}`); + return null; } - for (const path of paths) { - let contents; - try { - contents = open(path); - } catch (error) { - // Try the next path. k6 resolves open() relative to the script file. - continue; - } - - try { - return JSON.parse(contents); - } catch (error) { - console.warn(`Invalid benchmark summary at ${path}: ${error.message}`); - return null; - } + try { + return JSON.parse(contents); + } catch (error) { + console.warn(`Invalid benchmark summary at ${path}: ${error.message}`); + return null; } - - return null; } function deltaTable(before, after) { From 3d66078fe97bb1b545548a81b69631a3203edcc4 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 19:46:27 +0530 Subject: [PATCH 225/402] Increase benchmark iterations --- .github/workflows/benchmark-comment.js | 2 +- .github/workflows/ci.yml | 4 ++-- tests/benchmarks/http.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/benchmark-comment.js b/.github/workflows/benchmark-comment.js index fa0fea87f4..f25116c4f2 100644 --- a/.github/workflows/benchmark-comment.js +++ b/.github/workflows/benchmark-comment.js @@ -258,7 +258,7 @@ function metricValue(data, metric, stat) { function metricTable(rows, side) { return [ - '| Scenario | P50 (ms) | P95 (ms) | Iterations | RPS |', + '| Scenario | P50 (ms) | P95 (ms) | Requests | RPS |', '| --- | ---: | ---: | ---: | ---: |', ...rows.map((row) => metricRow(row, side)), ].join('\n'); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5486c739a..8a199f6eab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -726,7 +726,7 @@ jobs: uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d env: APPWRITE_ENDPOINT: 'http://localhost/v1' - APPWRITE_BENCHMARK_ITERATIONS: '1' + APPWRITE_BENCHMARK_ITERATIONS: '5' APPWRITE_BENCHMARK_VUS: '1' APPWRITE_WORKER_TIMEOUT_MS: '120000' APPWRITE_BENCHMARK_SUMMARY_PATH: 'benchmark-before-summary.json' @@ -778,7 +778,7 @@ jobs: uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d env: APPWRITE_ENDPOINT: 'http://localhost/v1' - APPWRITE_BENCHMARK_ITERATIONS: '1' + APPWRITE_BENCHMARK_ITERATIONS: '5' APPWRITE_BENCHMARK_VUS: '1' APPWRITE_WORKER_TIMEOUT_MS: '120000' APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH: '../../benchmark-before-summary.json' diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 2f3a73c41f..4009024069 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -529,7 +529,7 @@ export function handleSummary(data) { function summaryTable(data) { return [ - '| Scenario | P50 (ms) | P95 (ms) | Iterations | RPS |', + '| Scenario | P50 (ms) | P95 (ms) | Requests | RPS |', '| --- | ---: | ---: | ---: | ---: |', summaryRow(data, 'API total', 'appwrite_api_duration'), ].join('\n'); From b0939b92c36ac5fb3fb011ab1b43a13f82a34588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 17:02:22 +0200 Subject: [PATCH 226/402] Fix failing account tests --- tests/e2e/Services/Account/AccountCustomClientTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index c96676b598..da788c3caa 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -772,6 +772,7 @@ class AccountCustomClientTest extends Scope 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', + 'x-appwrite-response-format' => '1.9.1', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'status' => true, @@ -3695,6 +3696,7 @@ class AccountCustomClientTest extends Scope 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', + 'x-appwrite-response-format' => '1.9.1', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'status' => false, From c36b8fbabf6270936be86bd6307c07689dabfe44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 10:07:32 +0200 Subject: [PATCH 227/402] Fix membershiip privacy bug on production --- app/config/console.php | 5 +++++ .../Platform/Modules/Teams/Http/Memberships/Get.php | 11 ++++++----- .../Platform/Modules/Teams/Http/Memberships/XList.php | 11 ++++++----- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/app/config/console.php b/app/config/console.php index 0b0d6c5881..b7a3f2195a 100644 --- a/app/config/console.php +++ b/app/config/console.php @@ -34,6 +34,11 @@ $console = [ 'legalAddress' => '', 'legalTaxId' => '', 'auths' => [ + 'membershipsUserName' => true, + 'membershipsUserEmail' => true, + 'membershipsMfa' => true, + 'membershipsUserId' => true, + 'membershipsUserPhone' => true, 'mockNumbers' => [], 'invites' => System::getEnv('_APP_CONSOLE_INVITES', 'enabled') === 'enabled', 'limit' => (System::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php index d146684a20..49f9a36507 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php @@ -70,12 +70,13 @@ class Get extends Action throw new Exception(Exception::MEMBERSHIP_NOT_FOUND); } + // Default should be "false", but existing projects already relay on this being "true" $membershipsPrivacy = [ - 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? false, - 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? false, - 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? false, - 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? false, - 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? false, + 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, + 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, + 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true, + 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? true, + 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? true, ]; $roles = $authorization->getRoles(); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php index 70b78e02c6..816ca53e3a 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php @@ -123,12 +123,13 @@ class XList extends Action $memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId'))); + // Default should be "false", but existing projects already relay on this being "true" $membershipsPrivacy = [ - 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? false, - 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? false, - 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? false, - 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? false, - 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? false, + 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, + 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, + 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true, + 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? true, + 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? true, ]; $roles = $authorization->getRoles(); From 48353faa9b9d0af4920f48bd6a90b6f7e050b4cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 10:13:01 +0200 Subject: [PATCH 228/402] Apply suggestions from code review Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php | 2 +- src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php index 49f9a36507..ef8d130855 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php @@ -70,7 +70,7 @@ class Get extends Action throw new Exception(Exception::MEMBERSHIP_NOT_FOUND); } - // Default should be "false", but existing projects already relay on this being "true" + // Default should be "false", but existing projects already rely on this being "true" $membershipsPrivacy = [ 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php index 816ca53e3a..7835c8051f 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php @@ -123,7 +123,7 @@ class XList extends Action $memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId'))); - // Default should be "false", but existing projects already relay on this being "true" + // Default should be "false", but existing projects already rely on this being "true" $membershipsPrivacy = [ 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, From 83724ce96f0f7ef1ea56dda645996e6dedaa9d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 10:37:35 +0200 Subject: [PATCH 229/402] Console membership privacy test coverage --- .../Services/Teams/TeamsConsoleClientTest.php | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/e2e/Services/Teams/TeamsConsoleClientTest.php b/tests/e2e/Services/Teams/TeamsConsoleClientTest.php index 2a1367d749..da19a26c87 100644 --- a/tests/e2e/Services/Teams/TeamsConsoleClientTest.php +++ b/tests/e2e/Services/Teams/TeamsConsoleClientTest.php @@ -14,6 +14,65 @@ class TeamsConsoleClientTest extends Scope use ProjectConsole; use SideClient; + public function testConsoleMembershipPrivacyDefaults(): void + { + $teamData = $this->createTeamHelper(); + $membershipData = $this->createAndAcceptMembershipHelper($teamData['teamUid'], $teamData['teamName']); + + $teamUid = $teamData['teamUid']; + $projectId = $this->getProject()['$id']; + $owner = $this->getUser(); + $memberHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $membershipData['session'], + ]; + + $ownerMemberships = $this->client->call(Client::METHOD_GET, '/teams/' . $teamUid . '/memberships', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders())); + + $this->assertEquals(200, $ownerMemberships['headers']['status-code']); + $this->assertEquals(2, $ownerMemberships['body']['total']); + + $ownerMembershipsByUser = []; + foreach ($ownerMemberships['body']['memberships'] as $membership) { + $ownerMembershipsByUser[$membership['userId']] = $membership; + } + + $this->assertArrayHasKey($owner['$id'], $ownerMembershipsByUser); + $this->assertContains('owner', $ownerMembershipsByUser[$owner['$id']]['roles']); + + $this->assertArrayHasKey($membershipData['userUid'], $ownerMembershipsByUser); + $this->assertNotContains('owner', $ownerMembershipsByUser[$membershipData['userUid']]['roles']); + $this->assertSame($membershipData['userUid'], $ownerMembershipsByUser[$membershipData['userUid']]['userId']); + $this->assertSame($membershipData['name'], $ownerMembershipsByUser[$membershipData['userUid']]['userName']); + $this->assertSame($membershipData['email'], $ownerMembershipsByUser[$membershipData['userUid']]['userEmail']); + $this->assertFalse($ownerMembershipsByUser[$membershipData['userUid']]['mfa']); + + $memberMemberships = $this->client->call(Client::METHOD_GET, '/teams/' . $teamUid . '/memberships', $memberHeaders); + + $this->assertEquals(200, $memberMemberships['headers']['status-code']); + $this->assertEquals(2, $memberMemberships['body']['total']); + + $memberMembershipsByUser = []; + foreach ($memberMemberships['body']['memberships'] as $membership) { + $memberMembershipsByUser[$membership['userId']] = $membership; + } + + $this->assertArrayHasKey($owner['$id'], $memberMembershipsByUser); + $this->assertSame($owner['$id'], $memberMembershipsByUser[$owner['$id']]['userId']); + $this->assertSame($owner['name'], $memberMembershipsByUser[$owner['$id']]['userName']); + $this->assertSame($owner['email'], $memberMembershipsByUser[$owner['$id']]['userEmail']); + $this->assertFalse($memberMembershipsByUser[$owner['$id']]['mfa']); + $this->assertContains('owner', $memberMembershipsByUser[$owner['$id']]['roles']); + + $this->assertArrayHasKey($membershipData['userUid'], $memberMembershipsByUser); + $this->assertNotContains('owner', $memberMembershipsByUser[$membershipData['userUid']]['roles']); + } + public function testTeamCreateMembershipConsole(): void { $teamData = $this->createTeamHelper(); From 51fa0770a6866d8d6516ef75805237915c0ed764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 12:43:45 +0200 Subject: [PATCH 230/402] Add queries to mock numbers list --- .../Project/Http/Project/MockPhone/XList.php | 18 +++++++ tests/e2e/Services/Project/MockPhonesBase.php | 52 ++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php index a12aa11108..82aa7f1446 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php @@ -2,11 +2,17 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\MockPhone; +use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Document; +use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Query; +use Utopia\Database\Validator\Queries; +use Utopia\Database\Validator\Query\Limit; +use Utopia\Database\Validator\Query\Offset; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; @@ -43,6 +49,7 @@ class XList extends Action ) ] )) + ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('project') @@ -50,14 +57,25 @@ class XList extends Action } public function action( + array $queries, bool $includeTotal, Response $response, Document $project, ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + $auths = $project->getAttribute('auths', []); $mockNumbers = $auths['mockNumbers'] ?? []; + $grouped = Query::groupByType($queries); + $limit = $grouped['limit'] ?? null; + $offset = $grouped['offset'] ?? 0; $total = $includeTotal ? \count($mockNumbers) : 0; + $mockNumbers = \array_slice($mockNumbers, $offset, $limit); $mockNumbers = \array_map(fn ($mockNumber) => new Document($mockNumber), $mockNumbers); diff --git a/tests/e2e/Services/Project/MockPhonesBase.php b/tests/e2e/Services/Project/MockPhonesBase.php index 02ddcd73bc..e41a8901bf 100644 --- a/tests/e2e/Services/Project/MockPhonesBase.php +++ b/tests/e2e/Services/Project/MockPhonesBase.php @@ -3,6 +3,7 @@ namespace Tests\E2E\Services\Project; use Tests\E2E\Client; +use Utopia\Database\Query; use Utopia\Database\Validator\Datetime as DatetimeValidator; trait MockPhonesBase @@ -317,6 +318,52 @@ trait MockPhonesBase $this->deleteMockPhone($number); } + public function testListMockPhonesWithLimit(): void + { + $number1 = $this->uniquePhoneNumber(); + $number2 = $this->uniquePhoneNumber(); + + $this->assertSame(201, $this->createMockPhone($number1, '111111')['headers']['status-code']); + $this->assertSame(201, $this->createMockPhone($number2, '222222')['headers']['status-code']); + + $response = $this->listMockPhones([ + Query::limit(1)->toString(), + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['mockNumbers']); + $this->assertGreaterThanOrEqual(2, $response['body']['total']); + + // Cleanup + $this->deleteMockPhone($number1); + $this->deleteMockPhone($number2); + } + + public function testListMockPhonesWithOffset(): void + { + $number1 = $this->uniquePhoneNumber(); + $number2 = $this->uniquePhoneNumber(); + + $this->assertSame(201, $this->createMockPhone($number1, '111111')['headers']['status-code']); + $this->assertSame(201, $this->createMockPhone($number2, '222222')['headers']['status-code']); + + $listAll = $this->listMockPhones(); + $this->assertSame(200, $listAll['headers']['status-code']); + $totalAll = \count($listAll['body']['mockNumbers']); + + $listOffset = $this->listMockPhones([ + Query::offset(1)->toString(), + ]); + + $this->assertSame(200, $listOffset['headers']['status-code']); + $this->assertCount($totalAll - 1, $listOffset['body']['mockNumbers']); + $this->assertSame($listAll['body']['total'], $listOffset['body']['total']); + + // Cleanup + $this->deleteMockPhone($number1); + $this->deleteMockPhone($number2); + } + public function testListMockPhonesWithoutAuthentication(): void { $response = $this->listMockPhones(authenticated: false); @@ -458,7 +505,7 @@ trait MockPhonesBase return $this->client->call(Client::METHOD_PUT, '/project/mock-phones/' . $number, $headers, $params); } - protected function listMockPhones(?bool $total = null, bool $authenticated = true): mixed + protected function listMockPhones(?array $queries = null, ?bool $total = null, bool $authenticated = true): mixed { $headers = [ 'content-type' => 'application/json', @@ -470,6 +517,9 @@ trait MockPhonesBase } $params = []; + if ($queries !== null) { + $params['queries'] = $queries; + } if ($total !== null) { $params['total'] = $total; } From c1dfeae3238045052410e324b6a651230547f86c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 13:06:05 +0200 Subject: [PATCH 231/402] Add queries to email tempaltes list --- .../Http/Project/Templates/Email/XList.php | 23 +++++++ tests/e2e/Services/Project/TemplatesBase.php | 68 ++++++++++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php index 8b13bdb28a..d15f2f856c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -2,11 +2,17 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Templates\Email; +use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Document; +use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Query; +use Utopia\Database\Validator\Queries; +use Utopia\Database\Validator\Query\Limit; +use Utopia\Database\Validator\Query\Offset; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; @@ -43,17 +49,28 @@ class XList extends Action ) ] )) + ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('project') ->callback($this->action(...)); } + /** + * @param array $queries + */ public function action( + array $queries, bool $includeTotal, Response $response, Document $project, ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + $templates = $project->getAttribute('templates', []); $emailTemplates = []; @@ -83,6 +100,12 @@ class XList extends Action $total = $includeTotal ? \count($emailTemplates) : 0; + $grouped = Query::groupByType($queries); + $offset = $grouped['offset'] ?? 0; + $limit = $grouped['limit'] ?? null; + + $emailTemplates = \array_slice($emailTemplates, $offset, $limit); + $response->dynamic(new Document([ 'templates' => $emailTemplates, 'total' => $total, diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index b57a20a8d9..b240c945b3 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -4,6 +4,7 @@ namespace Tests\E2E\Services\Project; use Tests\E2E\Client; use Utopia\Database\Helpers\ID; +use Utopia\Database\Query; trait TemplatesBase { @@ -767,6 +768,68 @@ trait TemplatesBase $this->assertSame(\count($response['body']['templates']), $response['body']['total']); } + public function testListEmailTemplatesWithLimit(): void + { + $this->ensureSMTPEnabled(); + + $runId = \uniqid(); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: "Limit verification {$runId}", + message: 'Body', + )['headers']['status-code']); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'en', + subject: "Limit recovery {$runId}", + message: 'Body', + )['headers']['status-code']); + + $response = $this->listEmailTemplates([ + Query::limit(1)->toString(), + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['templates']); + $this->assertGreaterThanOrEqual(2, $response['body']['total']); + } + + public function testListEmailTemplatesWithOffset(): void + { + $this->ensureSMTPEnabled(); + + $runId = \uniqid(); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'magicSession', + locale: 'en', + subject: "Offset magic {$runId}", + message: 'Body', + )['headers']['status-code']); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: 'en', + subject: "Offset session {$runId}", + message: 'Body', + )['headers']['status-code']); + + $listAll = $this->listEmailTemplates(); + $this->assertSame(200, $listAll['headers']['status-code']); + $totalAll = \count($listAll['body']['templates']); + + $listOffset = $this->listEmailTemplates([ + Query::offset(1)->toString(), + ]); + + $this->assertSame(200, $listOffset['headers']['status-code']); + $this->assertCount($totalAll - 1, $listOffset['body']['templates']); + $this->assertSame($listAll['body']['total'], $listOffset['body']['total']); + } + public function testListEmailTemplatesOnlyReturnsCustomizedTemplates(): void { $this->ensureSMTPEnabled(); @@ -1031,7 +1094,7 @@ trait TemplatesBase return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $templateId, $headers, $params); } - protected function listEmailTemplates(?bool $total = null, bool $authenticated = true): mixed + protected function listEmailTemplates(?array $queries = null, ?bool $total = null, bool $authenticated = true): mixed { $headers = [ 'content-type' => 'application/json', @@ -1043,6 +1106,9 @@ trait TemplatesBase } $params = []; + if ($queries !== null) { + $params['queries'] = $queries; + } if ($total !== null) { $params['total'] = $total; } From cef7a5197f543ca60d93ecb552fab30791504d81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 13:24:39 +0200 Subject: [PATCH 232/402] List policies API --- app/init/models.php | 20 +++ .../Project/Http/Project/Policies/XList.php | 132 +++++++++++++++ src/Appwrite/Utopia/Response.php | 10 ++ .../Utopia/Response/Model/PolicyBase.php | 19 +++ .../Utopia/Response/Model/PolicyList.php | 46 +++++ .../Model/PolicyMembershipPrivacy.php | 59 +++++++ .../Model/PolicyPasswordDictionary.php | 34 ++++ .../Response/Model/PolicyPasswordHistory.php | 34 ++++ .../Model/PolicyPasswordPersonalData.php | 34 ++++ .../Response/Model/PolicySessionAlert.php | 34 ++++ .../Response/Model/PolicySessionDuration.php | 34 ++++ .../Model/PolicySessionInvalidation.php | 34 ++++ .../Response/Model/PolicySessionLimit.php | 34 ++++ .../Utopia/Response/Model/PolicyUserLimit.php | 34 ++++ tests/e2e/Services/Project/PoliciesBase.php | 160 ++++++++++++++++++ 15 files changed, 718 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicyBase.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicyList.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicyMembershipPrivacy.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicyPasswordDictionary.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicyPasswordHistory.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicyPasswordPersonalData.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicySessionAlert.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicySessionDuration.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicySessionInvalidation.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicySessionLimit.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicyUserLimit.php diff --git a/app/init/models.php b/app/init/models.php index 8f569d3252..b713d61cd2 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -112,6 +112,16 @@ use Appwrite\Utopia\Response\Model\PlatformLinux; use Appwrite\Utopia\Response\Model\PlatformList; use Appwrite\Utopia\Response\Model\PlatformWeb; use Appwrite\Utopia\Response\Model\PlatformWindows; +use Appwrite\Utopia\Response\Model\PolicyList; +use Appwrite\Utopia\Response\Model\PolicyMembershipPrivacy; +use Appwrite\Utopia\Response\Model\PolicyPasswordDictionary; +use Appwrite\Utopia\Response\Model\PolicyPasswordHistory; +use Appwrite\Utopia\Response\Model\PolicyPasswordPersonalData; +use Appwrite\Utopia\Response\Model\PolicySessionAlert; +use Appwrite\Utopia\Response\Model\PolicySessionDuration; +use Appwrite\Utopia\Response\Model\PolicySessionInvalidation; +use Appwrite\Utopia\Response\Model\PolicySessionLimit; +use Appwrite\Utopia\Response\Model\PolicyUserLimit; use Appwrite\Utopia\Response\Model\Preferences; use Appwrite\Utopia\Response\Model\Project; use Appwrite\Utopia\Response\Model\Provider; @@ -211,6 +221,7 @@ Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phon Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false)); Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE)); Response::setModel(new BaseList('Mock Numbers List', Response::MODEL_MOCK_NUMBER_LIST, 'mockNumbers', Response::MODEL_MOCK_NUMBER)); +Response::setModel(new PolicyList()); Response::setModel(new BaseList('Email Templates List', Response::MODEL_EMAIL_TEMPLATE_LIST, 'templates', Response::MODEL_EMAIL_TEMPLATE)); Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS)); Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE)); @@ -339,6 +350,15 @@ Response::setModel(new Webhook()); Response::setModel(new Key()); Response::setModel(new DevKey()); Response::setModel(new MockNumber()); +Response::setModel(new PolicyPasswordDictionary()); +Response::setModel(new PolicyPasswordHistory()); +Response::setModel(new PolicyPasswordPersonalData()); +Response::setModel(new PolicySessionAlert()); +Response::setModel(new PolicySessionDuration()); +Response::setModel(new PolicySessionInvalidation()); +Response::setModel(new PolicySessionLimit()); +Response::setModel(new PolicyUserLimit()); +Response::setModel(new PolicyMembershipPrivacy()); Response::setModel(new AuthProvider()); Response::setModel(new PlatformWeb()); Response::setModel(new PlatformApple()); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php new file mode 100644 index 0000000000..893b28fef2 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php @@ -0,0 +1,132 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/policies') + ->desc('List project policies') + ->groups(['api', 'project']) + ->label('scope', 'policies.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'listPolicies', + description: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + /** + * @param array $queries + */ + public function action( + array $queries, + bool $includeTotal, + Response $response, + Document $project, + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $auths = $project->getAttribute('auths', []); + + $policies = [ + new Document([ + '$id' => 'password-dictionary', + 'enabled' => $auths['passwordDictionary'] ?? false, + ]), + new Document([ + '$id' => 'password-history', + 'total' => $auths['passwordHistory'] ?? 0, + ]), + new Document([ + '$id' => 'password-personal-data', + 'enabled' => $auths['personalDataCheck'] ?? false, + ]), + new Document([ + '$id' => 'session-alert', + 'enabled' => $auths['sessionAlerts'] ?? false, + ]), + new Document([ + '$id' => 'session-duration', + 'duration' => $auths['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG, + ]), + new Document([ + '$id' => 'session-invalidation', + 'enabled' => $auths['invalidateSessions'] ?? true, + ]), + new Document([ + '$id' => 'session-limit', + 'total' => $auths['maxSessions'] ?? 0, + ]), + new Document([ + '$id' => 'user-limit', + 'total' => $auths['limit'] ?? 0, + ]), + new Document([ + '$id' => 'membership-privacy', + 'userId' => $auths['membershipsUserId'] ?? false, + 'userEmail' => $auths['membershipsUserEmail'] ?? false, + 'userPhone' => $auths['membershipsUserPhone'] ?? false, + 'userName' => $auths['membershipsUserName'] ?? false, + 'userMFA' => $auths['membershipsMfa'] ?? false, + ]), + ]; + + $total = $includeTotal ? \count($policies) : 0; + + $grouped = Query::groupByType($queries); + $offset = $grouped['offset'] ?? 0; + $limit = $grouped['limit'] ?? null; + + $policies = \array_slice($policies, $offset, $limit); + + $response->dynamic(new Document([ + 'policies' => $policies, + 'total' => $total, + ]), Response::MODEL_POLICY_LIST); + } +} diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index d72b52e4cb..c4e616ea12 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -255,6 +255,16 @@ class Response extends SwooleResponse public const MODEL_DEV_KEY_LIST = 'devKeyList'; public const MODEL_MOCK_NUMBER = 'mockNumber'; public const MODEL_MOCK_NUMBER_LIST = 'mockNumberList'; + public const MODEL_POLICY_LIST = 'policyList'; + public const MODEL_POLICY_PASSWORD_DICTIONARY = 'policyPasswordDictionary'; + public const MODEL_POLICY_PASSWORD_HISTORY = 'policyPasswordHistory'; + public const MODEL_POLICY_PASSWORD_PERSONAL_DATA = 'policyPasswordPersonalData'; + public const MODEL_POLICY_SESSION_ALERT = 'policySessionAlert'; + public const MODEL_POLICY_SESSION_DURATION = 'policySessionDuration'; + public const MODEL_POLICY_SESSION_INVALIDATION = 'policySessionInvalidation'; + public const MODEL_POLICY_SESSION_LIMIT = 'policySessionLimit'; + public const MODEL_POLICY_USER_LIMIT = 'policyUserLimit'; + public const MODEL_POLICY_MEMBERSHIP_PRIVACY = 'policyMembershipPrivacy'; public const MODEL_AUTH_PROVIDER = 'authProvider'; public const MODEL_AUTH_PROVIDER_LIST = 'authProviderList'; public const MODEL_PLATFORM_APPLE = 'platformApple'; diff --git a/src/Appwrite/Utopia/Response/Model/PolicyBase.php b/src/Appwrite/Utopia/Response/Model/PolicyBase.php new file mode 100644 index 0000000000..04a44d9ffd --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyBase.php @@ -0,0 +1,19 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Policy ID.', + 'default' => '', + 'example' => 'password-dictionary', + ]); + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyList.php b/src/Appwrite/Utopia/Response/Model/PolicyList.php new file mode 100644 index 0000000000..09548fedcf --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyList.php @@ -0,0 +1,46 @@ +addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of policies in the given project.', + 'default' => 0, + 'example' => 9, + ]) + ->addRule('policies', [ + 'type' => [ + Response::MODEL_POLICY_PASSWORD_DICTIONARY, + Response::MODEL_POLICY_PASSWORD_HISTORY, + Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA, + Response::MODEL_POLICY_SESSION_ALERT, + Response::MODEL_POLICY_SESSION_DURATION, + Response::MODEL_POLICY_SESSION_INVALIDATION, + Response::MODEL_POLICY_SESSION_LIMIT, + Response::MODEL_POLICY_USER_LIMIT, + Response::MODEL_POLICY_MEMBERSHIP_PRIVACY, + ], + 'description' => 'List of policies.', + 'default' => [], + 'array' => true, + ]); + } + + public function getName(): string + { + return 'Policies List'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_LIST; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyMembershipPrivacy.php b/src/Appwrite/Utopia/Response/Model/PolicyMembershipPrivacy.php new file mode 100644 index 0000000000..fe2851d35b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyMembershipPrivacy.php @@ -0,0 +1,59 @@ + 'membership-privacy', + ]; + + public function __construct() + { + parent::__construct(); + + $this + ->addRule('userId', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether user ID is visible in memberships.', + 'default' => false, + 'example' => true, + ]) + ->addRule('userEmail', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether user email is visible in memberships.', + 'default' => false, + 'example' => true, + ]) + ->addRule('userPhone', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether user phone is visible in memberships.', + 'default' => false, + 'example' => true, + ]) + ->addRule('userName', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether user name is visible in memberships.', + 'default' => false, + 'example' => true, + ]) + ->addRule('userMFA', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether user MFA status is visible in memberships.', + 'default' => false, + 'example' => true, + ]); + } + + public function getName(): string + { + return 'Policy Membership Privacy'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_MEMBERSHIP_PRIVACY; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyPasswordDictionary.php b/src/Appwrite/Utopia/Response/Model/PolicyPasswordDictionary.php new file mode 100644 index 0000000000..78cd284332 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyPasswordDictionary.php @@ -0,0 +1,34 @@ + 'password-dictionary', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether password dictionary policy is enabled.', + 'default' => false, + 'example' => true, + ]); + } + + public function getName(): string + { + return 'Policy Password Dictionary'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_PASSWORD_DICTIONARY; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyPasswordHistory.php b/src/Appwrite/Utopia/Response/Model/PolicyPasswordHistory.php new file mode 100644 index 0000000000..a9b5951db6 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyPasswordHistory.php @@ -0,0 +1,34 @@ + 'password-history', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Password history length. A value of 0 means the policy is disabled.', + 'default' => 0, + 'example' => 5, + ]); + } + + public function getName(): string + { + return 'Policy Password History'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_PASSWORD_HISTORY; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyPasswordPersonalData.php b/src/Appwrite/Utopia/Response/Model/PolicyPasswordPersonalData.php new file mode 100644 index 0000000000..feffd95f1b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyPasswordPersonalData.php @@ -0,0 +1,34 @@ + 'password-personal-data', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether password personal data policy is enabled.', + 'default' => false, + 'example' => true, + ]); + } + + public function getName(): string + { + return 'Policy Password Personal Data'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicySessionAlert.php b/src/Appwrite/Utopia/Response/Model/PolicySessionAlert.php new file mode 100644 index 0000000000..4f1a66c65c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicySessionAlert.php @@ -0,0 +1,34 @@ + 'session-alert', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether session alert policy is enabled.', + 'default' => false, + 'example' => true, + ]); + } + + public function getName(): string + { + return 'Policy Session Alert'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_SESSION_ALERT; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicySessionDuration.php b/src/Appwrite/Utopia/Response/Model/PolicySessionDuration.php new file mode 100644 index 0000000000..1242802c42 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicySessionDuration.php @@ -0,0 +1,34 @@ + 'session-duration', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('duration', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Session duration in seconds.', + 'default' => TOKEN_EXPIRATION_LOGIN_LONG, + 'example' => 3600, + ]); + } + + public function getName(): string + { + return 'Policy Session Duration'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_SESSION_DURATION; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicySessionInvalidation.php b/src/Appwrite/Utopia/Response/Model/PolicySessionInvalidation.php new file mode 100644 index 0000000000..12cbe10851 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicySessionInvalidation.php @@ -0,0 +1,34 @@ + 'session-invalidation', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether session invalidation policy is enabled.', + 'default' => true, + 'example' => true, + ]); + } + + public function getName(): string + { + return 'Policy Session Invalidation'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_SESSION_INVALIDATION; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicySessionLimit.php b/src/Appwrite/Utopia/Response/Model/PolicySessionLimit.php new file mode 100644 index 0000000000..2f187ef1f9 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicySessionLimit.php @@ -0,0 +1,34 @@ + 'session-limit', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Maximum number of sessions allowed per user. A value of 0 means the policy is disabled.', + 'default' => 0, + 'example' => 10, + ]); + } + + public function getName(): string + { + return 'Policy Session Limit'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_SESSION_LIMIT; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyUserLimit.php b/src/Appwrite/Utopia/Response/Model/PolicyUserLimit.php new file mode 100644 index 0000000000..0ae80445ea --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyUserLimit.php @@ -0,0 +1,34 @@ + 'user-limit', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Maximum number of users allowed in the project. A value of 0 means the policy is disabled.', + 'default' => 0, + 'example' => 100, + ]); + } + + public function getName(): string + { + return 'Policy User Limit'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_USER_LIMIT; + } +} diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php index 84f5938d3e..7d532c98c1 100644 --- a/tests/e2e/Services/Project/PoliciesBase.php +++ b/tests/e2e/Services/Project/PoliciesBase.php @@ -3,9 +3,154 @@ namespace Tests\E2E\Services\Project; use Tests\E2E\Client; +use Utopia\Database\Query; trait PoliciesBase { + // ========================================================================= + // List Policies + // ========================================================================= + + public function testListPolicies(): void + { + $response = $this->listPolicies(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('policies', $response['body']); + $this->assertArrayHasKey('total', $response['body']); + $this->assertIsArray($response['body']['policies']); + $this->assertIsInt($response['body']['total']); + $this->assertSame(9, $response['body']['total']); + $this->assertCount(9, $response['body']['policies']); + + $policyIds = \array_column($response['body']['policies'], '$id'); + + $this->assertContains('password-dictionary', $policyIds); + $this->assertContains('password-history', $policyIds); + $this->assertContains('password-personal-data', $policyIds); + $this->assertContains('session-alert', $policyIds); + $this->assertContains('session-duration', $policyIds); + $this->assertContains('session-invalidation', $policyIds); + $this->assertContains('session-limit', $policyIds); + $this->assertContains('user-limit', $policyIds); + $this->assertContains('membership-privacy', $policyIds); + } + + public function testListPoliciesResponseModel(): void + { + $response = $this->listPolicies(); + + $this->assertSame(200, $response['headers']['status-code']); + + foreach ($response['body']['policies'] as $policy) { + $this->assertArrayHasKey('$id', $policy); + } + + $byId = []; + foreach ($response['body']['policies'] as $policy) { + $byId[$policy['$id']] = $policy; + } + + $this->assertArrayHasKey('enabled', $byId['password-dictionary']); + $this->assertArrayHasKey('total', $byId['password-history']); + $this->assertArrayHasKey('enabled', $byId['password-personal-data']); + $this->assertArrayHasKey('enabled', $byId['session-alert']); + $this->assertArrayHasKey('duration', $byId['session-duration']); + $this->assertArrayHasKey('enabled', $byId['session-invalidation']); + $this->assertArrayHasKey('total', $byId['session-limit']); + $this->assertArrayHasKey('total', $byId['user-limit']); + $this->assertArrayHasKey('userId', $byId['membership-privacy']); + $this->assertArrayHasKey('userEmail', $byId['membership-privacy']); + $this->assertArrayHasKey('userPhone', $byId['membership-privacy']); + $this->assertArrayHasKey('userName', $byId['membership-privacy']); + $this->assertArrayHasKey('userMFA', $byId['membership-privacy']); + } + + public function testListPoliciesReflectsUpdates(): void + { + $this->updatePasswordDictionaryPolicy(true); + $this->updatePasswordHistoryPolicy(5); + $this->updateSessionDurationPolicy(3600); + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => false, + 'userName' => true, + 'userMFA' => true, + ]); + + $response = $this->listPolicies(); + + $this->assertSame(200, $response['headers']['status-code']); + + $byId = []; + foreach ($response['body']['policies'] as $policy) { + $byId[$policy['$id']] = $policy; + } + + $this->assertSame(true, $byId['password-dictionary']['enabled']); + $this->assertSame(5, $byId['password-history']['total']); + $this->assertSame(3600, $byId['session-duration']['duration']); + $this->assertSame(true, $byId['membership-privacy']['userId']); + $this->assertSame(true, $byId['membership-privacy']['userEmail']); + $this->assertSame(false, $byId['membership-privacy']['userPhone']); + $this->assertSame(true, $byId['membership-privacy']['userName']); + $this->assertSame(true, $byId['membership-privacy']['userMFA']); + + // Cleanup + $this->updatePasswordDictionaryPolicy(false); + $this->updatePasswordHistoryPolicy(null); + $this->updateSessionDurationPolicy(31536000); + $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + } + + public function testListPoliciesTotalFalse(): void + { + $response = $this->listPolicies(total: false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['total']); + $this->assertCount(9, $response['body']['policies']); + } + + public function testListPoliciesWithLimit(): void + { + $response = $this->listPolicies([ + Query::limit(1)->toString(), + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['policies']); + $this->assertSame(9, $response['body']['total']); + } + + public function testListPoliciesWithOffset(): void + { + $listAll = $this->listPolicies(); + $this->assertSame(200, $listAll['headers']['status-code']); + + $listOffset = $this->listPolicies([ + Query::offset(1)->toString(), + ]); + + $this->assertSame(200, $listOffset['headers']['status-code']); + $this->assertCount(\count($listAll['body']['policies']) - 1, $listOffset['body']['policies']); + $this->assertSame($listAll['body']['total'], $listOffset['body']['total']); + } + + public function testListPoliciesWithoutAuthentication(): void + { + $response = $this->listPolicies(authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + // ========================================================================= // Password Dictionary Policy // ========================================================================= @@ -842,6 +987,21 @@ trait PoliciesBase ]); } + protected function listPolicies(?array $queries = null, ?bool $total = null, bool $authenticated = true): mixed + { + $params = []; + + if ($queries !== null) { + $params['queries'] = $queries; + } + + if ($total !== null) { + $params['total'] = $total; + } + + return $this->client->call(Client::METHOD_GET, '/project/policies', $this->buildHeaders($authenticated), $params); + } + protected function updatePasswordDictionaryPolicy(bool $enabled, bool $authenticated = true): mixed { return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders($authenticated), [ From 6d86b8fd0d33ef15d30f9ef76bab988aeaa46a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 13:25:21 +0200 Subject: [PATCH 233/402] Removal of project JWTs --- app/controllers/api/projects.php | 45 -------------------------------- 1 file changed, 45 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index f24c9a2bed..748363e3be 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1,6 +1,5 @@ noContent(); }); -// JWT Keys - -Http::post('/v1/projects/:projectId/jwts') - ->groups(['api', 'projects']) - ->desc('Create JWT') - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'createJWT', - description: '/docs/references/projects/create-jwt.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_JWT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for JWT key. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') - ->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, array $scopes, int $duration, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0); - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic(new Document(['jwt' => API_KEY_DYNAMIC . '_' . $jwt->encode([ - 'projectId' => $project->getId(), - 'scopes' => $scopes - ])]), Response::MODEL_JWT); - }); - // Backwards compatibility Http::delete('/v1/projects/:projectId/templates/email') ->alias('/v1/projects/:projectId/templates/email/:type/:locale') From b99139661e4e8945a8652ac8ff1065b897889efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 13:37:19 +0200 Subject: [PATCH 234/402] Migrate delete project endpoint --- app/controllers/api/projects.php | 46 ----------- app/controllers/shared/api.php | 5 +- .../Modules/Project/Http/Project/Delete.php | 81 +++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 + 4 files changed, 86 insertions(+), 48 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 748363e3be..3e9aaf4458 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1,10 +1,8 @@ dynamic($project, Response::MODEL_PROJECT); }); -Http::delete('/v1/projects/:projectId') - ->desc('Delete project') - ->groups(['api', 'projects']) - ->label('audits.event', 'projects.delete') - ->label('audits.resource', 'project/{request.projectId}') - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'delete', - description: '/docs/references/projects/delete.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->inject('response') - ->inject('user') - ->inject('dbForPlatform') - ->inject('queueForDeletes') - ->action(function (string $projectId, Response $response, Document $user, Database $dbForPlatform, Delete $queueForDeletes) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $queueForDeletes - ->setProject($project) - ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($project); - - if (!$dbForPlatform->deleteDocument('projects', $projectId)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB'); - } - - $response->noContent(); - }); - // Backwards compatibility Http::delete('/v1/projects/:projectId/templates/email') ->alias('/v1/projects/:projectId/templates/email/:type/:locale') diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 8b8c7ee066..fa6e5c28ab 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -44,7 +44,7 @@ use Utopia\System\System; use Utopia\Telemetry\Adapter as Telemetry; use Utopia\Validator\WhiteList; -$parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user) { +$parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user, Document $project) { preg_match_all('/{(.*?)}/', $label, $matches); foreach ($matches[1] as $pos => $match) { $find = $matches[0][$pos]; @@ -59,6 +59,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar $params = match ($namespace) { 'user' => (array) $user, + 'project' => $project->getArrayCopy(), 'request' => $requestParams, default => $responsePayload, }; @@ -903,7 +904,7 @@ Http::shutdown() */ $pattern = $route->getLabel('audits.resource', null); if (! empty($pattern)) { - $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); + $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project); if (! empty($resource) && $resource !== $pattern) { $auditContext->resource = $resource; } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php new file mode 100644 index 0000000000..0a60e4ce4d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php @@ -0,0 +1,81 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project') + ->httpAlias('/v1/projects/:projectId') + ->desc('Delete project') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'project.delete') + ->label('audits.event', 'project.delete') + ->label('audits.resource', 'project/{project.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: null, + name: 'delete', + description: <<inject('response') + ->inject('dbForPlatform') + ->inject('queueForDeletes') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + Response $response, + Database $dbForPlatform, + DeleteQueue $queueForDeletes, + Authorization $authorization, + Document $project, + ) { + $queueForDeletes + ->setProject($project) + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($project); + + if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('projects', $project->getId()))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB'); + } + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index b0babc8247..04c2deed0b 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -4,6 +4,7 @@ namespace Appwrite\Platform\Modules\Project\Services; use Appwrite\Platform\Modules\Project\Http\Init; use Appwrite\Platform\Modules\Project\Http\Project\AuthMethods\Update as UpdateAuthMethod; +use Appwrite\Platform\Modules\Project\Http\Project\Delete as DeleteProject; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey; @@ -61,6 +62,7 @@ class Http extends Service $this->addAction(Init::getName(), new Init()); // Project + $this->addAction(DeleteProject::getName(), new DeleteProject()); $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); $this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol()); $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); From a0a3849b16e9aa054e9895e0d8d77a8f50fe980a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 13:37:32 +0200 Subject: [PATCH 235/402] Remove unsupported bulk endpoints --- app/controllers/api/projects.php | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 3e9aaf4458..cf920b695f 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -58,22 +58,6 @@ Http::get('/v1/projects/:projectId') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/service/all') - ->desc('Update all service status') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->action(function () { - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.'); - }); - -Http::patch('/v1/projects/:projectId/api/all') - ->desc('Update all API status') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->action(function () { - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.'); - }); - Http::patch('/v1/projects/:projectId/oauth2') ->desc('Update project OAuth2') ->groups(['api', 'projects']) From c246fb0f837af10afa955617c8cafdcf502dc24e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 13:41:11 +0200 Subject: [PATCH 236/402] Project deletion tests --- tests/e2e/Services/Projects/ProjectsBase.php | 78 +++++++++++++++++++ .../Projects/ProjectsCustomServerTest.php | 1 + 2 files changed, 79 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index ef83e65d95..220e3c62bd 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -2,6 +2,7 @@ namespace Tests\E2E\Services\Projects; +use PHPUnit\Framework\Attributes\Group; use Tests\E2E\Client; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Role; @@ -17,6 +18,83 @@ trait ProjectsBase private static array $cachedProjectWithAuthLimit = []; private static array $cachedProjectWithServicesDisabled = []; + protected function createProjectForDeleteTest(): array + { + $rootHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ]; + + $team = $this->client->call(Client::METHOD_POST, '/teams', $rootHeaders, [ + 'teamId' => ID::unique(), + 'name' => 'Delete Project Team', + ]); + + $this->assertSame(201, $team['headers']['status-code']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', $rootHeaders, [ + 'projectId' => ID::unique(), + 'name' => 'Delete Project Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertSame(201, $project['headers']['status-code']); + + $key = $this->client->call(Client::METHOD_POST, '/projects/' . $project['body']['$id'] . '/keys', $rootHeaders, [ + 'keyId' => ID::unique(), + 'name' => 'Delete Project Key', + 'scopes' => [ + 'project.read', + 'project.write', + ], + ]); + + $this->assertSame(201, $key['headers']['status-code']); + + return [ + 'projectId' => $project['body']['$id'], + 'apiKey' => $key['body']['secret'], + ]; + } + + #[Group('projectsCRUD')] + public function testDeleteProject(): void + { + $project = $this->createProjectForDeleteTest(); + + $headers = match ($this->getSide()) { + 'server' => [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project['projectId'], + 'x-appwrite-key' => $project['apiKey'], + 'x-appwrite-mode' => 'admin', + ], + default => [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => $project['projectId'], + 'x-appwrite-mode' => 'admin', + ], + }; + + $response = $this->client->call(Client::METHOD_DELETE, '/project', $headers); + + $this->assertSame(204, $response['headers']['status-code']); + + $get = $this->client->call(Client::METHOD_GET, '/projects/' . $project['projectId'], [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ]); + + $this->assertSame(404, $get['headers']['status-code']); + } + /** * Setup and cache a basic project with team */ diff --git a/tests/e2e/Services/Projects/ProjectsCustomServerTest.php b/tests/e2e/Services/Projects/ProjectsCustomServerTest.php index 313a4d53be..d87c2cbf78 100644 --- a/tests/e2e/Services/Projects/ProjectsCustomServerTest.php +++ b/tests/e2e/Services/Projects/ProjectsCustomServerTest.php @@ -10,6 +10,7 @@ use Utopia\System\System; class ProjectsCustomServerTest extends Scope { + use ProjectsBase; use ProjectCustom; use SideServer; From bdbc5b92df0bcb65b4a7b6dd1557275bfa97b129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 13:47:31 +0200 Subject: [PATCH 237/402] Fix after code review --- app/config/roles.php | 1 + app/config/scopes/project.php | 4 ++++ app/controllers/shared/api.php | 4 ++-- src/Appwrite/Platform/Workers/Migrations.php | 1 + tests/e2e/Scopes/ProjectCustom.php | 1 + 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/config/roles.php b/app/config/roles.php index 62efb4d809..33c7ffc9de 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -57,6 +57,7 @@ $admins = [ 'platforms.write', 'mocks.read', 'mocks.write', + 'policies.read', 'policies.write', 'templates.read', 'templates.write', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 2c78cb921c..592e032ba1 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -212,6 +212,10 @@ return [ // List of publicly visible scopes "description" => "Access to create, update, and delete project\'s mocks", ], + "policies.read" => [ + "description" => + "Access to read project\'s policies", + ], "policies.write" => [ "description" => "Access to update project\'s policies", diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index fa6e5c28ab..7c2f527ccf 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -977,12 +977,12 @@ Http::shutdown() if (! empty($data['payload']) && $statusCode >= 200 && $statusCode < 300) { $pattern = $route->getLabel('cache.resource', null); if (! empty($pattern)) { - $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); + $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project); } $pattern = $route->getLabel('cache.resourceType', null); if (! empty($pattern)) { - $resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user); + $resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project); } $cache = new Cache( diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 0225983d2f..cfe8d2d567 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -393,6 +393,7 @@ class Migrations extends Action 'platforms.write', 'mocks.read', 'mocks.write', + 'policies.read', 'policies.write', 'templates.read', 'templates.write', diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index e5a86c07fd..f531ed774d 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -171,6 +171,7 @@ trait ProjectCustom 'platforms.write', 'mocks.read', 'mocks.write', + 'policies.read', 'policies.write', 'templates.read', 'templates.write', From 9c6ed9565e05787d1e702443ff36aa4c2dc8586e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 14:07:58 +0200 Subject: [PATCH 238/402] Remove tests of removed endpoints --- .../Modules/Project/Services/Http.php | 2 + .../Projects/ProjectsConsoleClientTest.php | 166 ------------------ 2 files changed, 2 insertions(+), 166 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 04c2deed0b..703359ea4e 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -38,6 +38,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionDuration\Upda use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionInvalidation\Update as UpdateSessionInvalidationPolicy; use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionLimit\Update as UpdateSessionLimitPolicy; use Appwrite\Platform\Modules\Project\Http\Project\Policies\UserLimit\Update as UpdateUserLimitPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\XList as ListPolicies; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdateProjectProtocol; use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; @@ -113,6 +114,7 @@ class Http extends Service $this->addAction(DeleteMockPhone::getName(), new DeleteMockPhone()); // Policies + $this->addAction(ListPolicies::getName(), new ListPolicies()); $this->addAction(UpdateMembershipPrivacyPolicy::getName(), new UpdateMembershipPrivacyPolicy()); $this->addAction(UpdatePasswordDictionaryPolicy::getName(), new UpdatePasswordDictionaryPolicy()); $this->addAction(UpdatePasswordHistoryPolicy::getName(), new UpdatePasswordHistoryPolicy()); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 1de3f3786c..f88db41e8c 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -2636,120 +2636,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(false, $response['body']['authPersonalDataCheck']); } - public function testUpdateProjectServicesAll(): void - { - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'teamId' => ID::unique(), - 'name' => 'Project Test', - ]); - - $this->assertEquals(201, $team['headers']['status-code']); - $this->assertNotEmpty($team['body']['$id']); - - $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'projectId' => ID::unique(), - 'name' => 'Project Test', - 'teamId' => $team['body']['$id'], - 'region' => System::getEnv('_APP_REGION', 'default') - ]); - - $this->assertEquals(201, $project['headers']['status-code']); - $this->assertNotEmpty($project['body']['$id']); - - $id = $project['body']['$id']; - - // Bulk disable should no longer work - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.0', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'status' => false, - ]); - - $this->assertEquals(405, $response['headers']['status-code']); - $this->assertEquals('general_not_implemented', $response['body']['type']); - - // Bulk enable should no longer work - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.0', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'status' => true, - ]); - - $this->assertEquals(405, $response['headers']['status-code']); - $this->assertEquals('general_not_implemented', $response['body']['type']); - } - - public function testUpdateProjectApisAll(): void - { - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'teamId' => ID::unique(), - 'name' => 'Project Test', - ]); - - $this->assertEquals(201, $team['headers']['status-code']); - $this->assertNotEmpty($team['body']['$id']); - - $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'projectId' => ID::unique(), - 'name' => 'Project Test', - 'teamId' => $team['body']['$id'], - 'region' => System::getEnv('_APP_REGION', 'default') - ]); - - $this->assertEquals(201, $project['headers']['status-code']); - $this->assertNotEmpty($project['body']['$id']); - - $id = $project['body']['$id']; - - // Bulk disable should no longer work - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.0', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'status' => false, - ]); - - $this->assertEquals(405, $response['headers']['status-code']); - $this->assertEquals('general_not_implemented', $response['body']['type']); - - // Bulk enable should no longer work - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.0', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'status' => true, - ]); - - $this->assertEquals(405, $response['headers']['status-code']); - $this->assertEquals('general_not_implemented', $response['body']['type']); - } - public function testUpdateProjectApiStatus(): void { $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ @@ -4055,58 +3941,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertEmpty($response['body']); } - // JWT Keys - - public function testJWTKey(): void - { - $data = $this->setupProjectData(); - $id = $data['projectId']; - - // Create JWT key - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/jwts', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'duration' => 5, - 'scopes' => ['users.read'], - ]); - - $this->assertEquals(201, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['jwt']); - - $jwt = $response['body']['jwt']; - - // Ensure JWT key works - $response = $this->client->call(Client::METHOD_GET, '/users', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - 'x-appwrite-key' => $jwt, - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertArrayHasKey('users', $response['body']); - - // Ensure JWT key respect scopes - $response = $this->client->call(Client::METHOD_GET, '/functions', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - 'x-appwrite-key' => $jwt, - ]); - - $this->assertEquals(401, $response['headers']['status-code']); - - // Ensure JWT key expires - \sleep(10); - - $response = $this->client->call(Client::METHOD_GET, '/users', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - 'x-appwrite-key' => $jwt, - ]); - - $this->assertEquals(401, $response['headers']['status-code']); - } - // Platforms public function testCreateProjectPlatform(): void From a48fd13ced5c20a857d30f102a1f7c1cebd7cfc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 15:19:49 +0200 Subject: [PATCH 239/402] Add getPolicy + tests + move wrongly placed project tests --- .../Project/Http/Project/Policies/Get.php | 151 ++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 + tests/e2e/Services/Project/PoliciesBase.php | 115 +++++++++++++ tests/e2e/Services/Project/ProjectBase.php | 7 + .../Project/ProjectConsoleClientTest.php | 33 ++++ .../Project/ProjectCustomServerTest.php | 14 ++ tests/e2e/Services/Projects/ProjectsBase.php | 77 --------- 7 files changed, 322 insertions(+), 77 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php create mode 100644 tests/e2e/Services/Project/ProjectBase.php create mode 100644 tests/e2e/Services/Project/ProjectConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/ProjectCustomServerTest.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php new file mode 100644 index 0000000000..3d633cd2e4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php @@ -0,0 +1,151 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/policies/:policyId') + ->desc('Get project policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'getPolicy', + description: <<param('policyId', '', new WhiteList([ + 'password-dictionary', + 'password-history', + 'password-personal-data', + 'session-alert', + 'session-duration', + 'session-invalidation', + 'session-limit', + 'user-limit', + 'membership-privacy', + ], true), 'Policy ID. Can be one of: password-dictionary, password-history, password-personal-data, session-alert, session-duration, session-invalidation, session-limit, user-limit, membership-privacy.') + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $policyId, + Response $response, + Document $project, + ): void { + $auths = $project->getAttribute('auths', []); + + [$policy, $model] = match ($policyId) { + 'password-dictionary' => [ + new Document([ + '$id' => 'password-dictionary', + 'enabled' => $auths['passwordDictionary'] ?? false, + ]), + Response::MODEL_POLICY_PASSWORD_DICTIONARY, + ], + 'password-history' => [ + new Document([ + '$id' => 'password-history', + 'total' => $auths['passwordHistory'] ?? 0, + ]), + Response::MODEL_POLICY_PASSWORD_HISTORY, + ], + 'password-personal-data' => [ + new Document([ + '$id' => 'password-personal-data', + 'enabled' => $auths['personalDataCheck'] ?? false, + ]), + Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA, + ], + 'session-alert' => [ + new Document([ + '$id' => 'session-alert', + 'enabled' => $auths['sessionAlerts'] ?? false, + ]), + Response::MODEL_POLICY_SESSION_ALERT, + ], + 'session-duration' => [ + new Document([ + '$id' => 'session-duration', + 'duration' => $auths['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG, + ]), + Response::MODEL_POLICY_SESSION_DURATION, + ], + 'session-invalidation' => [ + new Document([ + '$id' => 'session-invalidation', + 'enabled' => $auths['invalidateSessions'] ?? true, + ]), + Response::MODEL_POLICY_SESSION_INVALIDATION, + ], + 'session-limit' => [ + new Document([ + '$id' => 'session-limit', + 'total' => $auths['maxSessions'] ?? 0, + ]), + Response::MODEL_POLICY_SESSION_LIMIT, + ], + 'user-limit' => [ + new Document([ + '$id' => 'user-limit', + 'total' => $auths['limit'] ?? 0, + ]), + Response::MODEL_POLICY_USER_LIMIT, + ], + 'membership-privacy' => [ + new Document([ + '$id' => 'membership-privacy', + 'userId' => $auths['membershipsUserId'] ?? false, + 'userEmail' => $auths['membershipsUserEmail'] ?? false, + 'userPhone' => $auths['membershipsUserPhone'] ?? false, + 'userName' => $auths['membershipsUserName'] ?? false, + 'userMFA' => $auths['membershipsMfa'] ?? false, + ]), + Response::MODEL_POLICY_MEMBERSHIP_PRIVACY, + ], + }; + + $response->dynamic($policy, $model); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 703359ea4e..64dad109f8 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -29,6 +29,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as Updat use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as CreateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\Get as GetPolicy; use Appwrite\Platform\Modules\Project\Http\Project\Policies\MembershipPrivacy\Update as UpdateMembershipPrivacyPolicy; use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordDictionary\Update as UpdatePasswordDictionaryPolicy; use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordHistory\Update as UpdatePasswordHistoryPolicy; @@ -115,6 +116,7 @@ class Http extends Service // Policies $this->addAction(ListPolicies::getName(), new ListPolicies()); + $this->addAction(GetPolicy::getName(), new GetPolicy()); $this->addAction(UpdateMembershipPrivacyPolicy::getName(), new UpdateMembershipPrivacyPolicy()); $this->addAction(UpdatePasswordDictionaryPolicy::getName(), new UpdatePasswordDictionaryPolicy()); $this->addAction(UpdatePasswordHistoryPolicy::getName(), new UpdatePasswordHistoryPolicy()); diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php index 7d532c98c1..04906c6c2b 100644 --- a/tests/e2e/Services/Project/PoliciesBase.php +++ b/tests/e2e/Services/Project/PoliciesBase.php @@ -7,6 +7,116 @@ use Utopia\Database\Query; trait PoliciesBase { + // ========================================================================= + // Get Policy + // ========================================================================= + + public function testGetPolicy(): void + { + $expectedFields = [ + 'password-dictionary' => ['enabled'], + 'password-history' => ['total'], + 'password-personal-data' => ['enabled'], + 'session-alert' => ['enabled'], + 'session-duration' => ['duration'], + 'session-invalidation' => ['enabled'], + 'session-limit' => ['total'], + 'user-limit' => ['total'], + 'membership-privacy' => ['userId', 'userEmail', 'userPhone', 'userName', 'userMFA'], + ]; + + foreach ($expectedFields as $policyId => $fields) { + $response = $this->getPolicy($policyId); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($policyId, $response['body']['$id']); + + foreach ($fields as $field) { + $this->assertArrayHasKey($field, $response['body']); + } + } + } + + public function testGetPolicyMatchesListPolicies(): void + { + $list = $this->listPolicies(); + + $this->assertSame(200, $list['headers']['status-code']); + + $byId = []; + foreach ($list['body']['policies'] as $policy) { + $byId[$policy['$id']] = $policy; + } + + foreach (\array_keys($byId) as $policyId) { + $response = $this->getPolicy($policyId); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($byId[$policyId], $response['body']); + } + } + + public function testGetPolicyReflectsUpdates(): void + { + $this->updatePasswordDictionaryPolicy(true); + $this->updatePasswordHistoryPolicy(5); + $this->updateSessionDurationPolicy(3600); + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => false, + 'userName' => true, + 'userMFA' => true, + ]); + + $passwordDictionary = $this->getPolicy('password-dictionary'); + $passwordHistory = $this->getPolicy('password-history'); + $sessionDuration = $this->getPolicy('session-duration'); + $membershipPrivacy = $this->getPolicy('membership-privacy'); + + $this->assertSame(200, $passwordDictionary['headers']['status-code']); + $this->assertSame(true, $passwordDictionary['body']['enabled']); + + $this->assertSame(200, $passwordHistory['headers']['status-code']); + $this->assertSame(5, $passwordHistory['body']['total']); + + $this->assertSame(200, $sessionDuration['headers']['status-code']); + $this->assertSame(3600, $sessionDuration['body']['duration']); + + $this->assertSame(200, $membershipPrivacy['headers']['status-code']); + $this->assertSame(true, $membershipPrivacy['body']['userId']); + $this->assertSame(true, $membershipPrivacy['body']['userEmail']); + $this->assertSame(false, $membershipPrivacy['body']['userPhone']); + $this->assertSame(true, $membershipPrivacy['body']['userName']); + $this->assertSame(true, $membershipPrivacy['body']['userMFA']); + + // Cleanup + $this->updatePasswordDictionaryPolicy(false); + $this->updatePasswordHistoryPolicy(null); + $this->updateSessionDurationPolicy(31536000); + $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + } + + public function testGetPolicyWithoutAuthentication(): void + { + $response = $this->getPolicy('password-dictionary', authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testGetPolicyInvalidPolicyId(): void + { + $response = $this->getPolicy('invalid-policy'); + + $this->assertSame(400, $response['headers']['status-code']); + } + // ========================================================================= // List Policies // ========================================================================= @@ -1002,6 +1112,11 @@ trait PoliciesBase return $this->client->call(Client::METHOD_GET, '/project/policies', $this->buildHeaders($authenticated), $params); } + protected function getPolicy(string $policyId, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_GET, '/project/policies/' . $policyId, $this->buildHeaders($authenticated)); + } + protected function updatePasswordDictionaryPolicy(bool $enabled, bool $authenticated = true): mixed { return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders($authenticated), [ diff --git a/tests/e2e/Services/Project/ProjectBase.php b/tests/e2e/Services/Project/ProjectBase.php new file mode 100644 index 0000000000..3caec392a5 --- /dev/null +++ b/tests/e2e/Services/Project/ProjectBase.php @@ -0,0 +1,7 @@ + 'http://localhost', - 'content-type' => 'application/json', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - 'x-appwrite-project' => 'console', - ]; - - $team = $this->client->call(Client::METHOD_POST, '/teams', $rootHeaders, [ - 'teamId' => ID::unique(), - 'name' => 'Delete Project Team', - ]); - - $this->assertSame(201, $team['headers']['status-code']); - - $project = $this->client->call(Client::METHOD_POST, '/projects', $rootHeaders, [ - 'projectId' => ID::unique(), - 'name' => 'Delete Project Test', - 'teamId' => $team['body']['$id'], - 'region' => System::getEnv('_APP_REGION', 'default'), - ]); - - $this->assertSame(201, $project['headers']['status-code']); - - $key = $this->client->call(Client::METHOD_POST, '/projects/' . $project['body']['$id'] . '/keys', $rootHeaders, [ - 'keyId' => ID::unique(), - 'name' => 'Delete Project Key', - 'scopes' => [ - 'project.read', - 'project.write', - ], - ]); - - $this->assertSame(201, $key['headers']['status-code']); - - return [ - 'projectId' => $project['body']['$id'], - 'apiKey' => $key['body']['secret'], - ]; - } - - #[Group('projectsCRUD')] - public function testDeleteProject(): void - { - $project = $this->createProjectForDeleteTest(); - - $headers = match ($this->getSide()) { - 'server' => [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $project['projectId'], - 'x-appwrite-key' => $project['apiKey'], - 'x-appwrite-mode' => 'admin', - ], - default => [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - 'x-appwrite-project' => $project['projectId'], - 'x-appwrite-mode' => 'admin', - ], - }; - - $response = $this->client->call(Client::METHOD_DELETE, '/project', $headers); - - $this->assertSame(204, $response['headers']['status-code']); - - $get = $this->client->call(Client::METHOD_GET, '/projects/' . $project['projectId'], [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - 'x-appwrite-project' => 'console', - ]); - - $this->assertSame(404, $get['headers']['status-code']); - } - /** * Setup and cache a basic project with team */ From 7a3c001452df542066ef23ee9b7c49f8fa94343a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 15:22:40 +0200 Subject: [PATCH 240/402] Re-add project removal tests --- .../Project/ProjectConsoleClientTest.php | 107 +++++++++++++++--- 1 file changed, 94 insertions(+), 13 deletions(-) diff --git a/tests/e2e/Services/Project/ProjectConsoleClientTest.php b/tests/e2e/Services/Project/ProjectConsoleClientTest.php index fecc1907f8..fd55d14e43 100644 --- a/tests/e2e/Services/Project/ProjectConsoleClientTest.php +++ b/tests/e2e/Services/Project/ProjectConsoleClientTest.php @@ -2,32 +2,113 @@ namespace Tests\E2E\Services\Project; +use Tests\E2E\Client; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideConsole; +use Utopia\Database\Helpers\ID; +use Utopia\System\System; class ProjectConsoleClientTest extends Scope { use ProjectBase; use ProjectCustom; use SideConsole; - + public function testDeleteProject(): void { - // TODO: - // 1. Create new team - // 2. Create new project - // 3. Delete project - // 4. Verify project is deleted + $team = $this->createTeam('Delete Project Team'); + $project = $this->createProject($team['body']['$id'], 'Delete Project'); + + $response = $this->client->call(Client::METHOD_DELETE, '/project', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project['body']['$id'], + ], $this->getHeaders())); + + $this->assertSame(204, $response['headers']['status-code']); + + $getProject = $this->getConsoleProject($project['body']['$id']); + + $this->assertSame(404, $getProject['headers']['status-code']); } - + public function testDeleteProjectUsingKey(): void { - // TODO: - // 1. Create new team - // 2. Create new project - // 3. Create new API key - // 4. Delete project using API key - // 5. Verify project is deleted + $team = $this->createTeam('Delete Project Key Team'); + $project = $this->createProject($team['body']['$id'], 'Delete Project Using Key'); + $apiKey = $this->createProjectKey($project['body']['$id'], ['project.write']); + + $response = $this->client->call(Client::METHOD_DELETE, '/project', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project['body']['$id'], + 'x-appwrite-key' => $apiKey, + ]); + + $this->assertSame(204, $response['headers']['status-code']); + + $getProject = $this->getConsoleProject($project['body']['$id']); + + $this->assertSame(404, $getProject['headers']['status-code']); + } + + protected function createTeam(string $name): array + { + $response = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => $name, + ]); + + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame($name, $response['body']['name']); + $this->assertNotEmpty($response['body']['$id']); + + return $response; + } + + protected function createProject(string $teamId, string $name): array + { + $response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'region' => System::getEnv('_APP_REGION', 'default'), + 'name' => $name, + 'teamId' => $teamId, + ]); + + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame($name, $response['body']['name']); + $this->assertNotEmpty($response['body']['$id']); + + return $response; + } + + protected function createProjectKey(string $projectId, array $scopes): string + { + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $projectId . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'keyId' => ID::unique(), + 'name' => 'Delete Project Key', + 'scopes' => $scopes, + ]); + + $this->assertSame(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['secret']); + + return $response['body']['secret']; + } + + protected function getConsoleProject(string $projectId): array + { + return $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); } } From 8c634a95e433374913d971b4926a54e3db38ad4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 15:28:10 +0200 Subject: [PATCH 241/402] Fix failing tests --- .../Project/ProjectConsoleClientTest.php | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/tests/e2e/Services/Project/ProjectConsoleClientTest.php b/tests/e2e/Services/Project/ProjectConsoleClientTest.php index fd55d14e43..0ba69c21b6 100644 --- a/tests/e2e/Services/Project/ProjectConsoleClientTest.php +++ b/tests/e2e/Services/Project/ProjectConsoleClientTest.php @@ -53,10 +53,7 @@ class ProjectConsoleClientTest extends Scope protected function createTeam(string $name): array { - $response = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ + $response = $this->client->call(Client::METHOD_POST, '/teams', $this->getConsoleSessionHeaders(), [ 'teamId' => ID::unique(), 'name' => $name, ]); @@ -70,10 +67,7 @@ class ProjectConsoleClientTest extends Scope protected function createProject(string $teamId, string $name): array { - $response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ + $response = $this->client->call(Client::METHOD_POST, '/projects', $this->getConsoleSessionHeaders(), [ 'projectId' => ID::unique(), 'region' => System::getEnv('_APP_REGION', 'default'), 'name' => $name, @@ -89,10 +83,7 @@ class ProjectConsoleClientTest extends Scope protected function createProjectKey(string $projectId, array $scopes): string { - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $projectId . '/keys', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $projectId . '/keys', $this->getConsoleSessionHeaders(), [ 'keyId' => ID::unique(), 'name' => 'Delete Project Key', 'scopes' => $scopes, @@ -106,9 +97,16 @@ class ProjectConsoleClientTest extends Scope protected function getConsoleProject(string $projectId): array { - return $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([ + return $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, $this->getConsoleSessionHeaders()); + } + + protected function getConsoleSessionHeaders(): array + { + return [ + 'origin' => 'http://localhost', 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ]; } } From 4b3963512cb153597f7a08a3a3907ec0813245ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 15:28:20 +0200 Subject: [PATCH 242/402] Linter fix --- tests/e2e/Services/Project/ProjectBase.php | 2 +- tests/e2e/Services/Projects/ProjectsBase.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/e2e/Services/Project/ProjectBase.php b/tests/e2e/Services/Project/ProjectBase.php index 3caec392a5..fa4d2ca7fa 100644 --- a/tests/e2e/Services/Project/ProjectBase.php +++ b/tests/e2e/Services/Project/ProjectBase.php @@ -4,4 +4,4 @@ namespace Tests\E2E\Services\Project; trait ProjectBase { -} \ No newline at end of file +} diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index 7c97c03ccc..ef83e65d95 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -2,7 +2,6 @@ namespace Tests\E2E\Services\Projects; -use PHPUnit\Framework\Attributes\Group; use Tests\E2E\Client; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Role; From 4de3009f67f06ce5f05b9d4c511fb978d575b982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 15:36:16 +0200 Subject: [PATCH 243/402] Fix analyser --- .../Platform/Modules/Project/Http/Project/Policies/Get.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php index 3d633cd2e4..3ffe30f1fa 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php @@ -144,6 +144,7 @@ class Get extends Action ]), Response::MODEL_POLICY_MEMBERSHIP_PRIVACY, ], + default => throw new \LogicException('Unknown policy ID: ' . $policyId), }; $response->dynamic($policy, $model); From 5beeca5a992b590c616d700f076cbf28c75c9e62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 15:57:09 +0200 Subject: [PATCH 244/402] Placeholder test --- tests/e2e/Services/Project/ProjectCustomServerTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/e2e/Services/Project/ProjectCustomServerTest.php b/tests/e2e/Services/Project/ProjectCustomServerTest.php index ccfd7ce549..0936b7b271 100644 --- a/tests/e2e/Services/Project/ProjectCustomServerTest.php +++ b/tests/e2e/Services/Project/ProjectCustomServerTest.php @@ -11,4 +11,11 @@ class ProjectCustomServerTest extends Scope use ProjectBase; use ProjectCustom; use SideServer; + + // Just a blank test so we dont have warning about empty test class + // You can remove this after adding some custom server tests, or some project base tests + public function testProjectServerLogic(): void + { + $this->assertTrue(true); + } } From e3231393b97d5d29e9e35720c187ba2b74f5d3f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 16:06:45 +0200 Subject: [PATCH 245/402] Fix anayser --- tests/e2e/Services/Project/ProjectCustomServerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Services/Project/ProjectCustomServerTest.php b/tests/e2e/Services/Project/ProjectCustomServerTest.php index 0936b7b271..a719d4b372 100644 --- a/tests/e2e/Services/Project/ProjectCustomServerTest.php +++ b/tests/e2e/Services/Project/ProjectCustomServerTest.php @@ -12,10 +12,10 @@ class ProjectCustomServerTest extends Scope use ProjectCustom; use SideServer; - // Just a blank test so we dont have warning about empty test class + // Placeholder until this scope has custom server-specific coverage. // You can remove this after adding some custom server tests, or some project base tests public function testProjectServerLogic(): void { - $this->assertTrue(true); + $this->expectNotToPerformAssertions(); } } From 7fbfb6266b9f69af7ac308c2b7510f0692c36d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 24 Apr 2026 10:56:39 +0200 Subject: [PATCH 246/402] GitHub oauth response model --- app/init/models.php | 2 + src/Appwrite/Utopia/Response.php | 1 + .../Utopia/Response/Model/OAuth2Base.php | 19 ++++++++ .../Utopia/Response/Model/OAuth2GitHub.php | 47 +++++++++++++++++++ 4 files changed, 69 insertions(+) create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Base.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php diff --git a/app/init/models.php b/app/init/models.php index b713d61cd2..ed3233e242 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -105,6 +105,7 @@ use Appwrite\Utopia\Response\Model\MigrationReport; use Appwrite\Utopia\Response\Model\Mock; use Appwrite\Utopia\Response\Model\MockNumber; use Appwrite\Utopia\Response\Model\None; +use Appwrite\Utopia\Response\Model\OAuth2GitHub; use Appwrite\Utopia\Response\Model\Phone; use Appwrite\Utopia\Response\Model\PlatformAndroid; use Appwrite\Utopia\Response\Model\PlatformApple; @@ -350,6 +351,7 @@ Response::setModel(new Webhook()); Response::setModel(new Key()); Response::setModel(new DevKey()); Response::setModel(new MockNumber()); +Response::setModel(new OAuth2GitHub()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); Response::setModel(new PolicyPasswordPersonalData()); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index c4e616ea12..5ca831ed31 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -278,6 +278,7 @@ class Response extends SwooleResponse public const MODEL_VCS = 'vcs'; public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; public const MODEL_EMAIL_TEMPLATE_LIST = 'emailTemplateList'; + public const MODEL_OAUTH2_GITHUB = 'oAuth2Github'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Base.php b/src/Appwrite/Utopia/Response/Model/OAuth2Base.php new file mode 100644 index 0000000000..f9972e9e50 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Base.php @@ -0,0 +1,19 @@ +addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'OAuth 2 provider is active and can be used to create sessions.', + 'default' => false, + 'example' => false, + ]); + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php b/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php new file mode 100644 index 0000000000..b3853b7cc2 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php @@ -0,0 +1,47 @@ +addRule('clientId', [ + 'type' => self::TYPE_STRING, + 'description' => 'GitHub OAuth 2 client ID. For GitHub Apps, use the "App ID" when both an App ID and client ID are available.', + 'default' => '', + 'example' => '123456', + ]) + ->addRule('clientSecret', [ + 'type' => self::TYPE_STRING, + 'description' => 'GitHub OAuth 2 client secret.', + 'default' => '', + 'example' => 'github-client-secret', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2GitHub'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_GITHUB; + } +} From 93f7a0d902ead4ffdfa19de3084daff37d59c35e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 24 Apr 2026 11:17:18 +0200 Subject: [PATCH 247/402] GitHub oauth endpoint --- app/config/roles.php | 2 + app/config/scopes/project.php | 8 + src/Appwrite/Auth/OAuth2.php | 7 + src/Appwrite/Auth/OAuth2/Github.php | 30 ++++ .../Http/Project/OAuth2/GitHub/Update.php | 144 ++++++++++++++++++ .../Modules/Project/Services/Http.php | 4 + src/Appwrite/Platform/Workers/Migrations.php | 2 + tests/benchmarks/http.js | 2 + tests/e2e/Scopes/ProjectCustom.php | 2 + 9 files changed, 201 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php diff --git a/app/config/roles.php b/app/config/roles.php index 33c7ffc9de..d653b4857c 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -55,6 +55,8 @@ $admins = [ 'tables.write', 'platforms.read', 'platforms.write', + 'oauth2.read', + 'oauth2.write', 'mocks.read', 'mocks.write', 'policies.read', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 592e032ba1..947cd863f8 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -228,4 +228,12 @@ return [ // List of publicly visible scopes "description" => "Access to create, update, and delete project\'s templates", ], + "oauth2.read" => [ + "description" => + "Access to read project\'s OAuth2 configuration", + ], + "oauth2.write" => [ + "description" => + "Access to update project\'s OAuth2 configuration", + ], ]; diff --git a/src/Appwrite/Auth/OAuth2.php b/src/Appwrite/Auth/OAuth2.php index a8a2d175b5..3861004498 100644 --- a/src/Appwrite/Auth/OAuth2.php +++ b/src/Appwrite/Auth/OAuth2.php @@ -50,6 +50,13 @@ abstract class OAuth2 $this->addScope($scope); } } + + /** + * Check if the OAuth credentials are valid + * + * @throws \Exception + */ + abstract public function verifyCredentials(): void; /** * @return string diff --git a/src/Appwrite/Auth/OAuth2/Github.php b/src/Appwrite/Auth/OAuth2/Github.php index 1cefc397c5..49d62aa022 100644 --- a/src/Appwrite/Auth/OAuth2/Github.php +++ b/src/Appwrite/Auth/OAuth2/Github.php @@ -1,6 +1,7 @@ addHeader('Accept', 'application/json'); + + $response = $client->fetch( + url: 'https://github.com/login/oauth/access_token', + method: FetchClient::METHOD_POST, + query: [ + 'client_id' => $this->appID, + 'client_secret' => $this->appSecret, + 'code' => 'intentionally-invalid-code', + 'redirect_uri' => 'intentionally-invalid-redirect', + ] + ); + + $json = \json_decode($response->getBody(), true); + + if (isset($json['error']) && $json['error'] === "Not Found") { + throw new \Exception('GitHub application with provided Client ID is does not exist.'); + } + + if (isset($json['error']) && $json['error'] === "incorrect_client_credentials") { + throw new \Exception('GitHub application with provided Client ID is valid, but the provided Client Secret is incorrect.'); + } + + // We still expect error, like redirect_uri_mismatch or bad_verification_code, + // but that indicates valid credentials + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php new file mode 100644 index 0000000000..ffdb2c78d0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php @@ -0,0 +1,144 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/github') + ->desc('Update project OAuth2 GitHub') + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.github.update') + ->label('audits.event', 'project.oauth2.github.update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: 'updateOAuth2GitHub', + description: <<param('clientId', null, new Nullable(new Text(256, 0)), 'Client ID of GitHub OAuth2 app, or App ID of GitHub generic app. For example: e4d87900000000540733', optional: true) + ->param('clientSecret', null, new Nullable(new Text(512, 0)), 'Client secret of GitHub OAuth2 app, or GitHub generic app. For example: 5e07c00000000000000000000000000000198bcc', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + ?string $clientId, + ?string $clientSecret, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $providerId = self::getProviderId(); + if(!(\in_array($providerId, \array_keys(Config::getParam('oAuthProviders'))))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Provider ' . $providerId . ' is not supported by server configuration.'); + } + + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + + $appIdKey = $providerId . 'Appid'; + $appSecretKey = $providerId . 'Secret'; + $enabledKey = $providerId . 'Enabled'; + + if (!\is_null($clientId)) { + $oAuthProviders[$appIdKey] = $clientId; + } + + if (!\is_null($clientSecret)) { + $oAuthProviders[$appSecretKey] = $clientSecret; + } + + if (!\is_null($enabled)) { + $oAuthProviders[$enabledKey] = $enabled; + } + + if($enabled === true || \is_null($enabled)) { + try { + if(empty($oAuthProviders[$appIdKey]) || empty($oAuthProviders[$appSecretKey])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Client ID and Client Secret are required when enabling OAuth2 provider.'); + } + + $providerClass = self::getProviderClass(); + $providerInstance = new $providerClass(appId: $oAuthProviders[$appIdKey], appSecret: $oAuthProviders[$appSecretKey], callback: '', state: [], scopes: []); + + $providerInstance->verifyCredentials(); + + $oAuthProviders[$enabledKey] = true; + } catch(\Throwable $err) { + if($enabled === true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Could not enable OAuth2 provider: ' . $err->getMessage()); + } + } + } + + $updates = new Document([ + 'oAuthProviders' => $oAuthProviders + ]); + + $project = $authorization->skip(fn() => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic(new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$enabledKey] ?? false, + 'clientId' => $oAuthProviders[$appIdKey] ?? '', + 'clientSecret' => $oAuthProviders[$appSecretKey] ?? '', + ]), Response::MODEL_OAUTH2_GITHUB); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 64dad109f8..b1441be304 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -17,6 +17,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Get as GetMockPhone use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Update as UpdateMockPhone; use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\XList as ListMockPhones; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Create as CreateAndroidPlatform; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\GitHub\Update as UpdateOAuth2GitHub; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Update as UpdateAndroidPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Apple\Create as CreateApplePlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Apple\Update as UpdateApplePlatform; @@ -129,5 +130,8 @@ class Http extends Service // Auth Methods $this->addAction(UpdateAuthMethod::getName(), new UpdateAuthMethod()); + + // OAuth2 + $this->addAction(UpdateOAuth2GitHub::getName(), new UpdateOAuth2GitHub()); } } diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index cfe8d2d567..fa2ed5883f 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -391,6 +391,8 @@ class Migrations extends Action 'keys.write', 'platforms.read', 'platforms.write', + 'oauth2.read', + 'oauth2.write', 'mocks.read', 'mocks.write', 'policies.read', diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 4009024069..6466ffd361 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -90,6 +90,8 @@ const API_SCOPES = [ 'tokens.write', 'platforms.read', 'platforms.write', + 'oauth2.read', + 'oauth2.write', ]; const BASE_PERMISSIONS = [ diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index f531ed774d..31d85524af 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -169,6 +169,8 @@ trait ProjectCustom 'keys.write', 'platforms.read', 'platforms.write', + 'oauth2.read', + 'oauth2.write', 'mocks.read', 'mocks.write', 'policies.read', From 36435d940dca6147634b35e153bbd4bdd513cdac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 24 Apr 2026 11:35:30 +0200 Subject: [PATCH 248/402] Add Discord OAuth endpoint --- app/init/models.php | 2 + src/Appwrite/Auth/OAuth2/Discord.php | 5 + .../Http/Project/OAuth2/Discord/Update.php | 144 ++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + .../Utopia/Response/Model/OAuth2Discord.php | 47 ++++++ 6 files changed, 201 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Discord.php diff --git a/app/init/models.php b/app/init/models.php index ed3233e242..9b31d17171 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -105,6 +105,7 @@ use Appwrite\Utopia\Response\Model\MigrationReport; use Appwrite\Utopia\Response\Model\Mock; use Appwrite\Utopia\Response\Model\MockNumber; use Appwrite\Utopia\Response\Model\None; +use Appwrite\Utopia\Response\Model\OAuth2Discord; use Appwrite\Utopia\Response\Model\OAuth2GitHub; use Appwrite\Utopia\Response\Model\Phone; use Appwrite\Utopia\Response\Model\PlatformAndroid; @@ -352,6 +353,7 @@ Response::setModel(new Key()); Response::setModel(new DevKey()); Response::setModel(new MockNumber()); Response::setModel(new OAuth2GitHub()); +Response::setModel(new OAuth2Discord()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); Response::setModel(new PolicyPasswordPersonalData()); diff --git a/src/Appwrite/Auth/OAuth2/Discord.php b/src/Appwrite/Auth/OAuth2/Discord.php index a5ecdb5e3c..ede5ce36c2 100644 --- a/src/Appwrite/Auth/OAuth2/Discord.php +++ b/src/Appwrite/Auth/OAuth2/Discord.php @@ -1,6 +1,7 @@ user; } + + public function verifyCredentials(): void { + // TODO: Implement, eventuelly. Refer to GitHub.php in this directory for inspiration + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php new file mode 100644 index 0000000000..091cc41637 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php @@ -0,0 +1,144 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/discord') + ->desc('Update project OAuth2 Discord') + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.discord.update') + ->label('audits.event', 'project.oauth2.discord.update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: 'updateOAuth2Discord', + description: <<param('clientId', null, new Nullable(new Text(256, 0)), 'Client ID of Discord OAuth2 app. For example: 950722000000343754', optional: true) + ->param('clientSecret', null, new Nullable(new Text(512, 0)), 'Client Secret of Discord OAuth2 app. For example: YmPXnM000000000000000000002zFg5D', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + ?string $clientId, + ?string $clientSecret, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $providerId = self::getProviderId(); + if(!(\in_array($providerId, \array_keys(Config::getParam('oAuthProviders'))))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Provider ' . $providerId . ' is not supported by server configuration.'); + } + + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + + $appIdKey = $providerId . 'Appid'; + $appSecretKey = $providerId . 'Secret'; + $enabledKey = $providerId . 'Enabled'; + + if (!\is_null($clientId)) { + $oAuthProviders[$appIdKey] = $clientId; + } + + if (!\is_null($clientSecret)) { + $oAuthProviders[$appSecretKey] = $clientSecret; + } + + if (!\is_null($enabled)) { + $oAuthProviders[$enabledKey] = $enabled; + } + + if($enabled === true || \is_null($enabled)) { + try { + if(empty($oAuthProviders[$appIdKey]) || empty($oAuthProviders[$appSecretKey])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Client ID and Client Secret are required when enabling OAuth2 provider.'); + } + + $providerClass = self::getProviderClass(); + $providerInstance = new $providerClass(appId: $oAuthProviders[$appIdKey], appSecret: $oAuthProviders[$appSecretKey], callback: '', state: [], scopes: []); + + $providerInstance->verifyCredentials(); + + $oAuthProviders[$enabledKey] = true; + } catch(\Throwable $err) { + if($enabled === true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Could not enable OAuth2 provider: ' . $err->getMessage()); + } + } + } + + $updates = new Document([ + 'oAuthProviders' => $oAuthProviders + ]); + + $project = $authorization->skip(fn() => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic(new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$enabledKey] ?? false, + 'clientId' => $oAuthProviders[$appIdKey] ?? '', + 'clientSecret' => $oAuthProviders[$appSecretKey] ?? '', + ]), Response::MODEL_OAUTH2_DISCORD); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index b1441be304..f69c9fa0ef 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -16,6 +16,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Delete as DeleteMoc use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Get as GetMockPhone; use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Update as UpdateMockPhone; use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\XList as ListMockPhones; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Discord\Update as UpdateOAuth2Discord; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Create as CreateAndroidPlatform; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\GitHub\Update as UpdateOAuth2GitHub; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Update as UpdateAndroidPlatform; @@ -133,5 +134,6 @@ class Http extends Service // OAuth2 $this->addAction(UpdateOAuth2GitHub::getName(), new UpdateOAuth2GitHub()); + $this->addAction(UpdateOAuth2Discord::getName(), new UpdateOAuth2Discord()); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 5ca831ed31..2eb774a630 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -279,6 +279,7 @@ class Response extends SwooleResponse public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; public const MODEL_EMAIL_TEMPLATE_LIST = 'emailTemplateList'; public const MODEL_OAUTH2_GITHUB = 'oAuth2Github'; + public const MODEL_OAUTH2_DISCORD = 'oAuth2Discord'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php b/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php new file mode 100644 index 0000000000..cd2b0b74e2 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php @@ -0,0 +1,47 @@ +addRule('clientId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Discord OAuth 2 client ID.', + 'default' => '', + 'example' => '950722000000343754', + ]) + ->addRule('clientSecret', [ + 'type' => self::TYPE_STRING, + 'description' => 'Discord OAuth 2 client secret.', + 'default' => '', + 'example' => 'YmPXnM000000000000000000002zFg5D', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Discord'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_DISCORD; + } +} From 5fbe6cba79b3eee3f3f8663db3f045c426af227a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 24 Apr 2026 11:39:14 +0200 Subject: [PATCH 249/402] Improve github samples --- src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php b/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php index b3853b7cc2..27b529aedd 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php @@ -15,13 +15,13 @@ class OAuth2GitHub extends OAuth2Base 'type' => self::TYPE_STRING, 'description' => 'GitHub OAuth 2 client ID. For GitHub Apps, use the "App ID" when both an App ID and client ID are available.', 'default' => '', - 'example' => '123456', + 'example' => 'e4d87900000000540733', ]) ->addRule('clientSecret', [ 'type' => self::TYPE_STRING, 'description' => 'GitHub OAuth 2 client secret.', 'default' => '', - 'example' => 'github-client-secret', + 'example' => '5e07c00000000000000000000000000000198bcc', ]); } From 335b1c2f6ccea1d7f3ba700e666faecca1344748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 24 Apr 2026 11:45:59 +0200 Subject: [PATCH 250/402] Figma OAuth endpoint --- app/init/models.php | 2 + src/Appwrite/Auth/OAuth2/Figma.php | 4 + .../Http/Project/OAuth2/Figma/Update.php | 144 ++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + .../Utopia/Response/Model/OAuth2Figma.php | 47 ++++++ 6 files changed, 200 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Figma.php diff --git a/app/init/models.php b/app/init/models.php index 9b31d17171..46e758d5b2 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -106,6 +106,7 @@ use Appwrite\Utopia\Response\Model\Mock; use Appwrite\Utopia\Response\Model\MockNumber; use Appwrite\Utopia\Response\Model\None; use Appwrite\Utopia\Response\Model\OAuth2Discord; +use Appwrite\Utopia\Response\Model\OAuth2Figma; use Appwrite\Utopia\Response\Model\OAuth2GitHub; use Appwrite\Utopia\Response\Model\Phone; use Appwrite\Utopia\Response\Model\PlatformAndroid; @@ -354,6 +355,7 @@ Response::setModel(new DevKey()); Response::setModel(new MockNumber()); Response::setModel(new OAuth2GitHub()); Response::setModel(new OAuth2Discord()); +Response::setModel(new OAuth2Figma()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); Response::setModel(new PolicyPasswordPersonalData()); diff --git a/src/Appwrite/Auth/OAuth2/Figma.php b/src/Appwrite/Auth/OAuth2/Figma.php index b5e53cbed4..b6ce166e6b 100644 --- a/src/Appwrite/Auth/OAuth2/Figma.php +++ b/src/Appwrite/Auth/OAuth2/Figma.php @@ -175,4 +175,8 @@ class Figma extends OAuth2 return $this->user; } + + public function verifyCredentials(): void { + // TODO: Implement, eventuelly. Refer to GitHub.php in this directory for inspiration + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php new file mode 100644 index 0000000000..34ec34be9d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php @@ -0,0 +1,144 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/figma') + ->desc('Update project OAuth2 Figma') + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.figma.update') + ->label('audits.event', 'project.oauth2.figma.update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: 'updateOAuth2Figma', + description: <<param('clientId', null, new Nullable(new Text(256, 0)), 'Client ID of Figma OAuth2 app. For example: byay5H0000000000VtiI40', optional: true) + ->param('clientSecret', null, new Nullable(new Text(512, 0)), 'Client Secret of Figma OAuth2 app. For example: yEpOYn0000000000000000004iIsU5', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + ?string $clientId, + ?string $clientSecret, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $providerId = self::getProviderId(); + if(!(\in_array($providerId, \array_keys(Config::getParam('oAuthProviders'))))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Provider ' . $providerId . ' is not supported by server configuration.'); + } + + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + + $appIdKey = $providerId . 'Appid'; + $appSecretKey = $providerId . 'Secret'; + $enabledKey = $providerId . 'Enabled'; + + if (!\is_null($clientId)) { + $oAuthProviders[$appIdKey] = $clientId; + } + + if (!\is_null($clientSecret)) { + $oAuthProviders[$appSecretKey] = $clientSecret; + } + + if (!\is_null($enabled)) { + $oAuthProviders[$enabledKey] = $enabled; + } + + if($enabled === true || \is_null($enabled)) { + try { + if(empty($oAuthProviders[$appIdKey]) || empty($oAuthProviders[$appSecretKey])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Client ID and Client Secret are required when enabling OAuth2 provider.'); + } + + $providerClass = self::getProviderClass(); + $providerInstance = new $providerClass(appId: $oAuthProviders[$appIdKey], appSecret: $oAuthProviders[$appSecretKey], callback: '', state: [], scopes: []); + + $providerInstance->verifyCredentials(); + + $oAuthProviders[$enabledKey] = true; + } catch(\Throwable $err) { + if($enabled === true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Could not enable OAuth2 provider: ' . $err->getMessage()); + } + } + } + + $updates = new Document([ + 'oAuthProviders' => $oAuthProviders + ]); + + $project = $authorization->skip(fn() => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic(new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$enabledKey] ?? false, + 'clientId' => $oAuthProviders[$appIdKey] ?? '', + 'clientSecret' => $oAuthProviders[$appSecretKey] ?? '', + ]), Response::MODEL_OAUTH2_FIGMA); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index f69c9fa0ef..8d1de316a4 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -17,6 +17,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Get as GetMockPhone use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Update as UpdateMockPhone; use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\XList as ListMockPhones; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Discord\Update as UpdateOAuth2Discord; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Figma\Update as UpdateOAuth2Figma; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Create as CreateAndroidPlatform; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\GitHub\Update as UpdateOAuth2GitHub; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Update as UpdateAndroidPlatform; @@ -135,5 +136,6 @@ class Http extends Service // OAuth2 $this->addAction(UpdateOAuth2GitHub::getName(), new UpdateOAuth2GitHub()); $this->addAction(UpdateOAuth2Discord::getName(), new UpdateOAuth2Discord()); + $this->addAction(UpdateOAuth2Figma::getName(), new UpdateOAuth2Figma()); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 2eb774a630..820ec8f75f 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -280,6 +280,7 @@ class Response extends SwooleResponse public const MODEL_EMAIL_TEMPLATE_LIST = 'emailTemplateList'; public const MODEL_OAUTH2_GITHUB = 'oAuth2Github'; public const MODEL_OAUTH2_DISCORD = 'oAuth2Discord'; + public const MODEL_OAUTH2_FIGMA = 'oAuth2Figma'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php b/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php new file mode 100644 index 0000000000..2ee60adaa8 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php @@ -0,0 +1,47 @@ +addRule('clientId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Figma OAuth 2 client ID.', + 'default' => '', + 'example' => 'byay5H0000000000VtiI40', + ]) + ->addRule('clientSecret', [ + 'type' => self::TYPE_STRING, + 'description' => 'Figma OAuth 2 client secret.', + 'default' => '', + 'example' => 'yEpOYn0000000000000000004iIsU5', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Figma'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_FIGMA; + } +} From dac184b281fd01b908edb6859bb84c7fee7f2a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 24 Apr 2026 12:06:58 +0200 Subject: [PATCH 251/402] abstract oauth adapters --- src/Appwrite/Auth/OAuth2.php | 7 - src/Appwrite/Auth/OAuth2/Discord.php | 4 - src/Appwrite/Auth/OAuth2/Figma.php | 4 - .../Project/Http/Project/OAuth2/Base.php | 175 ++++++++++++++++++ .../Http/Project/OAuth2/Discord/Update.php | 134 ++------------ .../Http/Project/OAuth2/Figma/Update.php | 134 ++------------ .../Http/Project/OAuth2/GitHub/Update.php | 136 ++------------ 7 files changed, 221 insertions(+), 373 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php diff --git a/src/Appwrite/Auth/OAuth2.php b/src/Appwrite/Auth/OAuth2.php index 3861004498..a8a2d175b5 100644 --- a/src/Appwrite/Auth/OAuth2.php +++ b/src/Appwrite/Auth/OAuth2.php @@ -50,13 +50,6 @@ abstract class OAuth2 $this->addScope($scope); } } - - /** - * Check if the OAuth credentials are valid - * - * @throws \Exception - */ - abstract public function verifyCredentials(): void; /** * @return string diff --git a/src/Appwrite/Auth/OAuth2/Discord.php b/src/Appwrite/Auth/OAuth2/Discord.php index ede5ce36c2..6cb682479a 100644 --- a/src/Appwrite/Auth/OAuth2/Discord.php +++ b/src/Appwrite/Auth/OAuth2/Discord.php @@ -184,8 +184,4 @@ class Discord extends OAuth2 return $this->user; } - - public function verifyCredentials(): void { - // TODO: Implement, eventuelly. Refer to GitHub.php in this directory for inspiration - } } diff --git a/src/Appwrite/Auth/OAuth2/Figma.php b/src/Appwrite/Auth/OAuth2/Figma.php index b6ce166e6b..b5e53cbed4 100644 --- a/src/Appwrite/Auth/OAuth2/Figma.php +++ b/src/Appwrite/Auth/OAuth2/Figma.php @@ -175,8 +175,4 @@ class Figma extends OAuth2 return $this->user; } - - public function verifyCredentials(): void { - // TODO: Implement, eventuelly. Refer to GitHub.php in this directory for inspiration - } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php new file mode 100644 index 0000000000..e2cc405a59 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php @@ -0,0 +1,175 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.' . $providerId . '.update') + ->label('audits.event', 'project.oauth2.' . $providerId . '.update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: 'updateOAuth2' . $providerLabel, + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param('clientId', null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param('clientSecret', null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + ?string $clientId, + ?string $clientSecret, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $providerId = static::getProviderId(); + if(!(\in_array($providerId, \array_keys(Config::getParam('oAuthProviders'))))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Provider ' . $providerId . ' is not supported by server configuration.'); + } + + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + + $appIdKey = $providerId . 'Appid'; + $appSecretKey = $providerId . 'Secret'; + $enabledKey = $providerId . 'Enabled'; + + if (!\is_null($clientId)) { + $oAuthProviders[$appIdKey] = $clientId; + } + + if (!\is_null($clientSecret)) { + $oAuthProviders[$appSecretKey] = $clientSecret; + } + + if (!\is_null($enabled)) { + $oAuthProviders[$enabledKey] = $enabled; + } + + if($enabled === true || \is_null($enabled)) { + try { + if(empty($oAuthProviders[$appIdKey]) || empty($oAuthProviders[$appSecretKey])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Client ID and Client Secret are required when enabling OAuth2 provider.'); + } + + $providerClass = static::getProviderClass(); + $providerInstance = new $providerClass(appId: $oAuthProviders[$appIdKey], appSecret: $oAuthProviders[$appSecretKey], callback: '', state: [], scopes: []); + + // E2E integration check + if(\method_exists($providerInstance,'verifyCredentials')) { + $providerInstance->verifyCredentials(); + } + + $oAuthProviders[$enabledKey] = true; + } catch(\Throwable $err) { + if($enabled === true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Could not enable OAuth2 provider: ' . $err->getMessage()); + } + } + } + + $updates = new Document([ + 'oAuthProviders' => $oAuthProviders + ]); + + $project = $authorization->skip(fn() => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic(new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$enabledKey] ?? false, + 'clientId' => $oAuthProviders[$appIdKey] ?? '', + 'clientSecret' => $oAuthProviders[$appSecretKey] ?? '', + ]), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php index 091cc41637..383aee12d6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php @@ -3,142 +3,38 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Discord; use Appwrite\Auth\OAuth2\Discord; -use Appwrite\Extend\Exception; -use Appwrite\Platform\Action; -use Appwrite\SDK\AuthType; -use Appwrite\SDK\Method; -use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base; use Appwrite\Utopia\Response; -use Utopia\Config\Config; -use Utopia\Database\Database; -use Utopia\Database\Document; -use Utopia\Database\Validator\Authorization; -use Utopia\Platform\Scope\HTTP; -use Utopia\Validator\ArrayList; -use Utopia\Validator\Boolean; -use Utopia\Validator\Nullable; -use Utopia\Validator\Text; -class Update extends Action +class Update extends Base { - use HTTP; - - public static function getName() - { - return 'updateProjectOAuth2Discord'; - } - public static function getProviderId(): string { return 'discord'; } - /** - * @return class-string - */ public static function getProviderClass(): string { return Discord::class; } - public function __construct() + public static function getProviderLabel(): string { - $this - ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/oauth2/discord') - ->desc('Update project OAuth2 Discord') - ->groups(['api', 'project']) - ->label('scope', 'oauth2.write') - ->label('event', 'oauth2.discord.update') - ->label('audits.event', 'project.oauth2.discord.update') - ->label('audits.resource', 'project.oauth2/{response.$id}') - ->label('sdk', new Method( - namespace: 'project', - group: 'oauth2', - name: 'updateOAuth2Discord', - description: <<param('clientId', null, new Nullable(new Text(256, 0)), 'Client ID of Discord OAuth2 app. For example: 950722000000343754', optional: true) - ->param('clientSecret', null, new Nullable(new Text(512, 0)), 'Client Secret of Discord OAuth2 app. For example: YmPXnM000000000000000000002zFg5D', optional: true) - ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('project') - ->inject('authorization') - ->callback($this->action(...)); + return 'Discord'; } - public function action( - ?string $clientId, - ?string $clientSecret, - ?bool $enabled, - Response $response, - Database $dbForPlatform, - Document $project, - Authorization $authorization - ): void { - $providerId = self::getProviderId(); - if(!(\in_array($providerId, \array_keys(Config::getParam('oAuthProviders'))))) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Provider ' . $providerId . ' is not supported by server configuration.'); - } + public static function getResponseModel(): string + { + return Response::MODEL_OAUTH2_DISCORD; + } - $oAuthProviders = $project->getAttribute('oAuthProviders', []); + public static function getClientIdDescription(): string + { + return 'Client ID of Discord OAuth2 app. For example: 950722000000343754'; + } - $appIdKey = $providerId . 'Appid'; - $appSecretKey = $providerId . 'Secret'; - $enabledKey = $providerId . 'Enabled'; - - if (!\is_null($clientId)) { - $oAuthProviders[$appIdKey] = $clientId; - } - - if (!\is_null($clientSecret)) { - $oAuthProviders[$appSecretKey] = $clientSecret; - } - - if (!\is_null($enabled)) { - $oAuthProviders[$enabledKey] = $enabled; - } - - if($enabled === true || \is_null($enabled)) { - try { - if(empty($oAuthProviders[$appIdKey]) || empty($oAuthProviders[$appSecretKey])) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Client ID and Client Secret are required when enabling OAuth2 provider.'); - } - - $providerClass = self::getProviderClass(); - $providerInstance = new $providerClass(appId: $oAuthProviders[$appIdKey], appSecret: $oAuthProviders[$appSecretKey], callback: '', state: [], scopes: []); - - $providerInstance->verifyCredentials(); - - $oAuthProviders[$enabledKey] = true; - } catch(\Throwable $err) { - if($enabled === true) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Could not enable OAuth2 provider: ' . $err->getMessage()); - } - } - } - - $updates = new Document([ - 'oAuthProviders' => $oAuthProviders - ]); - - $project = $authorization->skip(fn() => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - - $response->dynamic(new Document([ - '$id' => $providerId, - 'enabled' => $oAuthProviders[$enabledKey] ?? false, - 'clientId' => $oAuthProviders[$appIdKey] ?? '', - 'clientSecret' => $oAuthProviders[$appSecretKey] ?? '', - ]), Response::MODEL_OAUTH2_DISCORD); + public static function getClientSecretDescription(): string + { + return 'Client Secret of Discord OAuth2 app. For example: YmPXnM000000000000000000002zFg5D'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php index 34ec34be9d..c19b9fb30f 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php @@ -3,142 +3,38 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Figma; use Appwrite\Auth\OAuth2\Figma; -use Appwrite\Extend\Exception; -use Appwrite\Platform\Action; -use Appwrite\SDK\AuthType; -use Appwrite\SDK\Method; -use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base; use Appwrite\Utopia\Response; -use Utopia\Config\Config; -use Utopia\Database\Database; -use Utopia\Database\Document; -use Utopia\Database\Validator\Authorization; -use Utopia\Platform\Scope\HTTP; -use Utopia\Validator\ArrayList; -use Utopia\Validator\Boolean; -use Utopia\Validator\Nullable; -use Utopia\Validator\Text; -class Update extends Action +class Update extends Base { - use HTTP; - - public static function getName() - { - return 'updateProjectOAuth2Figma'; - } - public static function getProviderId(): string { return 'figma'; } - /** - * @return class-string - */ public static function getProviderClass(): string { return Figma::class; } - public function __construct() + public static function getProviderLabel(): string { - $this - ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/oauth2/figma') - ->desc('Update project OAuth2 Figma') - ->groups(['api', 'project']) - ->label('scope', 'oauth2.write') - ->label('event', 'oauth2.figma.update') - ->label('audits.event', 'project.oauth2.figma.update') - ->label('audits.resource', 'project.oauth2/{response.$id}') - ->label('sdk', new Method( - namespace: 'project', - group: 'oauth2', - name: 'updateOAuth2Figma', - description: <<param('clientId', null, new Nullable(new Text(256, 0)), 'Client ID of Figma OAuth2 app. For example: byay5H0000000000VtiI40', optional: true) - ->param('clientSecret', null, new Nullable(new Text(512, 0)), 'Client Secret of Figma OAuth2 app. For example: yEpOYn0000000000000000004iIsU5', optional: true) - ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('project') - ->inject('authorization') - ->callback($this->action(...)); + return 'Figma'; } - public function action( - ?string $clientId, - ?string $clientSecret, - ?bool $enabled, - Response $response, - Database $dbForPlatform, - Document $project, - Authorization $authorization - ): void { - $providerId = self::getProviderId(); - if(!(\in_array($providerId, \array_keys(Config::getParam('oAuthProviders'))))) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Provider ' . $providerId . ' is not supported by server configuration.'); - } + public static function getResponseModel(): string + { + return Response::MODEL_OAUTH2_FIGMA; + } - $oAuthProviders = $project->getAttribute('oAuthProviders', []); + public static function getClientIdDescription(): string + { + return 'Client ID of Figma OAuth2 app. For example: byay5H0000000000VtiI40'; + } - $appIdKey = $providerId . 'Appid'; - $appSecretKey = $providerId . 'Secret'; - $enabledKey = $providerId . 'Enabled'; - - if (!\is_null($clientId)) { - $oAuthProviders[$appIdKey] = $clientId; - } - - if (!\is_null($clientSecret)) { - $oAuthProviders[$appSecretKey] = $clientSecret; - } - - if (!\is_null($enabled)) { - $oAuthProviders[$enabledKey] = $enabled; - } - - if($enabled === true || \is_null($enabled)) { - try { - if(empty($oAuthProviders[$appIdKey]) || empty($oAuthProviders[$appSecretKey])) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Client ID and Client Secret are required when enabling OAuth2 provider.'); - } - - $providerClass = self::getProviderClass(); - $providerInstance = new $providerClass(appId: $oAuthProviders[$appIdKey], appSecret: $oAuthProviders[$appSecretKey], callback: '', state: [], scopes: []); - - $providerInstance->verifyCredentials(); - - $oAuthProviders[$enabledKey] = true; - } catch(\Throwable $err) { - if($enabled === true) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Could not enable OAuth2 provider: ' . $err->getMessage()); - } - } - } - - $updates = new Document([ - 'oAuthProviders' => $oAuthProviders - ]); - - $project = $authorization->skip(fn() => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - - $response->dynamic(new Document([ - '$id' => $providerId, - 'enabled' => $oAuthProviders[$enabledKey] ?? false, - 'clientId' => $oAuthProviders[$appIdKey] ?? '', - 'clientSecret' => $oAuthProviders[$appSecretKey] ?? '', - ]), Response::MODEL_OAUTH2_FIGMA); + public static function getClientSecretDescription(): string + { + return 'Client Secret of Figma OAuth2 app. For example: yEpOYn0000000000000000004iIsU5'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php index ffdb2c78d0..4490fa90cd 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php @@ -3,142 +3,38 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\GitHub; use Appwrite\Auth\OAuth2\Github; -use Appwrite\Extend\Exception; -use Appwrite\Platform\Action; -use Appwrite\SDK\AuthType; -use Appwrite\SDK\Method; -use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base; use Appwrite\Utopia\Response; -use Utopia\Config\Config; -use Utopia\Database\Database; -use Utopia\Database\Document; -use Utopia\Database\Validator\Authorization; -use Utopia\Platform\Scope\HTTP; -use Utopia\Validator\ArrayList; -use Utopia\Validator\Boolean; -use Utopia\Validator\Nullable; -use Utopia\Validator\Text; -class Update extends Action +class Update extends Base { - use HTTP; - - public static function getName() - { - return 'updateProjectOAuth2GitHub'; - } - public static function getProviderId(): string { return 'github'; } - - /** - * @return class-string - */ + public static function getProviderClass(): string { return Github::class; } - public function __construct() + public static function getProviderLabel(): string { - $this - ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/oauth2/github') - ->desc('Update project OAuth2 GitHub') - ->groups(['api', 'project']) - ->label('scope', 'oauth2.write') - ->label('event', 'oauth2.github.update') - ->label('audits.event', 'project.oauth2.github.update') - ->label('audits.resource', 'project.oauth2/{response.$id}') - ->label('sdk', new Method( - namespace: 'project', - group: 'oauth2', - name: 'updateOAuth2GitHub', - description: <<param('clientId', null, new Nullable(new Text(256, 0)), 'Client ID of GitHub OAuth2 app, or App ID of GitHub generic app. For example: e4d87900000000540733', optional: true) - ->param('clientSecret', null, new Nullable(new Text(512, 0)), 'Client secret of GitHub OAuth2 app, or GitHub generic app. For example: 5e07c00000000000000000000000000000198bcc', optional: true) - ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('project') - ->inject('authorization') - ->callback($this->action(...)); + return 'GitHub'; } - public function action( - ?string $clientId, - ?string $clientSecret, - ?bool $enabled, - Response $response, - Database $dbForPlatform, - Document $project, - Authorization $authorization - ): void { - $providerId = self::getProviderId(); - if(!(\in_array($providerId, \array_keys(Config::getParam('oAuthProviders'))))) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Provider ' . $providerId . ' is not supported by server configuration.'); - } - - $oAuthProviders = $project->getAttribute('oAuthProviders', []); - - $appIdKey = $providerId . 'Appid'; - $appSecretKey = $providerId . 'Secret'; - $enabledKey = $providerId . 'Enabled'; + public static function getResponseModel(): string + { + return Response::MODEL_OAUTH2_GITHUB; + } - if (!\is_null($clientId)) { - $oAuthProviders[$appIdKey] = $clientId; - } - - if (!\is_null($clientSecret)) { - $oAuthProviders[$appSecretKey] = $clientSecret; - } + public static function getClientIdDescription(): string + { + return 'Client ID of GitHub OAuth2 app, or App ID of GitHub generic app. For example: e4d87900000000540733'; + } - if (!\is_null($enabled)) { - $oAuthProviders[$enabledKey] = $enabled; - } - - if($enabled === true || \is_null($enabled)) { - try { - if(empty($oAuthProviders[$appIdKey]) || empty($oAuthProviders[$appSecretKey])) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Client ID and Client Secret are required when enabling OAuth2 provider.'); - } - - $providerClass = self::getProviderClass(); - $providerInstance = new $providerClass(appId: $oAuthProviders[$appIdKey], appSecret: $oAuthProviders[$appSecretKey], callback: '', state: [], scopes: []); - - $providerInstance->verifyCredentials(); - - $oAuthProviders[$enabledKey] = true; - } catch(\Throwable $err) { - if($enabled === true) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Could not enable OAuth2 provider: ' . $err->getMessage()); - } - } - } - - $updates = new Document([ - 'oAuthProviders' => $oAuthProviders - ]); - - $project = $authorization->skip(fn() => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - - $response->dynamic(new Document([ - '$id' => $providerId, - 'enabled' => $oAuthProviders[$enabledKey] ?? false, - 'clientId' => $oAuthProviders[$appIdKey] ?? '', - 'clientSecret' => $oAuthProviders[$appSecretKey] ?? '', - ]), Response::MODEL_OAUTH2_GITHUB); + public static function getClientSecretDescription(): string + { + return 'Client secret of GitHub OAuth2 app, or GitHub generic app. For example: 5e07c00000000000000000000000000000198bcc'; } } From c097d9fcdd7fb70d57750cfede5b1028e5e45c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 24 Apr 2026 12:20:48 +0200 Subject: [PATCH 252/402] Dropbox adapter --- app/init/models.php | 2 + .../Project/Http/Project/OAuth2/Base.php | 33 ++++++++++-- .../Http/Project/OAuth2/Dropbox/Update.php | 50 +++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + .../Utopia/Response/Model/OAuth2Dropbox.php | 47 +++++++++++++++++ 6 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php diff --git a/app/init/models.php b/app/init/models.php index 46e758d5b2..5c2910786d 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -106,6 +106,7 @@ use Appwrite\Utopia\Response\Model\Mock; use Appwrite\Utopia\Response\Model\MockNumber; use Appwrite\Utopia\Response\Model\None; use Appwrite\Utopia\Response\Model\OAuth2Discord; +use Appwrite\Utopia\Response\Model\OAuth2Dropbox; use Appwrite\Utopia\Response\Model\OAuth2Figma; use Appwrite\Utopia\Response\Model\OAuth2GitHub; use Appwrite\Utopia\Response\Model\Phone; @@ -356,6 +357,7 @@ Response::setModel(new MockNumber()); Response::setModel(new OAuth2GitHub()); Response::setModel(new OAuth2Discord()); Response::setModel(new OAuth2Figma()); +Response::setModel(new OAuth2Dropbox()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); Response::setModel(new PolicyPasswordPersonalData()); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php index e2cc405a59..b40b0f06e8 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php @@ -63,6 +63,31 @@ abstract class Base extends Action */ abstract public static function getClientSecretDescription(): string; + /** + * Public-facing name of the clientId param. Some providers use a different + * terminology (e.g. Dropbox calls it "App key"), so the param name and the + * corresponding response field can be customized by overriding this method. + * + * @return string e.g. 'clientId' (default), 'appKey' + */ + public static function getClientIdParamName(): string + { + return 'clientId'; + } + + /** + * Public-facing name of the clientSecret param. Some providers use a + * different terminology (e.g. Dropbox calls it "App secret"), so the param + * name and the corresponding response field can be customized by + * overriding this method. + * + * @return string e.g. 'clientSecret' (default), 'appSecret' + */ + public static function getClientSecretParamName(): string + { + return 'clientSecret'; + } + public static function getName() { return 'updateProjectOAuth2' . static::getProviderLabel(); @@ -95,8 +120,8 @@ abstract class Base extends Action ) ], )) - ->param('clientId', null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) - ->param('clientSecret', null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) ->inject('response') ->inject('dbForPlatform') @@ -168,8 +193,8 @@ abstract class Base extends Action $response->dynamic(new Document([ '$id' => $providerId, 'enabled' => $oAuthProviders[$enabledKey] ?? false, - 'clientId' => $oAuthProviders[$appIdKey] ?? '', - 'clientSecret' => $oAuthProviders[$appSecretKey] ?? '', + static::getClientIdParamName() => $oAuthProviders[$appIdKey] ?? '', + static::getClientSecretParamName() => $oAuthProviders[$appSecretKey] ?? '', ]), static::getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php new file mode 100644 index 0000000000..6cc34cc612 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php @@ -0,0 +1,50 @@ +addAction(UpdateOAuth2GitHub::getName(), new UpdateOAuth2GitHub()); $this->addAction(UpdateOAuth2Discord::getName(), new UpdateOAuth2Discord()); $this->addAction(UpdateOAuth2Figma::getName(), new UpdateOAuth2Figma()); + $this->addAction(UpdateOAuth2Dropbox::getName(), new UpdateOAuth2Dropbox()); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 820ec8f75f..36ab89d012 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -281,6 +281,7 @@ class Response extends SwooleResponse public const MODEL_OAUTH2_GITHUB = 'oAuth2Github'; public const MODEL_OAUTH2_DISCORD = 'oAuth2Discord'; public const MODEL_OAUTH2_FIGMA = 'oAuth2Figma'; + public const MODEL_OAUTH2_DROPBOX = 'oAuth2Dropbox'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php b/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php new file mode 100644 index 0000000000..9289168bcc --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php @@ -0,0 +1,47 @@ +addRule('appKey', [ + 'type' => self::TYPE_STRING, + 'description' => 'Dropbox OAuth 2 app key.', + 'default' => '', + 'example' => 'jl000000000009t', + ]) + ->addRule('appSecret', [ + 'type' => self::TYPE_STRING, + 'description' => 'Dropbox OAuth 2 app secret.', + 'default' => '', + 'example' => 'g200000000000vw', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Dropbox'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_DROPBOX; + } +} From faf09ed7c57270b8de57f874331fb8631484c5c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 24 Apr 2026 12:38:12 +0200 Subject: [PATCH 253/402] Abstrated oauth response model --- .../Utopia/Response/Model/OAuth2Base.php | 100 ++++++++++++++++++ .../Utopia/Response/Model/OAuth2Discord.php | 26 ++--- .../Utopia/Response/Model/OAuth2Dropbox.php | 46 +++++--- .../Utopia/Response/Model/OAuth2Figma.php | 26 ++--- .../Utopia/Response/Model/OAuth2GitHub.php | 31 +++--- 5 files changed, 169 insertions(+), 60 deletions(-) diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Base.php b/src/Appwrite/Utopia/Response/Model/OAuth2Base.php index f9972e9e50..b0bd642b34 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Base.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Base.php @@ -6,6 +6,94 @@ use Appwrite\Utopia\Response\Model; abstract class OAuth2Base extends Model { + /** + * Provider display label used in rule descriptions. + * + * @return string e.g. 'GitHub', 'Discord', 'Dropbox' + */ + abstract public function getProviderLabel(): string; + + /** + * Example value for the client ID rule. + * + * @return string + */ + abstract public function getClientIdExample(): string; + + /** + * Example value for the client secret rule. + * + * @return string + */ + abstract public function getClientSecretExample(): string; + + /** + * Public-facing field name of the client ID. Providers may override when + * they use different terminology (e.g. Dropbox -> 'appKey'). + * + * @return string + */ + public function getClientIdFieldName(): string + { + return 'clientId'; + } + + /** + * Public-facing field name of the client secret. Providers may override + * when they use different terminology (e.g. Dropbox -> 'appSecret'). + * + * @return string + */ + public function getClientSecretFieldName(): string + { + return 'clientSecret'; + } + + /** + * Human-readable label for the client ID, used in the generated rule + * description. Providers may override (e.g. Dropbox -> 'app key'). + * + * @return string + */ + public function getClientIdLabel(): string + { + return 'client ID'; + } + + /** + * Human-readable label for the client secret, used in the generated rule + * description. Providers may override (e.g. Dropbox -> 'app secret'). + * + * @return string + */ + public function getClientSecretLabel(): string + { + return 'client secret'; + } + + /** + * Rule description for the client ID. Auto-generated from the provider + * label and client ID label. Providers may override to add extra context. + * + * @return string + */ + public function getClientIdDescription(): string + { + return $this->getProviderLabel() . ' OAuth 2 ' . $this->getClientIdLabel() . '.'; + } + + /** + * Rule description for the client secret. Auto-generated from the provider + * label and client secret label. Providers may override to add extra + * context. + * + * @return string + */ + public function getClientSecretDescription(): string + { + return $this->getProviderLabel() . ' OAuth 2 ' . $this->getClientSecretLabel() . '.'; + } + public function __construct() { $this @@ -14,6 +102,18 @@ abstract class OAuth2Base extends Model 'description' => 'OAuth 2 provider is active and can be used to create sessions.', 'default' => false, 'example' => false, + ]) + ->addRule($this->getClientIdFieldName(), [ + 'type' => self::TYPE_STRING, + 'description' => $this->getClientIdDescription(), + 'default' => '', + 'example' => $this->getClientIdExample(), + ]) + ->addRule($this->getClientSecretFieldName(), [ + 'type' => self::TYPE_STRING, + 'description' => $this->getClientSecretDescription(), + 'default' => '', + 'example' => $this->getClientSecretExample(), ]); } } diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php b/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php index cd2b0b74e2..da7c4873b5 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php @@ -6,23 +6,19 @@ use Appwrite\Utopia\Response; class OAuth2Discord extends OAuth2Base { - public function __construct() + public function getProviderLabel(): string { - parent::__construct(); + return 'Discord'; + } - $this - ->addRule('clientId', [ - 'type' => self::TYPE_STRING, - 'description' => 'Discord OAuth 2 client ID.', - 'default' => '', - 'example' => '950722000000343754', - ]) - ->addRule('clientSecret', [ - 'type' => self::TYPE_STRING, - 'description' => 'Discord OAuth 2 client secret.', - 'default' => '', - 'example' => 'YmPXnM000000000000000000002zFg5D', - ]); + public function getClientIdExample(): string + { + return '950722000000343754'; + } + + public function getClientSecretExample(): string + { + return 'YmPXnM000000000000000000002zFg5D'; } /** diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php b/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php index 9289168bcc..4924db1397 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php @@ -6,23 +6,39 @@ use Appwrite\Utopia\Response; class OAuth2Dropbox extends OAuth2Base { - public function __construct() + public function getProviderLabel(): string { - parent::__construct(); + return 'Dropbox'; + } - $this - ->addRule('appKey', [ - 'type' => self::TYPE_STRING, - 'description' => 'Dropbox OAuth 2 app key.', - 'default' => '', - 'example' => 'jl000000000009t', - ]) - ->addRule('appSecret', [ - 'type' => self::TYPE_STRING, - 'description' => 'Dropbox OAuth 2 app secret.', - 'default' => '', - 'example' => 'g200000000000vw', - ]); + public function getClientIdExample(): string + { + return 'jl000000000009t'; + } + + public function getClientSecretExample(): string + { + return 'g200000000000vw'; + } + + public function getClientIdFieldName(): string + { + return 'appKey'; + } + + public function getClientSecretFieldName(): string + { + return 'appSecret'; + } + + public function getClientIdLabel(): string + { + return 'app key'; + } + + public function getClientSecretLabel(): string + { + return 'app secret'; } /** diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php b/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php index 2ee60adaa8..533d353d01 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php @@ -6,23 +6,19 @@ use Appwrite\Utopia\Response; class OAuth2Figma extends OAuth2Base { - public function __construct() + public function getProviderLabel(): string { - parent::__construct(); + return 'Figma'; + } - $this - ->addRule('clientId', [ - 'type' => self::TYPE_STRING, - 'description' => 'Figma OAuth 2 client ID.', - 'default' => '', - 'example' => 'byay5H0000000000VtiI40', - ]) - ->addRule('clientSecret', [ - 'type' => self::TYPE_STRING, - 'description' => 'Figma OAuth 2 client secret.', - 'default' => '', - 'example' => 'yEpOYn0000000000000000004iIsU5', - ]); + public function getClientIdExample(): string + { + return 'byay5H0000000000VtiI40'; + } + + public function getClientSecretExample(): string + { + return 'yEpOYn0000000000000000004iIsU5'; } /** diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php b/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php index 27b529aedd..30d3a71187 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php @@ -6,23 +6,24 @@ use Appwrite\Utopia\Response; class OAuth2GitHub extends OAuth2Base { - public function __construct() + public function getProviderLabel(): string { - parent::__construct(); + return 'GitHub'; + } - $this - ->addRule('clientId', [ - 'type' => self::TYPE_STRING, - 'description' => 'GitHub OAuth 2 client ID. For GitHub Apps, use the "App ID" when both an App ID and client ID are available.', - 'default' => '', - 'example' => 'e4d87900000000540733', - ]) - ->addRule('clientSecret', [ - 'type' => self::TYPE_STRING, - 'description' => 'GitHub OAuth 2 client secret.', - 'default' => '', - 'example' => '5e07c00000000000000000000000000000198bcc', - ]); + public function getClientIdExample(): string + { + return 'e4d87900000000540733'; + } + + public function getClientSecretExample(): string + { + return '5e07c00000000000000000000000000000198bcc'; + } + + public function getClientIdDescription(): string + { + return parent::getClientIdDescription() . ' For GitHub Apps, use the "App ID" when both an App ID and client ID are available.'; } /** From 89819db7758f7aee9d888f419e83032beab37010 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 24 Apr 2026 16:12:42 +0530 Subject: [PATCH 254/402] added exporter --- app/realtime.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/realtime.php b/app/realtime.php index 71aa251069..31ec3e4557 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -45,6 +45,7 @@ use Utopia\WebSocket\Adapter; use Utopia\WebSocket\Server; require_once __DIR__ . '/init.php'; +require_once __DIR__ . '/init/span.php'; /** @var Registry $register */ $register = $GLOBALS['register'] ?? throw new \RuntimeException('Registry not initialized'); @@ -272,6 +273,8 @@ $adapter ->setPackageMaxLength(64000) // Default maximum Package Size (64kb) ->setWorkerNumber($workerNumber); +$adapter->getNative()->set(['dispatch_mode' => 2]); + $server = new Server($adapter); // Allows overriding From 06336626955d25dfcf00a2c196371f44a9fdf034 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 24 Apr 2026 16:22:57 +0530 Subject: [PATCH 255/402] removed dispatch experiment --- app/realtime.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 31ec3e4557..0e7388b83f 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -273,8 +273,6 @@ $adapter ->setPackageMaxLength(64000) // Default maximum Package Size (64kb) ->setWorkerNumber($workerNumber); -$adapter->getNative()->set(['dispatch_mode' => 2]); - $server = new Server($adapter); // Allows overriding From fe08978851cc8e4656fc1a036091e822664e1dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 24 Apr 2026 12:58:32 +0200 Subject: [PATCH 256/402] More OAuth provider endpoints --- app/init/models.php | 12 ++++ .../Http/Project/OAuth2/Autodesk/Update.php | 40 ++++++++++++ .../Http/Project/OAuth2/Bitbucket/Update.php | 50 +++++++++++++++ .../Http/Project/OAuth2/Bitly/Update.php | 40 ++++++++++++ .../Http/Project/OAuth2/Box/Update.php | 40 ++++++++++++ .../Project/OAuth2/Dailymotion/Update.php | 50 +++++++++++++++ .../Http/Project/OAuth2/Google/Update.php | 40 ++++++++++++ .../Modules/Project/Services/Http.php | 12 ++++ src/Appwrite/Utopia/Response.php | 6 ++ .../Utopia/Response/Model/OAuth2Autodesk.php | 43 +++++++++++++ .../Utopia/Response/Model/OAuth2Bitbucket.php | 63 +++++++++++++++++++ .../Utopia/Response/Model/OAuth2Bitly.php | 43 +++++++++++++ .../Utopia/Response/Model/OAuth2Box.php | 43 +++++++++++++ .../Response/Model/OAuth2Dailymotion.php | 63 +++++++++++++++++++ .../Utopia/Response/Model/OAuth2Google.php | 43 +++++++++++++ 15 files changed, 588 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Bitbucket.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Bitly.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Box.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Dailymotion.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Google.php diff --git a/app/init/models.php b/app/init/models.php index 5c2910786d..da872b5d7b 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -105,10 +105,16 @@ use Appwrite\Utopia\Response\Model\MigrationReport; use Appwrite\Utopia\Response\Model\Mock; use Appwrite\Utopia\Response\Model\MockNumber; use Appwrite\Utopia\Response\Model\None; +use Appwrite\Utopia\Response\Model\OAuth2Autodesk; +use Appwrite\Utopia\Response\Model\OAuth2Bitbucket; +use Appwrite\Utopia\Response\Model\OAuth2Bitly; +use Appwrite\Utopia\Response\Model\OAuth2Box; +use Appwrite\Utopia\Response\Model\OAuth2Dailymotion; use Appwrite\Utopia\Response\Model\OAuth2Discord; use Appwrite\Utopia\Response\Model\OAuth2Dropbox; use Appwrite\Utopia\Response\Model\OAuth2Figma; use Appwrite\Utopia\Response\Model\OAuth2GitHub; +use Appwrite\Utopia\Response\Model\OAuth2Google; use Appwrite\Utopia\Response\Model\Phone; use Appwrite\Utopia\Response\Model\PlatformAndroid; use Appwrite\Utopia\Response\Model\PlatformApple; @@ -358,6 +364,12 @@ Response::setModel(new OAuth2GitHub()); Response::setModel(new OAuth2Discord()); Response::setModel(new OAuth2Figma()); Response::setModel(new OAuth2Dropbox()); +Response::setModel(new OAuth2Dailymotion()); +Response::setModel(new OAuth2Bitbucket()); +Response::setModel(new OAuth2Bitly()); +Response::setModel(new OAuth2Box()); +Response::setModel(new OAuth2Autodesk()); +Response::setModel(new OAuth2Google()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); Response::setModel(new PolicyPasswordPersonalData()); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php new file mode 100644 index 0000000000..29eaacdc87 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php @@ -0,0 +1,40 @@ +addAction(UpdateOAuth2Discord::getName(), new UpdateOAuth2Discord()); $this->addAction(UpdateOAuth2Figma::getName(), new UpdateOAuth2Figma()); $this->addAction(UpdateOAuth2Dropbox::getName(), new UpdateOAuth2Dropbox()); + $this->addAction(UpdateOAuth2Dailymotion::getName(), new UpdateOAuth2Dailymotion()); + $this->addAction(UpdateOAuth2Bitbucket::getName(), new UpdateOAuth2Bitbucket()); + $this->addAction(UpdateOAuth2Bitly::getName(), new UpdateOAuth2Bitly()); + $this->addAction(UpdateOAuth2Box::getName(), new UpdateOAuth2Box()); + $this->addAction(UpdateOAuth2Autodesk::getName(), new UpdateOAuth2Autodesk()); + $this->addAction(UpdateOAuth2Google::getName(), new UpdateOAuth2Google()); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 36ab89d012..dc315d83fd 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -282,6 +282,12 @@ class Response extends SwooleResponse public const MODEL_OAUTH2_DISCORD = 'oAuth2Discord'; public const MODEL_OAUTH2_FIGMA = 'oAuth2Figma'; public const MODEL_OAUTH2_DROPBOX = 'oAuth2Dropbox'; + public const MODEL_OAUTH2_DAILYMOTION = 'oAuth2Dailymotion'; + public const MODEL_OAUTH2_BITBUCKET = 'oAuth2Bitbucket'; + public const MODEL_OAUTH2_BITLY = 'oAuth2Bitly'; + public const MODEL_OAUTH2_BOX = 'oAuth2Box'; + public const MODEL_OAUTH2_AUTODESK = 'oAuth2Autodesk'; + public const MODEL_OAUTH2_GOOGLE = 'oAuth2Google'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php b/src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php new file mode 100644 index 0000000000..6f55b5d475 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php @@ -0,0 +1,43 @@ + Date: Fri, 24 Apr 2026 14:15:34 +0200 Subject: [PATCH 257/402] Add more oauth endpoints --- analyze.sh | 75 +++++++++++++++++++ app/init/models.php | 36 +++++++++ src/Appwrite/Auth/OAuth2/Discord.php | 1 - src/Appwrite/Auth/OAuth2/Github.php | 19 ++--- .../Http/Project/OAuth2/Amazon/Update.php | 40 ++++++++++ .../Project/Http/Project/OAuth2/Base.php | 14 ++-- .../Http/Project/OAuth2/Disqus/Update.php | 50 +++++++++++++ .../Http/Project/OAuth2/Etsy/Update.php | 50 +++++++++++++ .../Http/Project/OAuth2/Facebook/Update.php | 50 +++++++++++++ .../Http/Project/OAuth2/Linkedin/Update.php | 45 +++++++++++ .../Http/Project/OAuth2/Notion/Update.php | 50 +++++++++++++ .../Http/Project/OAuth2/Podio/Update.php | 40 ++++++++++ .../Http/Project/OAuth2/Salesforce/Update.php | 50 +++++++++++++ .../Http/Project/OAuth2/Slack/Update.php | 40 ++++++++++ .../Http/Project/OAuth2/Spotify/Update.php | 40 ++++++++++ .../Http/Project/OAuth2/Stripe/Update.php | 45 +++++++++++ .../Http/Project/OAuth2/Twitch/Update.php | 40 ++++++++++ .../Http/Project/OAuth2/WordPress/Update.php | 40 ++++++++++ .../Project/Http/Project/OAuth2/X/Update.php | 50 +++++++++++++ .../Http/Project/OAuth2/Yahoo/Update.php | 40 ++++++++++ .../Http/Project/OAuth2/Yandex/Update.php | 40 ++++++++++ .../Http/Project/OAuth2/Zoho/Update.php | 40 ++++++++++ .../Http/Project/OAuth2/Zoom/Update.php | 40 ++++++++++ .../Modules/Project/Services/Http.php | 40 +++++++++- src/Appwrite/Utopia/Response.php | 18 +++++ .../Utopia/Response/Model/OAuth2Amazon.php | 43 +++++++++++ .../Utopia/Response/Model/OAuth2Disqus.php | 63 ++++++++++++++++ .../Utopia/Response/Model/OAuth2Etsy.php | 63 ++++++++++++++++ .../Utopia/Response/Model/OAuth2Facebook.php | 63 ++++++++++++++++ .../Utopia/Response/Model/OAuth2Linkedin.php | 53 +++++++++++++ .../Utopia/Response/Model/OAuth2Notion.php | 53 +++++++++++++ .../Utopia/Response/Model/OAuth2Podio.php | 43 +++++++++++ .../Response/Model/OAuth2Salesforce.php | 63 ++++++++++++++++ .../Utopia/Response/Model/OAuth2Slack.php | 43 +++++++++++ .../Utopia/Response/Model/OAuth2Spotify.php | 43 +++++++++++ .../Utopia/Response/Model/OAuth2Stripe.php | 53 +++++++++++++ .../Utopia/Response/Model/OAuth2Twitch.php | 43 +++++++++++ .../Utopia/Response/Model/OAuth2WordPress.php | 43 +++++++++++ .../Utopia/Response/Model/OAuth2X.php | 63 ++++++++++++++++ .../Utopia/Response/Model/OAuth2Yahoo.php | 43 +++++++++++ .../Utopia/Response/Model/OAuth2Yandex.php | 43 +++++++++++ .../Utopia/Response/Model/OAuth2Zoho.php | 43 +++++++++++ .../Utopia/Response/Model/OAuth2Zoom.php | 43 +++++++++++ 43 files changed, 1878 insertions(+), 19 deletions(-) create mode 100755 analyze.sh create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Twitch/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/WordPress/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/X/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yandex/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoho/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoom/Update.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Amazon.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Disqus.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Etsy.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Facebook.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Notion.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Podio.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Salesforce.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Slack.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Spotify.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Stripe.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Twitch.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2WordPress.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2X.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Yahoo.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Yandex.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Zoho.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Zoom.php diff --git a/analyze.sh b/analyze.sh new file mode 100755 index 0000000000..1620e9bb73 --- /dev/null +++ b/analyze.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT="/Users/matejbaco/Documents/GitHub/appwrite" +ENDPOINT_DIR="$ROOT/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2" +CONFIG_FILE="$ROOT/app/config/oAuthProviders.php" + +if ! command -v php >/dev/null 2>&1; then + echo "php is required but was not found in PATH" >&2 + exit 1 +fi + +if [[ ! -d "$ENDPOINT_DIR" ]]; then + echo "Endpoint directory not found: $ENDPOINT_DIR" >&2 + exit 1 +fi + +if [[ ! -f "$CONFIG_FILE" ]]; then + echo "Config file not found: $CONFIG_FILE" >&2 + exit 1 +fi + +echo "OAuth2 endpoint files:" +find "$ENDPOINT_DIR" -type f | sort +echo + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +endpoint_file="$tmp_dir/endpoint-providers.txt" +config_file="$tmp_dir/config-providers.txt" + +find "$ENDPOINT_DIR" -mindepth 2 -maxdepth 2 -type f -name 'Update.php' \ + | while read -r file; do + basename "$(dirname "$file")" | tr '[:upper:]' '[:lower:]' + done \ + | sort -u > "$endpoint_file" + +php -r ' + $providers = require $argv[1]; + $names = []; + + foreach ($providers as $provider) { + if (($provider["mock"] ?? false) === true) { + continue; + } + + $class = $provider["class"] ?? ""; + if ($class === "") { + continue; + } + + $base = substr($class, strrpos($class, "\\") + 1); + $names[strtolower($base)] = true; + } + + $names = array_keys($names); + sort($names); + + foreach ($names as $name) { + echo $name, PHP_EOL; + } +' "$CONFIG_FILE" > "$config_file" + +echo "Configured provider classes:" +cat "$config_file" +echo + +echo "Endpoint provider directories:" +cat "$endpoint_file" +echo + +echo "Configured providers without endpoint:" +comm -23 "$config_file" "$endpoint_file" diff --git a/app/init/models.php b/app/init/models.php index da872b5d7b..df0d0d28d8 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -105,16 +105,34 @@ use Appwrite\Utopia\Response\Model\MigrationReport; use Appwrite\Utopia\Response\Model\Mock; use Appwrite\Utopia\Response\Model\MockNumber; use Appwrite\Utopia\Response\Model\None; +use Appwrite\Utopia\Response\Model\OAuth2Amazon; use Appwrite\Utopia\Response\Model\OAuth2Autodesk; use Appwrite\Utopia\Response\Model\OAuth2Bitbucket; use Appwrite\Utopia\Response\Model\OAuth2Bitly; use Appwrite\Utopia\Response\Model\OAuth2Box; use Appwrite\Utopia\Response\Model\OAuth2Dailymotion; use Appwrite\Utopia\Response\Model\OAuth2Discord; +use Appwrite\Utopia\Response\Model\OAuth2Disqus; use Appwrite\Utopia\Response\Model\OAuth2Dropbox; +use Appwrite\Utopia\Response\Model\OAuth2Etsy; +use Appwrite\Utopia\Response\Model\OAuth2Facebook; use Appwrite\Utopia\Response\Model\OAuth2Figma; use Appwrite\Utopia\Response\Model\OAuth2GitHub; use Appwrite\Utopia\Response\Model\OAuth2Google; +use Appwrite\Utopia\Response\Model\OAuth2Linkedin; +use Appwrite\Utopia\Response\Model\OAuth2Notion; +use Appwrite\Utopia\Response\Model\OAuth2Podio; +use Appwrite\Utopia\Response\Model\OAuth2Salesforce; +use Appwrite\Utopia\Response\Model\OAuth2Slack; +use Appwrite\Utopia\Response\Model\OAuth2Spotify; +use Appwrite\Utopia\Response\Model\OAuth2Stripe; +use Appwrite\Utopia\Response\Model\OAuth2Twitch; +use Appwrite\Utopia\Response\Model\OAuth2WordPress; +use Appwrite\Utopia\Response\Model\OAuth2X; +use Appwrite\Utopia\Response\Model\OAuth2Yahoo; +use Appwrite\Utopia\Response\Model\OAuth2Yandex; +use Appwrite\Utopia\Response\Model\OAuth2Zoho; +use Appwrite\Utopia\Response\Model\OAuth2Zoom; use Appwrite\Utopia\Response\Model\Phone; use Appwrite\Utopia\Response\Model\PlatformAndroid; use Appwrite\Utopia\Response\Model\PlatformApple; @@ -370,6 +388,24 @@ Response::setModel(new OAuth2Bitly()); Response::setModel(new OAuth2Box()); Response::setModel(new OAuth2Autodesk()); Response::setModel(new OAuth2Google()); +Response::setModel(new OAuth2Zoom()); +Response::setModel(new OAuth2Zoho()); +Response::setModel(new OAuth2Yandex()); +Response::setModel(new OAuth2X()); +Response::setModel(new OAuth2WordPress()); +Response::setModel(new OAuth2Twitch()); +Response::setModel(new OAuth2Stripe()); +Response::setModel(new OAuth2Spotify()); +Response::setModel(new OAuth2Slack()); +Response::setModel(new OAuth2Podio()); +Response::setModel(new OAuth2Notion()); +Response::setModel(new OAuth2Salesforce()); +Response::setModel(new OAuth2Yahoo()); +Response::setModel(new OAuth2Linkedin()); +Response::setModel(new OAuth2Disqus()); +Response::setModel(new OAuth2Amazon()); +Response::setModel(new OAuth2Etsy()); +Response::setModel(new OAuth2Facebook()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); Response::setModel(new PolicyPasswordPersonalData()); diff --git a/src/Appwrite/Auth/OAuth2/Discord.php b/src/Appwrite/Auth/OAuth2/Discord.php index 6cb682479a..a5ecdb5e3c 100644 --- a/src/Appwrite/Auth/OAuth2/Discord.php +++ b/src/Appwrite/Auth/OAuth2/Discord.php @@ -1,7 +1,6 @@ addHeader('Accept', 'application/json'); - + $response = $client->fetch( url: 'https://github.com/login/oauth/access_token', method: FetchClient::METHOD_POST, @@ -233,19 +234,19 @@ class Github extends OAuth2 'client_secret' => $this->appSecret, 'code' => 'intentionally-invalid-code', 'redirect_uri' => 'intentionally-invalid-redirect', - ] + ] ); - + $json = \json_decode($response->getBody(), true); - + if (isset($json['error']) && $json['error'] === "Not Found") { throw new \Exception('GitHub application with provided Client ID is does not exist.'); } - + if (isset($json['error']) && $json['error'] === "incorrect_client_credentials") { throw new \Exception('GitHub application with provided Client ID is valid, but the provided Client Secret is incorrect.'); } - + // We still expect error, like redirect_uri_mismatch or bad_verification_code, // but that indicates valid credentials } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php new file mode 100644 index 0000000000..b17ce97930 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php @@ -0,0 +1,40 @@ +verifyCredentials(); } $oAuthProviders[$enabledKey] = true; - } catch(\Throwable $err) { - if($enabled === true) { + } catch (\Throwable $err) { + if ($enabled === true) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Could not enable OAuth2 provider: ' . $err->getMessage()); } } @@ -188,7 +188,7 @@ abstract class Base extends Action 'oAuthProviders' => $oAuthProviders ]); - $project = $authorization->skip(fn() => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); $response->dynamic(new Document([ '$id' => $providerId, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php new file mode 100644 index 0000000000..978b5c9323 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php @@ -0,0 +1,50 @@ +addAction(UpdateAuthMethod::getName(), new UpdateAuthMethod()); - + // OAuth2 $this->addAction(UpdateOAuth2GitHub::getName(), new UpdateOAuth2GitHub()); $this->addAction(UpdateOAuth2Discord::getName(), new UpdateOAuth2Discord()); @@ -151,5 +169,23 @@ class Http extends Service $this->addAction(UpdateOAuth2Box::getName(), new UpdateOAuth2Box()); $this->addAction(UpdateOAuth2Autodesk::getName(), new UpdateOAuth2Autodesk()); $this->addAction(UpdateOAuth2Google::getName(), new UpdateOAuth2Google()); + $this->addAction(UpdateOAuth2Zoom::getName(), new UpdateOAuth2Zoom()); + $this->addAction(UpdateOAuth2Zoho::getName(), new UpdateOAuth2Zoho()); + $this->addAction(UpdateOAuth2Yandex::getName(), new UpdateOAuth2Yandex()); + $this->addAction(UpdateOAuth2X::getName(), new UpdateOAuth2X()); + $this->addAction(UpdateOAuth2WordPress::getName(), new UpdateOAuth2WordPress()); + $this->addAction(UpdateOAuth2Twitch::getName(), new UpdateOAuth2Twitch()); + $this->addAction(UpdateOAuth2Stripe::getName(), new UpdateOAuth2Stripe()); + $this->addAction(UpdateOAuth2Spotify::getName(), new UpdateOAuth2Spotify()); + $this->addAction(UpdateOAuth2Slack::getName(), new UpdateOAuth2Slack()); + $this->addAction(UpdateOAuth2Podio::getName(), new UpdateOAuth2Podio()); + $this->addAction(UpdateOAuth2Notion::getName(), new UpdateOAuth2Notion()); + $this->addAction(UpdateOAuth2Salesforce::getName(), new UpdateOAuth2Salesforce()); + $this->addAction(UpdateOAuth2Yahoo::getName(), new UpdateOAuth2Yahoo()); + $this->addAction(UpdateOAuth2Linkedin::getName(), new UpdateOAuth2Linkedin()); + $this->addAction(UpdateOAuth2Disqus::getName(), new UpdateOAuth2Disqus()); + $this->addAction(UpdateOAuth2Amazon::getName(), new UpdateOAuth2Amazon()); + $this->addAction(UpdateOAuth2Etsy::getName(), new UpdateOAuth2Etsy()); + $this->addAction(UpdateOAuth2Facebook::getName(), new UpdateOAuth2Facebook()); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index dc315d83fd..d005872845 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -288,6 +288,24 @@ class Response extends SwooleResponse public const MODEL_OAUTH2_BOX = 'oAuth2Box'; public const MODEL_OAUTH2_AUTODESK = 'oAuth2Autodesk'; public const MODEL_OAUTH2_GOOGLE = 'oAuth2Google'; + public const MODEL_OAUTH2_ZOOM = 'oAuth2Zoom'; + public const MODEL_OAUTH2_ZOHO = 'oAuth2Zoho'; + public const MODEL_OAUTH2_YANDEX = 'oAuth2Yandex'; + public const MODEL_OAUTH2_X = 'oAuth2X'; + public const MODEL_OAUTH2_WORDPRESS = 'oAuth2WordPress'; + public const MODEL_OAUTH2_TWITCH = 'oAuth2Twitch'; + public const MODEL_OAUTH2_STRIPE = 'oAuth2Stripe'; + public const MODEL_OAUTH2_SPOTIFY = 'oAuth2Spotify'; + public const MODEL_OAUTH2_SLACK = 'oAuth2Slack'; + public const MODEL_OAUTH2_PODIO = 'oAuth2Podio'; + public const MODEL_OAUTH2_NOTION = 'oAuth2Notion'; + public const MODEL_OAUTH2_SALESFORCE = 'oAuth2Salesforce'; + public const MODEL_OAUTH2_YAHOO = 'oAuth2Yahoo'; + public const MODEL_OAUTH2_LINKEDIN = 'oAuth2Linkedin'; + public const MODEL_OAUTH2_DISQUS = 'oAuth2Disqus'; + public const MODEL_OAUTH2_AMAZON = 'oAuth2Amazon'; + public const MODEL_OAUTH2_ETSY = 'oAuth2Etsy'; + public const MODEL_OAUTH2_FACEBOOK = 'oAuth2Facebook'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Amazon.php b/src/Appwrite/Utopia/Response/Model/OAuth2Amazon.php new file mode 100644 index 0000000000..33708374cc --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Amazon.php @@ -0,0 +1,43 @@ + Date: Fri, 24 Apr 2026 14:23:04 +0200 Subject: [PATCH 258/402] Improve OAuth SDK quality --- .../Project/Http/Project/OAuth2/Amazon/Update.php | 5 +++++ .../Project/Http/Project/OAuth2/Autodesk/Update.php | 5 +++++ .../Modules/Project/Http/Project/OAuth2/Base.php | 9 ++++++++- .../Project/Http/Project/OAuth2/Bitbucket/Update.php | 5 +++++ .../Modules/Project/Http/Project/OAuth2/Bitly/Update.php | 5 +++++ .../Modules/Project/Http/Project/OAuth2/Box/Update.php | 5 +++++ .../Project/Http/Project/OAuth2/Dailymotion/Update.php | 5 +++++ .../Project/Http/Project/OAuth2/Discord/Update.php | 5 +++++ .../Project/Http/Project/OAuth2/Disqus/Update.php | 5 +++++ .../Project/Http/Project/OAuth2/Dropbox/Update.php | 5 +++++ .../Modules/Project/Http/Project/OAuth2/Etsy/Update.php | 5 +++++ .../Project/Http/Project/OAuth2/Facebook/Update.php | 5 +++++ .../Modules/Project/Http/Project/OAuth2/Figma/Update.php | 5 +++++ .../Project/Http/Project/OAuth2/GitHub/Update.php | 5 +++++ .../Project/Http/Project/OAuth2/Google/Update.php | 5 +++++ .../Project/Http/Project/OAuth2/Linkedin/Update.php | 5 +++++ .../Project/Http/Project/OAuth2/Notion/Update.php | 5 +++++ .../Modules/Project/Http/Project/OAuth2/Podio/Update.php | 5 +++++ .../Project/Http/Project/OAuth2/Salesforce/Update.php | 5 +++++ .../Modules/Project/Http/Project/OAuth2/Slack/Update.php | 5 +++++ .../Project/Http/Project/OAuth2/Spotify/Update.php | 5 +++++ .../Project/Http/Project/OAuth2/Stripe/Update.php | 5 +++++ .../Project/Http/Project/OAuth2/Twitch/Update.php | 5 +++++ .../Project/Http/Project/OAuth2/WordPress/Update.php | 5 +++++ .../Modules/Project/Http/Project/OAuth2/X/Update.php | 5 +++++ .../Modules/Project/Http/Project/OAuth2/Yahoo/Update.php | 5 +++++ .../Project/Http/Project/OAuth2/Yandex/Update.php | 5 +++++ .../Modules/Project/Http/Project/OAuth2/Zoho/Update.php | 5 +++++ .../Modules/Project/Http/Project/OAuth2/Zoom/Update.php | 5 +++++ 29 files changed, 148 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php index b17ce97930..0129daf7f4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Amazon'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Amazon'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_AMAZON; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php index 29eaacdc87..6d959479f6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Autodesk'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Autodesk'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_AUTODESK; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php index 9e4d1d6a05..aaf1c1edc0 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php @@ -88,6 +88,13 @@ abstract class Base extends Action return 'clientSecret'; } + /** + * SDK method name exposed to clients. + * + * @return string e.g. 'updateOAuth2GitHub' + */ + abstract public static function getProviderSDKMethod(): string; + public static function getName() { return 'updateProjectOAuth2' . static::getProviderLabel(); @@ -110,7 +117,7 @@ abstract class Base extends Action ->label('sdk', new Method( namespace: 'project', group: 'oauth2', - name: 'updateOAuth2' . $providerLabel, + name: static::getProviderSDKMethod(), description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', auth: [AuthType::ADMIN, AuthType::KEY], responses: [ diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php index 0cd4b0ea2f..bc430101e5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Bitbucket'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Bitbucket'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_BITBUCKET; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php index 28f89c8891..9bb56ce221 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Bitly'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Bitly'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_BITLY; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php index 086930de20..306a7c8529 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Box'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Box'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_BOX; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php index 825683f3a2..2d4cb3307a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Dailymotion'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Dailymotion'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_DAILYMOTION; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php index 383aee12d6..449ed1067f 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Discord'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Discord'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_DISCORD; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php index 978b5c9323..50902c0263 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Disqus'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Disqus'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_DISQUS; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php index 6cc34cc612..27d2444955 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Dropbox'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Dropbox'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_DROPBOX; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php index 71e5ad14a8..36d79d2c99 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Etsy'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Etsy'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_ETSY; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php index ae8015db33..9a435b6123 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Facebook'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Facebook'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_FACEBOOK; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php index c19b9fb30f..2fa62a8428 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Figma'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Figma'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_FIGMA; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php index 4490fa90cd..04c6af54ee 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'GitHub'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2GitHub'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_GITHUB; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php index 466f7df464..f8d2cc21a2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Google'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Google'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_GOOGLE; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php index 97755e4b77..39ae950e03 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Linkedin'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Linkedin'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_LINKEDIN; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php index c32c54ece6..5c8473d75d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Notion'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Notion'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_NOTION; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php index 1e82e41a6c..9ad95ecef2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Podio'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Podio'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_PODIO; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php index 99973e71fb..be75dfa9f5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Salesforce'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Salesforce'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_SALESFORCE; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php index 8a2e351326..589ecd16b3 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Slack'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Slack'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_SLACK; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php index 9b7335791d..58e54891e8 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Spotify'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Spotify'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_SPOTIFY; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php index 39e9d67716..beed3737be 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Stripe'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Stripe'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_STRIPE; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Twitch/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Twitch/Update.php index f9b9ede9e3..73e473d9a2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Twitch/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Twitch/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Twitch'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Twitch'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_TWITCH; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/WordPress/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/WordPress/Update.php index ab5a82c49a..a7f744cfe5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/WordPress/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/WordPress/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'WordPress'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2WordPress'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_WORDPRESS; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/X/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/X/Update.php index 583d31209d..a232fe8f28 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/X/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/X/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'X'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2X'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_X; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php index 4097847e82..9160954e9c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Yahoo'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Yahoo'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_YAHOO; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yandex/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yandex/Update.php index bda2b75523..15a03252a3 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yandex/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yandex/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Yandex'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Yandex'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_YANDEX; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoho/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoho/Update.php index 843a29bd9c..a0a88cbeed 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoho/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoho/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Zoho'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Zoho'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_ZOHO; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoom/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoom/Update.php index f48e3bc3d7..8cc99f4e03 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoom/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoom/Update.php @@ -23,6 +23,11 @@ class Update extends Base return 'Zoom'; } + public static function getProviderSDKMethod(): string + { + return 'updateOAuth2Zoom'; + } + public static function getResponseModel(): string { return Response::MODEL_OAUTH2_ZOOM; From 975da667f5a8ad503139f2805e1d50acdeb3bd74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 24 Apr 2026 14:23:19 +0200 Subject: [PATCH 259/402] Remove leftover --- analyze.sh | 75 ------------------------------------------------------ 1 file changed, 75 deletions(-) delete mode 100755 analyze.sh diff --git a/analyze.sh b/analyze.sh deleted file mode 100755 index 1620e9bb73..0000000000 --- a/analyze.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -ROOT="/Users/matejbaco/Documents/GitHub/appwrite" -ENDPOINT_DIR="$ROOT/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2" -CONFIG_FILE="$ROOT/app/config/oAuthProviders.php" - -if ! command -v php >/dev/null 2>&1; then - echo "php is required but was not found in PATH" >&2 - exit 1 -fi - -if [[ ! -d "$ENDPOINT_DIR" ]]; then - echo "Endpoint directory not found: $ENDPOINT_DIR" >&2 - exit 1 -fi - -if [[ ! -f "$CONFIG_FILE" ]]; then - echo "Config file not found: $CONFIG_FILE" >&2 - exit 1 -fi - -echo "OAuth2 endpoint files:" -find "$ENDPOINT_DIR" -type f | sort -echo - -tmp_dir="$(mktemp -d)" -trap 'rm -rf "$tmp_dir"' EXIT - -endpoint_file="$tmp_dir/endpoint-providers.txt" -config_file="$tmp_dir/config-providers.txt" - -find "$ENDPOINT_DIR" -mindepth 2 -maxdepth 2 -type f -name 'Update.php' \ - | while read -r file; do - basename "$(dirname "$file")" | tr '[:upper:]' '[:lower:]' - done \ - | sort -u > "$endpoint_file" - -php -r ' - $providers = require $argv[1]; - $names = []; - - foreach ($providers as $provider) { - if (($provider["mock"] ?? false) === true) { - continue; - } - - $class = $provider["class"] ?? ""; - if ($class === "") { - continue; - } - - $base = substr($class, strrpos($class, "\\") + 1); - $names[strtolower($base)] = true; - } - - $names = array_keys($names); - sort($names); - - foreach ($names as $name) { - echo $name, PHP_EOL; - } -' "$CONFIG_FILE" > "$config_file" - -echo "Configured provider classes:" -cat "$config_file" -echo - -echo "Endpoint provider directories:" -cat "$endpoint_file" -echo - -echo "Configured providers without endpoint:" -comm -23 "$config_file" "$endpoint_file" From a62ca8612d96da5e45363bc4430b0a29e0922899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 24 Apr 2026 14:31:38 +0200 Subject: [PATCH 260/402] More OAuth endpoints --- app/init/models.php | 8 +++ .../Http/Project/OAuth2/Paypal/Update.php | 50 +++++++++++++++++ .../Project/OAuth2/PaypalSandbox/Update.php | 50 +++++++++++++++++ .../Http/Project/OAuth2/Tradeshift/Update.php | 55 +++++++++++++++++++ .../Project/OAuth2/TradeshiftBox/Update.php | 55 +++++++++++++++++++ .../Modules/Project/Services/Http.php | 8 +++ src/Appwrite/Utopia/Response.php | 4 ++ .../Utopia/Response/Model/OAuth2Paypal.php | 53 ++++++++++++++++++ .../Response/Model/OAuth2PaypalSandbox.php | 53 ++++++++++++++++++ .../Response/Model/OAuth2Tradeshift.php | 53 ++++++++++++++++++ .../Response/Model/OAuth2TradeshiftBox.php | 53 ++++++++++++++++++ 11 files changed, 442 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/PaypalSandbox/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Tradeshift/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/TradeshiftBox/Update.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2PaypalSandbox.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2TradeshiftBox.php diff --git a/app/init/models.php b/app/init/models.php index df0d0d28d8..b5cd534133 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -121,11 +121,15 @@ use Appwrite\Utopia\Response\Model\OAuth2GitHub; use Appwrite\Utopia\Response\Model\OAuth2Google; use Appwrite\Utopia\Response\Model\OAuth2Linkedin; use Appwrite\Utopia\Response\Model\OAuth2Notion; +use Appwrite\Utopia\Response\Model\OAuth2Paypal; +use Appwrite\Utopia\Response\Model\OAuth2PaypalSandbox; use Appwrite\Utopia\Response\Model\OAuth2Podio; use Appwrite\Utopia\Response\Model\OAuth2Salesforce; use Appwrite\Utopia\Response\Model\OAuth2Slack; use Appwrite\Utopia\Response\Model\OAuth2Spotify; use Appwrite\Utopia\Response\Model\OAuth2Stripe; +use Appwrite\Utopia\Response\Model\OAuth2Tradeshift; +use Appwrite\Utopia\Response\Model\OAuth2TradeshiftBox; use Appwrite\Utopia\Response\Model\OAuth2Twitch; use Appwrite\Utopia\Response\Model\OAuth2WordPress; use Appwrite\Utopia\Response\Model\OAuth2X; @@ -406,6 +410,10 @@ Response::setModel(new OAuth2Disqus()); Response::setModel(new OAuth2Amazon()); Response::setModel(new OAuth2Etsy()); Response::setModel(new OAuth2Facebook()); +Response::setModel(new OAuth2Tradeshift()); +Response::setModel(new OAuth2TradeshiftBox()); +Response::setModel(new OAuth2Paypal()); +Response::setModel(new OAuth2PaypalSandbox()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); Response::setModel(new PolicyPasswordPersonalData()); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php new file mode 100644 index 0000000000..a223de70f5 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php @@ -0,0 +1,50 @@ +addAction(UpdateOAuth2Amazon::getName(), new UpdateOAuth2Amazon()); $this->addAction(UpdateOAuth2Etsy::getName(), new UpdateOAuth2Etsy()); $this->addAction(UpdateOAuth2Facebook::getName(), new UpdateOAuth2Facebook()); + $this->addAction(UpdateOAuth2Tradeshift::getName(), new UpdateOAuth2Tradeshift()); + $this->addAction(UpdateOAuth2TradeshiftBox::getName(), new UpdateOAuth2TradeshiftBox()); + $this->addAction(UpdateOAuth2Paypal::getName(), new UpdateOAuth2Paypal()); + $this->addAction(UpdateOAuth2PaypalSandbox::getName(), new UpdateOAuth2PaypalSandbox()); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index d005872845..85780e3b5c 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -306,6 +306,10 @@ class Response extends SwooleResponse public const MODEL_OAUTH2_AMAZON = 'oAuth2Amazon'; public const MODEL_OAUTH2_ETSY = 'oAuth2Etsy'; public const MODEL_OAUTH2_FACEBOOK = 'oAuth2Facebook'; + public const MODEL_OAUTH2_TRADESHIFT = 'oAuth2Tradeshift'; + public const MODEL_OAUTH2_TRADESHIFT_BOX = 'oAuth2TradeshiftBox'; + public const MODEL_OAUTH2_PAYPAL = 'oAuth2Paypal'; + public const MODEL_OAUTH2_PAYPAL_SANDBOX = 'oAuth2PaypalSandbox'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php b/src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php new file mode 100644 index 0000000000..b8e836eedd --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php @@ -0,0 +1,53 @@ + Date: Fri, 24 Apr 2026 15:02:36 +0200 Subject: [PATCH 261/402] More OAuth endpoints --- app/init/models.php | 6 + .../Http/Project/OAuth2/Auth0/Update.php | 145 ++++++++++++++++ .../Http/Project/OAuth2/Authentik/Update.php | 142 ++++++++++++++++ .../Project/Http/Project/OAuth2/Base.php | 45 +++-- .../Http/Project/OAuth2/Gitlab/Update.php | 156 ++++++++++++++++++ .../Modules/Project/Services/Http.php | 6 + src/Appwrite/Utopia/Response.php | 3 + .../Utopia/Response/Model/OAuth2Auth0.php | 55 ++++++ .../Utopia/Response/Model/OAuth2Authentik.php | 55 ++++++ .../Utopia/Response/Model/OAuth2Gitlab.php | 75 +++++++++ 10 files changed, 677 insertions(+), 11 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php diff --git a/app/init/models.php b/app/init/models.php index b5cd534133..0ccff38a23 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -106,6 +106,8 @@ use Appwrite\Utopia\Response\Model\Mock; use Appwrite\Utopia\Response\Model\MockNumber; use Appwrite\Utopia\Response\Model\None; use Appwrite\Utopia\Response\Model\OAuth2Amazon; +use Appwrite\Utopia\Response\Model\OAuth2Auth0; +use Appwrite\Utopia\Response\Model\OAuth2Authentik; use Appwrite\Utopia\Response\Model\OAuth2Autodesk; use Appwrite\Utopia\Response\Model\OAuth2Bitbucket; use Appwrite\Utopia\Response\Model\OAuth2Bitly; @@ -118,6 +120,7 @@ use Appwrite\Utopia\Response\Model\OAuth2Etsy; use Appwrite\Utopia\Response\Model\OAuth2Facebook; use Appwrite\Utopia\Response\Model\OAuth2Figma; use Appwrite\Utopia\Response\Model\OAuth2GitHub; +use Appwrite\Utopia\Response\Model\OAuth2Gitlab; use Appwrite\Utopia\Response\Model\OAuth2Google; use Appwrite\Utopia\Response\Model\OAuth2Linkedin; use Appwrite\Utopia\Response\Model\OAuth2Notion; @@ -414,6 +417,9 @@ Response::setModel(new OAuth2Tradeshift()); Response::setModel(new OAuth2TradeshiftBox()); Response::setModel(new OAuth2Paypal()); Response::setModel(new OAuth2PaypalSandbox()); +Response::setModel(new OAuth2Gitlab()); +Response::setModel(new OAuth2Authentik()); +Response::setModel(new OAuth2Auth0()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); Response::setModel(new PolicyPasswordPersonalData()); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php new file mode 100644 index 0000000000..d551689d82 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php @@ -0,0 +1,145 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.' . $providerId . '.update') + ->label('audits.event', 'project.oauth2.' . $providerId . '.update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of Auth0 instance. For example: example.us.auth0.com', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->handle(...)); + } + + /** + * Custom callback used instead of the parent's `action()` because Auth0 + * takes an additional optional `endpoint` parameter. The method is named + * differently to avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + ?string $endpoint, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $providerId = static::getProviderId(); + + // The secret is stored as JSON `{"clientSecret": "...", "auth0Domain": "..."}` + // to match the shape Auth0's OAuth2 adapter expects (getAuth0Domain()). + // Merge new values with existing storage so that submitting only one of + // `clientSecret`/`endpoint` leaves the other untouched. + $encodedSecret = null; + if (!\is_null($clientSecret) || !\is_null($endpoint)) { + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'auth0Domain' => $endpoint ?? ($existing['auth0Domain'] ?? ''), + ]); + } + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $storedRaw = $oAuthProviders[$providerId . 'Secret'] ?? ''; + $decoded = []; + if (!empty($storedRaw)) { + $decoded = \json_decode($storedRaw, true) ?: []; + } + + $response->dynamic(new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => $decoded['clientSecret'] ?? '', + 'endpoint' => $decoded['auth0Domain'] ?? '', + ]), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php new file mode 100644 index 0000000000..2b69319a71 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php @@ -0,0 +1,142 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.' . $providerId . '.update') + ->label('audits.event', 'project.oauth2.' . $providerId . '.update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('endpoint', '', new Text(256, 1), 'Domain of Authentik instance. For example: example.authentik.com', optional: false) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->handle(...)); + } + + /** + * Custom callback used instead of the parent's `action()` because Authentik + * takes an additional required `endpoint` parameter. The method is named + * differently to avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + string $endpoint, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $providerId = static::getProviderId(); + + // The secret is stored as JSON `{"clientSecret": "...", "authentikDomain": "..."}` + // to match the shape Authentik's OAuth2 adapter expects (getAuthentikDomain()). + // The `endpoint` param is required on every call, so it's always written. + // `clientSecret` is optional; if omitted, the existing stored secret is preserved. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'authentikDomain' => $endpoint, + ]); + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $storedRaw = $oAuthProviders[$providerId . 'Secret'] ?? ''; + $decoded = []; + if (!empty($storedRaw)) { + $decoded = \json_decode($storedRaw, true) ?: []; + } + + $response->dynamic(new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => $decoded['clientSecret'] ?? '', + 'endpoint' => $decoded['authentikDomain'] ?? '', + ]), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php index aaf1c1edc0..2d74c1b61d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php @@ -137,15 +137,23 @@ abstract class Base extends Action ->callback($this->action(...)); } - public function action( + /** + * Apply the provided credential changes to the project's oAuthProviders map, + * run the optional credential verification hook, persist the project, and + * return the updated project document. + * + * Providers that need to serialize multiple values into a single secret + * (e.g. GitLab, which stores `{clientSecret, endpoint}` as JSON) should + * encode those values into `$clientSecret` before calling this method. + */ + protected function persistCredentials( + Document $project, + Database $dbForPlatform, + Authorization $authorization, ?string $clientId, ?string $clientSecret, - ?bool $enabled, - Response $response, - Database $dbForPlatform, - Document $project, - Authorization $authorization - ): void { + ?bool $enabled + ): Document { $providerId = static::getProviderId(); if (!(\in_array($providerId, \array_keys(Config::getParam('oAuthProviders'))))) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Provider ' . $providerId . ' is not supported by server configuration.'); @@ -195,13 +203,28 @@ abstract class Base extends Action 'oAuthProviders' => $oAuthProviders ]); - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + return $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + } + + public function action( + ?string $clientId, + ?string $clientSecret, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $clientSecret, $enabled); + + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); $response->dynamic(new Document([ '$id' => $providerId, - 'enabled' => $oAuthProviders[$enabledKey] ?? false, - static::getClientIdParamName() => $oAuthProviders[$appIdKey] ?? '', - static::getClientSecretParamName() => $oAuthProviders[$appSecretKey] ?? '', + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => $oAuthProviders[$providerId . 'Secret'] ?? '', ]), static::getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php new file mode 100644 index 0000000000..fafc97c836 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php @@ -0,0 +1,156 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.' . $providerId . '.update') + ->label('audits.event', 'project.oauth2.' . $providerId . '.update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('endpoint', null, new Nullable(new URL()), 'Endpoint URL of self-hosted GitLab instance. For example: https://gitlab.com', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->handle(...)); + } + + /** + * Custom callback used instead of the parent's `action()` because Gitlab + * takes an additional `endpoint` parameter. The method is named + * differently to avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $applicationId, + ?string $secret, + ?string $endpoint, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $providerId = static::getProviderId(); + + // The secret is stored as JSON `{"clientSecret": "...", "endpoint": "..."}` + // so that the Gitlab OAuth2 adapter can extract the endpoint via getEndpoint(). + // Merge the new values with what's already stored so that submitting only + // one of `secret`/`endpoint` leaves the other untouched. + $encodedSecret = null; + if (!\is_null($secret) || !\is_null($endpoint)) { + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $secret ?? ($existing['clientSecret'] ?? ''), + 'endpoint' => $endpoint ?? ($existing['endpoint'] ?? ''), + ]); + } + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled); + + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $storedRaw = $oAuthProviders[$providerId . 'Secret'] ?? ''; + $decoded = []; + if (!empty($storedRaw)) { + $decoded = \json_decode($storedRaw, true) ?: []; + } + + $response->dynamic(new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => $decoded['clientSecret'] ?? '', + 'endpoint' => $decoded['endpoint'] ?? '', + ]), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index a5fd19c6b0..47a48c331d 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -17,6 +17,8 @@ use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Get as GetMockPhone use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Update as UpdateMockPhone; use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\XList as ListMockPhones; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Amazon\Update as UpdateOAuth2Amazon; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Auth0\Update as UpdateOAuth2Auth0; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Authentik\Update as UpdateOAuth2Authentik; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Autodesk\Update as UpdateOAuth2Autodesk; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Bitbucket\Update as UpdateOAuth2Bitbucket; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Bitly\Update as UpdateOAuth2Bitly; @@ -29,6 +31,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Etsy\Update as UpdateO use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Facebook\Update as UpdateOAuth2Facebook; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Figma\Update as UpdateOAuth2Figma; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\GitHub\Update as UpdateOAuth2GitHub; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Gitlab\Update as UpdateOAuth2Gitlab; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Google\Update as UpdateOAuth2Google; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Linkedin\Update as UpdateOAuth2Linkedin; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Notion\Update as UpdateOAuth2Notion; @@ -195,5 +198,8 @@ class Http extends Service $this->addAction(UpdateOAuth2TradeshiftBox::getName(), new UpdateOAuth2TradeshiftBox()); $this->addAction(UpdateOAuth2Paypal::getName(), new UpdateOAuth2Paypal()); $this->addAction(UpdateOAuth2PaypalSandbox::getName(), new UpdateOAuth2PaypalSandbox()); + $this->addAction(UpdateOAuth2Gitlab::getName(), new UpdateOAuth2Gitlab()); + $this->addAction(UpdateOAuth2Authentik::getName(), new UpdateOAuth2Authentik()); + $this->addAction(UpdateOAuth2Auth0::getName(), new UpdateOAuth2Auth0()); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 85780e3b5c..099d42ec25 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -310,6 +310,9 @@ class Response extends SwooleResponse public const MODEL_OAUTH2_TRADESHIFT_BOX = 'oAuth2TradeshiftBox'; public const MODEL_OAUTH2_PAYPAL = 'oAuth2Paypal'; public const MODEL_OAUTH2_PAYPAL_SANDBOX = 'oAuth2PaypalSandbox'; + public const MODEL_OAUTH2_GITLAB = 'oAuth2Gitlab'; + public const MODEL_OAUTH2_AUTHENTIK = 'oAuth2Authentik'; + public const MODEL_OAUTH2_AUTH0 = 'oAuth2Auth0'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php b/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php new file mode 100644 index 0000000000..89cf1c92d5 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php @@ -0,0 +1,55 @@ +addRule('endpoint', [ + 'type' => self::TYPE_STRING, + 'description' => 'Auth0 OAuth 2 endpoint domain.', + 'default' => '', + 'example' => 'example.us.auth0.com', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Auth0'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_AUTH0; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php b/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php new file mode 100644 index 0000000000..ca6e828ed4 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php @@ -0,0 +1,55 @@ +addRule('endpoint', [ + 'type' => self::TYPE_STRING, + 'description' => 'Authentik OAuth 2 endpoint domain.', + 'default' => '', + 'example' => 'example.authentik.com', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Authentik'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_AUTHENTIK; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php b/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php new file mode 100644 index 0000000000..bae60c2f5d --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php @@ -0,0 +1,75 @@ +addRule('endpoint', [ + 'type' => self::TYPE_STRING, + 'description' => 'GitLab OAuth 2 endpoint URL. Defaults to https://gitlab.com for self-hosted instances.', + 'default' => '', + 'example' => 'https://gitlab.com', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Gitlab'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_GITLAB; + } +} From d9d87f813fac754648a5503fc1ea2342392a0f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 24 Apr 2026 16:31:21 +0200 Subject: [PATCH 262/402] apple oauth endpoints --- app/init/models.php | 2 + .../Http/Project/OAuth2/Apple/Update.php | 158 ++++++++++++++++++ src/Appwrite/Utopia/Response.php | 1 + .../Utopia/Response/Model/OAuth2Apple.php | 93 +++++++++++ 4 files changed, 254 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Apple.php diff --git a/app/init/models.php b/app/init/models.php index 0ccff38a23..e515713914 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -106,6 +106,7 @@ use Appwrite\Utopia\Response\Model\Mock; use Appwrite\Utopia\Response\Model\MockNumber; use Appwrite\Utopia\Response\Model\None; use Appwrite\Utopia\Response\Model\OAuth2Amazon; +use Appwrite\Utopia\Response\Model\OAuth2Apple; use Appwrite\Utopia\Response\Model\OAuth2Auth0; use Appwrite\Utopia\Response\Model\OAuth2Authentik; use Appwrite\Utopia\Response\Model\OAuth2Autodesk; @@ -420,6 +421,7 @@ Response::setModel(new OAuth2PaypalSandbox()); Response::setModel(new OAuth2Gitlab()); Response::setModel(new OAuth2Authentik()); Response::setModel(new OAuth2Auth0()); +Response::setModel(new OAuth2Apple()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); Response::setModel(new PolicyPasswordPersonalData()); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php new file mode 100644 index 0000000000..edbfdb8b9e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php @@ -0,0 +1,158 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.' . $providerId . '.update') + ->label('audits.event', 'project.oauth2.' . $providerId . '.update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param('keyId', null, new Nullable(new Text(256, 0)), 'Key ID of Apple OAuth2 app. For example: P4000000N8', optional: true) + ->param('teamId', null, new Nullable(new Text(256, 0)), 'Team ID of Apple OAuth2 app. For example: D4000000R6', optional: true) + ->param('p8File', null, new Nullable(new Text(4096, 0)), 'Contents of the Apple OAuth2 app .p8 private key file. The secret key wrapped by the PEM markers is 200 characters long. For example: -----BEGIN PRIVATE KEY-----MIGTAg...jy2Xbna-----END PRIVATE KEY-----', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->handle(...)); + } + + /** + * Custom callback used instead of the parent's `action()` because Apple's + * client secret is composed of three fields (.p8 file contents, Key ID and + * Team ID) that must be JSON-encoded to match the shape Apple's OAuth2 + * adapter expects in getAppSecret(). The method is named differently to + * avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $serviceId, + ?string $keyId, + ?string $teamId, + ?string $p8File, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $providerId = static::getProviderId(); + + // The secret is stored as JSON `{"p8": "...", "keyID": "...", "teamID": "..."}` + // to match the shape Apple's OAuth2 adapter expects in getAppSecret(). + // Merge new values with what's already stored so that submitting only + // some of the fields leaves the rest untouched. + $encodedSecret = null; + if (!\is_null($keyId) || !\is_null($teamId) || !\is_null($p8File)) { + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'p8' => $p8File ?? ($existing['p8'] ?? ''), + 'keyID' => $keyId ?? ($existing['keyID'] ?? ''), + 'teamID' => $teamId ?? ($existing['teamID'] ?? ''), + ]); + } + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $serviceId, $encodedSecret, $enabled); + + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $storedRaw = $oAuthProviders[$providerId . 'Secret'] ?? ''; + $decoded = []; + if (!empty($storedRaw)) { + $decoded = \json_decode($storedRaw, true) ?: []; + } + + $response->dynamic(new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + 'keyId' => $decoded['keyID'] ?? '', + 'teamId' => $decoded['teamID'] ?? '', + 'p8File' => $decoded['p8'] ?? '', + ]), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 099d42ec25..d929b3f98a 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -313,6 +313,7 @@ class Response extends SwooleResponse public const MODEL_OAUTH2_GITLAB = 'oAuth2Gitlab'; public const MODEL_OAUTH2_AUTHENTIK = 'oAuth2Authentik'; public const MODEL_OAUTH2_AUTH0 = 'oAuth2Auth0'; + public const MODEL_OAUTH2_APPLE = 'oAuth2Apple'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php b/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php new file mode 100644 index 0000000000..8120090420 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php @@ -0,0 +1,93 @@ +addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'OAuth 2 provider is active and can be used to create sessions.', + 'default' => false, + 'example' => false, + ]) + ->addRule($this->getClientIdFieldName(), [ + 'type' => self::TYPE_STRING, + 'description' => $this->getClientIdDescription(), + 'default' => '', + 'example' => $this->getClientIdExample(), + ]) + ->addRule('keyId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Apple OAuth 2 key ID.', + 'default' => '', + 'example' => 'P4000000N8', + ]) + ->addRule('teamId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Apple OAuth 2 team ID.', + 'default' => '', + 'example' => 'D4000000R6', + ]) + ->addRule('p8File', [ + 'type' => self::TYPE_STRING, + 'description' => 'Apple OAuth 2 .p8 private key file contents. The secret key wrapped by the PEM markers is 200 characters long.', + 'default' => '', + 'example' => '-----BEGIN PRIVATE KEY-----MIGTAg...jy2Xbna-----END PRIVATE KEY-----', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Apple'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_APPLE; + } +} From 8200d079c621433422775b03873f8b8b1e4f97b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 24 Apr 2026 16:37:27 +0200 Subject: [PATCH 263/402] Simplify specs --- app/controllers/api/projects.php | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index cf920b695f..494aa11150 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -58,23 +58,11 @@ Http::get('/v1/projects/:projectId') $response->dynamic($project, Response::MODEL_PROJECT); }); +// Backwards compatibility Http::patch('/v1/projects/:projectId/oauth2') ->desc('Update project OAuth2') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateOAuth2', - description: '/docs/references/projects/update-oauth2.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'Provider Name') ->param('appId', null, new Nullable(new Text(256)), 'Provider app ID. Max length: 256 chars.', true) From ffd0dbd406ba84c2fc99b8f93472daa3a2bf098c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 25 Apr 2026 10:20:00 +0200 Subject: [PATCH 264/402] Add OIDC endpoint --- app/init/models.php | 2 + .../Http/Project/OAuth2/Oidc/Update.php | 182 ++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + .../Utopia/Response/Model/OAuth2Oidc.php | 74 +++++++ 5 files changed, 261 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php diff --git a/app/init/models.php b/app/init/models.php index e515713914..8d95e50d02 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -125,6 +125,7 @@ use Appwrite\Utopia\Response\Model\OAuth2Gitlab; use Appwrite\Utopia\Response\Model\OAuth2Google; use Appwrite\Utopia\Response\Model\OAuth2Linkedin; use Appwrite\Utopia\Response\Model\OAuth2Notion; +use Appwrite\Utopia\Response\Model\OAuth2Oidc; use Appwrite\Utopia\Response\Model\OAuth2Paypal; use Appwrite\Utopia\Response\Model\OAuth2PaypalSandbox; use Appwrite\Utopia\Response\Model\OAuth2Podio; @@ -421,6 +422,7 @@ Response::setModel(new OAuth2PaypalSandbox()); Response::setModel(new OAuth2Gitlab()); Response::setModel(new OAuth2Authentik()); Response::setModel(new OAuth2Auth0()); +Response::setModel(new OAuth2Oidc()); Response::setModel(new OAuth2Apple()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php new file mode 100644 index 0000000000..d8f85bd6b6 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php @@ -0,0 +1,182 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.' . $providerId . '.update') + ->label('audits.event', 'project.oauth2.' . $providerId . '.update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('wellKnownURL', null, new Nullable(new URL()), 'OpenID Connect well-known configuration URL. When provided, authorization, token, and user info endpoints can be discovered automatically. For example: https://myoauth.com/.well-known/openid-configuration', optional: true) + ->param('authorizationURL', null, new Nullable(new URL()), 'OpenID Connect authorization endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/authorize', optional: true) + ->param('tokenUrl', null, new Nullable(new URL()), 'OpenID Connect token endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/token', optional: true) + ->param('userInfoUrl', null, new Nullable(new URL()), 'OpenID Connect user info endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/userinfo', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->handle(...)); + } + + /** + * Custom callback used instead of the parent's `action()` because OIDC takes + * a well-known URL plus three discovery URLs (authorization, token, user + * info), all stored together with the client secret as JSON. The method is + * named differently to avoid an LSP-incompatible override of Base::action(). + * + * Enabling the provider requires either a non-empty `wellKnownEndpoint`, + * or all three of `authorizationEndpoint`, `tokenEndpoint`, and + * `userInfoEndpoint` to be set. The check considers the merged state of + * existing stored values plus the new values from the request, so callers + * can enable the provider in a single request without re-sending fields + * that were configured previously. + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + ?string $wellKnownURL, + ?string $authorizationURL, + ?string $tokenUrl, + ?string $userInfoUrl, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $providerId = static::getProviderId(); + + // The secret is stored as JSON + // `{"clientSecret": "...", "wellKnownEndpoint": "...", "authorizationEndpoint": "...", "tokenEndpoint": "...", "userInfoEndpoint": "..."}` + // so that the OIDC OAuth2 adapter can extract each endpoint individually. + // Merge new values with what's already stored so that submitting only a + // subset of fields leaves the others untouched. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + + $merged = [ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'wellKnownEndpoint' => $wellKnownURL ?? ($existing['wellKnownEndpoint'] ?? ''), + 'authorizationEndpoint' => $authorizationURL ?? ($existing['authorizationEndpoint'] ?? ''), + 'tokenEndpoint' => $tokenUrl ?? ($existing['tokenEndpoint'] ?? ''), + 'userInfoEndpoint' => $userInfoUrl ?? ($existing['userInfoEndpoint'] ?? ''), + ]; + + // When enabling, require either wellKnownEndpoint alone, or all three + // discovery URLs (authorization, token, user info). Skip this check + // when disabling or when leaving the enabled flag unchanged. + if ($enabled === true) { + $hasWellKnown = !empty($merged['wellKnownEndpoint']); + $hasAllDiscovery = !empty($merged['authorizationEndpoint']) + && !empty($merged['tokenEndpoint']) + && !empty($merged['userInfoEndpoint']); + + if (!$hasWellKnown && !$hasAllDiscovery) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Enabling OpenID Connect requires either wellKnownURL, or all of authorizationURL, tokenUrl, and userInfoUrl.'); + } + } + + $encodedSecret = \json_encode($merged); + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $storedRaw = $oAuthProviders[$providerId . 'Secret'] ?? ''; + $decoded = []; + if (!empty($storedRaw)) { + $decoded = \json_decode($storedRaw, true) ?: []; + } + + $response->dynamic(new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => $decoded['clientSecret'] ?? '', + 'wellKnownURL' => $decoded['wellKnownEndpoint'] ?? '', + 'authorizationURL' => $decoded['authorizationEndpoint'] ?? '', + 'tokenUrl' => $decoded['tokenEndpoint'] ?? '', + 'userInfoUrl' => $decoded['userInfoEndpoint'] ?? '', + ]), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 47a48c331d..c87e16107d 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -35,6 +35,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Gitlab\Update as Updat use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Google\Update as UpdateOAuth2Google; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Linkedin\Update as UpdateOAuth2Linkedin; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Notion\Update as UpdateOAuth2Notion; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Oidc\Update as UpdateOAuth2Oidc; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Paypal\Update as UpdateOAuth2Paypal; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\PaypalSandbox\Update as UpdateOAuth2PaypalSandbox; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Podio\Update as UpdateOAuth2Podio; @@ -201,5 +202,6 @@ class Http extends Service $this->addAction(UpdateOAuth2Gitlab::getName(), new UpdateOAuth2Gitlab()); $this->addAction(UpdateOAuth2Authentik::getName(), new UpdateOAuth2Authentik()); $this->addAction(UpdateOAuth2Auth0::getName(), new UpdateOAuth2Auth0()); + $this->addAction(UpdateOAuth2Oidc::getName(), new UpdateOAuth2Oidc()); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index d929b3f98a..190b16b4a0 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -313,6 +313,7 @@ class Response extends SwooleResponse public const MODEL_OAUTH2_GITLAB = 'oAuth2Gitlab'; public const MODEL_OAUTH2_AUTHENTIK = 'oAuth2Authentik'; public const MODEL_OAUTH2_AUTH0 = 'oAuth2Auth0'; + public const MODEL_OAUTH2_OIDC = 'oAuth2Oidc'; public const MODEL_OAUTH2_APPLE = 'oAuth2Apple'; // Health diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php b/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php new file mode 100644 index 0000000000..97a9ace5ad --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php @@ -0,0 +1,74 @@ +addRule('wellKnownURL', [ + 'type' => self::TYPE_STRING, + 'description' => 'OpenID Connect well-known configuration URL. When set, authorization, token, and user info endpoints can be discovered automatically.', + 'default' => '', + 'example' => 'https://myoauth.com/.well-known/openid-configuration', + ]) + ->addRule('authorizationURL', [ + 'type' => self::TYPE_STRING, + 'description' => 'OpenID Connect authorization endpoint URL.', + 'default' => '', + 'example' => 'https://myoauth.com/oauth2/authorize', + ]) + ->addRule('tokenUrl', [ + 'type' => self::TYPE_STRING, + 'description' => 'OpenID Connect token endpoint URL.', + 'default' => '', + 'example' => 'https://myoauth.com/oauth2/token', + ]) + ->addRule('userInfoUrl', [ + 'type' => self::TYPE_STRING, + 'description' => 'OpenID Connect user info endpoint URL.', + 'default' => '', + 'example' => 'https://myoauth.com/oauth2/userinfo', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Oidc'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_OIDC; + } +} From a588a62277d90ac38351ac6bca3fcc07d7af8a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 25 Apr 2026 11:57:40 +0200 Subject: [PATCH 265/402] Prepare env for cicd integration with github oauth --- .env | 2 ++ .github/workflows/ci.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.env b/.env index 9abfa756e1..3dc7afe34a 100644 --- a/.env +++ b/.env @@ -146,3 +146,5 @@ _APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main _APP_TRUSTED_HEADERS=x-forwarded-for _APP_POOL_ADAPTER=stack _APP_WORKER_SCREENSHOTS_ROUTER=http://appwrite +_TESTS_OAUTH2_GITHUB_CLIENT_ID= +_TESTS_OAUTH2_GITHUB_CLIENT_SECRET= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a056ff8510..d28c00477a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -526,6 +526,8 @@ jobs: docker compose exec -T \ -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ + -e _TESTS_OAUTH2_GITHUB_CLIENT_ID="${{ secrets.TESTS_OAUTH2_GITHUB_CLIENT_ID }}" \ + -e _TESTS_OAUTH2_GITHUB_CLIENT_SECRET="${{ secrets.TESTS_OAUTH2_GITHUB_CLIENT_SECRET }}" \ appwrite vendor/bin/paratest --processes "$PARATEST_PROCESSES" $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml - name: Failure Logs From 184399023c7f4beec33ada8db682b5b005685878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 25 Apr 2026 11:58:09 +0200 Subject: [PATCH 266/402] Add github integration test --- .../Project/OAuthGitHubIntegrationTest.php | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php diff --git a/tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php b/tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php new file mode 100644 index 0000000000..1a6f05ec6f --- /dev/null +++ b/tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php @@ -0,0 +1,148 @@ +markTestSkipped('GitHub OAuth2 credentials not configured (_TESTS_OAUTH2_GITHUB_CLIENT_ID, _TESTS_OAUTH2_GITHUB_CLIENT_SECRET)'); + } + + $consoleHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ]; + + // Step 1: Create new organization (team) + $team = $this->client->call(Client::METHOD_POST, '/teams', $consoleHeaders, [ + 'teamId' => ID::unique(), + 'name' => 'GitHub OAuth Org ' . uniqid(), + ]); + $this->assertSame(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; + + // Step 2: Create new project + $project = $this->client->call(Client::METHOD_POST, '/projects', $consoleHeaders, [ + 'projectId' => 'githuboauthapp', // Must be this ID, its used in redirect URL set in GitHub app configuration + 'name' => 'GitHub OAuth Project', + 'teamId' => $teamId, + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + $this->assertSame(201, $project['headers']['status-code']); + $newProjectId = $project['body']['$id']; + + // Step 3: Configure GitHub provider on the new project via PATCH /v1/project/oauth2/github + $newProjectAdminHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => $newProjectId, + 'x-appwrite-mode' => 'admin', + ]; + + $configResponse = $this->client->call(Client::METHOD_PATCH, '/project/oauth2/github', $newProjectAdminHeaders, [ + 'clientId' => $clientId, + 'clientSecret' => $clientSecret, + 'enabled' => true, + ]); + $this->assertSame(200, $configResponse['headers']['status-code']); + $this->assertTrue($configResponse['body']['enabled']); + $this->assertSame($clientId, $configResponse['body']['clientId']); + + // Step 4: Verify OAuth provider is enabled via GET /v1/projects/:projectId + $projectDetails = $this->client->call(Client::METHOD_GET, '/projects/' . $newProjectId, $consoleHeaders); + $this->assertSame(200, $projectDetails['headers']['status-code']); + + $githubProvider = null; + foreach ($projectDetails['body']['oAuthProviders'] as $provider) { + if ($provider['key'] === 'github') { + $githubProvider = $provider; + break; + } + } + $this->assertNotNull($githubProvider, 'GitHub OAuth provider not found in project details'); + $this->assertTrue($githubProvider['enabled']); + $this->assertSame($clientId, $githubProvider['appId']); + $this->assertSame($clientSecret, $githubProvider['secret']); + + // Step 5: Without client headers (no API key), go through the OAuth flow + $clientHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $newProjectId, + ]; + + $oauthInit = $this->client->call( + Client::METHOD_GET, + '/account/sessions/oauth2/github', + $clientHeaders, + [ + 'success' => 'http://localhost/v1/mock/tests/general/oauth2/success', + 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', + ], + followRedirects: false + ); + + $this->assertSame(301, $oauthInit['headers']['status-code']); + $this->assertArrayHasKey('location', $oauthInit['headers']); + $this->assertStringStartsWith('https://github.com/login/oauth/authorize', $oauthInit['headers']['location']); + $this->assertStringContainsString('client_id=' . \urlencode($clientId), $oauthInit['headers']['location']); + $this->assertStringContainsString('redirect_uri=', $oauthInit['headers']['location']); + + // Follow the redirect to GitHub's authorization endpoint. With a real user agent, GitHub + // would prompt for login + app approval, then redirect back to Appwrite's callback with a + // valid `code`. Appwrite would then exchange the code, create the session and redirect to + // the success URL with the session cookie set. + $oauthClient = new Client(); + $oauthClient->setEndpoint(''); + + $githubResponse = $oauthClient->call( + Client::METHOD_GET, + $oauthInit['headers']['location'], + [], + [], + decode: false, + followRedirects: false + ); + + // GitHub returns 200 (login HTML) or 302 (redirect to login) — both indicate the flow + // reached GitHub. Anything else means our redirect is malformed. + $this->assertContains($githubResponse['headers']['status-code'], [200, 302]); + + // Final step: GET /v1/account with the session cookie set by the OAuth callback. In an + // automated environment that completes the GitHub authorization step, the call below + // returns 200 with the OAuth user. Without that step (no GitHub login/approval automated + // here), there is no session cookie, so the call returns 401. + $sessionCookieName = 'a_session_' . $newProjectId; + $sessionCookie = $githubResponse['cookies'][$sessionCookieName] ?? null; + + if ($sessionCookie === null) { + $accountUnauth = $this->client->call(Client::METHOD_GET, '/account', $clientHeaders); + $this->assertSame(401, $accountUnauth['headers']['status-code']); + return; + } + + $accountResponse = $this->client->call(Client::METHOD_GET, '/account', \array_merge($clientHeaders, [ + 'cookie' => $sessionCookieName . '=' . $sessionCookie, + ])); + $this->assertSame(200, $accountResponse['headers']['status-code']); + $this->assertNotEmpty($accountResponse['body']['$id']); + } +} From d0f6daa67a38485e2ca742cb68d76f235541e39e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 25 Apr 2026 12:05:35 +0200 Subject: [PATCH 267/402] Fix integration test --- docker-compose.yml | 2 ++ .../Project/OAuthGitHubIntegrationTest.php | 27 ++++++------------- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 7d53d2965d..da5efac438 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -247,6 +247,8 @@ services: - _APP_CUSTOM_DOMAIN_DENY_LIST - _APP_TRUSTED_HEADERS - _APP_MIGRATION_HOST + - _TESTS_OAUTH2_GITHUB_CLIENT_ID + - _TESTS_OAUTH2_GITHUB_CLIENT_SECRET extra_hosts: - "host.docker.internal:host-gateway" diff --git a/tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php b/tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php index 1a6f05ec6f..58123aeff3 100644 --- a/tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php +++ b/tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php @@ -94,8 +94,8 @@ class OAuthGitHubIntegrationTest extends Scope '/account/sessions/oauth2/github', $clientHeaders, [ - 'success' => 'http://localhost/v1/mock/tests/general/oauth2/success', - 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', + 'success' => 'http://localhost:4000/success', + 'failure' => 'http://localhost:4000/failure', ], followRedirects: false ); @@ -126,23 +126,12 @@ class OAuthGitHubIntegrationTest extends Scope // reached GitHub. Anything else means our redirect is malformed. $this->assertContains($githubResponse['headers']['status-code'], [200, 302]); - // Final step: GET /v1/account with the session cookie set by the OAuth callback. In an - // automated environment that completes the GitHub authorization step, the call below - // returns 200 with the OAuth user. Without that step (no GitHub login/approval automated - // here), there is no session cookie, so the call returns 401. - $sessionCookieName = 'a_session_' . $newProjectId; - $sessionCookie = $githubResponse['cookies'][$sessionCookieName] ?? null; + // Cleanup: delete the project + $deleteProject = $this->client->call(Client::METHOD_DELETE, '/projects/' . $newProjectId, $consoleHeaders); + $this->assertSame(204, $deleteProject['headers']['status-code']); - if ($sessionCookie === null) { - $accountUnauth = $this->client->call(Client::METHOD_GET, '/account', $clientHeaders); - $this->assertSame(401, $accountUnauth['headers']['status-code']); - return; - } - - $accountResponse = $this->client->call(Client::METHOD_GET, '/account', \array_merge($clientHeaders, [ - 'cookie' => $sessionCookieName . '=' . $sessionCookie, - ])); - $this->assertSame(200, $accountResponse['headers']['status-code']); - $this->assertNotEmpty($accountResponse['body']['$id']); + // Cleanup: delete the organization (team) + $deleteTeam = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, $consoleHeaders); + $this->assertSame(204, $deleteTeam['headers']['status-code']); } } From d25dac7d60f3eb2999c7ee9a3b1c948bf0400e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 26 Apr 2026 10:29:41 +0200 Subject: [PATCH 268/402] Manual quality improvmenets --- app/init/models.php | 4 -- .../Http/Project/OAuth2/Amazon/Update.php | 4 +- .../Http/Project/OAuth2/Apple/Update.php | 6 +- .../Http/Project/OAuth2/Auth0/Update.php | 4 +- .../Http/Project/OAuth2/Authentik/Update.php | 4 +- .../Http/Project/OAuth2/Autodesk/Update.php | 4 +- .../Http/Project/OAuth2/Bitbucket/Update.php | 4 +- .../Http/Project/OAuth2/Bitly/Update.php | 4 +- .../Http/Project/OAuth2/Box/Update.php | 4 +- .../Project/OAuth2/Dailymotion/Update.php | 4 +- .../Http/Project/OAuth2/Discord/Update.php | 4 +- .../Http/Project/OAuth2/Disqus/Update.php | 4 +- .../Http/Project/OAuth2/Dropbox/Update.php | 4 +- .../Http/Project/OAuth2/Etsy/Update.php | 4 +- .../Http/Project/OAuth2/Facebook/Update.php | 4 +- .../Http/Project/OAuth2/Figma/Update.php | 4 +- .../Http/Project/OAuth2/GitHub/Update.php | 4 +- .../Http/Project/OAuth2/Gitlab/Update.php | 4 +- .../Http/Project/OAuth2/Google/Update.php | 4 +- .../Http/Project/OAuth2/Linkedin/Update.php | 4 +- .../Http/Project/OAuth2/Notion/Update.php | 4 +- .../Http/Project/OAuth2/Oidc/Update.php | 4 +- .../Http/Project/OAuth2/Paypal/Update.php | 4 +- .../Project/OAuth2/PaypalSandbox/Update.php | 25 +-------- .../Http/Project/OAuth2/Podio/Update.php | 4 +- .../Http/Project/OAuth2/Salesforce/Update.php | 4 +- .../Http/Project/OAuth2/Slack/Update.php | 4 +- .../Http/Project/OAuth2/Spotify/Update.php | 4 +- .../Http/Project/OAuth2/Stripe/Update.php | 4 +- .../Http/Project/OAuth2/Tradeshift/Update.php | 4 +- .../Project/OAuth2/TradeshiftBox/Update.php | 55 ------------------- .../OAuth2/TradeshiftSandbox/Update.php | 29 ++++++++++ .../Http/Project/OAuth2/Twitch/Update.php | 4 +- .../Http/Project/OAuth2/WordPress/Update.php | 4 +- .../Project/Http/Project/OAuth2/X/Update.php | 4 +- .../Http/Project/OAuth2/Yahoo/Update.php | 4 +- .../Http/Project/OAuth2/Yandex/Update.php | 4 +- .../Http/Project/OAuth2/Zoho/Update.php | 4 +- .../Http/Project/OAuth2/Zoom/Update.php | 4 +- src/Appwrite/Utopia/Response.php | 2 - .../Response/Model/OAuth2PaypalSandbox.php | 53 ------------------ .../Response/Model/OAuth2TradeshiftBox.php | 53 ------------------ 42 files changed, 102 insertions(+), 261 deletions(-) delete mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/TradeshiftBox/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/TradeshiftSandbox/Update.php delete mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2PaypalSandbox.php delete mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2TradeshiftBox.php diff --git a/app/init/models.php b/app/init/models.php index 8d95e50d02..f24e2045df 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -127,14 +127,12 @@ use Appwrite\Utopia\Response\Model\OAuth2Linkedin; use Appwrite\Utopia\Response\Model\OAuth2Notion; use Appwrite\Utopia\Response\Model\OAuth2Oidc; use Appwrite\Utopia\Response\Model\OAuth2Paypal; -use Appwrite\Utopia\Response\Model\OAuth2PaypalSandbox; use Appwrite\Utopia\Response\Model\OAuth2Podio; use Appwrite\Utopia\Response\Model\OAuth2Salesforce; use Appwrite\Utopia\Response\Model\OAuth2Slack; use Appwrite\Utopia\Response\Model\OAuth2Spotify; use Appwrite\Utopia\Response\Model\OAuth2Stripe; use Appwrite\Utopia\Response\Model\OAuth2Tradeshift; -use Appwrite\Utopia\Response\Model\OAuth2TradeshiftBox; use Appwrite\Utopia\Response\Model\OAuth2Twitch; use Appwrite\Utopia\Response\Model\OAuth2WordPress; use Appwrite\Utopia\Response\Model\OAuth2X; @@ -416,9 +414,7 @@ Response::setModel(new OAuth2Amazon()); Response::setModel(new OAuth2Etsy()); Response::setModel(new OAuth2Facebook()); Response::setModel(new OAuth2Tradeshift()); -Response::setModel(new OAuth2TradeshiftBox()); Response::setModel(new OAuth2Paypal()); -Response::setModel(new OAuth2PaypalSandbox()); Response::setModel(new OAuth2Gitlab()); Response::setModel(new OAuth2Authentik()); Response::setModel(new OAuth2Auth0()); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php index 0129daf7f4..1542f3b3bc 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php @@ -35,11 +35,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Client ID of Amazon OAuth2 app. For example: amzn1.application-oa2-client.87400c00000000000000000000063d5b2'; + return '\'Client ID\' of Amazon OAuth2 app. For example: amzn1.application-oa2-client.87400c00000000000000000000063d5b2'; } public static function getClientSecretDescription(): string { - return 'Client Secret of Amazon OAuth2 app. For example: 79ffe4000000000000000000000000000000000000000000000000000002de55'; + return '\'Client Secret\' of Amazon OAuth2 app. For example: 79ffe4000000000000000000000000000000000000000000000000000002de55'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php index edbfdb8b9e..7a0cf59661 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php @@ -50,7 +50,7 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Service ID of Apple OAuth2 app. For example: ip.appwrite.app.web'; + return '\'Service ID\' of Apple OAuth2 app. For example: ip.appwrite.app.web'; } public static function getClientSecretDescription(): string @@ -88,8 +88,8 @@ class Update extends Base ], )) ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) - ->param('keyId', null, new Nullable(new Text(256, 0)), 'Key ID of Apple OAuth2 app. For example: P4000000N8', optional: true) - ->param('teamId', null, new Nullable(new Text(256, 0)), 'Team ID of Apple OAuth2 app. For example: D4000000R6', optional: true) + ->param('keyId', null, new Nullable(new Text(256, 0)), '\'Key ID\' of Apple OAuth2 app. For example: P4000000N8', optional: true) + ->param('teamId', null, new Nullable(new Text(256, 0)), '\'Team ID\' of Apple OAuth2 app. For example: D4000000R6', optional: true) ->param('p8File', null, new Nullable(new Text(4096, 0)), 'Contents of the Apple OAuth2 app .p8 private key file. The secret key wrapped by the PEM markers is 200 characters long. For example: -----BEGIN PRIVATE KEY-----MIGTAg...jy2Xbna-----END PRIVATE KEY-----', optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) ->inject('response') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php index d551689d82..9fe0b1384d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php @@ -45,12 +45,12 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Client ID of Auth0 OAuth2 app. For example: OaOkIA000000000000000000005KLSYq'; + return '\'Client ID\' of Auth0 OAuth2 app. For example: OaOkIA000000000000000000005KLSYq'; } public static function getClientSecretDescription(): string { - return 'Client Secret of Auth0 OAuth2 app. For example: zXz0000-00000000000000000000000000000-00000000000000000000PJafnF'; + return '\'Client Secret\' of Auth0 OAuth2 app. For example: zXz0000-00000000000000000000000000000-00000000000000000000PJafnF'; } public function __construct() diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php index 2b69319a71..48a7f1a22b 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php @@ -45,12 +45,12 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Client ID of Authentik OAuth2 app. For example: dTKOPa0000000000000000000000000000e7G8hv'; + return '\'Client ID\' of Authentik OAuth2 app. For example: dTKOPa0000000000000000000000000000e7G8hv'; } public static function getClientSecretDescription(): string { - return 'Client Secret of Authentik OAuth2 app. For example: ntQadq000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Hp5WK'; + return '\'Client Secret\' of Authentik OAuth2 app. For example: ntQadq000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Hp5WK'; } public function __construct() diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php index 6d959479f6..6331f23080 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php @@ -35,11 +35,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'client ID of Autodesk OAuth2 app. For example: 5zw90v00000000000000000000kVYXN7'; + return '\'client ID\' of Autodesk OAuth2 app. For example: 5zw90v00000000000000000000kVYXN7'; } public static function getClientSecretDescription(): string { - return 'client secret of Autodesk OAuth2 app. For example: 7I000000000000MW'; + return '\'client secret\' of Autodesk OAuth2 app. For example: 7I000000000000MW'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php index bc430101e5..cbb48445b5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php @@ -45,11 +45,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Key of Bitbucket OAuth2 app. For example: Knt70000000000ByRc'; + return '\'Key\' of Bitbucket OAuth2 app. For example: Knt70000000000ByRc'; } public static function getClientSecretDescription(): string { - return 'Secret of Bitbucket OAuth2 app. For example: NMfLZJ00000000000000000000TLQdDx'; + return '\'Secret\' of Bitbucket OAuth2 app. For example: NMfLZJ00000000000000000000TLQdDx'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php index 9bb56ce221..d8964610e6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php @@ -35,11 +35,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Client ID of Bitly OAuth2 app. For example: d95151000000000000000000000000000067af9b'; + return '\'Client ID\' of Bitly OAuth2 app. For example: d95151000000000000000000000000000067af9b'; } public static function getClientSecretDescription(): string { - return 'Client secret of Bitly OAuth2 app. For example: a13e250000000000000000000000000000d73095'; + return '\'Client Secret\' of Bitly OAuth2 app. For example: a13e250000000000000000000000000000d73095'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php index 306a7c8529..8cb9df835a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php @@ -35,11 +35,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Client ID of Box OAuth2 app. For example: deglcs00000000000000000000x2og6y'; + return '\'Client ID\' of Box OAuth2 app. For example: deglcs00000000000000000000x2og6y'; } public static function getClientSecretDescription(): string { - return 'Client Secret of Box OAuth2 app. For example: OKM1f100000000000000000000eshEif'; + return '\'Client Secret\' of Box OAuth2 app. For example: OKM1f100000000000000000000eshEif'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php index 2d4cb3307a..d2f38309b4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php @@ -45,11 +45,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'API key of Dailymotion OAuth2 app. For example: 07a9000000000000067f'; + return '\'API key\' of Dailymotion OAuth2 app. For example: 07a9000000000000067f'; } public static function getClientSecretDescription(): string { - return 'API secret of Dailymotion OAuth2 app. For example: a399a90000000000000000000000000000d90639'; + return '\'API secret\' of Dailymotion OAuth2 app. For example: a399a90000000000000000000000000000d90639'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php index 449ed1067f..5efc193019 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php @@ -35,11 +35,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Client ID of Discord OAuth2 app. For example: 950722000000343754'; + return '\'Client ID\' of Discord OAuth2 app. For example: 950722000000343754'; } public static function getClientSecretDescription(): string { - return 'Client Secret of Discord OAuth2 app. For example: YmPXnM000000000000000000002zFg5D'; + return '\'Client Secret\' of Discord OAuth2 app. For example: YmPXnM000000000000000000002zFg5D'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php index 50902c0263..e77cd9b152 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php @@ -45,11 +45,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Public key, also known as API Key, of Disqus OAuth2 app. For example: cgegH70000000000000000000000000000000000000000000000000000Hr1nYX'; + return '\'Public key\', also known as \'API Key\', of Disqus OAuth2 app. For example: cgegH70000000000000000000000000000000000000000000000000000Hr1nYX'; } public static function getClientSecretDescription(): string { - return 'Secret Key, also known as API Secret, of Disqus OAuth2 app. For example: W7Bykj00000000000000000000000000000000000000000000000000003o43w9'; + return '\'Secret Key\', also known as \'API Secret\', of Disqus OAuth2 app. For example: W7Bykj00000000000000000000000000000000000000000000000000003o43w9'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php index 27d2444955..385b7719df 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php @@ -45,11 +45,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'App key of Dropbox OAuth2 app. For example: jl000000000009t'; + return '\'App key\' of Dropbox OAuth2 app. For example: jl000000000009t'; } public static function getClientSecretDescription(): string { - return 'App secret of Dropbox OAuth2 app. For example: g200000000000vw'; + return '\'App secret\' of Dropbox OAuth2 app. For example: g200000000000vw'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php index 36d79d2c99..291daec414 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php @@ -45,11 +45,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Keystring of Etsy OAuth2 app. For example: nsgzxh0000000000008j85a2'; + return '\'Keystring\' of Etsy OAuth2 app. For example: nsgzxh0000000000008j85a2'; } public static function getClientSecretDescription(): string { - return 'Shared Secret of Etsy OAuth2 app. For example: tp000000ru'; + return '\'Shared Secret\' of Etsy OAuth2 app. For example: tp000000ru'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php index 9a435b6123..a3f97334a3 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php @@ -45,11 +45,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'App ID of Facebook OAuth2 app. For example: 260600000007694'; + return '\'App ID\' of Facebook OAuth2 app. For example: 260600000007694'; } public static function getClientSecretDescription(): string { - return 'App secret of Facebook OAuth2 app. For example: 2d0b2800000000000000000000d38af4'; + return '\'App secret\' of Facebook OAuth2 app. For example: 2d0b2800000000000000000000d38af4'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php index 2fa62a8428..b005bf17c9 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php @@ -35,11 +35,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Client ID of Figma OAuth2 app. For example: byay5H0000000000VtiI40'; + return '\'Client ID\' of Figma OAuth2 app. For example: byay5H0000000000VtiI40'; } public static function getClientSecretDescription(): string { - return 'Client Secret of Figma OAuth2 app. For example: yEpOYn0000000000000000004iIsU5'; + return '\'Client Secret\' of Figma OAuth2 app. For example: yEpOYn0000000000000000004iIsU5'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php index 04c6af54ee..3d4f77f117 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php @@ -35,11 +35,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Client ID of GitHub OAuth2 app, or App ID of GitHub generic app. For example: e4d87900000000540733'; + return '\'Client ID\' of GitHub OAuth2 app, or \'App ID\' of GitHub generic app. For example: e4d87900000000540733. Example of wrong value: 370006'; } public static function getClientSecretDescription(): string { - return 'Client secret of GitHub OAuth2 app, or GitHub generic app. For example: 5e07c00000000000000000000000000000198bcc'; + return '\'Client secret\' of GitHub OAuth2 app, or GitHub generic app. For example: 5e07c00000000000000000000000000000198bcc'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php index fafc97c836..ce7fa21ee1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php @@ -56,12 +56,12 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Application ID of GitLab OAuth2 app. For example: d41ffe0000000000000000000000000000000000000000000000000000d5e252'; + return '\'Application ID\' of GitLab OAuth2 app. For example: d41ffe0000000000000000000000000000000000000000000000000000d5e252'; } public static function getClientSecretDescription(): string { - return 'Secret of GitLab OAuth2 app. For example: gloas-838cfa0000000000000000000000000000000000000000000000000000ecbb38'; + return '\'Secret\' of GitLab OAuth2 app. For example: gloas-838cfa0000000000000000000000000000000000000000000000000000ecbb38'; } public function __construct() diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php index f8d2cc21a2..796b6dae20 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php @@ -35,11 +35,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Client ID of Google OAuth2 app. For example: 120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com'; + return '\'Client ID\' of Google OAuth2 app. For example: 120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com'; } public static function getClientSecretDescription(): string { - return 'Client secret of Google OAuth2 app. For example: GOCSPX-2k8gsR0000000000000000VNahJj'; + return '\'Client secret\' of Google OAuth2 app. For example: GOCSPX-2k8gsR0000000000000000VNahJj'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php index 39ae950e03..f23908279e 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php @@ -40,11 +40,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Client ID of LinkedIn OAuth2 app. For example: 770000000000dv'; + return '\'Client ID\' of LinkedIn OAuth2 app. For example: 770000000000dv'; } public static function getClientSecretDescription(): string { - return 'Primary Client Secret, also known as Secondary Client Secret, of LinkedIn OAuth2 app. For example: WPL_AP1.2Bf0000000000000'; + return '\'Primary Client Secret\' or \'Secondary Client Secret\', of LinkedIn OAuth2 app. For example: WPL_AP1.2Bf0000000000000./HtlYw=='; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php index 5c8473d75d..56451166a4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php @@ -45,11 +45,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'OAuth Client ID of Notion OAuth2 app. For example: 341d8700-0000-0000-0000-000000446ee3'; + return '\'OAuth Client ID\' of Notion OAuth2 app. For example: 341d8700-0000-0000-0000-000000446ee3'; } public static function getClientSecretDescription(): string { - return 'OAuth Client Secret of Notion OAuth2 app. For example: secret_dLUr4b000000000000000000000000000000lFHAa9'; + return '\'OAuth Client Secret\' of Notion OAuth2 app. For example: secret_dLUr4b000000000000000000000000000000lFHAa9'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php index d8f85bd6b6..39cf5b2f96 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php @@ -47,12 +47,12 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Client ID of OpenID Connect OAuth2 app. For example: qibI2x0000000000000000000000000006L2YFoG'; + return '\'Client ID\' of OpenID Connect OAuth2 app. For example: qibI2x0000000000000000000000000006L2YFoG'; } public static function getClientSecretDescription(): string { - return 'Client Secret of OpenID Connect OAuth2 app. For example: Ah68ed000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003qpcHV'; + return '\'Client Secret\' of OpenID Connect OAuth2 app. For example: Ah68ed000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003qpcHV'; } public function __construct() diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php index a223de70f5..36b50475da 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php @@ -40,11 +40,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Client ID of PayPal OAuth2 app. For example: AdhIEG7-000000000000-0000000000000000000000000000000-0000000000000000000000-2pyB'; + return '\'Client ID\' of ' . static::getProviderLabel() . ' OAuth2 app. For example: AdhIEG7-000000000000-0000000000000000000000000000000-0000000000000000000000-2pyB'; } public static function getClientSecretDescription(): string { - return 'Secret key 1, also known as Secret key 2, of PayPal OAuth2 app. For example: EH8KCXtew--000000000000000000000000000000000000000_C-1_5UP_000000000000000CB7KDp'; + return '\'Secret key 1\', or \'Secret key 2\', of ' . static::getProviderLabel() . ' OAuth2 app. For example: EH8KCXtew--000000000000000000000000000000000000000_C-1_5UP_000000000000000CB7KDp'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/PaypalSandbox/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/PaypalSandbox/Update.php index 0436074d6c..c9f40094d5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/PaypalSandbox/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/PaypalSandbox/Update.php @@ -3,10 +3,9 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\PaypalSandbox; use Appwrite\Auth\OAuth2\PaypalSandbox; -use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base; -use Appwrite\Utopia\Response; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Paypal\Update as PaypalUpdate; -class Update extends Base +class Update extends PaypalUpdate { public static function getProviderId(): string { @@ -27,24 +26,4 @@ class Update extends Base { return 'updateOAuth2PaypalSandbox'; } - - public static function getResponseModel(): string - { - return Response::MODEL_OAUTH2_PAYPAL_SANDBOX; - } - - public static function getClientSecretParamName(): string - { - return 'secretKey'; - } - - public static function getClientIdDescription(): string - { - return 'Client ID of PayPal Sandbox OAuth2 app. For example: AdhIEG7-000000000000-0000000000000000000000000000000-0000000000000000000000-2pyB'; - } - - public static function getClientSecretDescription(): string - { - return 'Secret key 1, also known as Secret key 2, of PayPal Sandbox OAuth2 app. For example: EH8KCXtew--000000000000000000000000000000000000000_C-1_5UP_000000000000000CB7KDp'; - } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php index 9ad95ecef2..47efa8b32b 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php @@ -35,11 +35,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Client ID of Podio OAuth2 app. For example: appwrite-oauth-test-app'; + return '\'Client ID\' of Podio OAuth2 app. For example: appwrite-o0000000st-app'; } public static function getClientSecretDescription(): string { - return 'Client Secret of Podio OAuth2 app. For example: Rn247T0000000000000000000000000000000000000000000000000000W2zWTN'; + return '\'Client Secret\' of Podio OAuth2 app. For example: Rn247T0000000000000000000000000000000000000000000000000000W2zWTN'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php index be75dfa9f5..8721114327 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php @@ -45,11 +45,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Consumer key of Salesforce OAuth2 app. For example: 3MVG9I0000000000000000000000000000000000000000000000000000000000000000000000000C5Aejq'; + return '\'Consumer key\' of Salesforce OAuth2 app. For example: 3MVG9I0000000000000000000000000000000000000000000000000000000000000000000000000C5Aejq'; } public static function getClientSecretDescription(): string { - return 'Consumer secret of Salesforce OAuth2 app. For example: 3w000000000000e2'; + return '\'Consumer secret\' of Salesforce OAuth2 app. For example: 3w000000000000e2'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php index 589ecd16b3..612bb26968 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php @@ -35,11 +35,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Client ID of Slack OAuth2 app. For example: 23000000089.15000000000023'; + return '\'Client ID\' of Slack OAuth2 app. For example: 23000000089.15000000000023'; } public static function getClientSecretDescription(): string { - return 'Client Secret of Slack OAuth2 app. For example: 81656000000000000000000000f3d2fd'; + return '\'Client Secret\' of Slack OAuth2 app. For example: 81656000000000000000000000f3d2fd'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php index 58e54891e8..d28bfac8a2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php @@ -35,11 +35,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Client ID of Spotify OAuth2 app. For example: 6ec271000000000000000000009beace'; + return '\'Client ID\' of Spotify OAuth2 app. For example: 6ec271000000000000000000009beace'; } public static function getClientSecretDescription(): string { - return 'Client secret of Spotify OAuth2 app. For example: db068a000000000000000000008b5b9f'; + return '\'Client secret\' of Spotify OAuth2 app. For example: db068a000000000000000000008b5b9f'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php index beed3737be..605804fa96 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php @@ -40,11 +40,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'client ID of Stripe OAuth2 app. For example: ca_UKibXX0000000000000000000006byvR'; + return '\'client ID\' of Stripe OAuth2 app. For example: ca_UKibXX0000000000000000000006byvR'; } public static function getClientSecretDescription(): string { - return 'API Secret key of Stripe OAuth2 app. For example: sk_51SfOd000000000000000000000000000000000000000000000000000000000000000000000000000000000000000QGWYfp'; + return '\'API Secret key\' of Stripe OAuth2 app. For example: sk_51SfOd000000000000000000000000000000000000000000000000000000000000000000000000000000000000000QGWYfp'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Tradeshift/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Tradeshift/Update.php index 7bb2b078e8..bff866cde6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Tradeshift/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Tradeshift/Update.php @@ -45,11 +45,11 @@ class Update extends Base public static function getClientIdDescription(): string { - return 'Oauth2 Client ID of Tradeshift OAuth2 app. For example: appwrite-test-org.appwrite-test-app'; + return '\'Oauth2 Client ID\' of ' . static::getProviderLabel() . ' OAuth2 app. For example: appwrite-tes00000.0000000000est-app'; } public static function getClientSecretDescription(): string { - return 'Oauth2 Client secret of Tradeshift OAuth2 app. For example: 7cb52700-0000-0000-0000-000000ca5b83'; + return '\'Oauth2 Client secret\' of ' . static::getProviderLabel() . ' OAuth2 app. For example: 7cb52700-0000-0000-0000-000000ca5b83'; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/TradeshiftBox/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/TradeshiftBox/Update.php deleted file mode 100644 index 3d153d408e..0000000000 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/TradeshiftBox/Update.php +++ /dev/null @@ -1,55 +0,0 @@ - Date: Sun, 26 Apr 2026 10:56:41 +0200 Subject: [PATCH 269/402] Make okta server ID optional --- src/Appwrite/Auth/OAuth2/Okta.php | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Auth/OAuth2/Okta.php b/src/Appwrite/Auth/OAuth2/Okta.php index 610d9847f2..13d420f6f2 100644 --- a/src/Appwrite/Auth/OAuth2/Okta.php +++ b/src/Appwrite/Auth/OAuth2/Okta.php @@ -42,7 +42,12 @@ class Okta extends OAuth2 */ public function getLoginURL(): string { - return 'https://' . $this->getOktaDomain() . '/oauth2/' . $this->getAuthorizationServerId() . '/v1/authorize?' . \http_build_query([ + $base = 'https://' . $this->getOktaDomain() . '/oauth2'; + if(!empty($this->getAuthorizationServerId())) { + $base .= '/' . $this->getAuthorizationServerId(); + } + + return $base . '/v1/authorize?' . \http_build_query([ 'client_id' => $this->appID, 'redirect_uri' => $this->callback, 'state' => \json_encode($this->state), @@ -59,10 +64,15 @@ class Okta extends OAuth2 protected function getTokens(string $code): array { if (empty($this->tokens)) { + $base = 'https://' . $this->getOktaDomain() . '/oauth2'; + if(!empty($this->getAuthorizationServerId())) { + $base .= '/' . $this->getAuthorizationServerId(); + } + $headers = ['Content-Type: application/x-www-form-urlencoded']; $this->tokens = \json_decode($this->request( 'POST', - 'https://' . $this->getOktaDomain() . '/oauth2/' . $this->getAuthorizationServerId() . '/v1/token', + $base . '/v1/token', $headers, \http_build_query([ 'code' => $code, @@ -86,10 +96,15 @@ class Okta extends OAuth2 */ public function refreshTokens(string $refreshToken): array { + $base = 'https://' . $this->getOktaDomain() . '/oauth2'; + if(!empty($this->getAuthorizationServerId())) { + $base .= '/' . $this->getAuthorizationServerId(); + } + $headers = ['Content-Type: application/x-www-form-urlencoded']; $this->tokens = \json_decode($this->request( 'POST', - 'https://' . $this->getOktaDomain() . '/oauth2/' . $this->getAuthorizationServerId() . '/v1/token', + $base . '/v1/token', $headers, \http_build_query([ 'refresh_token' => $refreshToken, @@ -170,8 +185,13 @@ class Okta extends OAuth2 protected function getUser(string $accessToken): array { if (empty($this->user)) { + $base = 'https://' . $this->getOktaDomain() . '/oauth2'; + if(!empty($this->getAuthorizationServerId())) { + $base .= '/' . $this->getAuthorizationServerId(); + } + $headers = ['Authorization: Bearer ' . \urlencode($accessToken)]; - $user = $this->request('GET', 'https://' . $this->getOktaDomain() . '/oauth2/' . $this->getAuthorizationServerId() . '/v1/userinfo', $headers); + $user = $this->request('GET', $base . '/v1/userinfo', $headers); $this->user = \json_decode($user, true); } From 0a7b7de197fe31ddd39801c1afb002d78d67002e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 26 Apr 2026 10:59:29 +0200 Subject: [PATCH 270/402] Revert changes - default works as fallback for optional serverID --- src/Appwrite/Auth/OAuth2/Okta.php | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/src/Appwrite/Auth/OAuth2/Okta.php b/src/Appwrite/Auth/OAuth2/Okta.php index 13d420f6f2..610d9847f2 100644 --- a/src/Appwrite/Auth/OAuth2/Okta.php +++ b/src/Appwrite/Auth/OAuth2/Okta.php @@ -42,12 +42,7 @@ class Okta extends OAuth2 */ public function getLoginURL(): string { - $base = 'https://' . $this->getOktaDomain() . '/oauth2'; - if(!empty($this->getAuthorizationServerId())) { - $base .= '/' . $this->getAuthorizationServerId(); - } - - return $base . '/v1/authorize?' . \http_build_query([ + return 'https://' . $this->getOktaDomain() . '/oauth2/' . $this->getAuthorizationServerId() . '/v1/authorize?' . \http_build_query([ 'client_id' => $this->appID, 'redirect_uri' => $this->callback, 'state' => \json_encode($this->state), @@ -64,15 +59,10 @@ class Okta extends OAuth2 protected function getTokens(string $code): array { if (empty($this->tokens)) { - $base = 'https://' . $this->getOktaDomain() . '/oauth2'; - if(!empty($this->getAuthorizationServerId())) { - $base .= '/' . $this->getAuthorizationServerId(); - } - $headers = ['Content-Type: application/x-www-form-urlencoded']; $this->tokens = \json_decode($this->request( 'POST', - $base . '/v1/token', + 'https://' . $this->getOktaDomain() . '/oauth2/' . $this->getAuthorizationServerId() . '/v1/token', $headers, \http_build_query([ 'code' => $code, @@ -96,15 +86,10 @@ class Okta extends OAuth2 */ public function refreshTokens(string $refreshToken): array { - $base = 'https://' . $this->getOktaDomain() . '/oauth2'; - if(!empty($this->getAuthorizationServerId())) { - $base .= '/' . $this->getAuthorizationServerId(); - } - $headers = ['Content-Type: application/x-www-form-urlencoded']; $this->tokens = \json_decode($this->request( 'POST', - $base . '/v1/token', + 'https://' . $this->getOktaDomain() . '/oauth2/' . $this->getAuthorizationServerId() . '/v1/token', $headers, \http_build_query([ 'refresh_token' => $refreshToken, @@ -185,13 +170,8 @@ class Okta extends OAuth2 protected function getUser(string $accessToken): array { if (empty($this->user)) { - $base = 'https://' . $this->getOktaDomain() . '/oauth2'; - if(!empty($this->getAuthorizationServerId())) { - $base .= '/' . $this->getAuthorizationServerId(); - } - $headers = ['Authorization: Bearer ' . \urlencode($accessToken)]; - $user = $this->request('GET', $base . '/v1/userinfo', $headers); + $user = $this->request('GET', 'https://' . $this->getOktaDomain() . '/oauth2/' . $this->getAuthorizationServerId() . '/v1/userinfo', $headers); $this->user = \json_decode($user, true); } From e4bfb38a57bb12ceb34ad351db39167ab12c1fd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 26 Apr 2026 11:14:50 +0200 Subject: [PATCH 271/402] add okta provider --- app/init/models.php | 2 + .../Http/Project/OAuth2/Okta/Update.php | 162 ++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + .../Utopia/Response/Model/OAuth2Okta.php | 62 +++++++ 5 files changed, 229 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Okta.php diff --git a/app/init/models.php b/app/init/models.php index f24e2045df..df2ebac150 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -126,6 +126,7 @@ use Appwrite\Utopia\Response\Model\OAuth2Google; use Appwrite\Utopia\Response\Model\OAuth2Linkedin; use Appwrite\Utopia\Response\Model\OAuth2Notion; use Appwrite\Utopia\Response\Model\OAuth2Oidc; +use Appwrite\Utopia\Response\Model\OAuth2Okta; use Appwrite\Utopia\Response\Model\OAuth2Paypal; use Appwrite\Utopia\Response\Model\OAuth2Podio; use Appwrite\Utopia\Response\Model\OAuth2Salesforce; @@ -419,6 +420,7 @@ Response::setModel(new OAuth2Gitlab()); Response::setModel(new OAuth2Authentik()); Response::setModel(new OAuth2Auth0()); Response::setModel(new OAuth2Oidc()); +Response::setModel(new OAuth2Okta()); Response::setModel(new OAuth2Apple()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php new file mode 100644 index 0000000000..dcbf1df343 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php @@ -0,0 +1,162 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.' . $providerId . '.update') + ->label('audits.event', 'project.oauth2.' . $providerId . '.update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('domain', null, new Nullable(new ValidatorDomain()), 'Okta company domain. Required when enabling the provider. For example: trial-6400025.okta.com. Example of wrong value: trial-6400025-admin.okta.com, or https://trial-6400025.okta.com/', optional: true) + ->param('authorizationServerId', null, new Nullable(new Text(256, 0)), 'Custom Authorization Servers. Optional, can be left empty or unconfigured. For example: aus000000000000000h7z', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->handle(...)); + } + + /** + * Custom callback used instead of the parent's `action()` because Okta + * takes additional optional `domain` and `authorizationServerId` parameters. + * The method is named differently to avoid an LSP-incompatible override of + * Base::action(). + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + ?string $domain, + ?string $authorizationServerId, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $providerId = static::getProviderId(); + + // The secret is stored as JSON `{"clientSecret": "...", "oktaDomain": "...", "authorizationServerId": "..."}` + // to match the shape Okta's OAuth2 adapter expects. + // Merge new values with existing storage so that submitting only some of + // the parameters leaves the others untouched. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + + $encodedSecret = null; + if (!\is_null($clientSecret) || !\is_null($domain) || !\is_null($authorizationServerId)) { + $encodedSecret = \json_encode([ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'oktaDomain' => $domain ?? ($existing['oktaDomain'] ?? ''), + 'authorizationServerId' => $authorizationServerId ?? ($existing['authorizationServerId'] ?? ''), + ]); + } + + // Domain is required when enabling the provider, since Okta builds its + // authorization, token and userinfo URLs from it. + if ($enabled === true) { + $effectiveDomain = $domain ?? ($existing['oktaDomain'] ?? ''); + if (empty($effectiveDomain)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Domain is required when enabling Okta OAuth2 provider.'); + } + } + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $storedRaw = $oAuthProviders[$providerId . 'Secret'] ?? ''; + $decoded = []; + if (!empty($storedRaw)) { + $decoded = \json_decode($storedRaw, true) ?: []; + } + + $response->dynamic(new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => $decoded['clientSecret'] ?? '', + 'domain' => $decoded['oktaDomain'] ?? '', + 'authorizationServerId' => $decoded['authorizationServerId'] ?? '', + ]), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index c87e16107d..ec0ffe2997 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -36,6 +36,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Google\Update as Updat use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Linkedin\Update as UpdateOAuth2Linkedin; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Notion\Update as UpdateOAuth2Notion; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Oidc\Update as UpdateOAuth2Oidc; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Okta\Update as UpdateOAuth2Okta; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Paypal\Update as UpdateOAuth2Paypal; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\PaypalSandbox\Update as UpdateOAuth2PaypalSandbox; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Podio\Update as UpdateOAuth2Podio; @@ -203,5 +204,6 @@ class Http extends Service $this->addAction(UpdateOAuth2Authentik::getName(), new UpdateOAuth2Authentik()); $this->addAction(UpdateOAuth2Auth0::getName(), new UpdateOAuth2Auth0()); $this->addAction(UpdateOAuth2Oidc::getName(), new UpdateOAuth2Oidc()); + $this->addAction(UpdateOAuth2Okta::getName(), new UpdateOAuth2Okta()); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 2046df3678..3d8902342f 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -313,6 +313,7 @@ class Response extends SwooleResponse public const MODEL_OAUTH2_AUTH0 = 'oAuth2Auth0'; public const MODEL_OAUTH2_OIDC = 'oAuth2Oidc'; public const MODEL_OAUTH2_APPLE = 'oAuth2Apple'; + public const MODEL_OAUTH2_OKTA = 'oAuth2Okta'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php b/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php new file mode 100644 index 0000000000..a0f9a6a06b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php @@ -0,0 +1,62 @@ +addRule('domain', [ + 'type' => self::TYPE_STRING, + 'description' => 'Okta OAuth 2 domain.', + 'default' => '', + 'example' => 'trial-6400025.okta.com', + ]); + + $this->addRule('authorizationServerId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Okta OAuth 2 authorization server ID.', + 'default' => '', + 'example' => 'aus000000000000000h7z', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Okta'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_OKTA; + } +} From c0c053ff20294ba021cdfad3fb0d1653e9fbe2b3 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 12:52:52 +0530 Subject: [PATCH 272/402] Enhance Realtime adapter with action channel support and tests - Introduced ACTION_ALL and SUPPORTED_ACTIONS constants for better action handling. - Updated channel subscription logic to support action suffixes. - Added tests for action channel parsing and filtering in MessagingTest. --- src/Appwrite/Messaging/Adapter/Realtime.php | 158 +++++++++++- tests/unit/Messaging/MessagingTest.php | 260 ++++++++++++++++++++ 2 files changed, 405 insertions(+), 13 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 8fe7342ec2..a03ccecfd9 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -14,6 +14,18 @@ use Utopia\Database\Query; class Realtime extends MessagingAdapter { + /** + * Action suffix that means "all actions" — i.e. no action filter on this subscription. + */ + public const ACTION_ALL = '*'; + + /** + * Action suffixes recognized in channel names. A channel like `documents.create` + * is split into base channel `documents` plus action `create`. Add new actions + * (e.g. `delete`) here to extend support — no other code change is required. + */ + public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert']; + /** * Connection Tree * @@ -21,7 +33,11 @@ class Realtime extends MessagingAdapter * 'projectId' -> [PROJECT_ID] * 'roles' -> [ROLE_x, ROLE_Y] * 'userId' -> [USER_ID] - * 'channels' -> [CHANNEL_NAME_X, CHANNEL_NAME_Y, CHANNEL_NAME_Z] + * 'channels' -> [BASE_CHANNEL_X, BASE_CHANNEL_Y, BASE_CHANNEL_Z] + * + * Channels here are stored in their *base* form (action suffix stripped) so they + * line up with subscription-tree keys; the original action-prefixed channel is + * reconstructed from per-subscription `actions` metadata when needed. */ public array $connections = []; @@ -32,9 +48,13 @@ class Realtime extends MessagingAdapter * [ROLE_X] -> * [CHANNEL_NAME_X] -> * [CONNECTION_ID] -> - * [SUB_ID] -> ['strings' => [...], 'compiled' => [...]] + * [SUB_ID] -> ['strings' => [...], 'compiled' => [...], 'actions' => [...]] + * + * Each subscription ID maps to query strings (for metadata), pre-compiled query + * filters, and an `actions` metadata list. `actions` is `['*']` by default + * meaning "no action filter"; otherwise a list of action suffixes (e.g. `['create']`) + * that the event must end with for delivery. * - * Each subscription ID maps to query strings (for metadata) and pre-compiled query filters. * Within a subscription: AND logic (all queries must match) * Across subscriptions: OR logic (any subscription matching = send event) */ @@ -81,10 +101,33 @@ class Realtime extends MessagingAdapter $this->subscriptions[$projectId] = []; } + // Split each channel into a base form + an action suffix. Channels without a + // recognised suffix get action '*' (no filter). When the same base channel + // appears with multiple actions in this call (e.g. `documents.create` and + // `documents.update`), the actions are merged onto a single tree entry. + $actionsByBase = []; + $baseChannels = []; + foreach ($channels as $channel) { + [$base, $action] = self::parseActionChannel($channel); + if (!\in_array($base, $baseChannels, true)) { + $baseChannels[] = $base; + } + if (!isset($actionsByBase[$base])) { + $actionsByBase[$base] = [$action]; + continue; + } + // '*' subsumes any specific action — once present, drop the rest. + if (\in_array(self::ACTION_ALL, $actionsByBase[$base], true) || $action === self::ACTION_ALL) { + $actionsByBase[$base] = [self::ACTION_ALL]; + } elseif (!\in_array($action, $actionsByBase[$base], true)) { + $actionsByBase[$base][] = $action; + } + } + $strings = []; $data = []; - if (!empty($channels)) { + if (!empty($baseChannels)) { if (empty($queryGroup)) { $strings[] = Query::select(['*'])->toString(); } else { @@ -103,19 +146,24 @@ class Realtime extends MessagingAdapter $this->subscriptions[$projectId][$role] = []; } - foreach ($channels as $channel) { - if (!isset($this->subscriptions[$projectId][$role][$channel])) { - $this->subscriptions[$projectId][$role][$channel] = []; + foreach ($actionsByBase as $base => $actions) { + if (!isset($this->subscriptions[$projectId][$role][$base])) { + $this->subscriptions[$projectId][$role][$base] = []; } - if (!isset($this->subscriptions[$projectId][$role][$channel][$identifier])) { - $this->subscriptions[$projectId][$role][$channel][$identifier] = []; + if (!isset($this->subscriptions[$projectId][$role][$base][$identifier])) { + $this->subscriptions[$projectId][$role][$base][$identifier] = []; } - $this->subscriptions[$projectId][$role][$channel][$identifier][$subscriptionId] = $data; + + $channelData = $data; + $channelData['actions'] = $actions; + + $this->subscriptions[$projectId][$role][$base][$identifier][$subscriptionId] = $channelData; } } // Union channels/roles across all subscriptions on the connection; overwriting would // leave getSubscriptionMetadata and full unsubscribe operating on stale state. + // Channels are stored in *base* form here so they match subscription-tree keys. $existing = $this->connections[$identifier] ?? []; $existingChannels = $existing['channels'] ?? []; $existingRoles = $existing['roles'] ?? []; @@ -124,7 +172,7 @@ class Realtime extends MessagingAdapter 'projectId' => $projectId, 'roles' => \array_values(\array_unique(\array_merge($existingRoles, $roles))), 'userId' => $userId ?? ($existing['userId'] ?? ''), - 'channels' => \array_values(\array_unique(\array_merge($existingChannels, $channels))), + 'channels' => \array_values(\array_unique(\array_merge($existingChannels, $baseChannels))), ]; if (\array_key_exists('authorization', $existing)) { @@ -171,8 +219,16 @@ class Realtime extends MessagingAdapter 'queries' => $data['strings'] ?? [] ]; } - if (!\in_array($channel, $subscriptions[$subscriptionId]['channels'])) { - $subscriptions[$subscriptionId]['channels'][] = $channel; + + // Re-attach the action suffix so the original subscription channel + // (e.g. `documents.create`) is round-tripped on response paths and + // re-subscribe flows. `*` means no action was set — emit the base. + $actions = $data['actions'] ?? [self::ACTION_ALL]; + foreach ($actions as $action) { + $name = $action === self::ACTION_ALL ? $channel : $channel . '.' . $action; + if (!\in_array($name, $subscriptions[$subscriptionId]['channels'], true)) { + $subscriptions[$subscriptionId]['channels'][] = $name; + } } } } @@ -373,6 +429,7 @@ class Realtime extends MessagingAdapter } $payload = $event['data']['payload'] ?? []; + $events = $event['data']['events'] ?? []; foreach ($this->subscriptions[$event['project']] as $role => $subscriptionsByChannel) { foreach ($event['data']['channels'] as $channel) { @@ -389,6 +446,11 @@ class Realtime extends MessagingAdapter foreach ($subscriptions as $subscriptionId => $data) { $compiled = $data['compiled'] ?? ['type' => 'selectAll']; $strings = $data['strings'] ?? []; + $actions = $data['actions'] ?? [self::ACTION_ALL]; + + if (!self::matchesActions($actions, $events)) { + continue; + } if (RuntimeQuery::filter($compiled, $payload) !== null) { $matched[$subscriptionId] = $strings; @@ -408,6 +470,76 @@ class Realtime extends MessagingAdapter return $receivers; } + /** + * Tests whether any event in `$events` ends with one of the listed actions. + * + * Implements `containsAny` semantics (any-of match) over the events array, + * comparing only the trailing `.`-segment of each event so wildcard variants + * like `databases.*.collections.*.documents.*.create` match action `create`. + * `['*']` (or empty) means "no action filter" and short-circuits to true. + * + * @param array $actions Stored action filter for the subscription. + * @param array $events Event names from the published event. + * @return bool + */ + private static function matchesActions(array $actions, array $events): bool + { + if (empty($actions) || \in_array(self::ACTION_ALL, $actions, true)) { + return true; + } + + foreach ($events as $event) { + $lastDot = \strrpos($event, '.'); + if ($lastDot === false) { + continue; + } + if (\in_array(\substr($event, $lastDot + 1), $actions, true)) { + return true; + } + } + + return false; + } + + /** + * Splits a channel name into its base form and an action suffix. + * + * A trailing segment that matches one of {@see self::SUPPORTED_ACTIONS} is treated + * as an action filter and stripped from the channel; the remaining prefix becomes + * the base channel used for subscription-tree lookup. When no recognised suffix is + * present, the channel is returned unchanged with action {@see self::ACTION_ALL} + * (meaning "no action filter"). + * + * Examples: + * `documents.create` -> [`documents`, `create`] + * `databases.X.collections.Y.documents.Z.create` -> [`databases.X.collections.Y.documents.Z`, `create`] + * `documents` -> [`documents`, `*`] + * `account.create` -> already filtered out by convertChannels() + * + * @param string $channel + * @return array{0: string, 1: string} [baseChannel, action] + */ + public static function parseActionChannel(string $channel): array + { + $lastDot = \strrpos($channel, '.'); + if ($lastDot === false) { + return [$channel, self::ACTION_ALL]; + } + + $suffix = \substr($channel, $lastDot + 1); + if (!\in_array($suffix, self::SUPPORTED_ACTIONS, true)) { + return [$channel, self::ACTION_ALL]; + } + + $base = \substr($channel, 0, $lastDot); + if ($base === '') { + // Pathological — channel was just ".create"; leave it alone. + return [$channel, self::ACTION_ALL]; + } + + return [$base, $suffix]; + } + /** * Converts the channels from the Query Params into an array. * Also renames the account channel to account.USER_ID and removes all illegal account channel variations. diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index f48be46202..0706f8e89b 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -517,4 +517,264 @@ class MessagingTest extends TestCase $this->assertContains(Role::any()->toString(), $result['roles']); $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } + + public function testParseActionChannel(): void + { + $this->assertSame(['documents', 'create'], Realtime::parseActionChannel('documents.create')); + $this->assertSame(['documents', 'update'], Realtime::parseActionChannel('documents.update')); + $this->assertSame(['documents', 'upsert'], Realtime::parseActionChannel('documents.upsert')); + $this->assertSame( + ['databases.X.collections.Y.documents.Z', 'create'], + Realtime::parseActionChannel('databases.X.collections.Y.documents.Z.create') + ); + + // No action suffix → unchanged with '*' default. + $this->assertSame(['documents', '*'], Realtime::parseActionChannel('documents')); + $this->assertSame(['documents.789', '*'], Realtime::parseActionChannel('documents.789')); + + // Unrecognised suffix (e.g. delete is not yet supported) → unchanged. + $this->assertSame(['documents.delete', '*'], Realtime::parseActionChannel('documents.delete')); + } + + public function testActionChannelFiltersByEventAction(): void + { + $realtime = new Realtime(); + + // Two subscriptions on the same connection: one filtered to creates only, + // one filtered to updates only. + $realtime->subscribe( + '1', + 1, + 'sub-create', + [Role::any()->toString()], + ['documents.create'], + ); + $realtime->subscribe( + '1', + 1, + 'sub-update', + [Role::any()->toString()], + ['documents.update'], + ); + + $createEvent = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents'], + 'events' => [ + 'databases.db.collections.col.documents.doc.create', + 'databases.*.collections.*.documents.*.create', + ], + 'payload' => ['$id' => 'doc'], + ], + ]; + + $updateEvent = $createEvent; + $updateEvent['data']['events'] = [ + 'databases.db.collections.col.documents.doc.update', + 'databases.*.collections.*.documents.*.update', + ]; + + // Create event should only deliver to sub-create. + $receivers = $realtime->getSubscribers($createEvent); + $this->assertCount(1, $receivers); + $this->assertArrayHasKey(1, $receivers); + $this->assertArrayHasKey('sub-create', $receivers[1]); + $this->assertArrayNotHasKey('sub-update', $receivers[1]); + + // Update event should only deliver to sub-update. + $receivers = $realtime->getSubscribers($updateEvent); + $this->assertCount(1, $receivers); + $this->assertArrayHasKey('sub-update', $receivers[1]); + $this->assertArrayNotHasKey('sub-create', $receivers[1]); + } + + public function testActionChannelHonorsResourceId(): void + { + $realtime = new Realtime(); + + // Subscribe to creates on a specific document only. + $realtime->subscribe( + '1', + 1, + 'sub-doc-create', + [Role::any()->toString()], + ['documents.789.create'], + ); + + // The base channel for `documents.789.create` is `documents.789`. + $event = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents.789'], + 'events' => [ + 'databases.db.collections.col.documents.789.create', + 'databases.*.collections.*.documents.*.create', + ], + 'payload' => ['$id' => '789'], + ], + ]; + + $receivers = $realtime->getSubscribers($event); + $this->assertCount(1, $receivers); + $this->assertArrayHasKey('sub-doc-create', $receivers[1]); + + // Update on the same document should not match. + $event['data']['events'] = [ + 'databases.db.collections.col.documents.789.update', + 'databases.*.collections.*.documents.*.update', + ]; + + $this->assertEmpty($realtime->getSubscribers($event)); + + // Create on a different document should not match (different base channel + // entirely; subscription tree key won't even line up). + $event['data']['channels'] = ['documents.999']; + $event['data']['events'] = [ + 'databases.db.collections.col.documents.999.create', + 'databases.*.collections.*.documents.*.create', + ]; + + $this->assertEmpty($realtime->getSubscribers($event)); + } + + public function testNonActionChannelStillReceivesAllEvents(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-all', + [Role::any()->toString()], + ['documents'], + ); + + $event = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents'], + 'events' => [ + 'databases.db.collections.col.documents.doc.create', + ], + 'payload' => ['$id' => 'doc'], + ], + ]; + + $this->assertArrayHasKey(1, $realtime->getSubscribers($event)); + + $event['data']['events'] = ['databases.db.collections.col.documents.doc.update']; + $this->assertArrayHasKey(1, $realtime->getSubscribers($event)); + + $event['data']['events'] = ['databases.db.collections.col.documents.doc.upsert']; + $this->assertArrayHasKey(1, $realtime->getSubscribers($event)); + } + + public function testMixedActionAndBaseChannelInSameSubscription(): void + { + $realtime = new Realtime(); + + // Same sub-id covers `documents.create` (filtered) and `files` (unfiltered). + // After parsing they live under different base-channel keys with their own + // action metadata, so each gets its own filter behaviour. + $realtime->subscribe( + '1', + 1, + 'sub-mixed', + [Role::any()->toString()], + ['documents.create', 'files'], + ); + + // Create event on documents → matches. + $createDoc = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents'], + 'events' => ['databases.db.collections.col.documents.doc.create'], + 'payload' => [], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($createDoc)); + + // Update event on documents → blocked by the action filter on the documents key. + $updateDoc = $createDoc; + $updateDoc['data']['events'] = ['databases.db.collections.col.documents.doc.update']; + $this->assertEmpty($realtime->getSubscribers($updateDoc)); + + // Files channel has no action filter — any action delivers. + $updateFile = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['files'], + 'events' => ['buckets.bucket.files.file.update'], + 'payload' => [], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($updateFile)); + } + + public function testActionChannelMetadataRoundTrips(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-create', + [Role::any()->toString()], + ['documents.create', 'files'], + ); + + $meta = $realtime->getSubscriptionMetadata(1); + + $this->assertArrayHasKey('sub-create', $meta); + $this->assertContains('documents.create', $meta['sub-create']['channels']); + $this->assertContains('files', $meta['sub-create']['channels']); + // Base form should NOT leak when an action was set. + $this->assertNotContains('documents', $meta['sub-create']['channels']); + } + + public function testMergingMultipleActionsOnSameBaseChannel(): void + { + $realtime = new Realtime(); + + // Subscribing to multiple actions on the same base merges their action lists + // onto a single tree entry. + $realtime->subscribe( + '1', + 1, + 'sub-multi', + [Role::any()->toString()], + ['documents.create', 'documents.update'], + ); + + $createEvent = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents'], + 'events' => ['databases.db.collections.col.documents.doc.create'], + 'payload' => [], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($createEvent)); + + $updateEvent = $createEvent; + $updateEvent['data']['events'] = ['databases.db.collections.col.documents.doc.update']; + $this->assertArrayHasKey(1, $realtime->getSubscribers($updateEvent)); + + // Upsert should not match — neither create nor update covers it. + $upsertEvent = $createEvent; + $upsertEvent['data']['events'] = ['databases.db.collections.col.documents.doc.upsert']; + $this->assertEmpty($realtime->getSubscribers($upsertEvent)); + + $meta = $realtime->getSubscriptionMetadata(1); + $this->assertContains('documents.create', $meta['sub-multi']['channels']); + $this->assertContains('documents.update', $meta['sub-multi']['channels']); + } } From d2423a5bb51e5fe0d1ec34c9c38458caa77fefbd Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 12:57:35 +0530 Subject: [PATCH 273/402] added tests --- .../Services/Realtime/RealtimeQueryBase.php | 420 ++++++++++++++++++ 1 file changed, 420 insertions(+) diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php index 04b8400b57..24d2a3511a 100644 --- a/tests/e2e/Services/Realtime/RealtimeQueryBase.php +++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php @@ -2446,4 +2446,424 @@ trait RealtimeQueryBase $clientWithMatchingQuery->close(); $clientWithNonMatchingQuery->close(); } + + /** + * Sets up a database + collection + 'name' string attribute, returning their IDs. + * Used by action-channel tests to avoid duplicating fixture code. + * + * @return array{databaseId: string, collectionId: string} + */ + private function createActorsCollection(): array + { + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Action Channel DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Actors', + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/name', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + return ['databaseId' => $databaseId, 'collectionId' => $collectionId]; + } + + /** + * Creates a document with the given ID and name. Returns the parsed body. + * Permissions allow Role::any() for all CRUD so any session can observe the events. + * + * @return array + */ + private function createActor(string $databaseId, string $collectionId, string $documentId, string $name): array + { + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => $documentId, + 'data' => ['name' => $name], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + return $document['body']; + } + + public function testChannelActionFilterReflectedInConnectedResponse(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + // Subscribing with an action suffix should round-trip the original channel + // name on the connected response. Only meaningful in URL-subscribe mode — + // the message-based path consumes the connected response inside its + // getWebsocket helper before returning, so we can't observe it here. + $client = $this->getWebsocket([ + 'documents.create', + 'documents.update', + 'documents.upsert', + 'documents', + ], $headers); + + $connected = $this->assertConnectionStatusIfSupported($client); + if ($connected === null) { + $client->close(); + $this->markTestSkipped('Connected-response channels are not surfaced through the message-based subscribe path.'); + } + + $this->assertContains('documents.create', $connected['data']['channels']); + $this->assertContains('documents.update', $connected['data']['channels']); + $this->assertContains('documents.upsert', $connected['data']['channels']); + $this->assertContains('documents', $connected['data']['channels']); + + $client->close(); + } + + public function testChannelActionFilterDeliversOnlyMatchingActions(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + $createChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.create"; + $updateChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.update"; + $upsertChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.upsert"; + + $clientCreate = $this->getWebsocket([$createChannel], $headers); + $clientUpdate = $this->getWebsocket([$updateChannel], $headers); + $clientUpsert = $this->getWebsocket([$upsertChannel], $headers); + + $this->assertConnectionStatusIfSupported($clientCreate); + $this->assertConnectionStatusIfSupported($clientUpdate); + $this->assertConnectionStatusIfSupported($clientUpsert); + + $documentId = ID::unique(); + $this->createActor($databaseId, $collectionId, $documentId, 'Chris Evans'); + + // Create event delivers only to the .create subscriber. + $createEvent = json_decode($clientCreate->receive(), true); + $this->assertEquals('event', $createEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}.create", + $createEvent['data']['events'] + ); + $this->assertEquals('Chris Evans', $createEvent['data']['payload']['name']); + + try { + $clientUpdate->receive(); + $this->fail('Update subscriber should not receive a create event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + try { + $clientUpsert->receive(); + $this->fail('Upsert subscriber should not receive a create event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + // Update fires update events; only the .update subscriber should hear them. + $this->client->call(Client::METHOD_PATCH, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'data' => ['name' => 'Chris Evans 2'], + ]); + + $updateEvent = json_decode($clientUpdate->receive(), true); + $this->assertEquals('event', $updateEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}.update", + $updateEvent['data']['events'] + ); + $this->assertEquals('Chris Evans 2', $updateEvent['data']['payload']['name']); + + try { + $clientCreate->receive(); + $this->fail('Create subscriber should not receive an update event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + try { + $clientUpsert->receive(); + $this->fail('Upsert subscriber should not receive an update event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + // PUT bulk upsert fires upsert events; only the .upsert subscriber should hear them. + $this->client->call(Client::METHOD_PUT, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'documents' => [ + [ + '$id' => ID::unique(), + 'name' => 'Robert Downey Jr.', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ], + ], + ]); + + $upsertEvent = json_decode($clientUpsert->receive(), true); + $this->assertEquals('event', $upsertEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.*.documents.*.upsert", + $upsertEvent['data']['events'] + ); + + try { + $clientCreate->receive(); + $this->fail('Create subscriber should not receive an upsert event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + try { + $clientUpdate->receive(); + $this->fail('Update subscriber should not receive an upsert event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $clientCreate->close(); + $clientUpdate->close(); + $clientUpsert->close(); + } + + public function testChannelActionFilterByDocumentId(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + // Use a known custom ID so the .id.action channel can be subscribed before the + // document exists. Without this the channel name can't be predicted. + $watchedId = 'actor-watched'; + $idCreateChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create"; + + $clientWatched = $this->getWebsocket([$idCreateChannel], $headers); + $connected = $this->assertConnectionStatusIfSupported($clientWatched); + if ($connected !== null) { + $this->assertContains($idCreateChannel, $connected['data']['channels']); + } + + // Creating a *different* document should not trigger the watched-id subscription. + $this->createActor($databaseId, $collectionId, ID::unique(), 'Other Actor'); + + try { + $clientWatched->receive(); + $this->fail('Subscriber to .{id}.create should not receive events for a different document.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + // Creating the watched document delivers exactly one create event. + $this->createActor($databaseId, $collectionId, $watchedId, 'Watched Actor'); + + $event = json_decode($clientWatched->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create", + $event['data']['events'] + ); + $this->assertEquals($watchedId, $event['data']['payload']['$id']); + $this->assertEquals('Watched Actor', $event['data']['payload']['name']); + + // Updating the watched document does NOT match — action filter is `create` only. + $this->client->call(Client::METHOD_PATCH, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$watchedId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'data' => ['name' => 'Watched Actor v2'], + ]); + + try { + $clientWatched->receive(); + $this->fail('Subscriber to .{id}.create should not receive update events on the same document.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $clientWatched->close(); + } + + public function testChannelActionFilterMultiChannelSubscription(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + $watchedId = 'actor-multi'; + $idCreateChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create"; + $rowsChannel = "databases.{$databaseId}.tables.{$collectionId}.rows"; + + // One subscription that listens on both: + // 1. `databases...documents.{watchedId}.create` — narrow, action-filtered + // 2. `databases...tables.{collectionId}.rows` — broad, non-action (tablesdb mirror) + // A create on the watched document must reach this subscriber via *both* channels. + $clientMulti = $this->getWebsocket([$idCreateChannel, $rowsChannel], $headers); + $connected = $this->assertConnectionStatusIfSupported($clientMulti); + if ($connected !== null) { + $this->assertContains($idCreateChannel, $connected['data']['channels']); + $this->assertContains($rowsChannel, $connected['data']['channels']); + } + + $this->createActor($databaseId, $collectionId, $watchedId, 'Multi Actor'); + + $event = json_decode($clientMulti->receive(), true); + $this->assertEquals('event', $event['type']); + // The event payload's channels list reports the underlying base channels that + // the published event carries. Both the broad rows channel and the document + // channel that the action filter is anchored on should be present. + $this->assertContains($rowsChannel, $event['data']['channels']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}", + $event['data']['channels'] + ); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create", + $event['data']['events'] + ); + $this->assertEquals('Multi Actor', $event['data']['payload']['name']); + + // Update on the same doc: the .{id}.create branch is filtered out, but the + // broad rows channel has no action filter — the subscription still receives + // the event via that branch (a single delivery, not two). + $this->client->call(Client::METHOD_PATCH, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$watchedId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'data' => ['name' => 'Multi Actor v2'], + ]); + + $update = json_decode($clientMulti->receive(), true); + $this->assertEquals('event', $update['type']); + $this->assertContains($rowsChannel, $update['data']['channels']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.update", + $update['data']['events'] + ); + + // No second copy of the same update should arrive — getSubscribers folds + // multi-channel matches into a single connection delivery. + try { + $clientMulti->receive(); + $this->fail('Multi-channel subscriber should receive a single delivery per event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $clientMulti->close(); + } + + public function testChannelActionFilterUnsupportedActionTreatedAsLiteral(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + // `delete` is intentionally NOT in SUPPORTED_ACTIONS yet, so parseActionChannel + // leaves the channel name intact and treats it as a literal channel that no + // published event ever carries — the subscriber should receive nothing. + $client = $this->getWebsocket(['documents.delete'], $headers); + $connected = $this->assertConnectionStatusIfSupported($client); + if ($connected !== null) { + $this->assertContains('documents.delete', $connected['data']['channels']); + } + + $documentId = ID::unique(); + $this->createActor($databaseId, $collectionId, $documentId, 'No Delete Listener'); + + $this->client->call(Client::METHOD_DELETE, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders())); + + try { + $client->receive(); + $this->fail('`documents.delete` is not (yet) a supported action channel and should not deliver.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $client->close(); + } } From 3aee54747caa6ff601149af2f2beee108d3852f4 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 13:15:04 +0530 Subject: [PATCH 274/402] Enhance Realtime adapter to support delete action and add corresponding tests --- src/Appwrite/Messaging/Adapter/Realtime.php | 4 +- .../Services/Realtime/RealtimeQueryBase.php | 65 ++++++++++++++++--- tests/unit/Messaging/MessagingTest.php | 47 +++++++++++++- 3 files changed, 103 insertions(+), 13 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index a03ccecfd9..f01b02ba21 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -22,9 +22,9 @@ class Realtime extends MessagingAdapter /** * Action suffixes recognized in channel names. A channel like `documents.create` * is split into base channel `documents` plus action `create`. Add new actions - * (e.g. `delete`) here to extend support — no other code change is required. + * here to extend support — no other code change is required. */ - public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert']; + public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete']; /** * Connection Tree diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php index 24d2a3511a..5ab5c26253 100644 --- a/tests/e2e/Services/Realtime/RealtimeQueryBase.php +++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php @@ -2827,7 +2827,7 @@ trait RealtimeQueryBase $clientMulti->close(); } - public function testChannelActionFilterUnsupportedActionTreatedAsLiteral(): void + public function testChannelActionFilterDeliversDeleteEvents(): void { $user = $this->getUser(); $session = $user['session'] ?? ''; @@ -2840,17 +2840,64 @@ trait RealtimeQueryBase ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); - // `delete` is intentionally NOT in SUPPORTED_ACTIONS yet, so parseActionChannel - // leaves the channel name intact and treats it as a literal channel that no - // published event ever carries — the subscriber should receive nothing. - $client = $this->getWebsocket(['documents.delete'], $headers); - $connected = $this->assertConnectionStatusIfSupported($client); + $deleteChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.delete"; + $clientDelete = $this->getWebsocket([$deleteChannel], $headers); + $connected = $this->assertConnectionStatusIfSupported($clientDelete); if ($connected !== null) { - $this->assertContains('documents.delete', $connected['data']['channels']); + $this->assertContains($deleteChannel, $connected['data']['channels']); } $documentId = ID::unique(); - $this->createActor($databaseId, $collectionId, $documentId, 'No Delete Listener'); + $this->createActor($databaseId, $collectionId, $documentId, 'About To Be Deleted'); + + // Create event must not arrive — the action filter is `delete`. + try { + $clientDelete->receive(); + $this->fail('Delete subscriber should not receive a create event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $this->client->call(Client::METHOD_DELETE, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders())); + + $deleteEvent = json_decode($clientDelete->receive(), true); + $this->assertEquals('event', $deleteEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}.delete", + $deleteEvent['data']['events'] + ); + $this->assertEquals($documentId, $deleteEvent['data']['payload']['$id']); + + $clientDelete->close(); + } + + public function testChannelActionFilterUnknownSuffixTreatedAsLiteral(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + // An unrecognised suffix is NOT in SUPPORTED_ACTIONS, so parseActionChannel + // leaves the channel name intact and treats it as a literal channel that no + // published event ever carries — the subscriber should receive nothing. + $client = $this->getWebsocket(['documents.bogus'], $headers); + $connected = $this->assertConnectionStatusIfSupported($client); + if ($connected !== null) { + $this->assertContains('documents.bogus', $connected['data']['channels']); + } + + $documentId = ID::unique(); + $this->createActor($databaseId, $collectionId, $documentId, 'No Bogus Listener'); $this->client->call(Client::METHOD_DELETE, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([ 'content-type' => 'application/json', @@ -2859,7 +2906,7 @@ trait RealtimeQueryBase try { $client->receive(); - $this->fail('`documents.delete` is not (yet) a supported action channel and should not deliver.'); + $this->fail('Unrecognised action suffix should not deliver any events.'); } catch (TimeoutException $e) { $this->addToAssertionCount(1); } diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 0706f8e89b..1fde8e6bc2 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -523,17 +523,22 @@ class MessagingTest extends TestCase $this->assertSame(['documents', 'create'], Realtime::parseActionChannel('documents.create')); $this->assertSame(['documents', 'update'], Realtime::parseActionChannel('documents.update')); $this->assertSame(['documents', 'upsert'], Realtime::parseActionChannel('documents.upsert')); + $this->assertSame(['documents', 'delete'], Realtime::parseActionChannel('documents.delete')); $this->assertSame( ['databases.X.collections.Y.documents.Z', 'create'], Realtime::parseActionChannel('databases.X.collections.Y.documents.Z.create') ); + $this->assertSame( + ['databases.X.collections.Y.documents.Z', 'delete'], + Realtime::parseActionChannel('databases.X.collections.Y.documents.Z.delete') + ); // No action suffix → unchanged with '*' default. $this->assertSame(['documents', '*'], Realtime::parseActionChannel('documents')); $this->assertSame(['documents.789', '*'], Realtime::parseActionChannel('documents.789')); - // Unrecognised suffix (e.g. delete is not yet supported) → unchanged. - $this->assertSame(['documents.delete', '*'], Realtime::parseActionChannel('documents.delete')); + // Unrecognised suffix → unchanged (treated as literal channel name). + $this->assertSame(['documents.bogus', '*'], Realtime::parseActionChannel('documents.bogus')); } public function testActionChannelFiltersByEventAction(): void @@ -590,6 +595,44 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('sub-create', $receivers[1]); } + public function testActionChannelDeleteFilter(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-delete', + [Role::any()->toString()], + ['documents.delete'], + ); + + $deleteEvent = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents'], + 'events' => [ + 'databases.db.collections.col.documents.doc.delete', + 'databases.*.collections.*.documents.*.delete', + ], + 'payload' => ['$id' => 'doc'], + ], + ]; + + $receivers = $realtime->getSubscribers($deleteEvent); + $this->assertArrayHasKey(1, $receivers); + $this->assertArrayHasKey('sub-delete', $receivers[1]); + + // Other actions on the same base channel should not match the delete filter. + $createEvent = $deleteEvent; + $createEvent['data']['events'] = [ + 'databases.db.collections.col.documents.doc.create', + 'databases.*.collections.*.documents.*.create', + ]; + $this->assertEmpty($realtime->getSubscribers($createEvent)); + } + public function testActionChannelHonorsResourceId(): void { $realtime = new Realtime(); From 6d4a66fbb380f398e5680dcea431ddd920818d09 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 13:30:18 +0530 Subject: [PATCH 275/402] Enhance Realtime adapter to support action-channel awareness in subscriber checks and add corresponding tests --- src/Appwrite/Messaging/Adapter/Realtime.php | 51 +++++++++-- tests/unit/Messaging/MessagingTest.php | 98 +++++++++++++++++++++ 2 files changed, 141 insertions(+), 8 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index f01b02ba21..66052bda3d 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -105,6 +105,13 @@ class Realtime extends MessagingAdapter // recognised suffix get action '*' (no filter). When the same base channel // appears with multiple actions in this call (e.g. `documents.create` and // `documents.update`), the actions are merged onto a single tree entry. + // + // We keep '*' alongside specific actions when both are subscribed to (e.g. + // `[documents, documents.create]`). matchesActions short-circuits on '*' so + // event delivery is unchanged, but getSubscriptionMetadata can faithfully + // round-trip both channel names through re-auth / permissions-changed flows + // — otherwise the `.create` would be dropped and the resubscribed entry + // would silently broaden its semantics on the next refresh. $actionsByBase = []; $baseChannels = []; foreach ($channels as $channel) { @@ -116,10 +123,7 @@ class Realtime extends MessagingAdapter $actionsByBase[$base] = [$action]; continue; } - // '*' subsumes any specific action — once present, drop the rest. - if (\in_array(self::ACTION_ALL, $actionsByBase[$base], true) || $action === self::ACTION_ALL) { - $actionsByBase[$base] = [self::ACTION_ALL]; - } elseif (!\in_array($action, $actionsByBase[$base], true)) { + if (!\in_array($action, $actionsByBase[$base], true)) { $actionsByBase[$base][] = $action; } } @@ -355,6 +359,13 @@ class Realtime extends MessagingAdapter /** * Checks if Channel has a subscriber. + * + * Action-channel aware: if `$channel` carries a recognised action suffix + * (e.g. `documents.create`), the lookup is performed against the *base* + * channel in the tree and additionally requires at least one subscription + * whose `actions` list includes that action (or `'*'`, which subsumes it). + * Plain channel names are matched as before. + * * @param string $projectId * @param string $role * @param string $channel @@ -368,10 +379,34 @@ class Realtime extends MessagingAdapter && array_key_exists($role, $this->subscriptions[$projectId]); } - return array_key_exists($projectId, $this->subscriptions) - && array_key_exists($role, $this->subscriptions[$projectId]) - && array_key_exists($channel, $this->subscriptions[$projectId][$role]) - && !empty($this->subscriptions[$projectId][$role][$channel]); + [$base, $action] = self::parseActionChannel($channel); + + if ( + !array_key_exists($projectId, $this->subscriptions) + || !array_key_exists($role, $this->subscriptions[$projectId]) + || !array_key_exists($base, $this->subscriptions[$projectId][$role]) + || empty($this->subscriptions[$projectId][$role][$base]) + ) { + return false; + } + + // Plain channel — any subscription on the base counts. + if ($action === self::ACTION_ALL) { + return true; + } + + // Action-specific channel — require a subscription whose actions list + // includes the action (or '*'). + foreach ($this->subscriptions[$projectId][$role][$base] as $byConnection) { + foreach ($byConnection as $data) { + $actions = $data['actions'] ?? [self::ACTION_ALL]; + if (\in_array(self::ACTION_ALL, $actions, true) || \in_array($action, $actions, true)) { + return true; + } + } + } + + return false; } /** diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 1fde8e6bc2..aecb3894eb 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -518,6 +518,56 @@ class MessagingTest extends TestCase $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } + public function testHasSubscriberIsActionChannelAware(): void + { + $realtime = new Realtime(); + + $realtime->subscribe( + '1', + 1, + 'sub-create', + [Role::any()->toString()], + ['documents.create'], + ); + + // Plain base lookup hits the subscription. + $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents')); + + // Action-channel lookup matches when the action is in the stored list. + $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.create')); + + // Action-channel lookup misses when the action is not stored — even though + // the base channel exists. + $this->assertFalse($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.update')); + + // Unknown project / role still resolves to false. + $this->assertFalse($realtime->hasSubscriber('nope', Role::any()->toString(), 'documents.create')); + $this->assertFalse($realtime->hasSubscriber('1', 'role:other', 'documents.create')); + + // No-channel form still works. + $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString())); + } + + public function testHasSubscriberWildcardActionsSubsumeSpecific(): void + { + $realtime = new Realtime(); + + // Subscribing to plain `documents` stores actions = ['*']. Any action-channel + // lookup against the same base must succeed because '*' subsumes specific actions. + $realtime->subscribe( + '1', + 1, + 'sub-all', + [Role::any()->toString()], + ['documents'], + ); + + $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents')); + $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.create')); + $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.update')); + $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.delete')); + } + public function testParseActionChannel(): void { $this->assertSame(['documents', 'create'], Realtime::parseActionChannel('documents.create')); @@ -782,6 +832,54 @@ class MessagingTest extends TestCase $this->assertNotContains('documents', $meta['sub-create']['channels']); } + public function testActionAndBaseChannelTogetherRoundTripsLosslessly(): void + { + $realtime = new Realtime(); + + // Subscribing with both a specific-action channel AND its plain base form must + // preserve both names: '*' short-circuits delivery (so update events still + // come through), but the metadata kept for re-auth/permissions-changed flows + // would otherwise drop `documents.create` entirely on the next refresh. + $realtime->subscribe( + '1', + 1, + 'sub-mixed', + [Role::any()->toString()], + ['documents.create', 'documents'], + ); + + $meta = $realtime->getSubscriptionMetadata(1); + $this->assertContains('documents.create', $meta['sub-mixed']['channels']); + $this->assertContains('documents', $meta['sub-mixed']['channels']); + + // Update events still deliver because '*' is in the actions list. + $updateEvent = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents'], + 'events' => ['databases.db.collections.col.documents.doc.update'], + 'payload' => [], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($updateEvent)); + + // Round-trip: feed the metadata back through subscribe() and the original + // pair of channel names must come out again. + $realtime->unsubscribe(1); + $realtime->subscribe( + '1', + 1, + 'sub-mixed', + [Role::any()->toString()], + $meta['sub-mixed']['channels'], + ); + + $metaAgain = $realtime->getSubscriptionMetadata(1); + $this->assertContains('documents.create', $metaAgain['sub-mixed']['channels']); + $this->assertContains('documents', $metaAgain['sub-mixed']['channels']); + } + public function testMergingMultipleActionsOnSameBaseChannel(): void { $realtime = new Realtime(); From df57ee2a321b5d4e40ed90cc15806bfe5832fd76 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 13:43:23 +0530 Subject: [PATCH 276/402] added unit test --- tests/unit/Messaging/MessagingTest.php | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index aecb3894eb..c82a55e438 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -832,6 +832,45 @@ class MessagingTest extends TestCase $this->assertNotContains('documents', $meta['sub-create']['channels']); } + public function testSubscribeWithSameSubIdReplacesActionsNotMerges(): void + { + $realtime = new Realtime(); + $role = Role::any()->toString(); + + // Initial subscribe: only `create` events on the documents base. + $realtime->subscribe('1', 1, 'sub-x', [$role], ['documents.create']); + + $createEvent = [ + 'project' => '1', + 'roles' => [$role], + 'data' => [ + 'channels' => ['documents'], + 'events' => ['databases.db.collections.col.documents.doc.create'], + 'payload' => [], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($createEvent)); + + // Re-subscribe with the SAME sub-id but a different action. Per the upsert + // contract documented on Realtime::subscribe, this fully replaces the prior + // state — actions are NOT unioned across calls (channels and queries already + // followed replace-not-merge semantics; actions match that rule). + $realtime->subscribe('1', 1, 'sub-x', [$role], ['documents.update']); + + // Create no longer matches: previous filter is gone. + $this->assertEmpty($realtime->getSubscribers($createEvent)); + + // Update now matches. + $updateEvent = $createEvent; + $updateEvent['data']['events'] = ['databases.db.collections.col.documents.doc.update']; + $this->assertArrayHasKey(1, $realtime->getSubscribers($updateEvent)); + + // Metadata reflects only the new state. + $meta = $realtime->getSubscriptionMetadata(1); + $this->assertContains('documents.update', $meta['sub-x']['channels']); + $this->assertNotContains('documents.create', $meta['sub-x']['channels']); + } + public function testActionAndBaseChannelTogetherRoundTripsLosslessly(): void { $realtime = new Realtime(); From 78715e4a1a8386e1f9a9e9da57353a7564191da4 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 15:46:02 +0530 Subject: [PATCH 277/402] refactor(tests): rename test methods to snake_case and update assertions for action channels - Changed test method names from camelCase to snake_case for consistency. - Updated assertions to ensure action channels are correctly emitted and filtered. - Improved readability and maintainability of the test suite by restructuring test cases. --- src/Appwrite/Messaging/Adapter/Realtime.php | 390 ++++--------- tests/unit/Messaging/MessagingTest.php | 612 ++++++-------------- 2 files changed, 288 insertions(+), 714 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 66052bda3d..7be0911b8c 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -14,18 +14,19 @@ use Utopia\Database\Query; class Realtime extends MessagingAdapter { - /** - * Action suffix that means "all actions" — i.e. no action filter on this subscription. - */ - public const ACTION_ALL = '*'; - - /** - * Action suffixes recognized in channel names. A channel like `documents.create` - * is split into base channel `documents` plus action `create`. Add new actions - * here to extend support — no other code change is required. - */ public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete']; + private const RESOURCE_LEAF_NAMES = [ + 'documents', + 'rows', + 'files', + 'executions', + 'functions', + 'account', + 'teams', + 'memberships', + ]; + /** * Connection Tree * @@ -33,11 +34,7 @@ class Realtime extends MessagingAdapter * 'projectId' -> [PROJECT_ID] * 'roles' -> [ROLE_x, ROLE_Y] * 'userId' -> [USER_ID] - * 'channels' -> [BASE_CHANNEL_X, BASE_CHANNEL_Y, BASE_CHANNEL_Z] - * - * Channels here are stored in their *base* form (action suffix stripped) so they - * line up with subscription-tree keys; the original action-prefixed channel is - * reconstructed from per-subscription `actions` metadata when needed. + * 'channels' -> [CHANNEL_NAME_X, CHANNEL_NAME_Y, CHANNEL_NAME_Z] */ public array $connections = []; @@ -48,13 +45,9 @@ class Realtime extends MessagingAdapter * [ROLE_X] -> * [CHANNEL_NAME_X] -> * [CONNECTION_ID] -> - * [SUB_ID] -> ['strings' => [...], 'compiled' => [...], 'actions' => [...]] - * - * Each subscription ID maps to query strings (for metadata), pre-compiled query - * filters, and an `actions` metadata list. `actions` is `['*']` by default - * meaning "no action filter"; otherwise a list of action suffixes (e.g. `['create']`) - * that the event must end with for delivery. + * [SUB_ID] -> ['strings' => [...], 'compiled' => [...]] * + * Each subscription ID maps to query strings (for metadata) and pre-compiled query filters. * Within a subscription: AND logic (all queries must match) * Across subscriptions: OR logic (any subscription matching = send event) */ @@ -65,8 +58,6 @@ class Realtime extends MessagingAdapter /** * Get the PubSubPool instance, initializing it lazily if needed. * This allows unit tests to work without requiring the global $register. - * - * @return PubSubPool */ private function getPubSubPool(): PubSubPool { @@ -74,19 +65,18 @@ class Realtime extends MessagingAdapter global $register; $this->pubSubPool = new PubSubPool($register->get('pools')->get('pubsub')); } + return $this->pubSubPool; } /** * Adds a subscription with a specific subscription ID. * - * @param string $projectId - * @param mixed $identifier Connection ID - * @param string $subscriptionId Unique subscription ID - * @param array $roles User roles - * @param array $channels Channels to subscribe to (array of channel names) - * @param array $queryGroup Array of Query objects for this subscription (AND logic within subscription) - * @return void + * @param mixed $identifier Connection ID + * @param string $subscriptionId Unique subscription ID + * @param array $roles User roles + * @param array $channels Channels to subscribe to (array of channel names) + * @param array $queryGroup Array of Query objects for this subscription (AND logic within subscription) */ public function subscribe( string $projectId, @@ -97,41 +87,14 @@ class Realtime extends MessagingAdapter array $queryGroup = [], ?string $userId = null ): void { - if (!isset($this->subscriptions[$projectId])) { // Init Project + if (! isset($this->subscriptions[$projectId])) { // Init Project $this->subscriptions[$projectId] = []; } - // Split each channel into a base form + an action suffix. Channels without a - // recognised suffix get action '*' (no filter). When the same base channel - // appears with multiple actions in this call (e.g. `documents.create` and - // `documents.update`), the actions are merged onto a single tree entry. - // - // We keep '*' alongside specific actions when both are subscribed to (e.g. - // `[documents, documents.create]`). matchesActions short-circuits on '*' so - // event delivery is unchanged, but getSubscriptionMetadata can faithfully - // round-trip both channel names through re-auth / permissions-changed flows - // — otherwise the `.create` would be dropped and the resubscribed entry - // would silently broaden its semantics on the next refresh. - $actionsByBase = []; - $baseChannels = []; - foreach ($channels as $channel) { - [$base, $action] = self::parseActionChannel($channel); - if (!\in_array($base, $baseChannels, true)) { - $baseChannels[] = $base; - } - if (!isset($actionsByBase[$base])) { - $actionsByBase[$base] = [$action]; - continue; - } - if (!\in_array($action, $actionsByBase[$base], true)) { - $actionsByBase[$base][] = $action; - } - } - $strings = []; $data = []; - if (!empty($baseChannels)) { + if (! empty($channels)) { if (empty($queryGroup)) { $strings[] = Query::select(['*'])->toString(); } else { @@ -146,28 +109,23 @@ class Realtime extends MessagingAdapter } foreach ($roles as $role) { - if (!isset($this->subscriptions[$projectId][$role])) { + if (! isset($this->subscriptions[$projectId][$role])) { $this->subscriptions[$projectId][$role] = []; } - foreach ($actionsByBase as $base => $actions) { - if (!isset($this->subscriptions[$projectId][$role][$base])) { - $this->subscriptions[$projectId][$role][$base] = []; + foreach ($channels as $channel) { + if (! isset($this->subscriptions[$projectId][$role][$channel])) { + $this->subscriptions[$projectId][$role][$channel] = []; } - if (!isset($this->subscriptions[$projectId][$role][$base][$identifier])) { - $this->subscriptions[$projectId][$role][$base][$identifier] = []; + if (! isset($this->subscriptions[$projectId][$role][$channel][$identifier])) { + $this->subscriptions[$projectId][$role][$channel][$identifier] = []; } - - $channelData = $data; - $channelData['actions'] = $actions; - - $this->subscriptions[$projectId][$role][$base][$identifier][$subscriptionId] = $channelData; + $this->subscriptions[$projectId][$role][$channel][$identifier][$subscriptionId] = $data; } } // Union channels/roles across all subscriptions on the connection; overwriting would // leave getSubscriptionMetadata and full unsubscribe operating on stale state. - // Channels are stored in *base* form here so they match subscription-tree keys. $existing = $this->connections[$identifier] ?? []; $existingChannels = $existing['channels'] ?? []; $existingRoles = $existing['roles'] ?? []; @@ -176,7 +134,7 @@ class Realtime extends MessagingAdapter 'projectId' => $projectId, 'roles' => \array_values(\array_unique(\array_merge($existingRoles, $roles))), 'userId' => $userId ?? ($existing['userId'] ?? ''), - 'channels' => \array_values(\array_unique(\array_merge($existingChannels, $baseChannels))), + 'channels' => \array_values(\array_unique(\array_merge($existingChannels, $channels))), ]; if (\array_key_exists('authorization', $existing)) { @@ -190,7 +148,7 @@ class Realtime extends MessagingAdapter * Get subscription metadata for a connection. * Retrieves subscription data including channels and queries directly from the subscriptions tree. * - * @param mixed $connection Connection ID + * @param mixed $connection Connection ID * @return array Array of [subscriptionId => ['channels' => string[], 'queries' => string[]]] */ public function getSubscriptionMetadata(mixed $connection): array @@ -199,7 +157,7 @@ class Realtime extends MessagingAdapter $roles = $this->connections[$connection]['roles'] ?? []; $channels = $this->connections[$connection]['channels'] ?? []; - if (!$projectId || empty($roles) || empty($channels)) { + if (! $projectId || empty($roles) || empty($channels)) { return []; } @@ -207,32 +165,24 @@ class Realtime extends MessagingAdapter // Extract subscription data from subscriptions tree foreach ($roles as $role) { - if (!isset($this->subscriptions[$projectId][$role])) { + if (! isset($this->subscriptions[$projectId][$role])) { continue; } foreach ($channels as $channel) { - if (!isset($this->subscriptions[$projectId][$role][$channel][$connection])) { + if (! isset($this->subscriptions[$projectId][$role][$channel][$connection])) { continue; } foreach ($this->subscriptions[$projectId][$role][$channel][$connection] as $subscriptionId => $data) { - if (!isset($subscriptions[$subscriptionId])) { + if (! isset($subscriptions[$subscriptionId])) { $subscriptions[$subscriptionId] = [ 'channels' => [], - 'queries' => $data['strings'] ?? [] + 'queries' => $data['strings'] ?? [], ]; } - - // Re-attach the action suffix so the original subscription channel - // (e.g. `documents.create`) is round-tripped on response paths and - // re-subscribe flows. `*` means no action was set — emit the base. - $actions = $data['actions'] ?? [self::ACTION_ALL]; - foreach ($actions as $action) { - $name = $action === self::ACTION_ALL ? $channel : $channel . '.' . $action; - if (!\in_array($name, $subscriptions[$subscriptionId]['channels'], true)) { - $subscriptions[$subscriptionId]['channels'][] = $name; - } + if (! \in_array($channel, $subscriptions[$subscriptionId]['channels'])) { + $subscriptions[$subscriptionId]['channels'][] = $channel; } } } @@ -243,9 +193,6 @@ class Realtime extends MessagingAdapter /** * Removes all subscriptions for a connection. - * - * @param mixed $connection - * @return void */ public function unsubscribe(mixed $connection): void { @@ -279,15 +226,11 @@ class Realtime extends MessagingAdapter /** * Removes a single subscription from a connection, keeping the connection alive so * the client can resubscribe. Idempotent — returns true only when something was removed. - * - * @param mixed $connection - * @param string $subscriptionId - * @return bool */ public function unsubscribeSubscription(mixed $connection, string $subscriptionId): bool { $projectId = $this->connections[$connection]['projectId'] ?? ''; - if ($projectId === '' || !isset($this->subscriptions[$projectId])) { + if ($projectId === '' || ! isset($this->subscriptions[$projectId])) { return false; } @@ -295,7 +238,7 @@ class Realtime extends MessagingAdapter foreach ($this->subscriptions[$projectId] as $role => $byChannel) { foreach ($byChannel as $channel => $byConnection) { - if (!isset($byConnection[$connection][$subscriptionId])) { + if (! isset($byConnection[$connection][$subscriptionId])) { continue; } @@ -333,13 +276,10 @@ class Realtime extends MessagingAdapter * context (set at onOpen, replaced on `authentication` / permission-change) and must survive * per-subscription removal — otherwise a client that unsubscribes every subscription and then * resubscribes would subscribe with an empty roles array and silently receive nothing. - * - * @param mixed $connection - * @return void */ private function recomputeConnectionState(mixed $connection): void { - if (!isset($this->connections[$connection])) { + if (! isset($this->connections[$connection])) { return; } @@ -359,65 +299,24 @@ class Realtime extends MessagingAdapter /** * Checks if Channel has a subscriber. - * - * Action-channel aware: if `$channel` carries a recognised action suffix - * (e.g. `documents.create`), the lookup is performed against the *base* - * channel in the tree and additionally requires at least one subscription - * whose `actions` list includes that action (or `'*'`, which subsumes it). - * Plain channel names are matched as before. - * - * @param string $projectId - * @param string $role - * @param string $channel - * @return bool */ public function hasSubscriber(string $projectId, string $role, string $channel = ''): bool { - //TODO: look into moving it to an abstract class in the parent class + // TODO: look into moving it to an abstract class in the parent class if (empty($channel)) { return array_key_exists($projectId, $this->subscriptions) && array_key_exists($role, $this->subscriptions[$projectId]); } - [$base, $action] = self::parseActionChannel($channel); - - if ( - !array_key_exists($projectId, $this->subscriptions) - || !array_key_exists($role, $this->subscriptions[$projectId]) - || !array_key_exists($base, $this->subscriptions[$projectId][$role]) - || empty($this->subscriptions[$projectId][$role][$base]) - ) { - return false; - } - - // Plain channel — any subscription on the base counts. - if ($action === self::ACTION_ALL) { - return true; - } - - // Action-specific channel — require a subscription whose actions list - // includes the action (or '*'). - foreach ($this->subscriptions[$projectId][$role][$base] as $byConnection) { - foreach ($byConnection as $data) { - $actions = $data['actions'] ?? [self::ACTION_ALL]; - if (\in_array(self::ACTION_ALL, $actions, true) || \in_array($action, $actions, true)) { - return true; - } - } - } - - return false; + return array_key_exists($projectId, $this->subscriptions) + && array_key_exists($role, $this->subscriptions[$projectId]) + && array_key_exists($channel, $this->subscriptions[$projectId][$role]) + && ! empty($this->subscriptions[$projectId][$role][$channel]); } /** * Sends an event to the Realtime Server - * @param string $projectId - * @param array $payload - * @param array $events - * @param array $channels - * @param array $roles - * @param array $options - * @return void + * * @throws \Exception */ public function send(string $projectId, array $payload, array $events, array $channels, array $roles, array $options = []): void @@ -438,8 +337,8 @@ class Realtime extends MessagingAdapter 'events' => $events, 'channels' => $channels, 'timestamp' => DateTime::formatTz(DateTime::now()), - 'payload' => $payload - ] + 'payload' => $payload, + ], ])); } @@ -452,25 +351,23 @@ class Realtime extends MessagingAdapter * - 1.5 ms | 1,000 Connections / 10,000 Subscriptions * - 15 ms | 10,000 Connections / 100,000 Subscriptions * - * @param array $event * @return array Map of connection IDs to matched query groups */ public function getSubscribers(array $event): array { $receivers = []; - if (!isset($this->subscriptions[$event['project']])) { + if (! isset($this->subscriptions[$event['project']])) { return $receivers; } $payload = $event['data']['payload'] ?? []; - $events = $event['data']['events'] ?? []; foreach ($this->subscriptions[$event['project']] as $role => $subscriptionsByChannel) { foreach ($event['data']['channels'] as $channel) { if ( - !\array_key_exists($channel, $subscriptionsByChannel) - || (!\in_array($role, $event['roles']) && !\in_array(Role::any()->toString(), $event['roles'])) + ! \array_key_exists($channel, $subscriptionsByChannel) + || (! \in_array($role, $event['roles']) && ! \in_array(Role::any()->toString(), $event['roles'])) ) { continue; } @@ -481,19 +378,14 @@ class Realtime extends MessagingAdapter foreach ($subscriptions as $subscriptionId => $data) { $compiled = $data['compiled'] ?? ['type' => 'selectAll']; $strings = $data['strings'] ?? []; - $actions = $data['actions'] ?? [self::ACTION_ALL]; - - if (!self::matchesActions($actions, $events)) { - continue; - } if (RuntimeQuery::filter($compiled, $payload) !== null) { $matched[$subscriptionId] = $strings; } } - if (!empty($matched)) { - if (!isset($receivers[$id])) { + if (! empty($matched)) { + if (! isset($receivers[$id])) { $receivers[$id] = []; } $receivers[$id] += $matched; @@ -505,82 +397,9 @@ class Realtime extends MessagingAdapter return $receivers; } - /** - * Tests whether any event in `$events` ends with one of the listed actions. - * - * Implements `containsAny` semantics (any-of match) over the events array, - * comparing only the trailing `.`-segment of each event so wildcard variants - * like `databases.*.collections.*.documents.*.create` match action `create`. - * `['*']` (or empty) means "no action filter" and short-circuits to true. - * - * @param array $actions Stored action filter for the subscription. - * @param array $events Event names from the published event. - * @return bool - */ - private static function matchesActions(array $actions, array $events): bool - { - if (empty($actions) || \in_array(self::ACTION_ALL, $actions, true)) { - return true; - } - - foreach ($events as $event) { - $lastDot = \strrpos($event, '.'); - if ($lastDot === false) { - continue; - } - if (\in_array(\substr($event, $lastDot + 1), $actions, true)) { - return true; - } - } - - return false; - } - - /** - * Splits a channel name into its base form and an action suffix. - * - * A trailing segment that matches one of {@see self::SUPPORTED_ACTIONS} is treated - * as an action filter and stripped from the channel; the remaining prefix becomes - * the base channel used for subscription-tree lookup. When no recognised suffix is - * present, the channel is returned unchanged with action {@see self::ACTION_ALL} - * (meaning "no action filter"). - * - * Examples: - * `documents.create` -> [`documents`, `create`] - * `databases.X.collections.Y.documents.Z.create` -> [`databases.X.collections.Y.documents.Z`, `create`] - * `documents` -> [`documents`, `*`] - * `account.create` -> already filtered out by convertChannels() - * - * @param string $channel - * @return array{0: string, 1: string} [baseChannel, action] - */ - public static function parseActionChannel(string $channel): array - { - $lastDot = \strrpos($channel, '.'); - if ($lastDot === false) { - return [$channel, self::ACTION_ALL]; - } - - $suffix = \substr($channel, $lastDot + 1); - if (!\in_array($suffix, self::SUPPORTED_ACTIONS, true)) { - return [$channel, self::ACTION_ALL]; - } - - $base = \substr($channel, 0, $lastDot); - if ($base === '') { - // Pathological — channel was just ".create"; leave it alone. - return [$channel, self::ACTION_ALL]; - } - - return [$base, $suffix]; - } - /** * Converts the channels from the Query Params into an array. * Also renames the account channel to account.USER_ID and removes all illegal account channel variations. - * @param array $channels - * @param string $userId - * @return array */ public static function convertChannels(array $channels, string $userId): array { @@ -593,8 +412,8 @@ class Realtime extends MessagingAdapter break; case $key === 'account': - if (!empty($userId)) { - $channels['account.' . $userId] = $value; + if (! empty($userId)) { + $channels['account.'.$userId] = $value; } break; } @@ -606,9 +425,8 @@ class Realtime extends MessagingAdapter /** * Constructs subscriptions from query parameters. * - * @param array $channelNames - * @param callable $getQueryParam * @return array [index => ['channels' => string[], 'queries' => Query[]]] + * * @throws QueryException */ public static function constructSubscriptions(array $channelNames, callable $getQueryParam): array @@ -642,26 +460,27 @@ class Realtime extends MessagingAdapter } if ($params === null) { - if (!isset($subscriptions[0])) { + if (! isset($subscriptions[0])) { $subscriptions[0] = ['channels' => [], 'queries' => []]; } $subscriptions[0]['channels'][] = $channel; if (empty($subscriptions[0]['queries'])) { $subscriptions[0]['queries'] = [Query::select(['*'])]; } + continue; } - if (!\is_array($params)) { + if (! \is_array($params)) { $params = [$params]; } foreach ($params as $index => $slot) { - if (!isset($subscriptions[$index])) { + if (! isset($subscriptions[$index])) { $subscriptions[$index] = ['channels' => [], 'queries' => []]; } - if (!\in_array($channel, $subscriptions[$index]['channels'], true)) { + if (! \in_array($channel, $subscriptions[$index]['channels'], true)) { $subscriptions[$index]['channels'][] = $channel; } @@ -677,8 +496,9 @@ class Realtime extends MessagingAdapter /** * Converts the queries from the Query Params into an array. - * @param array|string $queries - * @return array + * + * @param array|string $queries + * * @throws QueryException */ public static function convertQueries(mixed $queries): array @@ -687,11 +507,11 @@ class Realtime extends MessagingAdapter $stack = $queries; $allowed = implode(', ', RuntimeQuery::ALLOWED_QUERIES); - while (!empty($stack)) { + while (! empty($stack)) { $query = array_pop($stack); $method = $query->getMethod(); - if (!in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) { + if (! in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) { throw new QueryException( "Query method '{$method}' is not supported in Realtime queries. Allowed: {$allowed}" ); @@ -712,13 +532,6 @@ class Realtime extends MessagingAdapter /** * Create channels array based on the event name and payload. * - * @param string $event - * @param Document $payload - * @param Document|null $project - * @param Document|null $database - * @param Document|null $collection - * @param Document|null $bucket - * @return array * @throws \Exception */ public static function fromPayload(string $event, Document $payload, ?Document $project = null, ?Document $database = null, ?Document $collection = null, ?Document $bucket = null): array @@ -733,19 +546,19 @@ class Realtime extends MessagingAdapter switch ($parts[0]) { case 'users': $channels[] = 'account'; - $channels[] = 'account.' . $parts[1]; + $channels[] = 'account.'.$parts[1]; $roles = [Role::user(ID::custom($parts[1]))->toString()]; break; case 'rules': case 'migrations': $channels[] = 'console'; - $channels[] = 'projects.' . $project->getId(); + $channels[] = 'projects.'.$project->getId(); $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; break; case 'projects': $channels[] = 'console'; - $channels[] = 'projects.' . $parts[1]; + $channels[] = 'projects.'.$parts[1]; $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; break; @@ -753,11 +566,11 @@ class Realtime extends MessagingAdapter if ($parts[2] === 'memberships') { $permissionsChanged = $parts[4] ?? false; $channels[] = 'memberships'; - $channels[] = 'memberships.' . $parts[3]; + $channels[] = 'memberships.'.$parts[3]; } else { $permissionsChanged = $parts[2] === 'create'; $channels[] = 'teams'; - $channels[] = 'teams.' . $parts[1]; + $channels[] = 'teams.'.$parts[1]; } $roles = [Role::team(ID::custom($parts[1]))->toString()]; break; @@ -768,7 +581,7 @@ class Realtime extends MessagingAdapter $resource = $parts[4] ?? ''; if (in_array($resource, ['columns', 'attributes', 'indexes'])) { $channels[] = 'console'; - $channels[] = 'projects.' . $project->getId(); + $channels[] = 'projects.'.$project->getId(); $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; } elseif (in_array($resource, ['rows', 'documents'])) { @@ -810,8 +623,8 @@ class Realtime extends MessagingAdapter throw new \Exception('Bucket needs to be passed to Realtime for File events in the Storage.'); } $channels[] = 'files'; - $channels[] = 'buckets.' . $payload->getAttribute('bucketId') . '.files'; - $channels[] = 'buckets.' . $payload->getAttribute('bucketId') . '.files.' . $payload->getId(); + $channels[] = 'buckets.'.$payload->getAttribute('bucketId').'.files'; + $channels[] = 'buckets.'.$payload->getAttribute('bucketId').'.files.'.$payload->getId(); $roles = $bucket->getAttribute('fileSecurity', false) ? \array_merge($bucket->getRead(), $payload->getRead()) @@ -821,17 +634,17 @@ class Realtime extends MessagingAdapter break; case 'functions': if ($parts[2] === 'executions') { - if (!empty($payload->getRead())) { + if (! empty($payload->getRead())) { $channels[] = 'console'; - $channels[] = 'projects.' . $project->getId(); + $channels[] = 'projects.'.$project->getId(); $channels[] = 'executions'; - $channels[] = 'executions.' . $payload->getId(); - $channels[] = 'functions.' . $payload->getAttribute('functionId'); + $channels[] = 'executions.'.$payload->getId(); + $channels[] = 'functions.'.$payload->getAttribute('functionId'); $roles = $payload->getRead(); } } elseif ($parts[2] === 'deployments') { $channels[] = 'console'; - $channels[] = 'projects.' . $project->getId(); + $channels[] = 'projects.'.$project->getId(); $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; } @@ -840,30 +653,55 @@ class Realtime extends MessagingAdapter case 'sites': if ($parts[2] === 'deployments') { $channels[] = 'console'; - $channels[] = 'projects.' . $project->getId(); + $channels[] = 'projects.'.$project->getId(); $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; } break; } + // Action is the last segment of the event; for attribute-suffixed events + // it is second-to-last. + $count = \count($parts); + $action = null; + if ($count > 0 && \in_array($parts[$count - 1], self::SUPPORTED_ACTIONS, true)) { + $action = $parts[$count - 1]; + } elseif ($count > 1 && \in_array($parts[$count - 2], self::SUPPORTED_ACTIONS, true)) { + $action = $parts[$count - 2]; + } + + if ($action !== null && ! empty($channels)) { + $augmented = $channels; + foreach ($channels as $channel) { + $segments = \explode('.', $channel); + $segCount = \count($segments); + $leafIsResource = \in_array($segments[$segCount - 1], self::RESOURCE_LEAF_NAMES, true); + $parentIsResource = $segCount >= 2 && \in_array($segments[$segCount - 2], self::RESOURCE_LEAF_NAMES, true); + + if ($leafIsResource || $parentIsResource) { + $augmented[] = $channel.'.'.$action; + } + } + $channels = \array_values(\array_unique($augmented)); + } + return [ 'channels' => $channels, 'roles' => $roles, 'permissionsChanged' => $permissionsChanged, - 'projectId' => $projectId + 'projectId' => $projectId, ]; } /** * Generate realtime channels for database events * - * @param string $type The database API type - * @param string $databaseId The database ID - * @param string $resourceId The collection/table ID - * @param string $payloadId The document/row ID - * @param string $prefixOverride Override the channel prefix when different API types share the same terminology but need different prefixes - * (e.g., 'databases' and 'documentsdb' use same terminology but need different prefixes) + * @param string $type The database API type + * @param string $databaseId The database ID + * @param string $resourceId The collection/table ID + * @param string $payloadId The document/row ID + * @param string $prefixOverride Override the channel prefix when different API types share the same terminology but need different prefixes + * (e.g., 'databases' and 'documentsdb' use same terminology but need different prefixes) * @return array Array of channel names */ private static function getDatabaseChannels( @@ -875,7 +713,7 @@ class Realtime extends MessagingAdapter ): array { $basePrefix = $prefixOverride ?: $type; - if (!$databaseId || !$resourceId || !$payloadId) { + if (! $databaseId || ! $resourceId || ! $payloadId) { return []; } diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index c82a55e438..e101494f50 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -11,15 +11,15 @@ use Utopia\Database\Helpers\Role; class MessagingTest extends TestCase { - public function setUp(): void + protected function setUp(): void { } - public function tearDown(): void + protected function tearDown(): void { } - public function testUser(): void + public function test_user(): void { $realtime = new Realtime(); @@ -46,8 +46,8 @@ class MessagingTest extends TestCase 'data' => [ 'channels' => [ 0 => 'account.123', - ] - ] + ], + ], ]; $receivers = array_keys($realtime->getSubscribers($event)); @@ -147,7 +147,7 @@ class MessagingTest extends TestCase $this->assertEmpty($realtime->subscriptions); } - public function testSubscribeUnionsChannelsAndRoles(): void + public function test_subscribe_unions_channels_and_roles(): void { $realtime = new Realtime(); @@ -177,7 +177,7 @@ class MessagingTest extends TestCase $this->assertCount(2, $connection['roles']); } - public function testUnsubscribeSubscriptionRemovesOnlyOneSubscription(): void + public function test_unsubscribe_subscription_removes_only_one_subscription(): void { $realtime = new Realtime(); @@ -227,7 +227,7 @@ class MessagingTest extends TestCase $this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']); } - public function testUnsubscribeSubscriptionIsIdempotent(): void + public function test_unsubscribe_subscription_is_idempotent(): void { $realtime = new Realtime(); @@ -253,7 +253,7 @@ class MessagingTest extends TestCase $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); } - public function testUnsubscribeSubscriptionKeepsConnectionWhenLastSubRemoved(): void + public function test_unsubscribe_subscription_keeps_connection_when_last_sub_removed(): void { $realtime = new Realtime(); @@ -274,7 +274,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('1', $realtime->subscriptions); } - public function testResubscribeAfterUnsubscribingLastSubDelivers(): void + public function test_resubscribe_after_unsubscribing_last_sub_delivers(): void { $realtime = new Realtime(); @@ -304,7 +304,7 @@ class MessagingTest extends TestCase $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); } - public function testSubscribeAfterOnOpenEmptySentinelPreservesUnion(): void + public function test_subscribe_after_on_open_empty_sentinel_preserves_union(): void { $realtime = new Realtime(); @@ -334,10 +334,10 @@ class MessagingTest extends TestCase $this->assertContains(Role::user(ID::custom('user-123'))->toString(), $realtime->connections[1]['roles']); } - public function testConvertChannelsGuest(): void + public function test_convert_channels_guest(): void { $user = new Document([ - '$id' => '' + '$id' => '', ]); $channels = [ @@ -345,7 +345,7 @@ class MessagingTest extends TestCase 1 => 'documents', 2 => 'documents.789', 3 => 'account', - 4 => 'account.456' + 4 => 'account.456', ]; $channels = Realtime::convertChannels($channels, $user->getId()); @@ -357,32 +357,32 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } - public function testConvertChannelsUser(): void + public function test_convert_channels_user(): void { - $user = new Document([ + $user = new Document([ '$id' => ID::custom('123'), 'memberships' => [ [ 'teamId' => ID::custom('abc'), 'roles' => [ 'administrator', - 'moderator' - ] + 'moderator', + ], ], [ 'teamId' => ID::custom('def'), 'roles' => [ - 'guest' - ] - ] - ] + 'guest', + ], + ], + ], ]); $channels = [ 0 => 'files', 1 => 'documents', 2 => 'documents.789', 3 => 'account', - 4 => 'account.456' + 4 => 'account.456', ]; $channels = Realtime::convertChannels($channels, $user->getId()); @@ -396,7 +396,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } - public function testFromPayloadPermissions(): void + public function test_from_payload_permissions(): void { /** * Test Collection Level Permissions @@ -460,7 +460,7 @@ class MessagingTest extends TestCase $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } - public function testFromPayloadBucketLevelPermissions(): void + public function test_from_payload_bucket_level_permissions(): void { /** * Test Bucket Level Permissions @@ -510,7 +510,7 @@ class MessagingTest extends TestCase Permission::update(Role::team('123abc')), Permission::delete(Role::team('123abc')), ], - 'fileSecurity' => true + 'fileSecurity' => true, ]) ); @@ -518,443 +518,179 @@ class MessagingTest extends TestCase $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } - public function testHasSubscriberIsActionChannelAware(): void + public function test_from_payload_emits_action_suffixed_channels(): void + { + $result = Realtime::fromPayload( + event: 'databases.database_id.collections.collection_id.documents.document_id.create', + payload: new Document([ + '$id' => ID::custom('document_id'), + '$collection' => ID::custom('collection_id'), + '$collectionId' => 'collection_id', + '$permissions' => [Permission::read(Role::any())], + ]), + database: new Document(['$id' => ID::custom('database_id')]), + collection: new Document([ + '$id' => ID::custom('collection_id'), + '$permissions' => [Permission::read(Role::any())], + ]) + ); + + // Base channels remain. + $this->assertContains('documents', $result['channels']); + $this->assertContains('databases.database_id.collections.collection_id.documents', $result['channels']); + $this->assertContains('databases.database_id.collections.collection_id.documents.document_id', $result['channels']); + + // Action-suffixed variants are appended for every base channel. + $this->assertContains('documents.create', $result['channels']); + $this->assertContains('databases.database_id.collections.collection_id.documents.create', $result['channels']); + $this->assertContains('databases.database_id.collections.collection_id.documents.document_id.create', $result['channels']); + + // No mismatched action suffixes leak in. + $this->assertNotContains('documents.update', $result['channels']); + $this->assertNotContains('documents.delete', $result['channels']); + } + + public function test_from_payload_emits_action_suffix_for_every_action(): void + { + foreach (['create', 'update', 'upsert', 'delete'] as $action) { + $result = Realtime::fromPayload( + event: "databases.database_id.collections.collection_id.documents.document_id.{$action}", + payload: new Document([ + '$id' => ID::custom('document_id'), + '$collection' => ID::custom('collection_id'), + '$collectionId' => 'collection_id', + '$permissions' => [Permission::read(Role::any())], + ]), + database: new Document(['$id' => ID::custom('database_id')]), + collection: new Document([ + '$id' => ID::custom('collection_id'), + '$permissions' => [Permission::read(Role::any())], + ]) + ); + + $this->assertContains("documents.{$action}", $result['channels'], "documents.{$action} missing"); + $this->assertContains( + "databases.database_id.collections.collection_id.documents.document_id.{$action}", + $result['channels'], + "specific-doc {$action} channel missing" + ); + } + } + + public function test_from_payload_does_not_suffix_when_no_action(): void + { + // Synthetic event without an action segment: e.g. an attribute event whose + // last segment is not a known action and whose second-to-last segment is + // also not a known action. + $result = Realtime::fromPayload( + event: 'buckets.bucket_id.files.file_id.update', + payload: new Document([ + '$id' => ID::custom('file_id'), + 'bucketId' => 'bucket_id', + '$permissions' => [Permission::read(Role::any())], + ]), + bucket: new Document([ + '$id' => ID::custom('bucket_id'), + '$permissions' => [Permission::read(Role::any())], + ]) + ); + + // Action-suffixed variants for the file event. + $this->assertContains('files.update', $result['channels']); + $this->assertContains('buckets.bucket_id.files.update', $result['channels']); + $this->assertContains('buckets.bucket_id.files.file_id.update', $result['channels']); + + // Base channels remain. + $this->assertContains('files', $result['channels']); + $this->assertContains('buckets.bucket_id.files', $result['channels']); + $this->assertContains('buckets.bucket_id.files.file_id', $result['channels']); + } + + public function test_from_payload_does_not_suffix_admin_channels(): void + { + // Function execution event emits resource-leaf channels (executions / functions) + // alongside admin channels (console / projects.X). Admin channels must NOT + // get an action suffix — only the resource-leaf channels do. + $result = Realtime::fromPayload( + event: 'functions.function_id.executions.execution_id.create', + payload: new Document([ + '$id' => ID::custom('execution_id'), + 'functionId' => 'function_id', + '$read' => [Role::any()->toString()], + '$permissions' => [Permission::read(Role::any())], + ]), + project: new Document([ + '$id' => ID::custom('project_id'), + 'teamId' => '123abc', + ]) + ); + + // Resource-leaf channels are suffixed. + $this->assertContains('executions', $result['channels']); + $this->assertContains('executions.create', $result['channels']); + $this->assertContains('executions.execution_id', $result['channels']); + $this->assertContains('executions.execution_id.create', $result['channels']); + $this->assertContains('functions.function_id', $result['channels']); + $this->assertContains('functions.function_id.create', $result['channels']); + + // Admin channels are NOT suffixed. + $this->assertContains('console', $result['channels']); + $this->assertNotContains('console.create', $result['channels']); + $this->assertContains('projects.project_id', $result['channels']); + $this->assertNotContains('projects.project_id.create', $result['channels']); + } + + public function test_action_suffix_delivers_only_matching_action_end_to_end(): void { $realtime = new Realtime(); - $realtime->subscribe( - '1', - 1, - 'sub-create', - [Role::any()->toString()], - ['documents.create'], - ); - - // Plain base lookup hits the subscription. - $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents')); - - // Action-channel lookup matches when the action is in the stored list. - $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.create')); - - // Action-channel lookup misses when the action is not stored — even though - // the base channel exists. - $this->assertFalse($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.update')); - - // Unknown project / role still resolves to false. - $this->assertFalse($realtime->hasSubscriber('nope', Role::any()->toString(), 'documents.create')); - $this->assertFalse($realtime->hasSubscriber('1', 'role:other', 'documents.create')); - - // No-channel form still works. - $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString())); - } - - public function testHasSubscriberWildcardActionsSubsumeSpecific(): void - { - $realtime = new Realtime(); - - // Subscribing to plain `documents` stores actions = ['*']. Any action-channel - // lookup against the same base must succeed because '*' subsumes specific actions. - $realtime->subscribe( - '1', - 1, - 'sub-all', - [Role::any()->toString()], - ['documents'], - ); - - $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents')); - $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.create')); - $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.update')); - $this->assertTrue($realtime->hasSubscriber('1', Role::any()->toString(), 'documents.delete')); - } - - public function testParseActionChannel(): void - { - $this->assertSame(['documents', 'create'], Realtime::parseActionChannel('documents.create')); - $this->assertSame(['documents', 'update'], Realtime::parseActionChannel('documents.update')); - $this->assertSame(['documents', 'upsert'], Realtime::parseActionChannel('documents.upsert')); - $this->assertSame(['documents', 'delete'], Realtime::parseActionChannel('documents.delete')); - $this->assertSame( - ['databases.X.collections.Y.documents.Z', 'create'], - Realtime::parseActionChannel('databases.X.collections.Y.documents.Z.create') - ); - $this->assertSame( - ['databases.X.collections.Y.documents.Z', 'delete'], - Realtime::parseActionChannel('databases.X.collections.Y.documents.Z.delete') - ); - - // No action suffix → unchanged with '*' default. - $this->assertSame(['documents', '*'], Realtime::parseActionChannel('documents')); - $this->assertSame(['documents.789', '*'], Realtime::parseActionChannel('documents.789')); - - // Unrecognised suffix → unchanged (treated as literal channel name). - $this->assertSame(['documents.bogus', '*'], Realtime::parseActionChannel('documents.bogus')); - } - - public function testActionChannelFiltersByEventAction(): void - { - $realtime = new Realtime(); - - // Two subscriptions on the same connection: one filtered to creates only, - // one filtered to updates only. - $realtime->subscribe( - '1', - 1, - 'sub-create', - [Role::any()->toString()], - ['documents.create'], - ); - $realtime->subscribe( - '1', - 1, - 'sub-update', - [Role::any()->toString()], - ['documents.update'], - ); + // Subscriber A scopes to creates; Subscriber B scopes to deletes. + $realtime->subscribe('1', 1, 'sub-create', [Role::any()->toString()], ['documents.create']); + $realtime->subscribe('1', 2, 'sub-delete', [Role::any()->toString()], ['documents.delete']); + // Simulate what fromPayload would publish for a create event. $createEvent = [ 'project' => '1', 'roles' => [Role::any()->toString()], 'data' => [ - 'channels' => ['documents'], - 'events' => [ - 'databases.db.collections.col.documents.doc.create', - 'databases.*.collections.*.documents.*.create', - ], + 'channels' => ['documents', 'documents.create'], 'payload' => ['$id' => 'doc'], ], ]; + $createReceivers = $realtime->getSubscribers($createEvent); + $this->assertArrayHasKey(1, $createReceivers); + $this->assertArrayNotHasKey(2, $createReceivers); - $updateEvent = $createEvent; - $updateEvent['data']['events'] = [ - 'databases.db.collections.col.documents.doc.update', - 'databases.*.collections.*.documents.*.update', - ]; - - // Create event should only deliver to sub-create. - $receivers = $realtime->getSubscribers($createEvent); - $this->assertCount(1, $receivers); - $this->assertArrayHasKey(1, $receivers); - $this->assertArrayHasKey('sub-create', $receivers[1]); - $this->assertArrayNotHasKey('sub-update', $receivers[1]); - - // Update event should only deliver to sub-update. - $receivers = $realtime->getSubscribers($updateEvent); - $this->assertCount(1, $receivers); - $this->assertArrayHasKey('sub-update', $receivers[1]); - $this->assertArrayNotHasKey('sub-create', $receivers[1]); - } - - public function testActionChannelDeleteFilter(): void - { - $realtime = new Realtime(); - - $realtime->subscribe( - '1', - 1, - 'sub-delete', - [Role::any()->toString()], - ['documents.delete'], - ); - + // Delete event. $deleteEvent = [ 'project' => '1', 'roles' => [Role::any()->toString()], 'data' => [ - 'channels' => ['documents'], - 'events' => [ - 'databases.db.collections.col.documents.doc.delete', - 'databases.*.collections.*.documents.*.delete', - ], + 'channels' => ['documents', 'documents.delete'], 'payload' => ['$id' => 'doc'], ], ]; - - $receivers = $realtime->getSubscribers($deleteEvent); - $this->assertArrayHasKey(1, $receivers); - $this->assertArrayHasKey('sub-delete', $receivers[1]); - - // Other actions on the same base channel should not match the delete filter. - $createEvent = $deleteEvent; - $createEvent['data']['events'] = [ - 'databases.db.collections.col.documents.doc.create', - 'databases.*.collections.*.documents.*.create', - ]; - $this->assertEmpty($realtime->getSubscribers($createEvent)); + $deleteReceivers = $realtime->getSubscribers($deleteEvent); + $this->assertArrayHasKey(2, $deleteReceivers); + $this->assertArrayNotHasKey(1, $deleteReceivers); } - public function testActionChannelHonorsResourceId(): void + public function test_plain_channel_still_receives_all_actions_end_to_end(): void { $realtime = new Realtime(); - // Subscribe to creates on a specific document only. - $realtime->subscribe( - '1', - 1, - 'sub-doc-create', - [Role::any()->toString()], - ['documents.789.create'], - ); + $realtime->subscribe('1', 1, 'sub-all', [Role::any()->toString()], ['documents']); - // The base channel for `documents.789.create` is `documents.789`. - $event = [ - 'project' => '1', - 'roles' => [Role::any()->toString()], - 'data' => [ - 'channels' => ['documents.789'], - 'events' => [ - 'databases.db.collections.col.documents.789.create', - 'databases.*.collections.*.documents.*.create', + foreach (['create', 'update', 'upsert', 'delete'] as $action) { + $event = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents', "documents.{$action}"], + 'payload' => ['$id' => 'doc'], ], - 'payload' => ['$id' => '789'], - ], - ]; - - $receivers = $realtime->getSubscribers($event); - $this->assertCount(1, $receivers); - $this->assertArrayHasKey('sub-doc-create', $receivers[1]); - - // Update on the same document should not match. - $event['data']['events'] = [ - 'databases.db.collections.col.documents.789.update', - 'databases.*.collections.*.documents.*.update', - ]; - - $this->assertEmpty($realtime->getSubscribers($event)); - - // Create on a different document should not match (different base channel - // entirely; subscription tree key won't even line up). - $event['data']['channels'] = ['documents.999']; - $event['data']['events'] = [ - 'databases.db.collections.col.documents.999.create', - 'databases.*.collections.*.documents.*.create', - ]; - - $this->assertEmpty($realtime->getSubscribers($event)); - } - - public function testNonActionChannelStillReceivesAllEvents(): void - { - $realtime = new Realtime(); - - $realtime->subscribe( - '1', - 1, - 'sub-all', - [Role::any()->toString()], - ['documents'], - ); - - $event = [ - 'project' => '1', - 'roles' => [Role::any()->toString()], - 'data' => [ - 'channels' => ['documents'], - 'events' => [ - 'databases.db.collections.col.documents.doc.create', - ], - 'payload' => ['$id' => 'doc'], - ], - ]; - - $this->assertArrayHasKey(1, $realtime->getSubscribers($event)); - - $event['data']['events'] = ['databases.db.collections.col.documents.doc.update']; - $this->assertArrayHasKey(1, $realtime->getSubscribers($event)); - - $event['data']['events'] = ['databases.db.collections.col.documents.doc.upsert']; - $this->assertArrayHasKey(1, $realtime->getSubscribers($event)); - } - - public function testMixedActionAndBaseChannelInSameSubscription(): void - { - $realtime = new Realtime(); - - // Same sub-id covers `documents.create` (filtered) and `files` (unfiltered). - // After parsing they live under different base-channel keys with their own - // action metadata, so each gets its own filter behaviour. - $realtime->subscribe( - '1', - 1, - 'sub-mixed', - [Role::any()->toString()], - ['documents.create', 'files'], - ); - - // Create event on documents → matches. - $createDoc = [ - 'project' => '1', - 'roles' => [Role::any()->toString()], - 'data' => [ - 'channels' => ['documents'], - 'events' => ['databases.db.collections.col.documents.doc.create'], - 'payload' => [], - ], - ]; - $this->assertArrayHasKey(1, $realtime->getSubscribers($createDoc)); - - // Update event on documents → blocked by the action filter on the documents key. - $updateDoc = $createDoc; - $updateDoc['data']['events'] = ['databases.db.collections.col.documents.doc.update']; - $this->assertEmpty($realtime->getSubscribers($updateDoc)); - - // Files channel has no action filter — any action delivers. - $updateFile = [ - 'project' => '1', - 'roles' => [Role::any()->toString()], - 'data' => [ - 'channels' => ['files'], - 'events' => ['buckets.bucket.files.file.update'], - 'payload' => [], - ], - ]; - $this->assertArrayHasKey(1, $realtime->getSubscribers($updateFile)); - } - - public function testActionChannelMetadataRoundTrips(): void - { - $realtime = new Realtime(); - - $realtime->subscribe( - '1', - 1, - 'sub-create', - [Role::any()->toString()], - ['documents.create', 'files'], - ); - - $meta = $realtime->getSubscriptionMetadata(1); - - $this->assertArrayHasKey('sub-create', $meta); - $this->assertContains('documents.create', $meta['sub-create']['channels']); - $this->assertContains('files', $meta['sub-create']['channels']); - // Base form should NOT leak when an action was set. - $this->assertNotContains('documents', $meta['sub-create']['channels']); - } - - public function testSubscribeWithSameSubIdReplacesActionsNotMerges(): void - { - $realtime = new Realtime(); - $role = Role::any()->toString(); - - // Initial subscribe: only `create` events on the documents base. - $realtime->subscribe('1', 1, 'sub-x', [$role], ['documents.create']); - - $createEvent = [ - 'project' => '1', - 'roles' => [$role], - 'data' => [ - 'channels' => ['documents'], - 'events' => ['databases.db.collections.col.documents.doc.create'], - 'payload' => [], - ], - ]; - $this->assertArrayHasKey(1, $realtime->getSubscribers($createEvent)); - - // Re-subscribe with the SAME sub-id but a different action. Per the upsert - // contract documented on Realtime::subscribe, this fully replaces the prior - // state — actions are NOT unioned across calls (channels and queries already - // followed replace-not-merge semantics; actions match that rule). - $realtime->subscribe('1', 1, 'sub-x', [$role], ['documents.update']); - - // Create no longer matches: previous filter is gone. - $this->assertEmpty($realtime->getSubscribers($createEvent)); - - // Update now matches. - $updateEvent = $createEvent; - $updateEvent['data']['events'] = ['databases.db.collections.col.documents.doc.update']; - $this->assertArrayHasKey(1, $realtime->getSubscribers($updateEvent)); - - // Metadata reflects only the new state. - $meta = $realtime->getSubscriptionMetadata(1); - $this->assertContains('documents.update', $meta['sub-x']['channels']); - $this->assertNotContains('documents.create', $meta['sub-x']['channels']); - } - - public function testActionAndBaseChannelTogetherRoundTripsLosslessly(): void - { - $realtime = new Realtime(); - - // Subscribing with both a specific-action channel AND its plain base form must - // preserve both names: '*' short-circuits delivery (so update events still - // come through), but the metadata kept for re-auth/permissions-changed flows - // would otherwise drop `documents.create` entirely on the next refresh. - $realtime->subscribe( - '1', - 1, - 'sub-mixed', - [Role::any()->toString()], - ['documents.create', 'documents'], - ); - - $meta = $realtime->getSubscriptionMetadata(1); - $this->assertContains('documents.create', $meta['sub-mixed']['channels']); - $this->assertContains('documents', $meta['sub-mixed']['channels']); - - // Update events still deliver because '*' is in the actions list. - $updateEvent = [ - 'project' => '1', - 'roles' => [Role::any()->toString()], - 'data' => [ - 'channels' => ['documents'], - 'events' => ['databases.db.collections.col.documents.doc.update'], - 'payload' => [], - ], - ]; - $this->assertArrayHasKey(1, $realtime->getSubscribers($updateEvent)); - - // Round-trip: feed the metadata back through subscribe() and the original - // pair of channel names must come out again. - $realtime->unsubscribe(1); - $realtime->subscribe( - '1', - 1, - 'sub-mixed', - [Role::any()->toString()], - $meta['sub-mixed']['channels'], - ); - - $metaAgain = $realtime->getSubscriptionMetadata(1); - $this->assertContains('documents.create', $metaAgain['sub-mixed']['channels']); - $this->assertContains('documents', $metaAgain['sub-mixed']['channels']); - } - - public function testMergingMultipleActionsOnSameBaseChannel(): void - { - $realtime = new Realtime(); - - // Subscribing to multiple actions on the same base merges their action lists - // onto a single tree entry. - $realtime->subscribe( - '1', - 1, - 'sub-multi', - [Role::any()->toString()], - ['documents.create', 'documents.update'], - ); - - $createEvent = [ - 'project' => '1', - 'roles' => [Role::any()->toString()], - 'data' => [ - 'channels' => ['documents'], - 'events' => ['databases.db.collections.col.documents.doc.create'], - 'payload' => [], - ], - ]; - $this->assertArrayHasKey(1, $realtime->getSubscribers($createEvent)); - - $updateEvent = $createEvent; - $updateEvent['data']['events'] = ['databases.db.collections.col.documents.doc.update']; - $this->assertArrayHasKey(1, $realtime->getSubscribers($updateEvent)); - - // Upsert should not match — neither create nor update covers it. - $upsertEvent = $createEvent; - $upsertEvent['data']['events'] = ['databases.db.collections.col.documents.doc.upsert']; - $this->assertEmpty($realtime->getSubscribers($upsertEvent)); - - $meta = $realtime->getSubscriptionMetadata(1); - $this->assertContains('documents.create', $meta['sub-multi']['channels']); - $this->assertContains('documents.update', $meta['sub-multi']['channels']); + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($event), "plain `documents` should match {$action} event"); + } } } From 8ce7aa2abe8a0eb94ce7d0b83c65e66351dbe58a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 12:27:52 +0200 Subject: [PATCH 278/402] Fix crashing http --- src/Appwrite/Platform/Modules/Project/Services/Http.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index ec0ffe2997..83f85fd4ae 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -45,7 +45,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Slack\Update as Update use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Spotify\Update as UpdateOAuth2Spotify; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Stripe\Update as UpdateOAuth2Stripe; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Tradeshift\Update as UpdateOAuth2Tradeshift; -use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\TradeshiftBox\Update as UpdateOAuth2TradeshiftBox; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\TradeshiftSandbox\Update as UpdateOAuth2TradeshiftSandbox; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Twitch\Update as UpdateOAuth2Twitch; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\WordPress\Update as UpdateOAuth2WordPress; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\X\Update as UpdateOAuth2X; @@ -197,7 +197,7 @@ class Http extends Service $this->addAction(UpdateOAuth2Etsy::getName(), new UpdateOAuth2Etsy()); $this->addAction(UpdateOAuth2Facebook::getName(), new UpdateOAuth2Facebook()); $this->addAction(UpdateOAuth2Tradeshift::getName(), new UpdateOAuth2Tradeshift()); - $this->addAction(UpdateOAuth2TradeshiftBox::getName(), new UpdateOAuth2TradeshiftBox()); + $this->addAction(UpdateOAuth2TradeshiftSandbox::getName(), new UpdateOAuth2TradeshiftSandbox()); $this->addAction(UpdateOAuth2Paypal::getName(), new UpdateOAuth2Paypal()); $this->addAction(UpdateOAuth2PaypalSandbox::getName(), new UpdateOAuth2PaypalSandbox()); $this->addAction(UpdateOAuth2Gitlab::getName(), new UpdateOAuth2Gitlab()); From d25ccb784da6b33bfd24cb683c48e32a3ba1a610 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 15:59:34 +0530 Subject: [PATCH 279/402] refactor(Realtime): remove SUPPORTED_ACTIONS constant and simplify action extraction logic --- src/Appwrite/Messaging/Adapter/Realtime.php | 15 +---- tests/unit/Messaging/MessagingTest.php | 68 ++++++++++----------- 2 files changed, 37 insertions(+), 46 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 7be0911b8c..337a99af50 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -14,8 +14,6 @@ use Utopia\Database\Query; class Realtime extends MessagingAdapter { - public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete']; - private const RESOURCE_LEAF_NAMES = [ 'documents', 'rows', @@ -660,17 +658,10 @@ class Realtime extends MessagingAdapter break; } - // Action is the last segment of the event; for attribute-suffixed events - // it is second-to-last. - $count = \count($parts); - $action = null; - if ($count > 0 && \in_array($parts[$count - 1], self::SUPPORTED_ACTIONS, true)) { - $action = $parts[$count - 1]; - } elseif ($count > 1 && \in_array($parts[$count - 2], self::SUPPORTED_ACTIONS, true)) { - $action = $parts[$count - 2]; - } - if ($action !== null && ! empty($channels)) { + if (! empty($channels)) { + // create, update, upsert, delete + $action = $parts[\count($parts) - 1]; $augmented = $channels; foreach ($channels as $channel) { $segments = \explode('.', $channel); diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index e101494f50..9230423727 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -11,15 +11,15 @@ use Utopia\Database\Helpers\Role; class MessagingTest extends TestCase { - protected function setUp(): void + public function setUp(): void { } - protected function tearDown(): void + public function tearDown(): void { } - public function test_user(): void + public function testUser(): void { $realtime = new Realtime(); @@ -46,8 +46,8 @@ class MessagingTest extends TestCase 'data' => [ 'channels' => [ 0 => 'account.123', - ], - ], + ] + ] ]; $receivers = array_keys($realtime->getSubscribers($event)); @@ -147,7 +147,7 @@ class MessagingTest extends TestCase $this->assertEmpty($realtime->subscriptions); } - public function test_subscribe_unions_channels_and_roles(): void + public function testSubscribeUnionsChannelsAndRoles(): void { $realtime = new Realtime(); @@ -177,7 +177,7 @@ class MessagingTest extends TestCase $this->assertCount(2, $connection['roles']); } - public function test_unsubscribe_subscription_removes_only_one_subscription(): void + public function testUnsubscribeSubscriptionRemovesOnlyOneSubscription(): void { $realtime = new Realtime(); @@ -227,7 +227,7 @@ class MessagingTest extends TestCase $this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']); } - public function test_unsubscribe_subscription_is_idempotent(): void + public function testUnsubscribeSubscriptionIsIdempotent(): void { $realtime = new Realtime(); @@ -253,7 +253,7 @@ class MessagingTest extends TestCase $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); } - public function test_unsubscribe_subscription_keeps_connection_when_last_sub_removed(): void + public function testUnsubscribeSubscriptionKeepsConnectionWhenLastSubRemoved(): void { $realtime = new Realtime(); @@ -274,7 +274,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('1', $realtime->subscriptions); } - public function test_resubscribe_after_unsubscribing_last_sub_delivers(): void + public function testResubscribeAfterUnsubscribingLastSubDelivers(): void { $realtime = new Realtime(); @@ -304,7 +304,7 @@ class MessagingTest extends TestCase $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); } - public function test_subscribe_after_on_open_empty_sentinel_preserves_union(): void + public function testSubscribeAfterOnOpenEmptySentinelPreservesUnion(): void { $realtime = new Realtime(); @@ -334,10 +334,10 @@ class MessagingTest extends TestCase $this->assertContains(Role::user(ID::custom('user-123'))->toString(), $realtime->connections[1]['roles']); } - public function test_convert_channels_guest(): void + public function testConvertChannelsGuest(): void { $user = new Document([ - '$id' => '', + '$id' => '' ]); $channels = [ @@ -345,7 +345,7 @@ class MessagingTest extends TestCase 1 => 'documents', 2 => 'documents.789', 3 => 'account', - 4 => 'account.456', + 4 => 'account.456' ]; $channels = Realtime::convertChannels($channels, $user->getId()); @@ -357,32 +357,32 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } - public function test_convert_channels_user(): void + public function testConvertChannelsUser(): void { - $user = new Document([ + $user = new Document([ '$id' => ID::custom('123'), 'memberships' => [ [ 'teamId' => ID::custom('abc'), 'roles' => [ 'administrator', - 'moderator', - ], + 'moderator' + ] ], [ 'teamId' => ID::custom('def'), 'roles' => [ - 'guest', - ], - ], - ], + 'guest' + ] + ] + ] ]); $channels = [ 0 => 'files', 1 => 'documents', 2 => 'documents.789', 3 => 'account', - 4 => 'account.456', + 4 => 'account.456' ]; $channels = Realtime::convertChannels($channels, $user->getId()); @@ -396,7 +396,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } - public function test_from_payload_permissions(): void + public function testFromPayloadPermissions(): void { /** * Test Collection Level Permissions @@ -460,7 +460,7 @@ class MessagingTest extends TestCase $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } - public function test_from_payload_bucket_level_permissions(): void + public function testFromPayloadBucketLevelPermissions(): void { /** * Test Bucket Level Permissions @@ -510,15 +510,15 @@ class MessagingTest extends TestCase Permission::update(Role::team('123abc')), Permission::delete(Role::team('123abc')), ], - 'fileSecurity' => true, + 'fileSecurity' => true ]) ); $this->assertContains(Role::any()->toString(), $result['roles']); $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } - - public function test_from_payload_emits_action_suffixed_channels(): void + + public function testFromPayloadEmitsActionSuffixedChannels(): void { $result = Realtime::fromPayload( event: 'databases.database_id.collections.collection_id.documents.document_id.create', @@ -550,7 +550,7 @@ class MessagingTest extends TestCase $this->assertNotContains('documents.delete', $result['channels']); } - public function test_from_payload_emits_action_suffix_for_every_action(): void + public function testFromPayloadEmitsActionSuffixForEveryAction(): void { foreach (['create', 'update', 'upsert', 'delete'] as $action) { $result = Realtime::fromPayload( @@ -577,7 +577,7 @@ class MessagingTest extends TestCase } } - public function test_from_payload_does_not_suffix_when_no_action(): void + public function testFromPayloadDoesNotSuffixWhenNoAction(): void { // Synthetic event without an action segment: e.g. an attribute event whose // last segment is not a known action and whose second-to-last segment is @@ -606,7 +606,7 @@ class MessagingTest extends TestCase $this->assertContains('buckets.bucket_id.files.file_id', $result['channels']); } - public function test_from_payload_does_not_suffix_admin_channels(): void + public function testFromPayloadDoesNotSuffixAdminChannels(): void { // Function execution event emits resource-leaf channels (executions / functions) // alongside admin channels (console / projects.X). Admin channels must NOT @@ -640,7 +640,7 @@ class MessagingTest extends TestCase $this->assertNotContains('projects.project_id.create', $result['channels']); } - public function test_action_suffix_delivers_only_matching_action_end_to_end(): void + public function testActionSuffixDeliversOnlyMatchingActionEndToEnd(): void { $realtime = new Realtime(); @@ -675,7 +675,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey(1, $deleteReceivers); } - public function test_plain_channel_still_receives_all_actions_end_to_end(): void + public function testPlainChannelStillReceivesAllActionsEndToEnd(): void { $realtime = new Realtime(); @@ -693,4 +693,4 @@ class MessagingTest extends TestCase $this->assertArrayHasKey(1, $realtime->getSubscribers($event), "plain `documents` should match {$action} event"); } } -} +} \ No newline at end of file From e6d5c216ebe219865bfc3e367dd6204ed917c051 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 16:06:00 +0530 Subject: [PATCH 280/402] refactor(Realtime): update action extraction logic and enhance test method naming conventions --- src/Appwrite/Messaging/Adapter/Realtime.php | 17 ++- tests/unit/Messaging/MessagingTest.php | 112 ++++++++++++++------ 2 files changed, 92 insertions(+), 37 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 337a99af50..73bcc6e088 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -14,6 +14,8 @@ use Utopia\Database\Query; class Realtime extends MessagingAdapter { + public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete']; + private const RESOURCE_LEAF_NAMES = [ 'documents', 'rows', @@ -658,10 +660,19 @@ class Realtime extends MessagingAdapter break; } + // Action is the last segment for plain CRUD events (e.g. `documents.X.create`), + // and the second-to-last segment for attribute-trailing events + // (e.g. `users.U.update.email`, `teams.T.update.prefs`, + // `teams.T.memberships.M.update.status`). Without the second-to-last fallback + $count = \count($parts); + $action = null; + if (\in_array($parts[$count - 1], self::SUPPORTED_ACTIONS, true)) { + $action = $parts[$count - 1]; + } elseif ($count >= 2 && \in_array($parts[$count - 2], self::SUPPORTED_ACTIONS, true)) { + $action = $parts[$count - 2]; + } - if (! empty($channels)) { - // create, update, upsert, delete - $action = $parts[\count($parts) - 1]; + if ($action !== null && ! empty($channels)) { $augmented = $channels; foreach ($channels as $channel) { $segments = \explode('.', $channel); diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 9230423727..9abe71c890 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -11,15 +11,15 @@ use Utopia\Database\Helpers\Role; class MessagingTest extends TestCase { - public function setUp(): void + protected function setUp(): void { } - public function tearDown(): void + protected function tearDown(): void { } - public function testUser(): void + public function test_user(): void { $realtime = new Realtime(); @@ -46,8 +46,8 @@ class MessagingTest extends TestCase 'data' => [ 'channels' => [ 0 => 'account.123', - ] - ] + ], + ], ]; $receivers = array_keys($realtime->getSubscribers($event)); @@ -147,7 +147,7 @@ class MessagingTest extends TestCase $this->assertEmpty($realtime->subscriptions); } - public function testSubscribeUnionsChannelsAndRoles(): void + public function test_subscribe_unions_channels_and_roles(): void { $realtime = new Realtime(); @@ -177,7 +177,7 @@ class MessagingTest extends TestCase $this->assertCount(2, $connection['roles']); } - public function testUnsubscribeSubscriptionRemovesOnlyOneSubscription(): void + public function test_unsubscribe_subscription_removes_only_one_subscription(): void { $realtime = new Realtime(); @@ -227,7 +227,7 @@ class MessagingTest extends TestCase $this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']); } - public function testUnsubscribeSubscriptionIsIdempotent(): void + public function test_unsubscribe_subscription_is_idempotent(): void { $realtime = new Realtime(); @@ -253,7 +253,7 @@ class MessagingTest extends TestCase $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); } - public function testUnsubscribeSubscriptionKeepsConnectionWhenLastSubRemoved(): void + public function test_unsubscribe_subscription_keeps_connection_when_last_sub_removed(): void { $realtime = new Realtime(); @@ -274,7 +274,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('1', $realtime->subscriptions); } - public function testResubscribeAfterUnsubscribingLastSubDelivers(): void + public function test_resubscribe_after_unsubscribing_last_sub_delivers(): void { $realtime = new Realtime(); @@ -304,7 +304,7 @@ class MessagingTest extends TestCase $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); } - public function testSubscribeAfterOnOpenEmptySentinelPreservesUnion(): void + public function test_subscribe_after_on_open_empty_sentinel_preserves_union(): void { $realtime = new Realtime(); @@ -334,10 +334,10 @@ class MessagingTest extends TestCase $this->assertContains(Role::user(ID::custom('user-123'))->toString(), $realtime->connections[1]['roles']); } - public function testConvertChannelsGuest(): void + public function test_convert_channels_guest(): void { $user = new Document([ - '$id' => '' + '$id' => '', ]); $channels = [ @@ -345,7 +345,7 @@ class MessagingTest extends TestCase 1 => 'documents', 2 => 'documents.789', 3 => 'account', - 4 => 'account.456' + 4 => 'account.456', ]; $channels = Realtime::convertChannels($channels, $user->getId()); @@ -357,32 +357,32 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } - public function testConvertChannelsUser(): void + public function test_convert_channels_user(): void { - $user = new Document([ + $user = new Document([ '$id' => ID::custom('123'), 'memberships' => [ [ 'teamId' => ID::custom('abc'), 'roles' => [ 'administrator', - 'moderator' - ] + 'moderator', + ], ], [ 'teamId' => ID::custom('def'), 'roles' => [ - 'guest' - ] - ] - ] + 'guest', + ], + ], + ], ]); $channels = [ 0 => 'files', 1 => 'documents', 2 => 'documents.789', 3 => 'account', - 4 => 'account.456' + 4 => 'account.456', ]; $channels = Realtime::convertChannels($channels, $user->getId()); @@ -396,7 +396,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } - public function testFromPayloadPermissions(): void + public function test_from_payload_permissions(): void { /** * Test Collection Level Permissions @@ -460,7 +460,7 @@ class MessagingTest extends TestCase $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } - public function testFromPayloadBucketLevelPermissions(): void + public function test_from_payload_bucket_level_permissions(): void { /** * Test Bucket Level Permissions @@ -510,15 +510,15 @@ class MessagingTest extends TestCase Permission::update(Role::team('123abc')), Permission::delete(Role::team('123abc')), ], - 'fileSecurity' => true + 'fileSecurity' => true, ]) ); $this->assertContains(Role::any()->toString(), $result['roles']); $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } - - public function testFromPayloadEmitsActionSuffixedChannels(): void + + public function test_from_payload_emits_action_suffixed_channels(): void { $result = Realtime::fromPayload( event: 'databases.database_id.collections.collection_id.documents.document_id.create', @@ -550,7 +550,7 @@ class MessagingTest extends TestCase $this->assertNotContains('documents.delete', $result['channels']); } - public function testFromPayloadEmitsActionSuffixForEveryAction(): void + public function test_from_payload_emits_action_suffix_for_every_action(): void { foreach (['create', 'update', 'upsert', 'delete'] as $action) { $result = Realtime::fromPayload( @@ -577,7 +577,7 @@ class MessagingTest extends TestCase } } - public function testFromPayloadDoesNotSuffixWhenNoAction(): void + public function test_from_payload_does_not_suffix_when_no_action(): void { // Synthetic event without an action segment: e.g. an attribute event whose // last segment is not a known action and whose second-to-last segment is @@ -606,7 +606,7 @@ class MessagingTest extends TestCase $this->assertContains('buckets.bucket_id.files.file_id', $result['channels']); } - public function testFromPayloadDoesNotSuffixAdminChannels(): void + public function test_from_payload_does_not_suffix_admin_channels(): void { // Function execution event emits resource-leaf channels (executions / functions) // alongside admin channels (console / projects.X). Admin channels must NOT @@ -640,7 +640,51 @@ class MessagingTest extends TestCase $this->assertNotContains('projects.project_id.create', $result['channels']); } - public function testActionSuffixDeliversOnlyMatchingActionEndToEnd(): void + public function test_from_payload_handles_attribute_trailing_action_events(): void + { + // `users.[userId].update.{attr}` (e.g. .email, .prefs, .name) — action is the + // second-to-last segment, not the last one. The suffix must still be `.update`. + $userResult = Realtime::fromPayload( + event: 'users.user_id.update.email', + payload: new Document(['$id' => ID::custom('user_id')]) + ); + + $this->assertContains('account', $userResult['channels']); + $this->assertContains('account.user_id', $userResult['channels']); + $this->assertContains('account.update', $userResult['channels']); + $this->assertContains('account.user_id.update', $userResult['channels']); + // The attribute name must NOT leak into the channel namespace. + $this->assertNotContains('account.email', $userResult['channels']); + $this->assertNotContains('account.user_id.email', $userResult['channels']); + + // `teams.[teamId].update.prefs` — same shape at the team level. + $teamResult = Realtime::fromPayload( + event: 'teams.team_id.update.prefs', + payload: new Document(['$id' => ID::custom('team_id')]) + ); + + $this->assertContains('teams', $teamResult['channels']); + $this->assertContains('teams.team_id', $teamResult['channels']); + $this->assertContains('teams.update', $teamResult['channels']); + $this->assertContains('teams.team_id.update', $teamResult['channels']); + $this->assertNotContains('teams.prefs', $teamResult['channels']); + $this->assertNotContains('teams.team_id.prefs', $teamResult['channels']); + + // `teams.[teamId].memberships.[membershipId].update.{attr}` — same again, deeper. + $membershipResult = Realtime::fromPayload( + event: 'teams.team_id.memberships.membership_id.update.status', + payload: new Document(['$id' => ID::custom('membership_id')]) + ); + + $this->assertContains('memberships', $membershipResult['channels']); + $this->assertContains('memberships.membership_id', $membershipResult['channels']); + $this->assertContains('memberships.update', $membershipResult['channels']); + $this->assertContains('memberships.membership_id.update', $membershipResult['channels']); + $this->assertNotContains('memberships.status', $membershipResult['channels']); + $this->assertNotContains('memberships.membership_id.status', $membershipResult['channels']); + } + + public function test_action_suffix_delivers_only_matching_action_end_to_end(): void { $realtime = new Realtime(); @@ -675,7 +719,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey(1, $deleteReceivers); } - public function testPlainChannelStillReceivesAllActionsEndToEnd(): void + public function test_plain_channel_still_receives_all_actions_end_to_end(): void { $realtime = new Realtime(); @@ -693,4 +737,4 @@ class MessagingTest extends TestCase $this->assertArrayHasKey(1, $realtime->getSubscribers($event), "plain `documents` should match {$action} event"); } } -} \ No newline at end of file +} From 340ce9d56b5ac985c729cd9ca181fde7b31fe031 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 16:40:15 +0530 Subject: [PATCH 281/402] Add tests for channel conversion and event handling in Messaging - Implement `test_convert_channels_rewrites_account_action_suffixes` to ensure that account action suffixes are correctly rewritten to user-scoped channels. - Add `test_convert_channels_drops_account_actions_for_guest` to verify that account actions are dropped for guests without a user ID. - Introduce `test_from_payload_does_not_suffix_account_for_nested_user_events` to confirm that nested user events do not leak action suffixes onto account channels. --- src/Appwrite/Messaging/Adapter/Realtime.php | 40 +++- .../Realtime/RealtimeCustomClientTest.php | 191 +++++++++++------- tests/unit/Messaging/MessagingTest.php | 89 ++++++++ 3 files changed, 239 insertions(+), 81 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 73bcc6e088..ee2dd5fe13 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -399,7 +399,11 @@ class Realtime extends MessagingAdapter /** * Converts the channels from the Query Params into an array. - * Also renames the account channel to account.USER_ID and removes all illegal account channel variations. + * Also renames the account channel to account.USER_ID, rewrites action-suffixed + * account variants (`account.create`, `account.update`, `account.upsert`, + * `account.delete`) to `account.USER_ID.{action}` so they match the channels + * fromPayload() publishes for top-level user events, and removes all other + * illegal account channel variations (e.g. another user's `account.{otherId}`). */ public static function convertChannels(array $channels, string $userId): array { @@ -407,15 +411,26 @@ class Realtime extends MessagingAdapter foreach ($channels as $key => $value) { switch (true) { - case str_starts_with($key, 'account.'): - unset($channels[$key]); - break; - case $key === 'account': if (! empty($userId)) { $channels['account.'.$userId] = $value; } break; + + case \in_array(\substr($key, \strlen('account.')), self::SUPPORTED_ACTIONS, true) && str_starts_with($key, 'account.'): + // Translate `account.{action}` into the user-scoped `account.{userId}.{action}` + // so a subscriber only receives their own account events. Without the rewrite + // the literal `account.{action}` channel would match every user's events. + unset($channels[$key]); + if (! empty($userId)) { + $action = \substr($key, \strlen('account.')); + $channels['account.'.$userId.'.'.$action] = $value; + } + break; + + case str_starts_with($key, 'account.'): + unset($channels[$key]); + break; } } @@ -672,6 +687,21 @@ class Realtime extends MessagingAdapter $action = $parts[$count - 2]; } + // The `users` branch emits only user-level account channels + // (`account`, `account.{userId}`) regardless of event depth, so nested events + // like `users.U.sessions.S.create` or `users.U.challenges.C.create` would + // otherwise be suffixed as `account.create` — making a subscription to + // `account.create` receive unrelated session/challenge/recovery/verification + // events. Restrict suffixing to top-level user events where the action sits + // at parts[2] (`users.U.create`, `users.U.update.email`, etc.). + if ( + $action !== null + && ($parts[0] ?? null) === 'users' + && ($parts[2] ?? null) !== $action + ) { + $action = null; + } + if ($action !== null && ! empty($channels)) { $augmented = $channels; foreach ($channels as $channel) { diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index ef1c5fce7a..4960f05147 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -335,10 +335,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.update.name", $response['data']['events']); $this->assertContains("users.{$userId}.update", $response['data']['events']); $this->assertContains("users.{$userId}", $response['data']['events']); @@ -368,10 +370,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.update.password", $response['data']['events']); $this->assertContains("users.{$userId}.update", $response['data']['events']); $this->assertContains("users.{$userId}", $response['data']['events']); @@ -401,10 +405,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.update.email", $response['data']['events']); $this->assertContains("users.{$userId}.update", $response['data']['events']); $this->assertContains("users.{$userId}", $response['data']['events']); @@ -432,11 +438,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertArrayNotHasKey('secret', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.create', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.verification.{$verificationId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.verification.{$verificationId}", $response['data']['events']); $this->assertContains("users.{$userId}.verification.*.create", $response['data']['events']); @@ -475,10 +483,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.verification.{$verificationId}.update", $response['data']['events']); $this->assertContains("users.{$userId}.verification.{$verificationId}", $response['data']['events']); $this->assertContains("users.{$userId}.verification.*.update", $response['data']['events']); @@ -510,10 +520,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.update.prefs", $response['data']['events']); $this->assertContains("users.{$userId}.update", $response['data']['events']); $this->assertContains("users.{$userId}", $response['data']['events']); @@ -551,10 +563,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.create', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.create", $response['data']['events']); @@ -583,10 +597,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.delete', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.delete', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.delete", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.delete", $response['data']['events']); @@ -620,10 +636,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.delete', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.delete', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.delete", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.delete", $response['data']['events']); @@ -661,10 +679,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.create', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.*.create", $response['data']['events']); @@ -695,10 +715,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}.update", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.*.update", $response['data']['events']); @@ -820,7 +842,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -865,7 +887,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -921,7 +943,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -977,7 +999,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); @@ -1009,7 +1031,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); @@ -1058,7 +1080,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -1086,7 +1108,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -1114,7 +1136,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -1151,7 +1173,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -1180,7 +1202,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -1209,7 +1231,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -1256,7 +1278,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -1435,7 +1457,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response1['type']); $this->assertNotEmpty($response1['data']); $this->assertArrayHasKey('timestamp', $response1['data']); - $this->assertCount(8, $response1['data']['channels']); + $this->assertCount(16, $response1['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.create", $response1['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response1['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response1['data']['events']); @@ -1466,7 +1488,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response2['type']); $this->assertNotEmpty($response2['data']); $this->assertArrayHasKey('timestamp', $response2['data']); - $this->assertCount(8, $response2['data']['channels']); + $this->assertCount(16, $response2['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.create", $response2['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response2['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response2['data']['events']); @@ -1516,7 +1538,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response1['type']); $this->assertNotEmpty($response1['data']); $this->assertArrayHasKey('timestamp', $response1['data']); - $this->assertCount(8, $response1['data']['channels']); + $this->assertCount(16, $response1['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.update", $response1['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response1['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response1['data']['events']); @@ -1570,7 +1592,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response2['type']); $this->assertNotEmpty($response2['data']); $this->assertArrayHasKey('timestamp', $response2['data']); - $this->assertCount(8, $response2['data']['channels']); + $this->assertCount(16, $response2['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.update", $response2['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response2['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response2['data']['events']); @@ -1623,7 +1645,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response1['type']); $this->assertNotEmpty($response1['data']); $this->assertArrayHasKey('timestamp', $response1['data']); - $this->assertCount(8, $response1['data']['channels']); + $this->assertCount(16, $response1['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.update", $response1['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response1['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response1['data']['events']); @@ -1650,7 +1672,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response2['type']); $this->assertNotEmpty($response2['data']); $this->assertArrayHasKey('timestamp', $response2['data']); - $this->assertCount(8, $response2['data']['channels']); + $this->assertCount(16, $response2['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.update", $response2['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response2['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response2['data']['events']); @@ -1689,7 +1711,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response1['type']); $this->assertNotEmpty($response1['data']); $this->assertArrayHasKey('timestamp', $response1['data']); - $this->assertCount(8, $response1['data']['channels']); + $this->assertCount(16, $response1['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.delete", $response1['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response1['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response1['data']['events']); @@ -1720,7 +1742,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response2['type']); $this->assertNotEmpty($response2['data']); $this->assertArrayHasKey('timestamp', $response2['data']); - $this->assertCount(8, $response2['data']['channels']); + $this->assertCount(16, $response2['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.delete", $response2['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response2['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response2['data']['events']); @@ -1773,7 +1795,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -1811,7 +1833,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -1953,7 +1975,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -1992,7 +2014,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -2042,7 +2064,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -2130,10 +2152,13 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('files', $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files", $response['data']['channels']); + $this->assertContains('files.create', $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.create", $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.{$fileId}.create", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}.create", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.*.create", $response['data']['events']); @@ -2169,10 +2194,13 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('files', $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files", $response['data']['channels']); + $this->assertContains('files.update', $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.update", $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.{$fileId}.update", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}.update", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.*.update", $response['data']['events']); @@ -2200,10 +2228,13 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('files', $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files", $response['data']['channels']); + $this->assertContains('files.delete', $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.delete", $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.{$fileId}.delete", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}.delete", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.*.delete", $response['data']['events']); @@ -2320,7 +2351,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(5, $response['data']['channels']); + $this->assertCount(8, $response['data']['channels']); $this->assertContains('console', $response['data']['channels']); $this->assertContains("projects.{$this->getProject()['$id']}", $response['data']['channels']); $this->assertContains('executions', $response['data']['channels']); @@ -2343,7 +2374,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $responseUpdate['type']); $this->assertNotEmpty($responseUpdate['data']); $this->assertArrayHasKey('timestamp', $responseUpdate['data']); - $this->assertCount(5, $responseUpdate['data']['channels']); + $this->assertCount(8, $responseUpdate['data']['channels']); $this->assertContains('console', $responseUpdate['data']['channels']); $this->assertContains("projects.{$this->getProject()['$id']}", $response['data']['channels']); $this->assertContains('executions', $responseUpdate['data']['channels']); @@ -2418,9 +2449,11 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertContains('teams', $response['data']['channels']); $this->assertContains("teams.{$teamId}", $response['data']['channels']); + $this->assertContains('teams.create', $response['data']['channels']); + $this->assertContains("teams.{$teamId}.create", $response['data']['channels']); $this->assertContains("teams.{$teamId}.create", $response['data']['events']); $this->assertContains("teams.{$teamId}", $response['data']['events']); $this->assertContains("teams.*.create", $response['data']['events']); @@ -2447,9 +2480,11 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertContains('teams', $response['data']['channels']); $this->assertContains("teams.{$teamId}", $response['data']['channels']); + $this->assertContains('teams.update', $response['data']['channels']); + $this->assertContains("teams.{$teamId}.update", $response['data']['channels']); $this->assertContains("teams.{$teamId}.update", $response['data']['events']); $this->assertContains("teams.{$teamId}", $response['data']['events']); $this->assertContains("teams.*.update", $response['data']['events']); @@ -2480,9 +2515,11 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertContains('teams', $response['data']['channels']); $this->assertContains("teams.{$teamId}", $response['data']['channels']); + $this->assertContains('teams.update', $response['data']['channels']); + $this->assertContains("teams.{$teamId}.update", $response['data']['channels']); $this->assertContains("teams.{$teamId}.update", $response['data']['events']); $this->assertContains("teams.{$teamId}.update.prefs", $response['data']['events']); $this->assertContains("teams.{$teamId}", $response['data']['events']); @@ -2547,9 +2584,11 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertContains('memberships', $response['data']['channels']); $this->assertContains("memberships.{$membershipId}", $response['data']['channels']); + $this->assertContains('memberships.update', $response['data']['channels']); + $this->assertContains("memberships.{$membershipId}.update", $response['data']['channels']); $this->assertContains("teams.{$teamId}.memberships.{$membershipId}.update", $response['data']['events']); $this->assertContains("teams.{$teamId}.memberships.{$membershipId}", $response['data']['events']); $this->assertContains("teams.{$teamId}.memberships.*.update", $response['data']['events']); @@ -4276,7 +4315,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $rowId, $response['data']['channels']); $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -4333,7 +4372,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -4401,7 +4440,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('rows', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['channels']); @@ -4472,7 +4511,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); @@ -4518,7 +4557,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); @@ -4582,7 +4621,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -4624,7 +4663,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -4666,7 +4705,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -4717,7 +4756,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -4760,7 +4799,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -4789,7 +4828,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -4836,7 +4875,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -4957,7 +4996,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -4992,7 +5031,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -5036,7 +5075,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -5080,7 +5119,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertNotEmpty($response['data']['payload']); $this->assertIsArray($response['data']['payload']); $this->assertArrayHasKey('$id', $response['data']['payload']); @@ -5098,7 +5137,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertNotEmpty($response['data']['payload']); $this->assertIsArray($response['data']['payload']); $this->assertArrayHasKey('$id', $response['data']['payload']); @@ -5133,7 +5172,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -5161,7 +5200,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -5189,7 +5228,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -5226,7 +5265,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -5255,7 +5294,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -5284,7 +5323,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -5331,7 +5370,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -5436,7 +5475,7 @@ class RealtimeCustomClientTest extends Scope $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); // vectorsdb channels should include 3 items like documentsdb - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -5467,7 +5506,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); $this->assertNotEmpty($response['data']['payload']); @@ -5486,7 +5525,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -5525,7 +5564,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $response['data']['payload']['$id'] . '.create', $response['data']['events']); $this->assertContains('vectorsdb.*.collections.*.documents.*.create', $response['data']['events']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.*.documents.*.create', $response['data']['events']); @@ -5540,7 +5579,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $response['data']['payload']['$id'] . '.create', $response['data']['events']); $client->close(); @@ -5643,7 +5682,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); $this->assertNotEmpty($response['data']['payload']); @@ -5674,7 +5713,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); $this->assertNotEmpty($response['data']['payload']); diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 9abe71c890..d66a86a4f1 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -396,6 +396,56 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } + public function test_convert_channels_rewrites_account_action_suffixes(): void + { + // A subscriber to `account.{action}` should receive the user-scoped + // `account.{userId}.{action}` channel that fromPayload publishes for + // top-level user events. Without the rewrite the channel would either be + // stripped (security guard against subscribing to other users' account) + // or, if left literal, leak every user's account events to this client. + $channels = Realtime::convertChannels( + ['account.create', 'account.update', 'account.upsert', 'account.delete'], + '123', + ); + + $this->assertArrayHasKey('account.123.create', $channels); + $this->assertArrayHasKey('account.123.update', $channels); + $this->assertArrayHasKey('account.123.upsert', $channels); + $this->assertArrayHasKey('account.123.delete', $channels); + + // The literal forms must not survive — they would otherwise match every + // user's events, not just the subscribed user's. + $this->assertArrayNotHasKey('account.create', $channels); + $this->assertArrayNotHasKey('account.update', $channels); + $this->assertArrayNotHasKey('account.upsert', $channels); + $this->assertArrayNotHasKey('account.delete', $channels); + + // Other-user channels and unknown action-like suffixes still get stripped. + $channels = Realtime::convertChannels( + ['account.other_id', 'account.bogus', 'account.123', 'account.create'], + '123', + ); + $this->assertArrayNotHasKey('account.other_id', $channels); + $this->assertArrayNotHasKey('account.bogus', $channels); + $this->assertArrayNotHasKey('account.123', $channels); + $this->assertArrayHasKey('account.123.create', $channels); + } + + public function test_convert_channels_drops_account_actions_for_guest(): void + { + // No userId → no place to scope the action-suffixed channel, so the + // action-suffixed forms are dropped entirely. Plain `account` survives + // (matching existing guest behavior — see test_convert_channels_guest). + $channels = Realtime::convertChannels( + ['account.create', 'account.update', 'account'], + '', + ); + + $this->assertArrayNotHasKey('account.create', $channels); + $this->assertArrayNotHasKey('account.update', $channels); + $this->assertArrayHasKey('account', $channels); + } + public function test_from_payload_permissions(): void { /** @@ -684,6 +734,45 @@ class MessagingTest extends TestCase $this->assertNotContains('memberships.membership_id.status', $membershipResult['channels']); } + public function test_from_payload_does_not_suffix_account_for_nested_user_events(): void + { + // Nested user events (challenges/sessions/recovery/verification) emit only + // user-level account channels in fromPayload. The trailing action belongs to + // the nested resource, NOT to the user account. A subscriber to + // `account.create` must not receive `users.U.challenges.C.create` or + // `users.U.sessions.S.delete` events — that would silently leak unrelated + // MFA / session traffic into account-level filters. + foreach (['challenges', 'sessions', 'recovery', 'verification'] as $sub) { + foreach (['create', 'update', 'delete'] as $action) { + $result = Realtime::fromPayload( + event: "users.user_id.{$sub}.sub_id.{$action}", + payload: new Document(['$id' => ID::custom('sub_id')]) + ); + + $this->assertContains('account', $result['channels'], "{$sub}.{$action} should still emit base account channel"); + $this->assertContains('account.user_id', $result['channels'], "{$sub}.{$action} should still emit user-scoped account channel"); + $this->assertNotContains("account.{$action}", $result['channels'], "{$sub}.{$action} must NOT leak action suffix onto account channel"); + $this->assertNotContains("account.user_id.{$action}", $result['channels'], "{$sub}.{$action} must NOT leak action suffix onto user-scoped account channel"); + } + } + + // Top-level user events SHOULD still suffix — guard against an over-eager fix + // that suppresses the suffix for legitimate account-level CRUD. + $createResult = Realtime::fromPayload( + event: 'users.user_id.create', + payload: new Document(['$id' => ID::custom('user_id')]) + ); + $this->assertContains('account.create', $createResult['channels']); + $this->assertContains('account.user_id.create', $createResult['channels']); + + $updateResult = Realtime::fromPayload( + event: 'users.user_id.update.email', + payload: new Document(['$id' => ID::custom('user_id')]) + ); + $this->assertContains('account.update', $updateResult['channels']); + $this->assertContains('account.user_id.update', $updateResult['channels']); + } + public function test_action_suffix_delivers_only_matching_action_end_to_end(): void { $realtime = new Realtime(); From 1928605bd995afa3761ce6d67a55fe0da9977851 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 16:42:45 +0530 Subject: [PATCH 282/402] linting --- src/Appwrite/Messaging/Adapter/Realtime.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index ee2dd5fe13..7085e2062b 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -696,7 +696,7 @@ class Realtime extends MessagingAdapter // at parts[2] (`users.U.create`, `users.U.update.email`, etc.). if ( $action !== null - && ($parts[0] ?? null) === 'users' + && $parts[0] === 'users' && ($parts[2] ?? null) !== $action ) { $action = null; From 2e960b90df1dc371e35942cec7e1573732ae957d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 13:38:26 +0200 Subject: [PATCH 283/402] Fix unused env variable --- app/controllers/general.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/general.php b/app/controllers/general.php index 2cec14cc1d..70bd323fb5 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -120,7 +120,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S } } - if (!in_array($host, $platformHostnames)) { + if (!in_array($host, $platformHostnames) && System::getEnv('_APP_OPTIONS_ROUTER_PROTECTION', 'enabled') === 'enabled') { throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Router protection does not allow accessing Appwrite over this domain. Please add it as custom domain to your project or disable _APP_OPTIONS_ROUTER_PROTECTION environment variable.', view: $errorView); } From ef4b9c49346019fa304d9fba69bbe114134e04e3 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 17:16:17 +0530 Subject: [PATCH 284/402] updated --- .../Realtime/RealtimeCustomClientTest.php | 49 +++++++++++-------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 4960f05147..813ef70ff0 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -438,13 +438,14 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(4, $response['data']['channels']); + // Nested user event (verification) — must NOT suffix the account channels. + $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertArrayNotHasKey('secret', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); - $this->assertContains('account.create', $response['data']['channels']); - $this->assertContains('account.' . $userId . '.create', $response['data']['channels']); + $this->assertNotContains('account.create', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.verification.{$verificationId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.verification.{$verificationId}", $response['data']['events']); $this->assertContains("users.{$userId}.verification.*.create", $response['data']['events']); @@ -483,12 +484,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(4, $response['data']['channels']); + // Nested user event (verification) — must NOT suffix the account channels. + $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); - $this->assertContains('account.update', $response['data']['channels']); - $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); + $this->assertNotContains('account.update', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.verification.{$verificationId}.update", $response['data']['events']); $this->assertContains("users.{$userId}.verification.{$verificationId}", $response['data']['events']); $this->assertContains("users.{$userId}.verification.*.update", $response['data']['events']); @@ -563,12 +565,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(4, $response['data']['channels']); + // Nested user event (sessions) — must NOT suffix the account channels. + $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); - $this->assertContains('account.create', $response['data']['channels']); - $this->assertContains('account.' . $userId . '.create', $response['data']['channels']); + $this->assertNotContains('account.create', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.create", $response['data']['events']); @@ -597,12 +600,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(4, $response['data']['channels']); + // Nested user event (sessions) — must NOT suffix the account channels. + $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); - $this->assertContains('account.delete', $response['data']['channels']); - $this->assertContains('account.' . $userId . '.delete', $response['data']['channels']); + $this->assertNotContains('account.delete', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.delete', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.delete", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.delete", $response['data']['events']); @@ -636,12 +640,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(4, $response['data']['channels']); + // Nested user event (sessions) — must NOT suffix the account channels. + $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); - $this->assertContains('account.delete', $response['data']['channels']); - $this->assertContains('account.' . $userId . '.delete', $response['data']['channels']); + $this->assertNotContains('account.delete', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.delete', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.delete", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.delete", $response['data']['events']); @@ -679,12 +684,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(4, $response['data']['channels']); + // Nested user event (recovery) — must NOT suffix the account channels. + $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); - $this->assertContains('account.create', $response['data']['channels']); - $this->assertContains('account.' . $userId . '.create', $response['data']['channels']); + $this->assertNotContains('account.create', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.*.create", $response['data']['events']); @@ -715,12 +721,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(4, $response['data']['channels']); + // Nested user event (recovery) — must NOT suffix the account channels. + $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); - $this->assertContains('account.update', $response['data']['channels']); - $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); + $this->assertNotContains('account.update', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}.update", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.*.update", $response['data']['events']); From 7e3114d733a1c77529eb645e40b3312bb26503c2 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 17:26:27 +0530 Subject: [PATCH 285/402] linting --- src/Appwrite/Messaging/Adapter/Realtime.php | 95 ++++++++++----------- tests/unit/Messaging/MessagingTest.php | 2 +- 2 files changed, 47 insertions(+), 50 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 7085e2062b..7526df9dd0 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -87,14 +87,14 @@ class Realtime extends MessagingAdapter array $queryGroup = [], ?string $userId = null ): void { - if (! isset($this->subscriptions[$projectId])) { // Init Project + if (!isset($this->subscriptions[$projectId])) { // Init Project $this->subscriptions[$projectId] = []; } $strings = []; $data = []; - if (! empty($channels)) { + if (!empty($channels)) { if (empty($queryGroup)) { $strings[] = Query::select(['*'])->toString(); } else { @@ -109,15 +109,15 @@ class Realtime extends MessagingAdapter } foreach ($roles as $role) { - if (! isset($this->subscriptions[$projectId][$role])) { + if (!isset($this->subscriptions[$projectId][$role])) { $this->subscriptions[$projectId][$role] = []; } foreach ($channels as $channel) { - if (! isset($this->subscriptions[$projectId][$role][$channel])) { + if (!isset($this->subscriptions[$projectId][$role][$channel])) { $this->subscriptions[$projectId][$role][$channel] = []; } - if (! isset($this->subscriptions[$projectId][$role][$channel][$identifier])) { + if (!isset($this->subscriptions[$projectId][$role][$channel][$identifier])) { $this->subscriptions[$projectId][$role][$channel][$identifier] = []; } $this->subscriptions[$projectId][$role][$channel][$identifier][$subscriptionId] = $data; @@ -165,23 +165,23 @@ class Realtime extends MessagingAdapter // Extract subscription data from subscriptions tree foreach ($roles as $role) { - if (! isset($this->subscriptions[$projectId][$role])) { + if (!isset($this->subscriptions[$projectId][$role])) { continue; } foreach ($channels as $channel) { - if (! isset($this->subscriptions[$projectId][$role][$channel][$connection])) { + if (!isset($this->subscriptions[$projectId][$role][$channel][$connection])) { continue; } foreach ($this->subscriptions[$projectId][$role][$channel][$connection] as $subscriptionId => $data) { - if (! isset($subscriptions[$subscriptionId])) { + if (!isset($subscriptions[$subscriptionId])) { $subscriptions[$subscriptionId] = [ 'channels' => [], 'queries' => $data['strings'] ?? [], ]; } - if (! \in_array($channel, $subscriptions[$subscriptionId]['channels'])) { + if (!\in_array($channel, $subscriptions[$subscriptionId]['channels'])) { $subscriptions[$subscriptionId]['channels'][] = $channel; } } @@ -230,7 +230,7 @@ class Realtime extends MessagingAdapter public function unsubscribeSubscription(mixed $connection, string $subscriptionId): bool { $projectId = $this->connections[$connection]['projectId'] ?? ''; - if ($projectId === '' || ! isset($this->subscriptions[$projectId])) { + if ($projectId === '' || !isset($this->subscriptions[$projectId])) { return false; } @@ -238,7 +238,7 @@ class Realtime extends MessagingAdapter foreach ($this->subscriptions[$projectId] as $role => $byChannel) { foreach ($byChannel as $channel => $byConnection) { - if (! isset($byConnection[$connection][$subscriptionId])) { + if (!isset($byConnection[$connection][$subscriptionId])) { continue; } @@ -279,7 +279,7 @@ class Realtime extends MessagingAdapter */ private function recomputeConnectionState(mixed $connection): void { - if (! isset($this->connections[$connection])) { + if (!isset($this->connections[$connection])) { return; } @@ -311,7 +311,7 @@ class Realtime extends MessagingAdapter return array_key_exists($projectId, $this->subscriptions) && array_key_exists($role, $this->subscriptions[$projectId]) && array_key_exists($channel, $this->subscriptions[$projectId][$role]) - && ! empty($this->subscriptions[$projectId][$role][$channel]); + && !empty($this->subscriptions[$projectId][$role][$channel]); } /** @@ -357,7 +357,7 @@ class Realtime extends MessagingAdapter { $receivers = []; - if (! isset($this->subscriptions[$event['project']])) { + if (!isset($this->subscriptions[$event['project']])) { return $receivers; } @@ -367,7 +367,7 @@ class Realtime extends MessagingAdapter foreach ($event['data']['channels'] as $channel) { if ( ! \array_key_exists($channel, $subscriptionsByChannel) - || (! \in_array($role, $event['roles']) && ! \in_array(Role::any()->toString(), $event['roles'])) + || (!\in_array($role, $event['roles']) && !\in_array(Role::any()->toString(), $event['roles'])) ) { continue; } @@ -384,8 +384,8 @@ class Realtime extends MessagingAdapter } } - if (! empty($matched)) { - if (! isset($receivers[$id])) { + if (!empty($matched)) { + if (!isset($receivers[$id])) { $receivers[$id] = []; } $receivers[$id] += $matched; @@ -412,7 +412,7 @@ class Realtime extends MessagingAdapter foreach ($channels as $key => $value) { switch (true) { case $key === 'account': - if (! empty($userId)) { + if (!empty($userId)) { $channels['account.'.$userId] = $value; } break; @@ -422,7 +422,7 @@ class Realtime extends MessagingAdapter // so a subscriber only receives their own account events. Without the rewrite // the literal `account.{action}` channel would match every user's events. unset($channels[$key]); - if (! empty($userId)) { + if (!empty($userId)) { $action = \substr($key, \strlen('account.')); $channels['account.'.$userId.'.'.$action] = $value; } @@ -475,7 +475,7 @@ class Realtime extends MessagingAdapter } if ($params === null) { - if (! isset($subscriptions[0])) { + if (!isset($subscriptions[0])) { $subscriptions[0] = ['channels' => [], 'queries' => []]; } $subscriptions[0]['channels'][] = $channel; @@ -491,11 +491,11 @@ class Realtime extends MessagingAdapter } foreach ($params as $index => $slot) { - if (! isset($subscriptions[$index])) { + if (!isset($subscriptions[$index])) { $subscriptions[$index] = ['channels' => [], 'queries' => []]; } - if (! \in_array($channel, $subscriptions[$index]['channels'], true)) { + if (!\in_array($channel, $subscriptions[$index]['channels'], true)) { $subscriptions[$index]['channels'][] = $channel; } @@ -522,7 +522,7 @@ class Realtime extends MessagingAdapter $stack = $queries; $allowed = implode(', ', RuntimeQuery::ALLOWED_QUERIES); - while (! empty($stack)) { + while (!empty($stack)) { $query = array_pop($stack); $method = $query->getMethod(); @@ -561,19 +561,19 @@ class Realtime extends MessagingAdapter switch ($parts[0]) { case 'users': $channels[] = 'account'; - $channels[] = 'account.'.$parts[1]; + $channels[] = 'account.' . $parts[1]; $roles = [Role::user(ID::custom($parts[1]))->toString()]; break; case 'rules': case 'migrations': $channels[] = 'console'; - $channels[] = 'projects.'.$project->getId(); + $channels[] = 'projects.' . $project->getId(); $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; break; case 'projects': $channels[] = 'console'; - $channels[] = 'projects.'.$parts[1]; + $channels[] = 'projects.' . $parts[1]; $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; break; @@ -581,11 +581,11 @@ class Realtime extends MessagingAdapter if ($parts[2] === 'memberships') { $permissionsChanged = $parts[4] ?? false; $channels[] = 'memberships'; - $channels[] = 'memberships.'.$parts[3]; + $channels[] = 'memberships.' . $parts[3]; } else { $permissionsChanged = $parts[2] === 'create'; $channels[] = 'teams'; - $channels[] = 'teams.'.$parts[1]; + $channels[] = 'teams.' . $parts[1]; } $roles = [Role::team(ID::custom($parts[1]))->toString()]; break; @@ -596,7 +596,7 @@ class Realtime extends MessagingAdapter $resource = $parts[4] ?? ''; if (in_array($resource, ['columns', 'attributes', 'indexes'])) { $channels[] = 'console'; - $channels[] = 'projects.'.$project->getId(); + $channels[] = 'projects.' . $project->getId(); $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; } elseif (in_array($resource, ['rows', 'documents'])) { @@ -638,8 +638,8 @@ class Realtime extends MessagingAdapter throw new \Exception('Bucket needs to be passed to Realtime for File events in the Storage.'); } $channels[] = 'files'; - $channels[] = 'buckets.'.$payload->getAttribute('bucketId').'.files'; - $channels[] = 'buckets.'.$payload->getAttribute('bucketId').'.files.'.$payload->getId(); + $channels[] = 'buckets.' . $payload->getAttribute('bucketId') . '.files'; + $channels[] = 'buckets.' . $payload->getAttribute('bucketId') . '.files.'.$payload->getId(); $roles = $bucket->getAttribute('fileSecurity', false) ? \array_merge($bucket->getRead(), $payload->getRead()) @@ -649,17 +649,17 @@ class Realtime extends MessagingAdapter break; case 'functions': if ($parts[2] === 'executions') { - if (! empty($payload->getRead())) { + if (!empty($payload->getRead())) { $channels[] = 'console'; - $channels[] = 'projects.'.$project->getId(); + $channels[] = 'projects.' . $project->getId(); $channels[] = 'executions'; - $channels[] = 'executions.'.$payload->getId(); - $channels[] = 'functions.'.$payload->getAttribute('functionId'); + $channels[] = 'executions.' . $payload->getId(); + $channels[] = 'functions.' . $payload->getAttribute('functionId'); $roles = $payload->getRead(); } } elseif ($parts[2] === 'deployments') { $channels[] = 'console'; - $channels[] = 'projects.'.$project->getId(); + $channels[] = 'projects.' . $project->getId(); $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; } @@ -668,7 +668,7 @@ class Realtime extends MessagingAdapter case 'sites': if ($parts[2] === 'deployments') { $channels[] = 'console'; - $channels[] = 'projects.'.$project->getId(); + $channels[] = 'projects.' . $project->getId(); $projectId = 'console'; $roles = [Role::team($project->getAttribute('teamId'))->toString()]; } @@ -702,7 +702,7 @@ class Realtime extends MessagingAdapter $action = null; } - if ($action !== null && ! empty($channels)) { + if ($action !== null && !empty($channels)) { $augmented = $channels; foreach ($channels as $channel) { $segments = \explode('.', $channel); @@ -711,7 +711,7 @@ class Realtime extends MessagingAdapter $parentIsResource = $segCount >= 2 && \in_array($segments[$segCount - 2], self::RESOURCE_LEAF_NAMES, true); if ($leafIsResource || $parentIsResource) { - $augmented[] = $channel.'.'.$action; + $augmented[] = $channel. '.' .$action; } } $channels = \array_values(\array_unique($augmented)); @@ -726,15 +726,12 @@ class Realtime extends MessagingAdapter } /** - * Generate realtime channels for database events - * - * @param string $type The database API type - * @param string $databaseId The database ID - * @param string $resourceId The collection/table ID - * @param string $payloadId The document/row ID - * @param string $prefixOverride Override the channel prefix when different API types share the same terminology but need different prefixes - * (e.g., 'databases' and 'documentsdb' use same terminology but need different prefixes) - * @return array Array of channel names + * @param string $type The database API type + * @param string $databaseId The database ID + * @param string $resourceId The collection/table ID + * @param string $payloadId The document/row ID + * @param string $prefixOverride Override the channel prefix when different API types share the same terminology but need different prefixes + * (e.g., 'databases' and 'documentsdb' use same terminology but need different prefixes) */ private static function getDatabaseChannels( string $type = 'databases', @@ -745,7 +742,7 @@ class Realtime extends MessagingAdapter ): array { $basePrefix = $prefixOverride ?: $type; - if (! $databaseId || ! $resourceId || ! $payloadId) { + if (!$databaseId || !$resourceId || !$payloadId) { return []; } diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index d66a86a4f1..1740b3d62a 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -734,7 +734,7 @@ class MessagingTest extends TestCase $this->assertNotContains('memberships.membership_id.status', $membershipResult['channels']); } - public function test_from_payload_does_not_suffix_account_for_nested_user_events(): void + public function testFromPayloadDoesNotSuffixAccountForNestedUserEvents(): void { // Nested user events (challenges/sessions/recovery/verification) emit only // user-level account channels in fromPayload. The trailing action belongs to From ca105ff9bc381472ad27b4e8737f41666dbd872a Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 17:31:31 +0530 Subject: [PATCH 286/402] feat(Realtime): implement rebindAccountChannels method for userId changes and add corresponding tests --- app/realtime.php | 20 ++++++- src/Appwrite/Messaging/Adapter/Realtime.php | 38 +++++++++++++ tests/unit/Messaging/MessagingTest.php | 63 +++++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 0e7388b83f..bc95fc6cdc 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -566,6 +566,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $roles = $user->getRoles($database->getAuthorization()); $authorization = $realtime->connections[$connection]['authorization'] ?? null; + $previousUserId = $realtime->connections[$connection]['userId'] ?? ''; $meta = $realtime->getSubscriptionMetadata($connection); @@ -573,12 +574,17 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, foreach ($meta as $subscriptionId => $subscription) { $queries = Query::parseQueries($subscription['queries'] ?? []); + $channels = Realtime::rebindAccountChannels( + $subscription['channels'] ?? [], + $previousUserId, + $userId + ); $realtime->subscribe( $projectId, $connection, $subscriptionId, $roles, - $subscription['channels'] ?? [], + $channels, $queries ); } @@ -1068,6 +1074,11 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $authorization = $realtime->connections[$connection]['authorization'] ?? null; $projectId = $realtime->connections[$connection]['projectId'] ?? null; + // Capture the pre-auth userId so we can rebind any account channels + // that were stored under it (e.g. guest who subscribed to `account` + // and now authenticates). unsubscribe() below clears the connection + // entry, so we must read it first. + $previousUserId = $realtime->connections[$connection]['userId'] ?? ''; $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); $meta = $realtime->getSubscriptionMetadata($connection); @@ -1077,13 +1088,18 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re if (!empty($projectId)) { foreach ($meta as $subscriptionId => $subscription) { $queries = Query::parseQueries($subscription['queries'] ?? []); + $channels = Realtime::rebindAccountChannels( + $subscription['channels'] ?? [], + $previousUserId, + $user->getId() + ); $realtime->subscribe( $projectId, $connection, $subscriptionId, $roles, - $subscription['channels'] ?? [], + $channels, $queries, $user->getId() ); diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 7526df9dd0..33b2e76889 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -437,6 +437,44 @@ class Realtime extends MessagingAdapter return $channels; } + /** + * Rewrites stored account channels (`account.{oldUserId}` and + * `account.{oldUserId}.{action}`) to match a new userId. Used when in-band + * authentication changes the connection's user identity (typically + * guest → authenticated user, or rare reauth as a different user) — without + * this, channels stay bound to the old userId and the connection silently + * receives the previous user's account events. + * + * Returns channels unchanged when the user identity has not changed + * (oldUserId === newUserId) or when the connection had no userId previously + * (guest connections never store userId-suffixed channels because + * convertChannels strips the suffix when userId is empty). + */ + public static function rebindAccountChannels(array $channels, string $oldUserId, string $newUserId): array + { + if ($oldUserId === '' || $oldUserId === $newUserId) { + return $channels; + } + + $oldExact = 'account.'.$oldUserId; + $oldPrefix = $oldExact.'.'; + + return \array_map(function (string $channel) use ($oldExact, $oldPrefix, $newUserId) { + if ($channel === $oldExact) { + return 'account.'.$newUserId; + } + + if (\str_starts_with($channel, $oldPrefix)) { + $action = \substr($channel, \strlen($oldPrefix)); + if (\in_array($action, self::SUPPORTED_ACTIONS, true)) { + return 'account.'.$newUserId.'.'.$action; + } + } + + return $channel; + }, $channels); + } + /** * Constructs subscriptions from query parameters. * diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 1740b3d62a..395a4fba01 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -446,6 +446,69 @@ class MessagingTest extends TestCase $this->assertArrayHasKey('account', $channels); } + public function test_rebind_account_channels_remaps_after_reauth(): void + { + // Captures the in-band auth scenario: a guest connects and subscribes to + // `account` (stored as `account` because there's no userId). After the + // client sends an authentication message, the connection's userId becomes + // 'B' — but its stored channels are still bound to whatever the previous + // identity was. This helper rewrites them so the resubscribe lands on the + // new user's account namespace. + $rebound = Realtime::rebindAccountChannels( + ['account.A', 'account.A.create', 'account.A.update', 'documents', 'documents.A.something'], + 'A', + 'B', + ); + + // account-scoped channels are rebound to the new user. + $this->assertContains('account.B', $rebound); + $this->assertContains('account.B.create', $rebound); + $this->assertContains('account.B.update', $rebound); + $this->assertNotContains('account.A', $rebound); + $this->assertNotContains('account.A.create', $rebound); + $this->assertNotContains('account.A.update', $rebound); + + // Non-account channels are left alone — the rewrite must be precise. + $this->assertContains('documents', $rebound); + $this->assertContains('documents.A.something', $rebound); + } + + public function test_rebind_account_channels_is_noop_for_unchanged_user(): void + { + // Same user → nothing to rewrite. Avoids unnecessary churn when the + // permissionsChanged path fires (roles change but userId is constant). + $channels = ['account.A', 'account.A.create', 'documents']; + $this->assertSame($channels, Realtime::rebindAccountChannels($channels, 'A', 'A')); + } + + public function test_rebind_account_channels_is_noop_for_guest_origin(): void + { + // Guest connections never store userId-suffixed channels (convertChannels + // strips the suffix when userId is empty), so rebinding from '' to a real + // userId should be a no-op — the plain `account` channel doesn't carry + // any userId binding to remap. + $channels = ['account', 'documents']; + $this->assertSame($channels, Realtime::rebindAccountChannels($channels, '', 'B')); + } + + public function test_rebind_account_channels_only_remaps_known_actions(): void + { + // Defensive: we intentionally restrict the rewrite to suffixes in + // SUPPORTED_ACTIONS so we don't accidentally rewrite a channel that + // happens to have `account.{userId}.{something}` shape from outside the + // documented set. + $rebound = Realtime::rebindAccountChannels( + ['account.A.bogus', 'account.A.create'], + 'A', + 'B', + ); + + $this->assertContains('account.A.bogus', $rebound); + $this->assertContains('account.B.create', $rebound); + $this->assertNotContains('account.B.bogus', $rebound); + $this->assertNotContains('account.A.create', $rebound); + } + public function test_from_payload_permissions(): void { /** From 15f94d99caecdf09da9e9071b4988cd91646e360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 14:02:30 +0200 Subject: [PATCH 287/402] Add Kick OAuth adapter --- app/config/oAuthProviders.php | 11 + app/init/models.php | 2 + src/Appwrite/Auth/OAuth2/Kick.php | 230 ++++++++++++++++++ .../Http/Project/OAuth2/Kick/Update.php | 45 ++++ .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + .../Utopia/Response/Model/OAuth2Kick.php | 43 ++++ 7 files changed, 334 insertions(+) create mode 100644 src/Appwrite/Auth/OAuth2/Kick.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Kick.php diff --git a/app/config/oAuthProviders.php b/app/config/oAuthProviders.php index cda6459519..0dc2cb8b1e 100644 --- a/app/config/oAuthProviders.php +++ b/app/config/oAuthProviders.php @@ -200,6 +200,17 @@ return [ 'mock' => false, 'class' => 'Appwrite\\Auth\\OAuth2\\Google', ], + 'kick' => [ + 'name' => 'Kick', + 'developers' => 'https://docs.kick.com/', + 'icon' => 'icon-kick', + 'enabled' => true, + 'sandbox' => false, + 'form' => false, + 'beta' => false, + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Kick', + ], 'linkedin' => [ 'name' => 'LinkedIn', 'developers' => 'https://developer.linkedin.com/', diff --git a/app/init/models.php b/app/init/models.php index df2ebac150..c439bdf28f 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -123,6 +123,7 @@ use Appwrite\Utopia\Response\Model\OAuth2Figma; use Appwrite\Utopia\Response\Model\OAuth2GitHub; use Appwrite\Utopia\Response\Model\OAuth2Gitlab; use Appwrite\Utopia\Response\Model\OAuth2Google; +use Appwrite\Utopia\Response\Model\OAuth2Kick; use Appwrite\Utopia\Response\Model\OAuth2Linkedin; use Appwrite\Utopia\Response\Model\OAuth2Notion; use Appwrite\Utopia\Response\Model\OAuth2Oidc; @@ -421,6 +422,7 @@ Response::setModel(new OAuth2Authentik()); Response::setModel(new OAuth2Auth0()); Response::setModel(new OAuth2Oidc()); Response::setModel(new OAuth2Okta()); +Response::setModel(new OAuth2Kick()); Response::setModel(new OAuth2Apple()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); diff --git a/src/Appwrite/Auth/OAuth2/Kick.php b/src/Appwrite/Auth/OAuth2/Kick.php new file mode 100644 index 0000000000..85b447fcd8 --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/Kick.php @@ -0,0 +1,230 @@ +state; + $state[self::PKCE_STATE_KEY] = $this->getPKCEVerifier(); + + return 'https://id.kick.com/oauth/authorize?' . \http_build_query([ + 'response_type' => 'code', + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'scope' => \implode(' ', $this->getScopes()), + 'state' => \json_encode($state), + 'code_challenge' => $this->getPKCEChallenge(), + 'code_challenge_method' => 'S256', + ]); + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + 'https://id.kick.com/oauth/token', + $headers, + \http_build_query([ + 'grant_type' => 'authorization_code', + 'client_id' => $this->appID, + 'client_secret' => $this->appSecret, + 'redirect_uri' => $this->callback, + 'code_verifier' => $this->getPKCEVerifier(), + 'code' => $code, + ]) + ), true); + } + + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + 'https://id.kick.com/oauth/token', + $headers, + \http_build_query([ + 'grant_type' => 'refresh_token', + 'client_id' => $this->appID, + 'client_secret' => $this->appSecret, + 'refresh_token' => $refreshToken, + ]) + ), true); + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return isset($user['user_id']) ? (string)$user['user_id'] : ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['email'] ?? ''; + } + + /** + * Check if the OAuth email is verified. + * + * Kick only returns an email when the user has granted the `user:read` + * scope and the account email is verified, so a non-empty email is + * treated as verified. + * + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + return !empty($this->getUserEmail($accessToken)); + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['name'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken): array + { + if (empty($this->user)) { + $headers = ['Authorization: Bearer ' . $accessToken]; + $response = \json_decode($this->request( + 'GET', + 'https://api.kick.com/public/v1/users', + $headers + ), true); + + $this->user = $response['data'][0] ?? []; + } + + return $this->user; + } + + /** + * Extract the PKCE verifier from the state on the callback so the same + * value generated in getLoginURL() can be sent to the token endpoint. + * + * @param string $state + * + * @return array|null + */ + public function parseState(string $state): ?array + { + $parsed = \json_decode($state, true); + + if (!\is_array($parsed)) { + return null; + } + + $verifier = $parsed[self::PKCE_STATE_KEY] ?? null; + if (\is_string($verifier)) { + $this->pkceVerifier = $verifier; + } + + unset($parsed[self::PKCE_STATE_KEY]); + + return $parsed; + } + + private function getPKCEVerifier(): string + { + if ($this->pkceVerifier === '') { + $this->pkceVerifier = \rtrim(\strtr(\base64_encode(\random_bytes(64)), '+/', '-_'), '='); + } + + return $this->pkceVerifier; + } + + private function getPKCEChallenge(): string + { + return \rtrim(\strtr(\base64_encode(\hash('sha256', $this->getPKCEVerifier(), true)), '+/', '-_'), '='); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php new file mode 100644 index 0000000000..b5c126a08c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php @@ -0,0 +1,45 @@ +addAction(UpdateOAuth2Auth0::getName(), new UpdateOAuth2Auth0()); $this->addAction(UpdateOAuth2Oidc::getName(), new UpdateOAuth2Oidc()); $this->addAction(UpdateOAuth2Okta::getName(), new UpdateOAuth2Okta()); + $this->addAction(UpdateOAuth2Kick::getName(), new UpdateOAuth2Kick()); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 3d8902342f..1ac9054766 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -314,6 +314,7 @@ class Response extends SwooleResponse public const MODEL_OAUTH2_OIDC = 'oAuth2Oidc'; public const MODEL_OAUTH2_APPLE = 'oAuth2Apple'; public const MODEL_OAUTH2_OKTA = 'oAuth2Okta'; + public const MODEL_OAUTH2_KICK = 'oAuth2Kick'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php b/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php new file mode 100644 index 0000000000..e4692ac6ea --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php @@ -0,0 +1,43 @@ + Date: Mon, 27 Apr 2026 17:35:56 +0530 Subject: [PATCH 288/402] refactor(MessagingTest): update method visibility and naming conventions for consistency --- tests/unit/Messaging/MessagingTest.php | 180 +++++-------------------- 1 file changed, 33 insertions(+), 147 deletions(-) diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 395a4fba01..9190bdbb83 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -11,15 +11,15 @@ use Utopia\Database\Helpers\Role; class MessagingTest extends TestCase { - protected function setUp(): void + public function setUp(): void { } - protected function tearDown(): void + public function tearDown(): void { } - public function test_user(): void + public function testUser(): void { $realtime = new Realtime(); @@ -46,8 +46,8 @@ class MessagingTest extends TestCase 'data' => [ 'channels' => [ 0 => 'account.123', - ], - ], + ] + ] ]; $receivers = array_keys($realtime->getSubscribers($event)); @@ -147,7 +147,7 @@ class MessagingTest extends TestCase $this->assertEmpty($realtime->subscriptions); } - public function test_subscribe_unions_channels_and_roles(): void + public function testSubscribeUnionsChannelsAndRoles(): void { $realtime = new Realtime(); @@ -177,7 +177,7 @@ class MessagingTest extends TestCase $this->assertCount(2, $connection['roles']); } - public function test_unsubscribe_subscription_removes_only_one_subscription(): void + public function testUnsubscribeSubscriptionRemovesOnlyOneSubscription(): void { $realtime = new Realtime(); @@ -227,7 +227,7 @@ class MessagingTest extends TestCase $this->assertContains(Role::users()->toString(), $realtime->connections[1]['roles']); } - public function test_unsubscribe_subscription_is_idempotent(): void + public function testUnsubscribeSubscriptionIsIdempotent(): void { $realtime = new Realtime(); @@ -253,7 +253,7 @@ class MessagingTest extends TestCase $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); } - public function test_unsubscribe_subscription_keeps_connection_when_last_sub_removed(): void + public function testUnsubscribeSubscriptionKeepsConnectionWhenLastSubRemoved(): void { $realtime = new Realtime(); @@ -274,7 +274,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('1', $realtime->subscriptions); } - public function test_resubscribe_after_unsubscribing_last_sub_delivers(): void + public function testResubscribeAfterUnsubscribingLastSubDelivers(): void { $realtime = new Realtime(); @@ -304,7 +304,7 @@ class MessagingTest extends TestCase $this->assertEquals([1], array_keys($realtime->getSubscribers($event))); } - public function test_subscribe_after_on_open_empty_sentinel_preserves_union(): void + public function testSubscribeAfterOnOpenEmptySentinelPreservesUnion(): void { $realtime = new Realtime(); @@ -334,10 +334,10 @@ class MessagingTest extends TestCase $this->assertContains(Role::user(ID::custom('user-123'))->toString(), $realtime->connections[1]['roles']); } - public function test_convert_channels_guest(): void + public function testConvertChannelsGuest(): void { $user = new Document([ - '$id' => '', + '$id' => '' ]); $channels = [ @@ -345,7 +345,7 @@ class MessagingTest extends TestCase 1 => 'documents', 2 => 'documents.789', 3 => 'account', - 4 => 'account.456', + 4 => 'account.456' ]; $channels = Realtime::convertChannels($channels, $user->getId()); @@ -357,32 +357,32 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } - public function test_convert_channels_user(): void + public function testConvertChannelsUser(): void { - $user = new Document([ + $user = new Document([ '$id' => ID::custom('123'), 'memberships' => [ [ 'teamId' => ID::custom('abc'), 'roles' => [ 'administrator', - 'moderator', - ], + 'moderator' + ] ], [ 'teamId' => ID::custom('def'), 'roles' => [ - 'guest', - ], - ], - ], + 'guest' + ] + ] + ] ]); $channels = [ 0 => 'files', 1 => 'documents', 2 => 'documents.789', 3 => 'account', - 4 => 'account.456', + 4 => 'account.456' ]; $channels = Realtime::convertChannels($channels, $user->getId()); @@ -396,120 +396,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } - public function test_convert_channels_rewrites_account_action_suffixes(): void - { - // A subscriber to `account.{action}` should receive the user-scoped - // `account.{userId}.{action}` channel that fromPayload publishes for - // top-level user events. Without the rewrite the channel would either be - // stripped (security guard against subscribing to other users' account) - // or, if left literal, leak every user's account events to this client. - $channels = Realtime::convertChannels( - ['account.create', 'account.update', 'account.upsert', 'account.delete'], - '123', - ); - - $this->assertArrayHasKey('account.123.create', $channels); - $this->assertArrayHasKey('account.123.update', $channels); - $this->assertArrayHasKey('account.123.upsert', $channels); - $this->assertArrayHasKey('account.123.delete', $channels); - - // The literal forms must not survive — they would otherwise match every - // user's events, not just the subscribed user's. - $this->assertArrayNotHasKey('account.create', $channels); - $this->assertArrayNotHasKey('account.update', $channels); - $this->assertArrayNotHasKey('account.upsert', $channels); - $this->assertArrayNotHasKey('account.delete', $channels); - - // Other-user channels and unknown action-like suffixes still get stripped. - $channels = Realtime::convertChannels( - ['account.other_id', 'account.bogus', 'account.123', 'account.create'], - '123', - ); - $this->assertArrayNotHasKey('account.other_id', $channels); - $this->assertArrayNotHasKey('account.bogus', $channels); - $this->assertArrayNotHasKey('account.123', $channels); - $this->assertArrayHasKey('account.123.create', $channels); - } - - public function test_convert_channels_drops_account_actions_for_guest(): void - { - // No userId → no place to scope the action-suffixed channel, so the - // action-suffixed forms are dropped entirely. Plain `account` survives - // (matching existing guest behavior — see test_convert_channels_guest). - $channels = Realtime::convertChannels( - ['account.create', 'account.update', 'account'], - '', - ); - - $this->assertArrayNotHasKey('account.create', $channels); - $this->assertArrayNotHasKey('account.update', $channels); - $this->assertArrayHasKey('account', $channels); - } - - public function test_rebind_account_channels_remaps_after_reauth(): void - { - // Captures the in-band auth scenario: a guest connects and subscribes to - // `account` (stored as `account` because there's no userId). After the - // client sends an authentication message, the connection's userId becomes - // 'B' — but its stored channels are still bound to whatever the previous - // identity was. This helper rewrites them so the resubscribe lands on the - // new user's account namespace. - $rebound = Realtime::rebindAccountChannels( - ['account.A', 'account.A.create', 'account.A.update', 'documents', 'documents.A.something'], - 'A', - 'B', - ); - - // account-scoped channels are rebound to the new user. - $this->assertContains('account.B', $rebound); - $this->assertContains('account.B.create', $rebound); - $this->assertContains('account.B.update', $rebound); - $this->assertNotContains('account.A', $rebound); - $this->assertNotContains('account.A.create', $rebound); - $this->assertNotContains('account.A.update', $rebound); - - // Non-account channels are left alone — the rewrite must be precise. - $this->assertContains('documents', $rebound); - $this->assertContains('documents.A.something', $rebound); - } - - public function test_rebind_account_channels_is_noop_for_unchanged_user(): void - { - // Same user → nothing to rewrite. Avoids unnecessary churn when the - // permissionsChanged path fires (roles change but userId is constant). - $channels = ['account.A', 'account.A.create', 'documents']; - $this->assertSame($channels, Realtime::rebindAccountChannels($channels, 'A', 'A')); - } - - public function test_rebind_account_channels_is_noop_for_guest_origin(): void - { - // Guest connections never store userId-suffixed channels (convertChannels - // strips the suffix when userId is empty), so rebinding from '' to a real - // userId should be a no-op — the plain `account` channel doesn't carry - // any userId binding to remap. - $channels = ['account', 'documents']; - $this->assertSame($channels, Realtime::rebindAccountChannels($channels, '', 'B')); - } - - public function test_rebind_account_channels_only_remaps_known_actions(): void - { - // Defensive: we intentionally restrict the rewrite to suffixes in - // SUPPORTED_ACTIONS so we don't accidentally rewrite a channel that - // happens to have `account.{userId}.{something}` shape from outside the - // documented set. - $rebound = Realtime::rebindAccountChannels( - ['account.A.bogus', 'account.A.create'], - 'A', - 'B', - ); - - $this->assertContains('account.A.bogus', $rebound); - $this->assertContains('account.B.create', $rebound); - $this->assertNotContains('account.B.bogus', $rebound); - $this->assertNotContains('account.A.create', $rebound); - } - - public function test_from_payload_permissions(): void + public function testFromPayloadPermissions(): void { /** * Test Collection Level Permissions @@ -573,7 +460,7 @@ class MessagingTest extends TestCase $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } - public function test_from_payload_bucket_level_permissions(): void + public function testFromPayloadBucketLevelPermissions(): void { /** * Test Bucket Level Permissions @@ -623,15 +510,14 @@ class MessagingTest extends TestCase Permission::update(Role::team('123abc')), Permission::delete(Role::team('123abc')), ], - 'fileSecurity' => true, + 'fileSecurity' => true ]) ); $this->assertContains(Role::any()->toString(), $result['roles']); $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } - - public function test_from_payload_emits_action_suffixed_channels(): void + public function testFromPayloadEmitsActionSuffixedChannels(): void { $result = Realtime::fromPayload( event: 'databases.database_id.collections.collection_id.documents.document_id.create', @@ -663,7 +549,7 @@ class MessagingTest extends TestCase $this->assertNotContains('documents.delete', $result['channels']); } - public function test_from_payload_emits_action_suffix_for_every_action(): void + public function testFromPayloadEmitsActionSuffixForEveryAction(): void { foreach (['create', 'update', 'upsert', 'delete'] as $action) { $result = Realtime::fromPayload( @@ -690,7 +576,7 @@ class MessagingTest extends TestCase } } - public function test_from_payload_does_not_suffix_when_no_action(): void + public function testFromPayloadDoesNotSuffixWhenNoAction(): void { // Synthetic event without an action segment: e.g. an attribute event whose // last segment is not a known action and whose second-to-last segment is @@ -719,7 +605,7 @@ class MessagingTest extends TestCase $this->assertContains('buckets.bucket_id.files.file_id', $result['channels']); } - public function test_from_payload_does_not_suffix_admin_channels(): void + public function testFromPayloadDoesNotSuffixAdminChannels(): void { // Function execution event emits resource-leaf channels (executions / functions) // alongside admin channels (console / projects.X). Admin channels must NOT @@ -753,7 +639,7 @@ class MessagingTest extends TestCase $this->assertNotContains('projects.project_id.create', $result['channels']); } - public function test_from_payload_handles_attribute_trailing_action_events(): void + public function testFromPayloadHandlesAttributeTrailingActionEvents(): void { // `users.[userId].update.{attr}` (e.g. .email, .prefs, .name) — action is the // second-to-last segment, not the last one. The suffix must still be `.update`. @@ -836,7 +722,7 @@ class MessagingTest extends TestCase $this->assertContains('account.user_id.update', $updateResult['channels']); } - public function test_action_suffix_delivers_only_matching_action_end_to_end(): void + public function testActionSuffixDeliversOnlyMatchingActionEndToEnd(): void { $realtime = new Realtime(); @@ -871,7 +757,7 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey(1, $deleteReceivers); } - public function test_plain_channel_still_receives_all_actions_end_to_end(): void + public function testPlainChannelStillReceivesAllActionsEndToEnd(): void { $realtime = new Realtime(); From a1a88ae57e3b3b0addf5d480084298c04b71c5b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 14:09:24 +0200 Subject: [PATCH 289/402] Make oauth secret write only --- src/Appwrite/Utopia/Response/Model/AuthProvider.php | 4 ++-- src/Appwrite/Utopia/Response/Model/Project.php | 2 +- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Utopia/Response/Model/AuthProvider.php b/src/Appwrite/Utopia/Response/Model/AuthProvider.php index 2b8f962cd0..034be623e8 100644 --- a/src/Appwrite/Utopia/Response/Model/AuthProvider.php +++ b/src/Appwrite/Utopia/Response/Model/AuthProvider.php @@ -30,9 +30,9 @@ class AuthProvider extends Model ]) ->addRule('secret', [ 'type' => self::TYPE_STRING, - 'description' => 'OAuth 2.0 application secret. Might be JSON string if provider requires extra configuration.', + 'description' => 'OAuth 2.0 application secret. Might be JSON string if provider requires extra configuration. This property is write-only and always returned empty.', 'default' => '', - 'example' => 'Bpw_g9c2TGXxfgLshDbSaL8tsCcqgczQ', + 'example' => '', ]) ->addRule('enabled', [ 'type' => self::TYPE_BOOLEAN, diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 97b58d8a51..36be3b751f 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -525,7 +525,7 @@ class Project extends Model 'key' => $key, 'name' => $provider['name'] ?? '', 'appId' => $providerValues[$key . 'Appid'] ?? '', - 'secret' => $providerValues[$key . 'Secret'] ?? '', + 'secret' => '', // Write-only: never expose the stored value 'enabled' => $providerValues[$key . 'Enabled'] ?? false, ]); } diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index f88db41e8c..8322e37de1 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1634,7 +1634,7 @@ class ProjectsConsoleClientTest extends Scope foreach ($response['body']['oAuthProviders'] as $responseProvider) { if ($responseProvider['key'] === $key) { $this->assertEquals('AppId-' . ucfirst($key), $responseProvider['appId']); - $this->assertEquals('Secret-' . ucfirst($key), $responseProvider['secret']); + $this->assertEmpty($responseProvider['secret']); $this->assertFalse($responseProvider['enabled']); $asserted = true; break; From 2e57500d7e6063f180feb4516ca2bc84f17dabc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 14:16:43 +0200 Subject: [PATCH 290/402] WIP: Read endpoints for oauth --- .../Project/Http/Project/OAuth2/Get.php | 74 +++++++++++++++++ .../Project/Http/Project/OAuth2/XList.php | 79 +++++++++++++++++++ .../Modules/Project/Services/Http.php | 4 + 3 files changed, 157 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/XList.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php new file mode 100644 index 0000000000..db7a19f51b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php @@ -0,0 +1,74 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/oauth2/:provider') + ->desc('Get project OAuth2 provider') + ->groups(['api', 'project']) + ->label('scope', 'oauth2.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: 'getOAuth2Provider', + description: <<param('provider', '', new Text(128), 'OAuth2 provider key. For example: github, google, apple.') + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $provider, + Response $response, + Document $project, + ): void { + $providers = Config::getParam('oAuthProviders', []); + if (!\array_key_exists($provider, $providers) || !($providers[$provider]['enabled'] ?? false)) { + throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); + } + + $providerValues = $project->getAttribute('oAuthProviders', []); + + $response->dynamic(new Document([ + 'key' => $provider, + 'name' => $providers[$provider]['name'] ?? '', + 'appId' => $providerValues[$provider . 'Appid'] ?? '', + 'secret' => '', + 'enabled' => $providerValues[$provider . 'Enabled'] ?? false, + ]), Response::MODEL_AUTH_PROVIDER); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/XList.php new file mode 100644 index 0000000000..df0f436293 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/XList.php @@ -0,0 +1,79 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/oauth2') + ->desc('List project OAuth2 providers') + ->groups(['api', 'project']) + ->label('scope', 'oauth2.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: 'listOAuth2Providers', + description: <<inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + Response $response, + Document $project, + ): void { + $providers = Config::getParam('oAuthProviders', []); + $providerValues = $project->getAttribute('oAuthProviders', []); + + $projectProviders = []; + foreach ($providers as $key => $provider) { + if (!($provider['enabled'] ?? false)) { + // Disabled by Appwrite configuration, exclude from response + continue; + } + + $projectProviders[] = new Document([ + 'key' => $key, + 'name' => $provider['name'] ?? '', + 'appId' => $providerValues[$key . 'Appid'] ?? '', + 'secret' => '', + 'enabled' => $providerValues[$key . 'Enabled'] ?? false, + ]); + } + + $response->dynamic(new Document([ + 'total' => \count($projectProviders), + 'platforms' => $projectProviders, + ]), Response::MODEL_AUTH_PROVIDER_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 7c9424d34c..908e688367 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -30,6 +30,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Dropbox\Update as Upda use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Etsy\Update as UpdateOAuth2Etsy; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Facebook\Update as UpdateOAuth2Facebook; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Figma\Update as UpdateOAuth2Figma; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Get as GetOAuth2Provider; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\GitHub\Update as UpdateOAuth2GitHub; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Gitlab\Update as UpdateOAuth2Gitlab; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Google\Update as UpdateOAuth2Google; @@ -50,6 +51,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\TradeshiftSandbox\Upda use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Twitch\Update as UpdateOAuth2Twitch; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\WordPress\Update as UpdateOAuth2WordPress; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\X\Update as UpdateOAuth2X; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\XList as ListOAuth2Providers; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Yahoo\Update as UpdateOAuth2Yahoo; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Yandex\Update as UpdateOAuth2Yandex; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Zoho\Update as UpdateOAuth2Zoho; @@ -169,6 +171,8 @@ class Http extends Service $this->addAction(UpdateAuthMethod::getName(), new UpdateAuthMethod()); // OAuth2 + $this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers()); + $this->addAction(GetOAuth2Provider::getName(), new GetOAuth2Provider()); $this->addAction(UpdateOAuth2GitHub::getName(), new UpdateOAuth2GitHub()); $this->addAction(UpdateOAuth2Discord::getName(), new UpdateOAuth2Discord()); $this->addAction(UpdateOAuth2Figma::getName(), new UpdateOAuth2Figma()); From 3f120622591cb737f4f327e2a43100d847ee5d2d Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 17:54:48 +0530 Subject: [PATCH 291/402] updated --- app/realtime.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index bc95fc6cdc..48e2218f57 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -585,7 +585,8 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $subscriptionId, $roles, $channels, - $queries + $queries, + $userId ); } From a781325679e2b0cb5591c5adb10ab7b4821c2a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 14:47:47 +0200 Subject: [PATCH 292/402] Add oauth read operations --- app/init/models.php | 2 + .../Http/Project/OAuth2/Apple/Update.php | 15 ++++ .../Http/Project/OAuth2/Auth0/Update.php | 15 ++++ .../Http/Project/OAuth2/Authentik/Update.php | 15 ++++ .../Project/Http/Project/OAuth2/Base.php | 90 +++++++++++++++++++ .../Project/Http/Project/OAuth2/Get.php | 58 +++++++++--- .../Http/Project/OAuth2/Gitlab/Update.php | 15 ++++ .../Http/Project/OAuth2/Oidc/Update.php | 18 ++++ .../Http/Project/OAuth2/Okta/Update.php | 16 ++++ .../Project/Http/Project/OAuth2/XList.php | 27 +++--- src/Appwrite/Utopia/Response.php | 1 + .../Utopia/Response/Model/OAuth2Apple.php | 10 +-- .../Utopia/Response/Model/OAuth2Auth0.php | 2 +- .../Utopia/Response/Model/OAuth2Authentik.php | 2 +- .../Utopia/Response/Model/OAuth2Base.php | 6 +- .../Utopia/Response/Model/OAuth2Gitlab.php | 2 +- .../Utopia/Response/Model/OAuth2Okta.php | 4 +- .../Response/Model/OAuth2ProviderList.php | 75 ++++++++++++++++ 18 files changed, 334 insertions(+), 39 deletions(-) create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php diff --git a/app/init/models.php b/app/init/models.php index c439bdf28f..20272db413 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -130,6 +130,7 @@ use Appwrite\Utopia\Response\Model\OAuth2Oidc; use Appwrite\Utopia\Response\Model\OAuth2Okta; use Appwrite\Utopia\Response\Model\OAuth2Paypal; use Appwrite\Utopia\Response\Model\OAuth2Podio; +use Appwrite\Utopia\Response\Model\OAuth2ProviderList; use Appwrite\Utopia\Response\Model\OAuth2Salesforce; use Appwrite\Utopia\Response\Model\OAuth2Slack; use Appwrite\Utopia\Response\Model\OAuth2Spotify; @@ -424,6 +425,7 @@ Response::setModel(new OAuth2Oidc()); Response::setModel(new OAuth2Okta()); Response::setModel(new OAuth2Kick()); Response::setModel(new OAuth2Apple()); +Response::setModel(new OAuth2ProviderList()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); Response::setModel(new PolicyPasswordPersonalData()); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php index 7a0cf59661..4f8437ce8d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php @@ -99,6 +99,21 @@ class Update extends Base ->callback($this->handle(...)); } + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + ]); + } + /** * Custom callback used instead of the parent's `action()` because Apple's * client secret is composed of three fields (.p8 file contents, Key ID and diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php index 9fe0b1384d..1bbdd02a0d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php @@ -91,6 +91,21 @@ class Update extends Base ->callback($this->handle(...)); } + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'endpoint' => $decoded['auth0Domain'] ?? '', + ]); + } + /** * Custom callback used instead of the parent's `action()` because Auth0 * takes an additional optional `endpoint` parameter. The method is named diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php index 48a7f1a22b..62e314053a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php @@ -91,6 +91,21 @@ class Update extends Base ->callback($this->handle(...)); } + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'endpoint' => $decoded['authentikDomain'] ?? '', + ]); + } + /** * Custom callback used instead of the parent's `action()` because Authentik * takes an additional required `endpoint` parameter. The method is named diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php index 2d74c1b61d..f0aa50a695 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php @@ -137,6 +137,96 @@ abstract class Base extends Action ->callback($this->action(...)); } + /** + * Registry of provider ID -> Update action class. Mirrors the OAuth2 + * actions registered in Project\Services\Http. Used by the Get and XList + * read endpoints to dispatch per-provider response shaping. + * + * @return array> + */ + public static function getProviderActions(): array + { + return [ + 'github' => GitHub\Update::class, + 'discord' => Discord\Update::class, + 'figma' => Figma\Update::class, + 'dropbox' => Dropbox\Update::class, + 'dailymotion' => Dailymotion\Update::class, + 'bitbucket' => Bitbucket\Update::class, + 'bitly' => Bitly\Update::class, + 'box' => Box\Update::class, + 'autodesk' => Autodesk\Update::class, + 'google' => Google\Update::class, + 'zoom' => Zoom\Update::class, + 'zoho' => Zoho\Update::class, + 'yandex' => Yandex\Update::class, + 'x' => X\Update::class, + 'wordpress' => WordPress\Update::class, + 'twitch' => Twitch\Update::class, + 'stripe' => Stripe\Update::class, + 'spotify' => Spotify\Update::class, + 'slack' => Slack\Update::class, + 'podio' => Podio\Update::class, + 'notion' => Notion\Update::class, + 'salesforce' => Salesforce\Update::class, + 'yahoo' => Yahoo\Update::class, + 'linkedin' => Linkedin\Update::class, + 'disqus' => Disqus\Update::class, + 'amazon' => Amazon\Update::class, + 'etsy' => Etsy\Update::class, + 'facebook' => Facebook\Update::class, + 'tradeshift' => Tradeshift\Update::class, + 'tradeshiftSandbox' => TradeshiftSandbox\Update::class, + 'paypal' => Paypal\Update::class, + 'paypalSandbox' => PaypalSandbox\Update::class, + 'gitlab' => Gitlab\Update::class, + 'authentik' => Authentik\Update::class, + 'auth0' => Auth0\Update::class, + 'oidc' => Oidc\Update::class, + 'okta' => Okta\Update::class, + 'kick' => Kick\Update::class, + 'apple' => Apple\Update::class, + ]; + } + + /** + * Build the read-only response document for this provider, with credential + * fields zeroed out (write-only). Default implementation handles providers + * that store a plain client ID + client secret. Special providers (Apple, + * Gitlab, Auth0, Authentik, Oidc, Okta) override to expose their + * non-secret extras (endpoint, domain, discovery URLs, ...) decoded from + * the JSON-encoded secret blob. + */ + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + ]); + } + + /** + * Decode the JSON-encoded secret blob stored under `{providerId}Secret`. + * Returns an empty array when the value is empty or not valid JSON. + */ + protected function decodeStoredSecret(Document $project): array + { + $providerId = static::getProviderId(); + $stored = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + + if (empty($stored)) { + return []; + } + + $decoded = \json_decode($stored, true); + return \is_array($decoded) ? $decoded : []; + } + /** * Apply the provided credential changes to the project's oAuthProviders map, * run the optional credential verification hook, persist the project, and diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php index db7a19f51b..29db552e46 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php @@ -35,13 +35,51 @@ class Get extends Action group: 'oauth2', name: 'getOAuth2Provider', description: <<getAttribute('oAuthProviders', []); + $actions = Base::getProviderActions(); + if (!isset($actions[$provider])) { + throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); + } - $response->dynamic(new Document([ - 'key' => $provider, - 'name' => $providers[$provider]['name'] ?? '', - 'appId' => $providerValues[$provider . 'Appid'] ?? '', - 'secret' => '', - 'enabled' => $providerValues[$provider . 'Enabled'] ?? false, - ]), Response::MODEL_AUTH_PROVIDER); + $updateClass = $actions[$provider]; + $action = new $updateClass(); + + $response->dynamic($action->buildReadResponse($project), $updateClass::getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php index ce7fa21ee1..8d4f4e88da 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php @@ -102,6 +102,21 @@ class Update extends Base ->callback($this->handle(...)); } + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'endpoint' => $decoded['endpoint'] ?? '', + ]); + } + /** * Custom callback used instead of the parent's `action()` because Gitlab * takes an additional `endpoint` parameter. The method is named diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php index 39cf5b2f96..d849e18efd 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php @@ -96,6 +96,24 @@ class Update extends Base ->callback($this->handle(...)); } + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'wellKnownURL' => $decoded['wellKnownEndpoint'] ?? '', + 'authorizationURL' => $decoded['authorizationEndpoint'] ?? '', + 'tokenUrl' => $decoded['tokenEndpoint'] ?? '', + 'userInfoUrl' => $decoded['userInfoEndpoint'] ?? '', + ]); + } + /** * Custom callback used instead of the parent's `action()` because OIDC takes * a well-known URL plus three discovery URLs (authorization, token, user diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php index dcbf1df343..47d6cb2add 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php @@ -94,6 +94,22 @@ class Update extends Base ->callback($this->handle(...)); } + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'domain' => $decoded['oktaDomain'] ?? '', + 'authorizationServerId' => $decoded['authorizationServerId'] ?? '', + ]); + } + /** * Custom callback used instead of the parent's `action()` because Okta * takes additional optional `domain` and `authorizationServerId` parameters. diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/XList.php index df0f436293..d0780e4bae 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/XList.php @@ -33,13 +33,13 @@ class XList extends Action group: 'oauth2', name: 'listOAuth2Providers', description: <<getAttribute('oAuthProviders', []); + $actions = Base::getProviderActions(); - $projectProviders = []; - foreach ($providers as $key => $provider) { - if (!($provider['enabled'] ?? false)) { + $documents = []; + foreach ($actions as $providerId => $updateClass) { + if (!($providers[$providerId]['enabled'] ?? false)) { // Disabled by Appwrite configuration, exclude from response continue; } - $projectProviders[] = new Document([ - 'key' => $key, - 'name' => $provider['name'] ?? '', - 'appId' => $providerValues[$key . 'Appid'] ?? '', - 'secret' => '', - 'enabled' => $providerValues[$key . 'Enabled'] ?? false, - ]); + $action = new $updateClass(); + $documents[] = $action->buildReadResponse($project); } $response->dynamic(new Document([ - 'total' => \count($projectProviders), - 'platforms' => $projectProviders, - ]), Response::MODEL_AUTH_PROVIDER_LIST); + 'total' => \count($documents), + 'providers' => $documents, + ]), Response::MODEL_OAUTH2_PROVIDER_LIST); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 1ac9054766..b8948a062e 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -315,6 +315,7 @@ class Response extends SwooleResponse public const MODEL_OAUTH2_APPLE = 'oAuth2Apple'; public const MODEL_OAUTH2_OKTA = 'oAuth2Okta'; public const MODEL_OAUTH2_KICK = 'oAuth2Kick'; + public const MODEL_OAUTH2_PROVIDER_LIST = 'oAuth2ProviderList'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php b/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php index 8120090420..080925e6d8 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php @@ -35,13 +35,13 @@ class OAuth2Apple extends OAuth2Base public function __construct() { - // Apple's OAuth 2 app credential is split into three fields (.p8 file + // Apple's OAuth2 app credential is split into three fields (.p8 file // contents, Key ID, Team ID) instead of a single clientSecret, so the // rules are defined manually rather than delegating to OAuth2Base. $this ->addRule('enabled', [ 'type' => self::TYPE_BOOLEAN, - 'description' => 'OAuth 2 provider is active and can be used to create sessions.', + 'description' => 'OAuth2 provider is active and can be used to create sessions.', 'default' => false, 'example' => false, ]) @@ -53,19 +53,19 @@ class OAuth2Apple extends OAuth2Base ]) ->addRule('keyId', [ 'type' => self::TYPE_STRING, - 'description' => 'Apple OAuth 2 key ID.', + 'description' => 'Apple OAuth2 key ID.', 'default' => '', 'example' => 'P4000000N8', ]) ->addRule('teamId', [ 'type' => self::TYPE_STRING, - 'description' => 'Apple OAuth 2 team ID.', + 'description' => 'Apple OAuth2 team ID.', 'default' => '', 'example' => 'D4000000R6', ]) ->addRule('p8File', [ 'type' => self::TYPE_STRING, - 'description' => 'Apple OAuth 2 .p8 private key file contents. The secret key wrapped by the PEM markers is 200 characters long.', + 'description' => 'Apple OAuth2 .p8 private key file contents. The secret key wrapped by the PEM markers is 200 characters long.', 'default' => '', 'example' => '-----BEGIN PRIVATE KEY-----MIGTAg...jy2Xbna-----END PRIVATE KEY-----', ]); diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php b/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php index 89cf1c92d5..2f1893f4d5 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php @@ -27,7 +27,7 @@ class OAuth2Auth0 extends OAuth2Base $this->addRule('endpoint', [ 'type' => self::TYPE_STRING, - 'description' => 'Auth0 OAuth 2 endpoint domain.', + 'description' => 'Auth0 OAuth2 endpoint domain.', 'default' => '', 'example' => 'example.us.auth0.com', ]); diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php b/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php index ca6e828ed4..4e67e1f4fe 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php @@ -27,7 +27,7 @@ class OAuth2Authentik extends OAuth2Base $this->addRule('endpoint', [ 'type' => self::TYPE_STRING, - 'description' => 'Authentik OAuth 2 endpoint domain.', + 'description' => 'Authentik OAuth2 endpoint domain.', 'default' => '', 'example' => 'example.authentik.com', ]); diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Base.php b/src/Appwrite/Utopia/Response/Model/OAuth2Base.php index b0bd642b34..8eb8d0f4cb 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Base.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Base.php @@ -79,7 +79,7 @@ abstract class OAuth2Base extends Model */ public function getClientIdDescription(): string { - return $this->getProviderLabel() . ' OAuth 2 ' . $this->getClientIdLabel() . '.'; + return $this->getProviderLabel() . ' OAuth2 ' . $this->getClientIdLabel() . '.'; } /** @@ -91,7 +91,7 @@ abstract class OAuth2Base extends Model */ public function getClientSecretDescription(): string { - return $this->getProviderLabel() . ' OAuth 2 ' . $this->getClientSecretLabel() . '.'; + return $this->getProviderLabel() . ' OAuth2 ' . $this->getClientSecretLabel() . '.'; } public function __construct() @@ -99,7 +99,7 @@ abstract class OAuth2Base extends Model $this ->addRule('enabled', [ 'type' => self::TYPE_BOOLEAN, - 'description' => 'OAuth 2 provider is active and can be used to create sessions.', + 'description' => 'OAuth2 provider is active and can be used to create sessions.', 'default' => false, 'example' => false, ]) diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php b/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php index bae60c2f5d..41c91acfe8 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php @@ -47,7 +47,7 @@ class OAuth2Gitlab extends OAuth2Base $this->addRule('endpoint', [ 'type' => self::TYPE_STRING, - 'description' => 'GitLab OAuth 2 endpoint URL. Defaults to https://gitlab.com for self-hosted instances.', + 'description' => 'GitLab OAuth2 endpoint URL. Defaults to https://gitlab.com for self-hosted instances.', 'default' => '', 'example' => 'https://gitlab.com', ]); diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php b/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php index a0f9a6a06b..f0926193d8 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php @@ -27,14 +27,14 @@ class OAuth2Okta extends OAuth2Base $this->addRule('domain', [ 'type' => self::TYPE_STRING, - 'description' => 'Okta OAuth 2 domain.', + 'description' => 'Okta OAuth2 domain.', 'default' => '', 'example' => 'trial-6400025.okta.com', ]); $this->addRule('authorizationServerId', [ 'type' => self::TYPE_STRING, - 'description' => 'Okta OAuth 2 authorization server ID.', + 'description' => 'Okta OAuth2 authorization server ID.', 'default' => '', 'example' => 'aus000000000000000h7z', ]); diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php b/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php new file mode 100644 index 0000000000..fd6ad1355b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php @@ -0,0 +1,75 @@ +addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of OAuth2 providers in the given project.', + 'default' => 0, + 'example' => 5, + ]) + ->addRule('providers', [ + 'type' => [ + Response::MODEL_OAUTH2_GITHUB, + Response::MODEL_OAUTH2_DISCORD, + Response::MODEL_OAUTH2_FIGMA, + Response::MODEL_OAUTH2_DROPBOX, + Response::MODEL_OAUTH2_DAILYMOTION, + Response::MODEL_OAUTH2_BITBUCKET, + Response::MODEL_OAUTH2_BITLY, + Response::MODEL_OAUTH2_BOX, + Response::MODEL_OAUTH2_AUTODESK, + Response::MODEL_OAUTH2_GOOGLE, + Response::MODEL_OAUTH2_ZOOM, + Response::MODEL_OAUTH2_ZOHO, + Response::MODEL_OAUTH2_YANDEX, + Response::MODEL_OAUTH2_X, + Response::MODEL_OAUTH2_WORDPRESS, + Response::MODEL_OAUTH2_TWITCH, + Response::MODEL_OAUTH2_STRIPE, + Response::MODEL_OAUTH2_SPOTIFY, + Response::MODEL_OAUTH2_SLACK, + Response::MODEL_OAUTH2_PODIO, + Response::MODEL_OAUTH2_NOTION, + Response::MODEL_OAUTH2_SALESFORCE, + Response::MODEL_OAUTH2_YAHOO, + Response::MODEL_OAUTH2_LINKEDIN, + Response::MODEL_OAUTH2_DISQUS, + Response::MODEL_OAUTH2_AMAZON, + Response::MODEL_OAUTH2_ETSY, + Response::MODEL_OAUTH2_FACEBOOK, + Response::MODEL_OAUTH2_TRADESHIFT, + Response::MODEL_OAUTH2_PAYPAL, + Response::MODEL_OAUTH2_GITLAB, + Response::MODEL_OAUTH2_AUTHENTIK, + Response::MODEL_OAUTH2_AUTH0, + Response::MODEL_OAUTH2_OIDC, + Response::MODEL_OAUTH2_APPLE, + Response::MODEL_OAUTH2_OKTA, + Response::MODEL_OAUTH2_KICK, + ], + 'description' => 'List of OAuth2 providers.', + 'default' => [], + 'array' => true, + ]) + ; + } + + public function getName(): string + { + return 'OAuth2 Providers List'; + } + + public function getType(): string + { + return Response::MODEL_OAUTH2_PROVIDER_LIST; + } +} From cb8640b56f8e2ae600a8d053feb52ab3638f7163 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 18:24:52 +0530 Subject: [PATCH 293/402] feat(Realtime): enhance channel management for user authentication and account actions --- src/Appwrite/Messaging/Adapter/Realtime.php | 55 +++-- tests/unit/Messaging/MessagingTest.php | 242 ++++++++++++++++++++ 2 files changed, 278 insertions(+), 19 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 33b2e76889..bd4c3f80b4 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -418,11 +418,14 @@ class Realtime extends MessagingAdapter break; case \in_array(\substr($key, \strlen('account.')), self::SUPPORTED_ACTIONS, true) && str_starts_with($key, 'account.'): - // Translate `account.{action}` into the user-scoped `account.{userId}.{action}` - // so a subscriber only receives their own account events. Without the rewrite - // the literal `account.{action}` channel would match every user's events. - unset($channels[$key]); + // Authenticated: rewrite `account.{action}` → `account.{userId}.{action}` + // so the subscriber only receives their own account events. + // Guest: keep the literal `account.{action}` so the action filter + // applies to the broadcast `account.{action}` channel that fromPayload + // emits for top-level user events. On in-band auth, rebindAccountChannels + // rewrites the literal to the user-scoped form. if (!empty($userId)) { + unset($channels[$key]); $action = \substr($key, \strlen('account.')); $channels['account.'.$userId.'.'.$action] = $value; } @@ -438,32 +441,46 @@ class Realtime extends MessagingAdapter } /** - * Rewrites stored account channels (`account.{oldUserId}` and - * `account.{oldUserId}.{action}`) to match a new userId. Used when in-band - * authentication changes the connection's user identity (typically - * guest → authenticated user, or rare reauth as a different user) — without - * this, channels stay bound to the old userId and the connection silently - * receives the previous user's account events. + * Rewrites stored account channels to match a new userId. Used when in-band + * authentication changes the connection's user identity: * - * Returns channels unchanged when the user identity has not changed - * (oldUserId === newUserId) or when the connection had no userId previously - * (guest connections never store userId-suffixed channels because - * convertChannels strips the suffix when userId is empty). + * - guest → authenticated: rewrites the literal `account.{action}` form + * that convertChannels preserves for guests into `account.{userId}.{action}`. + * - reauth as a different user: rewrites `account.{oldUserId}` and + * `account.{oldUserId}.{action}` to the new userId. + * + * Returns channels unchanged when there's nothing to do — same user, or an + * empty target (defensive: avoids producing malformed `account.` strings if + * a caller ever passes `$newUserId = ''`, e.g. an in-band logout flow). */ public static function rebindAccountChannels(array $channels, string $oldUserId, string $newUserId): array { - if ($oldUserId === '' || $oldUserId === $newUserId) { + if ($newUserId === '' || $oldUserId === $newUserId) { return $channels; } - $oldExact = 'account.'.$oldUserId; - $oldPrefix = $oldExact.'.'; + return \array_map(function (string $channel) use ($oldUserId, $newUserId) { + if (!\str_starts_with($channel, 'account.')) { + return $channel; + } - return \array_map(function (string $channel) use ($oldExact, $oldPrefix, $newUserId) { - if ($channel === $oldExact) { + // Guest origin: literal `account.{action}` (preserved by convertChannels + // for unauthenticated connections) becomes `account.{newUserId}.{action}`. + if ($oldUserId === '') { + $suffix = \substr($channel, \strlen('account.')); + if (\in_array($suffix, self::SUPPORTED_ACTIONS, true)) { + return 'account.'.$newUserId.'.'.$suffix; + } + + return $channel; + } + + // Authenticated → different user. + if ($channel === 'account.'.$oldUserId) { return 'account.'.$newUserId; } + $oldPrefix = 'account.'.$oldUserId.'.'; if (\str_starts_with($channel, $oldPrefix)) { $action = \substr($channel, \strlen($oldPrefix)); if (\in_array($action, self::SUPPORTED_ACTIONS, true)) { diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 9190bdbb83..6fbb5a3e68 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -396,6 +396,248 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } + public function testConvertChannelsRewritesAccountActionSuffixes(): void + { + // Authenticated subscriber to `account.{action}` is translated to the + // user-scoped `account.{userId}.{action}` form so events from other + // users' accounts don't leak through the literal channel. + $channels = Realtime::convertChannels( + ['account.create', 'account.update', 'account.upsert', 'account.delete'], + '123', + ); + + $this->assertArrayHasKey('account.123.create', $channels); + $this->assertArrayHasKey('account.123.update', $channels); + $this->assertArrayHasKey('account.123.upsert', $channels); + $this->assertArrayHasKey('account.123.delete', $channels); + $this->assertArrayNotHasKey('account.create', $channels); + $this->assertArrayNotHasKey('account.update', $channels); + $this->assertArrayNotHasKey('account.upsert', $channels); + $this->assertArrayNotHasKey('account.delete', $channels); + + // Other-user channels and unknown action-like suffixes still get stripped. + $channels = Realtime::convertChannels( + ['account.other_id', 'account.bogus', 'account.123', 'account.create'], + '123', + ); + $this->assertArrayNotHasKey('account.other_id', $channels); + $this->assertArrayNotHasKey('account.bogus', $channels); + $this->assertArrayNotHasKey('account.123', $channels); + $this->assertArrayHasKey('account.123.create', $channels); + } + + public function testConvertChannelsPreservesAccountActionsForGuest(): void + { + // Guests can't scope an action filter to a userId yet, so `account.{action}` + // is preserved verbatim. fromPayload publishes the unscoped `account.{action}` + // channel for top-level user events, so the guest's stored form matches and + // delivers correctly. After the connection authenticates, + // rebindAccountChannels rewrites the literal to `account.{userId}.{action}` + // so the action filter survives the auth transition. + $channels = Realtime::convertChannels( + ['account.create', 'account.update', 'account.upsert', 'account.delete', 'account'], + '', + ); + + $this->assertArrayHasKey('account.create', $channels); + $this->assertArrayHasKey('account.update', $channels); + $this->assertArrayHasKey('account.upsert', $channels); + $this->assertArrayHasKey('account.delete', $channels); + $this->assertArrayHasKey('account', $channels); + } + + public function testRebindAccountChannelsRemapsAfterReauth(): void + { + // Reauth as a different user must remap the user-scoped channels so the + // connection no longer receives the previous user's account events. + $rebound = Realtime::rebindAccountChannels( + ['account.A', 'account.A.create', 'account.A.update', 'documents', 'documents.A.something'], + 'A', + 'B', + ); + + $this->assertContains('account.B', $rebound); + $this->assertContains('account.B.create', $rebound); + $this->assertContains('account.B.update', $rebound); + $this->assertNotContains('account.A', $rebound); + $this->assertNotContains('account.A.create', $rebound); + $this->assertNotContains('account.A.update', $rebound); + + // Non-account channels left alone — the rewrite is precise. + $this->assertContains('documents', $rebound); + $this->assertContains('documents.A.something', $rebound); + } + + public function testRebindAccountChannelsIsNoopForUnchangedUser(): void + { + // Same user → nothing to rewrite. Avoids unnecessary churn when the + // permissionsChanged path fires (roles change, userId is constant). + $channels = ['account.A', 'account.A.create', 'documents']; + $this->assertSame($channels, Realtime::rebindAccountChannels($channels, 'A', 'A')); + } + + public function testRebindAccountChannelsIsNoopForEmptyTarget(): void + { + // Defensive: if a caller ever passes an empty $newUserId (e.g. a + // hypothetical in-band logout), we leave channels untouched rather than + // producing malformed `account.` strings. + $channels = ['account.A', 'account.A.create', 'account.create', 'documents']; + $this->assertSame($channels, Realtime::rebindAccountChannels($channels, 'A', '')); + $this->assertSame($channels, Realtime::rebindAccountChannels($channels, '', '')); + } + + public function testRebindAccountChannelsPromotesGuestActionFilters(): void + { + // Guest connections store `account.{action}` literally (convertChannels + // preserves the form when userId is empty). On in-band authentication, + // rebindAccountChannels promotes those literals to user-scoped form so + // the action filter survives. + $rebound = Realtime::rebindAccountChannels( + ['account', 'account.create', 'account.update', 'documents'], + '', + 'B', + ); + + $this->assertContains('account.B.create', $rebound); + $this->assertContains('account.B.update', $rebound); + $this->assertNotContains('account.create', $rebound); + $this->assertNotContains('account.update', $rebound); + + // Plain `account` and unrelated channels are left alone. + $this->assertContains('account', $rebound); + $this->assertContains('documents', $rebound); + } + + public function testRebindAccountChannelsOnlyRemapsKnownActions(): void + { + // Defensive: only suffixes in SUPPORTED_ACTIONS are rewritten, so a + // channel like `account.A.bogus` stays intact rather than being + // silently rebound. + $rebound = Realtime::rebindAccountChannels( + ['account.A.bogus', 'account.A.create'], + 'A', + 'B', + ); + + $this->assertContains('account.A.bogus', $rebound); + $this->assertContains('account.B.create', $rebound); + $this->assertNotContains('account.B.bogus', $rebound); + $this->assertNotContains('account.A.create', $rebound); + } + + public function testReauthThenPermissionsChangeThenReauthPreservesAccountAction(): void + { + // Full lifecycle, mirrors the auth + permissionsChanged handler logic in + // app/realtime.php: + // 1. user A subscribes to account.create (stored as account.A.create) + // 2. in-band reauth as B → rebound to account.B.create, userId=B + // 3. permissions-change for B → userId on connection MUST stay 'B' + // so a subsequent reauth as C still has previousUserId='B'. + // 4. reauth as C → rebound to account.C.create, userId=C + $realtime = new Realtime(); + + // Step 1. + $aChannels = \array_keys(Realtime::convertChannels(['account.create'], 'A')); + $this->assertSame(['account.A.create'], $aChannels); + $realtime->subscribe('1', 1, 'sub-1', [Role::user(ID::custom('A'))->toString()], $aChannels, [], 'A'); + $this->assertSame('A', $realtime->connections[1]['userId']); + + // Step 2: A → B. + $previousUserId = $realtime->connections[1]['userId']; + $meta = $realtime->getSubscriptionMetadata(1); + $realtime->unsubscribe(1); + foreach ($meta as $subId => $sub) { + $rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'B'); + $realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('B'))->toString()], $rebound, [], 'B'); + } + $this->assertSame('B', $realtime->connections[1]['userId']); + $this->assertContains('account.B.create', $realtime->connections[1]['channels']); + + // Step 3: permissions-change for B (userId stays 'B'). + $previousUserId = $realtime->connections[1]['userId']; + $meta = $realtime->getSubscriptionMetadata(1); + $realtime->unsubscribe(1); + foreach ($meta as $subId => $sub) { + $rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'B'); + $realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('B'))->toString()], $rebound, [], 'B'); + } + $this->assertSame('B', $realtime->connections[1]['userId']); + $this->assertContains('account.B.create', $realtime->connections[1]['channels']); + + // Step 4: B → C. + $previousUserId = $realtime->connections[1]['userId']; + $meta = $realtime->getSubscriptionMetadata(1); + $realtime->unsubscribe(1); + foreach ($meta as $subId => $sub) { + $rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'C'); + $realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('C'))->toString()], $rebound, [], 'C'); + } + $this->assertSame('C', $realtime->connections[1]['userId']); + $this->assertContains('account.C.create', $realtime->connections[1]['channels']); + $this->assertNotContains('account.B.create', $realtime->connections[1]['channels']); + $this->assertNotContains('account.A.create', $realtime->connections[1]['channels']); + } + + public function testGuestAccountActionFilterSurvivesAuthenticationEndToEnd(): void + { + // Full lifecycle: + // 1. Guest connects, subscribes to `account.create`. + // 2. fromPayload publishes a top-level `users.B.create` event — guest + // receives it via the unscoped `account.create` broadcast channel. + // 3. Guest authenticates as B. Resubscribe goes through + // rebindAccountChannels so the same subscription is now scoped to + // `account.B.create` and only matches B's events. + $realtime = new Realtime(); + + // Step 1: guest subscribes. convertChannels preserves the literal form. + $guestChannels = \array_keys(Realtime::convertChannels(['account.create'], '')); + $this->assertSame(['account.create'], $guestChannels); + $realtime->subscribe('1', 1, 'sub-1', [Role::guests()->toString()], $guestChannels, [], ''); + + // Step 2: fromPayload publishes account.create alongside the user-scoped form. + $publish = Realtime::fromPayload( + event: 'users.B.create', + payload: new Document(['$id' => ID::custom('B')]), + ); + $this->assertContains('account.create', $publish['channels']); + $this->assertContains('account.B.create', $publish['channels']); + + // Guest receives the unscoped channel. + $event = [ + 'project' => '1', + 'roles' => [Role::guests()->toString()], + 'data' => [ + 'channels' => $publish['channels'], + 'payload' => ['$id' => 'B'], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($event)); + + // Step 3: in-band auth promotes the guest to user 'B'. + $previousUserId = $realtime->connections[1]['userId'] ?? ''; + $meta = $realtime->getSubscriptionMetadata(1); + $realtime->unsubscribe(1); + foreach ($meta as $subId => $sub) { + $rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'B'); + $realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('B'))->toString()], $rebound, [], 'B'); + } + + // Literal channel is gone; user-scoped form is in place. + $this->assertNotContains('account.create', $realtime->connections[1]['channels']); + $this->assertContains('account.B.create', $realtime->connections[1]['channels']); + + // B-scoped event delivers via the user-scoped channel. + $bEvent = [ + 'project' => '1', + 'roles' => [Role::user(ID::custom('B'))->toString()], + 'data' => [ + 'channels' => $publish['channels'], + 'payload' => ['$id' => 'B'], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($bEvent)); + } + public function testFromPayloadPermissions(): void { /** From 1fdcca959293ac5db482fc153dd8294179c7c720 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 18:33:47 +0530 Subject: [PATCH 294/402] added a guard to skip double import --- app/realtime.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 0e7388b83f..88b1137c30 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -45,7 +45,10 @@ use Utopia\WebSocket\Adapter; use Utopia\WebSocket\Server; require_once __DIR__ . '/init.php'; -require_once __DIR__ . '/init/span.php'; + +if (!defined('APPWRITE_SKIP_CE_SPAN_INIT')) { + require_once __DIR__ . '/init/span.php'; +} /** @var Registry $register */ $register = $GLOBALS['register'] ?? throw new \RuntimeException('Registry not initialized'); From 49d2db65e6f7279520c2d896ccbda9e42ad1f935 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 27 Apr 2026 17:15:00 +0400 Subject: [PATCH 295/402] feat: support out-of-order chunked uploads - Add APP_LIMIT_UPLOAD_CHUNK_SIZE constant (5MB) matching official SDKs - Replace dynamic chunk calculation with fixed 5MB chunk math in all upload endpoints - Remove -1 last-chunk sentinel that broke when last chunk arrived first - Fix duplicate-retry guards: return existing resource instead of erroring for chunked uploads - Add out-of-order e2e tests for Storage, Functions, and Sites - Upgrade utopia-php/storage to 2.0.0 for device-level out-of-order assembly support --- app/init/constants.php | 1 + composer.json | 2 +- composer.lock | 181 +++++++++--------- .../Functions/Http/Deployments/Create.php | 26 +-- .../Modules/Sites/Http/Deployments/Create.php | 26 +-- .../Storage/Http/Buckets/Files/Create.php | 30 +-- .../Functions/FunctionsCustomServerTest.php | 112 +++++++++++ .../Services/Sites/SitesCustomServerTest.php | 130 +++++++++++++ tests/e2e/Services/Storage/StorageBase.php | 147 ++++++++++++++ 9 files changed, 510 insertions(+), 145 deletions(-) diff --git a/app/init/constants.php b/app/init/constants.php index 8eacf2fe12..0f12036b69 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -244,6 +244,7 @@ const APP_AUTH_TYPE_KEY = 'Key'; const APP_AUTH_TYPE_ADMIN = 'Admin'; // Response related const MAX_OUTPUT_CHUNK_SIZE = 10 * 1024 * 1024; // 10MB +const APP_LIMIT_UPLOAD_CHUNK_SIZE = 5 * 1024 * 1024; // 5MB const APP_FUNCTION_LOG_LENGTH_LIMIT = 1000000; const APP_FUNCTION_ERROR_LENGTH_LIMIT = 1000000; // Function headers diff --git a/composer.json b/composer.json index 6312243e32..7a61f2c1e6 100644 --- a/composer.json +++ b/composer.json @@ -81,7 +81,7 @@ "utopia-php/queue": "0.17.*", "utopia-php/servers": "0.3.*", "utopia-php/registry": "0.5.*", - "utopia-php/storage": "1.0.*", + "utopia-php/storage": "2.*", "utopia-php/system": "0.10.*", "utopia-php/telemetry": "0.2.*", "utopia-php/vcs": "3.*", diff --git a/composer.lock b/composer.lock index 02590020e0..ca4a58b2cb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c5ae97637fd0ec0a950044d1c33677ea", + "content-hash": "ba332fbec7c2e7d462ee5bb3fad9775c", "packages": [ { "name": "adhocore/jwt", @@ -1996,16 +1996,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.51", + "version": "3.0.52", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748" + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748", - "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce", "shasum": "" }, "require": { @@ -2086,7 +2086,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.51" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52" }, "funding": [ { @@ -2102,7 +2102,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T01:33:53+00:00" + "time": "2026-04-27T07:02:15+00:00" }, { "name": "psr/clock", @@ -2887,7 +2887,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -2948,7 +2948,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -2972,7 +2972,7 @@ }, { "name": "symfony/polyfill-php82", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", @@ -3028,7 +3028,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.37.0" }, "funding": [ { @@ -3052,7 +3052,7 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", @@ -3108,7 +3108,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" }, "funding": [ { @@ -3132,16 +3132,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "2c408a6bb0313e6001a83628dc5506100474254e" + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/2c408a6bb0313e6001a83628dc5506100474254e", - "reference": "2c408a6bb0313e6001a83628dc5506100474254e", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", "shasum": "" }, "require": { @@ -3188,7 +3188,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" }, "funding": [ { @@ -3208,7 +3208,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:50:15+00:00" + "time": "2026-04-26T13:10:57+00:00" }, { "name": "symfony/service-contracts", @@ -3658,16 +3658,16 @@ }, { "name": "utopia-php/cli", - "version": "0.23.1", + "version": "0.23.2", "source": { "type": "git", "url": "https://github.com/utopia-php/cli.git", - "reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621" + "reference": "145b91fef827853bcceaa3ab8ca2b1d6faaca2ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cli/zipball/8d1955b8bc4dc631f45d7c7df689ed7b63f70621", - "reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621", + "url": "https://api.github.com/repos/utopia-php/cli/zipball/145b91fef827853bcceaa3ab8ca2b1d6faaca2ab", + "reference": "145b91fef827853bcceaa3ab8ca2b1d6faaca2ab", "shasum": "" }, "require": { @@ -3703,9 +3703,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cli/issues", - "source": "https://github.com/utopia-php/cli/tree/0.23.1" + "source": "https://github.com/utopia-php/cli/tree/0.23.2" }, - "time": "2026-04-05T15:27:35+00:00" + "time": "2026-04-27T09:19:04+00:00" }, { "name": "utopia-php/compression", @@ -4271,21 +4271,20 @@ }, { "name": "utopia-php/http", - "version": "0.34.21", + "version": "0.34.24", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "49a6bd3ea0d2966aa19cf707255d442675288a24" + "reference": "d1eced0627c5a9fceddf53992ed97d664b810d33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/49a6bd3ea0d2966aa19cf707255d442675288a24", - "reference": "49a6bd3ea0d2966aa19cf707255d442675288a24", + "url": "https://api.github.com/repos/utopia-php/http/zipball/d1eced0627c5a9fceddf53992ed97d664b810d33", + "reference": "d1eced0627c5a9fceddf53992ed97d664b810d33", "shasum": "" }, "require": { - "ext-swoole": "*", - "php": ">=8.2", + "php": ">=8.3", "utopia-php/compression": "0.1.*", "utopia-php/di": "0.3.*", "utopia-php/servers": "0.3.*", @@ -4295,11 +4294,14 @@ "require-dev": { "doctrine/instantiator": "^1.5", "laravel/pint": "1.*", - "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "1.*", - "phpunit/phpunit": "^9.5.25", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.0", + "rector/rector": "^2.4", "swoole/ide-helper": "4.8.3" }, + "suggest": { + "ext-swoole": "Required to use the Swoole server adapter (\\Utopia\\Http\\Adapter\\Swoole\\Server)." + }, "type": "library", "autoload": { "psr-4": { @@ -4319,9 +4321,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.34.21" + "source": "https://github.com/utopia-php/http/tree/0.34.24" }, - "time": "2026-04-19T19:44:04+00:00" + "time": "2026-04-24T12:16:53+00:00" }, { "name": "utopia-php/image", @@ -4528,16 +4530,16 @@ }, { "name": "utopia-php/migration", - "version": "1.9.1", + "version": "1.9.4", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2" + "reference": "969dc9477ea962f16da9254facdbd8944cf13477" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/7a86aeadf182b63a9f4ceba7e137588b31c5d2e2", - "reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/969dc9477ea962f16da9254facdbd8944cf13477", + "reference": "969dc9477ea962f16da9254facdbd8944cf13477", "shasum": "" }, "require": { @@ -4548,7 +4550,7 @@ "php": ">=8.1", "utopia-php/database": "5.*", "utopia-php/dsn": "0.2.*", - "utopia-php/storage": "1.0.*" + "utopia-php/storage": "2.*" }, "require-dev": { "ext-pdo": "*", @@ -4577,22 +4579,22 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.9.1" + "source": "https://github.com/utopia-php/migration/tree/1.9.4" }, - "time": "2026-03-25T07:05:27+00:00" + "time": "2026-04-27T12:42:51+00:00" }, { "name": "utopia-php/mongo", - "version": "1.0.2", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "677a21c53f7a1316c528b4b45b3fce886cee7223" + "reference": "73593682deee4696525a04e26524c1c1226e1530" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/677a21c53f7a1316c528b4b45b3fce886cee7223", - "reference": "677a21c53f7a1316c528b4b45b3fce886cee7223", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/73593682deee4696525a04e26524c1c1226e1530", + "reference": "73593682deee4696525a04e26524c1c1226e1530", "shasum": "" }, "require": { @@ -4638,9 +4640,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/1.0.2" + "source": "https://github.com/utopia-php/mongo/tree/1.1.0" }, - "time": "2026-03-18T02:45:50+00:00" + "time": "2026-04-24T06:15:10+00:00" }, { "name": "utopia-php/platform", @@ -5018,16 +5020,16 @@ }, { "name": "utopia-php/storage", - "version": "1.0.1", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/utopia-php/storage.git", - "reference": "f014be445f0baa635d0764e1673196f412511618" + "reference": "52d1f89a47165ef0d3deff63043cda182175adfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/storage/zipball/f014be445f0baa635d0764e1673196f412511618", - "reference": "f014be445f0baa635d0764e1673196f412511618", + "url": "https://api.github.com/repos/utopia-php/storage/zipball/52d1f89a47165ef0d3deff63043cda182175adfb", + "reference": "52d1f89a47165ef0d3deff63043cda182175adfb", "shasum": "" }, "require": { @@ -5041,9 +5043,8 @@ "utopia-php/validators": "0.2.*" }, "require-dev": { - "laravel/pint": "1.2.*", - "phpunit/phpunit": "^9.3", - "vimeo/psalm": "4.0.1" + "laravel/pint": "^1.21", + "phpunit/phpunit": "^9.3" }, "type": "library", "autoload": { @@ -5065,9 +5066,9 @@ ], "support": { "issues": "https://github.com/utopia-php/storage/issues", - "source": "https://github.com/utopia-php/storage/tree/1.0.1" + "source": "https://github.com/utopia-php/storage/tree/2.0.0" }, - "time": "2026-02-23T05:59:32+00:00" + "time": "2026-04-27T11:39:32+00:00" }, { "name": "utopia-php/system", @@ -5464,16 +5465,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.20", + "version": "1.24.0", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "525f0630520c95100fcdfb63c9dac859c1d02588" + "reference": "6d4d26659bc7a1c347c1d4d8dae3b77b5562e0cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/525f0630520c95100fcdfb63c9dac859c1d02588", - "reference": "525f0630520c95100fcdfb63c9dac859c1d02588", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/6d4d26659bc7a1c347c1d4d8dae3b77b5562e0cb", + "reference": "6d4d26659bc7a1c347c1d4d8dae3b77b5562e0cb", "shasum": "" }, "require": { @@ -5509,9 +5510,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.20" + "source": "https://github.com/appwrite/sdk-generator/tree/1.24.0" }, - "time": "2026-04-20T05:45:00+00:00" + "time": "2026-04-24T12:50:05+00:00" }, { "name": "brianium/paratest", @@ -5793,16 +5794,16 @@ }, { "name": "laravel/pint", - "version": "v1.29.0", + "version": "v1.29.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", - "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", "shasum": "" }, "require": { @@ -5813,14 +5814,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.94.2", - "illuminate/view": "^12.54.1", - "larastan/larastan": "^3.9.3", - "laravel-zero/framework": "^12.0.5", + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.4.0", "pestphp/pest": "^3.8.6", - "shipfastlabs/agent-detector": "^1.1.0" + "shipfastlabs/agent-detector": "^1.1.3" }, "bin": [ "builds/pint" @@ -5857,7 +5858,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-03-12T15:51:39+00:00" + "time": "2026-04-20T15:26:14+00:00" }, { "name": "matthiasmullie/minify", @@ -6220,11 +6221,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.50", + "version": "2.1.51", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", - "reference": "d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc3b523c45e714c70de2ac5113b958223b55dc59", + "reference": "dc3b523c45e714c70de2ac5113b958223b55dc59", "shasum": "" }, "require": { @@ -6269,7 +6270,7 @@ "type": "github" } ], - "time": "2026-04-17T13:10:32+00:00" + "time": "2026-04-21T18:22:01+00:00" }, { "name": "phpunit/php-code-coverage", @@ -7779,7 +7780,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -7838,7 +7839,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -7862,16 +7863,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df" + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df", - "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "shasum": "" }, "require": { @@ -7920,7 +7921,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" }, "funding": [ { @@ -7940,11 +7941,11 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-04-26T13:13:48+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -8005,7 +8006,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" }, "funding": [ { @@ -8029,7 +8030,7 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -8085,7 +8086,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0" }, "funding": [ { diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index 11736c8ca5..decf1323c1 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -175,15 +175,8 @@ class Create extends Action throw new Exception(Exception::STORAGE_INVALID_CONTENT_RANGE); } - // TODO remove the condition that checks `$end === $fileSize` in next breaking version - if ($end === $fileSize - 1 || $end === $fileSize) { - //if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to notify it's last chunk - $chunks = $chunk = -1; - } else { - // Calculate total number of chunks based on the chunk size i.e ($rangeEnd - $rangeStart) - $chunks = (int) ceil($fileSize / ($end + 1 - $start)); - $chunk = (int) ($start / ($end + 1 - $start)) + 1; - } + $chunks = (int) ceil($fileSize / APP_LIMIT_UPLOAD_CHUNK_SIZE); + $chunk = (int) ($start / APP_LIMIT_UPLOAD_CHUNK_SIZE) + 1; } if (!$fileSizeValidator->isValid($fileSize) && $functionSizeLimit !== 0) { // Check if file size is exceeding allowed limit @@ -202,15 +195,14 @@ class Create extends Action $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; if (!$deployment->isEmpty()) { $chunks = $deployment->getAttribute('sourceChunksTotal', 1); + $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0); $metadata = $deployment->getAttribute('sourceMetadata', []); - if ($chunk === -1) { - $chunk = $chunks; - } - } else { - // Guard against manually setting range header for single chunk upload - if ($chunks === -1) { - $chunks = 1; - $chunk = 1; + + if ($uploaded === $chunks) { + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($deployment, Response::MODEL_DEPLOYMENT); + return; } } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index 0b8ca24aaa..d6e3e68e90 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -177,15 +177,8 @@ class Create extends Action throw new Exception(Exception::STORAGE_INVALID_CONTENT_RANGE); } - // TODO remove the condition that checks `$end === $fileSize` in next breaking version - if ($end === $fileSize - 1 || $end === $fileSize) { - //if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to notify it's last chunk - $chunks = $chunk = -1; - } else { - // Calculate total number of chunks based on the chunk size i.e ($rangeEnd - $rangeStart) - $chunks = (int) ceil($fileSize / ($end + 1 - $start)); - $chunk = (int) ($start / ($end + 1 - $start)) + 1; - } + $chunks = (int) ceil($fileSize / APP_LIMIT_UPLOAD_CHUNK_SIZE); + $chunk = (int) ($start / APP_LIMIT_UPLOAD_CHUNK_SIZE) + 1; } if (!$fileSizeValidator->isValid($fileSize) && $siteSizeLimit !== 0) { // Check if file size is exceeding allowed limit @@ -204,15 +197,14 @@ class Create extends Action $metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)]; if (!$deployment->isEmpty()) { $chunks = $deployment->getAttribute('sourceChunksTotal', 1); + $uploaded = $deployment->getAttribute('sourceChunksUploaded', 0); $metadata = $deployment->getAttribute('sourceMetadata', []); - if ($chunk === -1) { - $chunk = $chunks; - } - } else { - // Guard against manually setting range header for single chunk upload - if ($chunks === -1) { - $chunks = 1; - $chunk = 1; + + if ($uploaded === $chunks) { + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($deployment, Response::MODEL_DEPLOYMENT); + return; } } diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php index befc02a1df..2ce5ef97f5 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -204,15 +204,8 @@ class Create extends Action throw new Exception(Exception::STORAGE_INVALID_APPWRITE_ID); } - // TODO remove the condition that checks `$end === $fileSize` in next breaking version - if ($end === $fileSize - 1 || $end === $fileSize) { - //if it's a last chunks the chunk size might differ, so we set the $chunks and $chunk to -1 notify it's last chunk - $chunks = $chunk = -1; - } else { - // Calculate total number of chunks based on the chunk size i.e ($rangeEnd - $rangeStart) - $chunks = (int) ceil($fileSize / ($end + 1 - $start)); - $chunk = (int) ($start / ($end + 1 - $start)) + 1; - } + $chunks = (int) ceil($fileSize / APP_LIMIT_UPLOAD_CHUNK_SIZE); + $chunk = (int) ($start / APP_LIMIT_UPLOAD_CHUNK_SIZE) + 1; } /** @@ -249,18 +242,15 @@ class Create extends Action $uploaded = $file->getAttribute('chunksUploaded', 0); $metadata = $file->getAttribute('metadata', []); - if ($chunk === -1) { - $chunk = $chunks; - } - if ($uploaded === $chunks) { - throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); - } - } else { - // Guard against manually setting range header for single chunk upload - if ($chunks === -1) { - $chunks = 1; - $chunk = 1; + if (empty($contentRange)) { + throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic($file, Response::MODEL_FILE); + return; } } diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 4255774f18..87f73dd7d3 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -1079,6 +1079,118 @@ class FunctionsCustomServerTest extends Scope }, 120000, 500); } + public function testCreateDeploymentOutOfOrder(): void + { + $data = $this->setupTestFunction(); + $functionId = $data['functionId']; + + // Prepare a code file that spans at least 3 chunks + $folder = 'large'; + $code = realpath(__DIR__ . '/../../../resources/functions') . "/$folder/code.tar.gz"; + Console::execute('cd ' . realpath(__DIR__ . "/../../../resources/functions") . "/$folder && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $this->stdout, $this->stderr); + + $totalSize = filesize($code); + $chunkSize = 5 * 1024 * 1024; // 5MB chunks + $mimeType = 'application/x-gzip'; + $chunksTotal = (int) ceil($totalSize / $chunkSize); + + // Read all chunks into memory + $handle = fopen($code, "rb"); + $this->assertNotFalse($handle, "Could not open test resource: $code"); + $chunks = []; + for ($i = 0; $i < $chunksTotal; $i++) { + $start = $i * $chunkSize; + $end = min($start + $chunkSize, $totalSize); + $length = $end - $start; + $data = fread($handle, $length); + $chunks[] = [ + 'data' => $data, + 'start' => $start, + 'end' => $end - 1, + 'index' => $i, + ]; + } + fclose($handle); + + // We need at least 3 chunks for a meaningful out-of-order test + $this->assertGreaterThanOrEqual(3, count($chunks), 'Test file must span at least 3 chunks'); + + // Upload chunks in out-of-order sequence: last chunk first, then first, then second + $uploadOrder = [count($chunks) - 1, 0, 1]; + $deploymentId = ''; + $deployment = null; + + foreach ($uploadOrder as $chunkIndex) { + $chunk = $chunks[$chunkIndex]; + $curlFile = new \CURLFile( + 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']), + $mimeType, + 'large-fx.tar.gz' + ); + + $headers = [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize, + ]; + + if (!empty($deploymentId)) { + $headers['x-appwrite-id'] = $deploymentId; + } + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge($headers, $this->getHeaders()), [ + 'entrypoint' => 'index.js', + 'code' => $curlFile, + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $deploymentId = $deployment['body']['$id']; + } + + // Upload remaining chunks in any order to complete the file + $remainingChunks = []; + for ($i = 2; $i < count($chunks) - 1; $i++) { + $remainingChunks[] = $i; + } + shuffle($remainingChunks); + + foreach ($remainingChunks as $chunkIndex) { + $chunk = $chunks[$chunkIndex]; + $curlFile = new \CURLFile( + 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']), + $mimeType, + 'large-fx.tar.gz' + ); + + $headers = [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize, + 'x-appwrite-id' => $deploymentId, + ]; + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge($headers, $this->getHeaders()), [ + 'entrypoint' => 'index.js', + 'code' => $curlFile, + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + } + + // Verify the final upload response indicates completion + $this->assertEquals($chunksTotal, $deployment['body']['sourceChunksTotal']); + $this->assertEquals($chunksTotal, $deployment['body']['sourceChunksUploaded']); + + // Wait for build to complete + $this->assertEventually(function () use ($functionId, $deploymentId) { + $deployment = $this->getDeployment($functionId, $deploymentId); + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('ready', $deployment['body']['status']); + }, 120000, 500); + } + public function testUpdateDeployment(): void { $data = $this->setupTestDeployment(); diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 71f6675561..418fc242c2 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -906,6 +906,136 @@ class SitesCustomServerTest extends Scope $this->cleanupSite($siteId); } + public function testCreateDeploymentOutOfOrder(): void + { + $siteId = $this->setupSite([ + 'buildRuntime' => 'node-22', + 'fallbackFile' => '', + 'framework' => 'other', + 'name' => 'Test Site Out of Order Upload', + 'outputDirectory' => './', + 'providerBranch' => 'main', + 'providerRootDirectory' => './', + 'siteId' => ID::unique() + ]); + + // Create a temporary large site package for chunked upload + $tempDir = sys_get_temp_dir() . '/appwrite-test-site-' . uniqid(); + mkdir($tempDir, 0777, true); + file_put_contents($tempDir . '/index.html', 'Hello World'); + // Add a large dummy file to make the package span multiple chunks + file_put_contents($tempDir . '/large.bin', str_repeat('X', 12 * 1024 * 1024)); // 12MB + + $codePath = $tempDir . '/code.tar.gz'; + Console::execute("cd $tempDir && tar --exclude code.tar.gz -czf code.tar.gz .", '', $this->stdout, $this->stderr); + + $totalSize = filesize($codePath); + $chunkSize = 5 * 1024 * 1024; // 5MB chunks + $mimeType = 'application/x-gzip'; + $chunksTotal = (int) ceil($totalSize / $chunkSize); + + $this->assertGreaterThanOrEqual(3, $chunksTotal, 'Test file must span at least 3 chunks'); + + // Read all chunks into memory + $handle = fopen($codePath, "rb"); + $this->assertNotFalse($handle, "Could not open test resource: $codePath"); + $chunks = []; + for ($i = 0; $i < $chunksTotal; $i++) { + $start = $i * $chunkSize; + $end = min($start + $chunkSize, $totalSize); + $length = $end - $start; + $data = fread($handle, $length); + $chunks[] = [ + 'data' => $data, + 'start' => $start, + 'end' => $end - 1, + 'index' => $i, + ]; + } + fclose($handle); + + // Upload chunks in out-of-order sequence: last chunk first, then first, then second + $uploadOrder = [count($chunks) - 1, 0, 1]; + $deploymentId = ''; + $deployment = null; + + foreach ($uploadOrder as $chunkIndex) { + $chunk = $chunks[$chunkIndex]; + $curlFile = new \CURLFile( + 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']), + $mimeType, + 'code.tar.gz' + ); + + $headers = [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize, + ]; + + if (!empty($deploymentId)) { + $headers['x-appwrite-id'] = $deploymentId; + } + + $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', array_merge($headers, $this->getHeaders()), [ + 'code' => $curlFile, + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $deploymentId = $deployment['body']['$id']; + } + + // Upload remaining chunks in any order to complete the file + $remainingChunks = []; + for ($i = 2; $i < count($chunks) - 1; $i++) { + $remainingChunks[] = $i; + } + shuffle($remainingChunks); + + foreach ($remainingChunks as $chunkIndex) { + $chunk = $chunks[$chunkIndex]; + $curlFile = new \CURLFile( + 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']), + $mimeType, + 'code.tar.gz' + ); + + $headers = [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize, + 'x-appwrite-id' => $deploymentId, + ]; + + $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', array_merge($headers, $this->getHeaders()), [ + 'code' => $curlFile, + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + } + + // Verify the final upload response indicates completion + $this->assertEquals($chunksTotal, $deployment['body']['sourceChunksTotal']); + $this->assertEquals($chunksTotal, $deployment['body']['sourceChunksUploaded']); + + // Wait for build to complete + $this->assertEventually(function () use ($siteId, $deploymentId) { + $deployment = $this->getDeployment($siteId, $deploymentId); + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertEquals('ready', $deployment['body']['status']); + }, 120000, 500); + + // Clean up temp files + unlink($codePath); + unlink($tempDir . '/index.html'); + unlink($tempDir . '/large.bin'); + rmdir($tempDir); + + $this->cleanupSite($siteId); + } + public function testCreateDeployment() { $siteId = $this->setupSite([ diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 60a4aefc85..29f7d70435 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1227,6 +1227,153 @@ trait StorageBase $this->assertEquals(204, $deleteBucketResponse['headers']['status-code']); } + public function testCreateBucketFileOutOfOrder(): void + { + // Create a bucket for this test + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'bucketId' => ID::unique(), + 'name' => 'Test Bucket Out of Order Upload', + 'fileSecurity' => true, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $bucket['headers']['status-code']); + $bucketId = $bucket['body']['$id']; + + // Prepare a file that spans at least 3 chunks + $source = __DIR__ . "/../../../resources/disk-a/large-file.mp4"; + $totalSize = \filesize($source); + $chunkSize = 5 * 1024 * 1024; // 5MB chunks + $mimeType = mime_content_type($source); + $chunksTotal = (int) ceil($totalSize / $chunkSize); + + // Read all chunks into memory + $handle = fopen($source, "rb"); + $this->assertNotFalse($handle, "Could not open test resource: $source"); + $chunks = []; + for ($i = 0; $i < $chunksTotal; $i++) { + $start = $i * $chunkSize; + $end = min($start + $chunkSize, $totalSize); + $length = $end - $start; + $data = fread($handle, $length); + $chunks[] = [ + 'data' => $data, + 'start' => $start, + 'end' => $end - 1, + 'index' => $i, + ]; + } + fclose($handle); + + // We need at least 3 chunks for a meaningful out-of-order test + $this->assertGreaterThanOrEqual(3, count($chunks), 'Test file must span at least 3 chunks'); + + // Upload chunks in out-of-order sequence: last chunk first, then first, then middle + $uploadOrder = [count($chunks) - 1, 0, 1]; // last, first, second (for 3+ chunks) + $fileId = ID::unique(); + $id = ''; + $uploadedFile = null; + + foreach ($uploadOrder as $chunkIndex) { + $chunk = $chunks[$chunkIndex]; + $curlFile = new \CURLFile( + 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']), + $mimeType, + 'large-file.mp4' + ); + + $headers = [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize, + ]; + + if (!empty($id)) { + $headers['x-appwrite-id'] = $id; + } + + $uploadedFile = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge($headers, $this->getHeaders()), [ + 'fileId' => $fileId, + 'file' => $curlFile, + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $this->assertEquals(201, $uploadedFile['headers']['status-code']); + $id = $uploadedFile['body']['$id']; + } + + // Upload remaining chunks in any order to complete the file + $remainingChunks = []; + for ($i = 2; $i < count($chunks) - 1; $i++) { + $remainingChunks[] = $i; + } + // Shuffle remaining chunks for extra randomness + shuffle($remainingChunks); + + foreach ($remainingChunks as $chunkIndex) { + $chunk = $chunks[$chunkIndex]; + $curlFile = new \CURLFile( + 'data://' . $mimeType . ';base64,' . base64_encode($chunk['data']), + $mimeType, + 'large-file.mp4' + ); + + $headers = [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes ' . $chunk['start'] . '-' . $chunk['end'] . '/' . $totalSize, + 'x-appwrite-id' => $id, + ]; + + $uploadedFile = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge($headers, $this->getHeaders()), [ + 'fileId' => $fileId, + 'file' => $curlFile, + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $this->assertEquals(201, $uploadedFile['headers']['status-code']); + } + + // Verify the final upload response indicates completion + $this->assertEquals($chunksTotal, $uploadedFile['body']['chunksTotal']); + $this->assertEquals($chunksTotal, $uploadedFile['body']['chunksUploaded']); + + // Verify the file can be downloaded and matches the original + $download = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $id . '/download', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $download['headers']['status-code']); + $this->assertEquals($totalSize, strlen($download['body'])); + $this->assertEquals(md5_file($source), md5($download['body'])); + + // Clean up + $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId . '/files/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + } + public function testDeleteBucketFile(): void { // Create a fresh file just for deletion testing (not using cache since we delete it) From 70b9c60e2cf8f6e653d3f777fb2d917339685a7b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Apr 2026 18:46:04 +0530 Subject: [PATCH 296/402] test(Messaging): validate that bare functions channel is not emitted in published channels --- src/Appwrite/Messaging/Adapter/Realtime.php | 67 ++++++++++++++++++--- tests/unit/Messaging/MessagingTest.php | 8 +++ 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index bd4c3f80b4..5a9c02a2bd 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -16,6 +16,15 @@ class Realtime extends MessagingAdapter { public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete']; + // Resources whose channels receive an action-suffixed sibling at publish time. + // The suffix loop in fromPayload() treats any channel whose last OR second-to-last + // segment matches an entry here as a candidate for `.{action}` suffixing. + // + // `functions` is intentionally a parent-only entry: fromPayload publishes + // `functions.{functionId}` (suffixed to `functions.{functionId}.{action}`) but + // never emits a bare `functions` channel — so subscribing to bare + // `functions.{action}` is a silent no-op. Per-function filters + // (`functions.{functionId}.{action}`) are the supported form. private const RESOURCE_LEAF_NAMES = [ 'documents', 'rows', @@ -72,11 +81,13 @@ class Realtime extends MessagingAdapter /** * Adds a subscription with a specific subscription ID. * - * @param mixed $identifier Connection ID - * @param string $subscriptionId Unique subscription ID - * @param array $roles User roles - * @param array $channels Channels to subscribe to (array of channel names) - * @param array $queryGroup Array of Query objects for this subscription (AND logic within subscription) + * @param string $projectId + * @param mixed $identifier Connection ID + * @param string $subscriptionId Unique subscription ID + * @param array $roles User roles + * @param array $channels Channels to subscribe to (array of channel names) + * @param array $queryGroup Array of Query objects for this subscription (AND logic within subscription) + * @return void */ public function subscribe( string $projectId, @@ -148,7 +159,7 @@ class Realtime extends MessagingAdapter * Get subscription metadata for a connection. * Retrieves subscription data including channels and queries directly from the subscriptions tree. * - * @param mixed $connection Connection ID + * @param mixed $connection Connection ID * @return array Array of [subscriptionId => ['channels' => string[], 'queries' => string[]]] */ public function getSubscriptionMetadata(mixed $connection): array @@ -193,6 +204,9 @@ class Realtime extends MessagingAdapter /** * Removes all subscriptions for a connection. + * + * @param mixed $connection + * @return void */ public function unsubscribe(mixed $connection): void { @@ -226,6 +240,10 @@ class Realtime extends MessagingAdapter /** * Removes a single subscription from a connection, keeping the connection alive so * the client can resubscribe. Idempotent — returns true only when something was removed. + * + * @param mixed $connection + * @param string $subscriptionId + * @return bool */ public function unsubscribeSubscription(mixed $connection, string $subscriptionId): bool { @@ -276,6 +294,9 @@ class Realtime extends MessagingAdapter * context (set at onOpen, replaced on `authentication` / permission-change) and must survive * per-subscription removal — otherwise a client that unsubscribes every subscription and then * resubscribes would subscribe with an empty roles array and silently receive nothing. + * + * @param mixed $connection + * @return void */ private function recomputeConnectionState(mixed $connection): void { @@ -299,6 +320,10 @@ class Realtime extends MessagingAdapter /** * Checks if Channel has a subscriber. + * @param string $projectId + * @param string $role + * @param string $channel + * @return bool */ public function hasSubscriber(string $projectId, string $role, string $channel = ''): bool { @@ -316,6 +341,13 @@ class Realtime extends MessagingAdapter /** * Sends an event to the Realtime Server + * @param string $projectId + * @param array $payload + * @param array $events + * @param array $channels + * @param array $roles + * @param array $options + * @return void * * @throws \Exception */ @@ -404,6 +436,11 @@ class Realtime extends MessagingAdapter * `account.delete`) to `account.USER_ID.{action}` so they match the channels * fromPayload() publishes for top-level user events, and removes all other * illegal account channel variations (e.g. another user's `account.{otherId}`). + * + * Also renames the account channel to account.USER_ID and removes all illegal account channel variations. + * @param array $channels + * @param string $userId + * @return array */ public static function convertChannels(array $channels, string $userId): array { @@ -495,6 +532,8 @@ class Realtime extends MessagingAdapter /** * Constructs subscriptions from query parameters. * + * @param array $channelNames + * @param callable $getQueryParam * @return array [index => ['channels' => string[], 'queries' => Query[]]] * * @throws QueryException @@ -566,8 +605,8 @@ class Realtime extends MessagingAdapter /** * Converts the queries from the Query Params into an array. - * - * @param array|string $queries + * @param array|string $queries + * @return array * * @throws QueryException */ @@ -602,6 +641,13 @@ class Realtime extends MessagingAdapter /** * Create channels array based on the event name and payload. * + * @param string $event + * @param Document $payload + * @param Document|null $project + * @param Document|null $database + * @param Document|null $collection + * @param Document|null $bucket + * @return array * @throws \Exception */ public static function fromPayload(string $event, Document $payload, ?Document $project = null, ?Document $database = null, ?Document $collection = null, ?Document $bucket = null): array @@ -694,7 +740,7 @@ class Realtime extends MessagingAdapter } $channels[] = 'files'; $channels[] = 'buckets.' . $payload->getAttribute('bucketId') . '.files'; - $channels[] = 'buckets.' . $payload->getAttribute('bucketId') . '.files.'.$payload->getId(); + $channels[] = 'buckets.' . $payload->getAttribute('bucketId') . '.files.' . $payload->getId(); $roles = $bucket->getAttribute('fileSecurity', false) ? \array_merge($bucket->getRead(), $payload->getRead()) @@ -781,12 +827,15 @@ class Realtime extends MessagingAdapter } /** + * Generate realtime channels for database events + * * @param string $type The database API type * @param string $databaseId The database ID * @param string $resourceId The collection/table ID * @param string $payloadId The document/row ID * @param string $prefixOverride Override the channel prefix when different API types share the same terminology but need different prefixes * (e.g., 'databases' and 'documentsdb' use same terminology but need different prefixes) + * @return array Array of channel names */ private static function getDatabaseChannels( string $type = 'databases', diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 6fbb5a3e68..bf901bbe43 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -879,6 +879,14 @@ class MessagingTest extends TestCase $this->assertNotContains('console.create', $result['channels']); $this->assertContains('projects.project_id', $result['channels']); $this->assertNotContains('projects.project_id.create', $result['channels']); + + // The bare `functions` channel is never emitted by fromPayload (only + // `functions.{functionId}` is). The per-function action variant + // (`functions.{functionId}.create`) is the supported subscription + // form — bare `functions.create` would be a silent no-op and must + // therefore NOT appear in the published channel set either. + $this->assertNotContains('functions', $result['channels']); + $this->assertNotContains('functions.create', $result['channels']); } public function testFromPayloadHandlesAttributeTrailingActionEvents(): void From 54997638e81078884d476ea0706a4640951f174d Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 27 Apr 2026 17:24:51 +0400 Subject: [PATCH 297/402] fix: persist sourceChunksUploaded on finalization and avoid variable shadowing - Functions/Sites: include sourceChunksUploaded in updateDocument when finalizing existing deployments, fixing the retry guard - Functions test: rename loop variable to avoid shadowing setup result --- .../Platform/Modules/Functions/Http/Deployments/Create.php | 1 + .../Platform/Modules/Sites/Http/Deployments/Create.php | 1 + tests/e2e/Services/Functions/FunctionsCustomServerTest.php | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index decf1323c1..2775d04137 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -264,6 +264,7 @@ class Create extends Action } else { $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ 'sourceSize' => $fileSize, + 'sourceChunksUploaded' => $chunksUploaded, 'sourceMetadata' => $metadata, ])); } diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index d6e3e68e90..4c3abdba3f 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -307,6 +307,7 @@ class Create extends Action } else { $deployment = $dbForProject->updateDocument('deployments', $deploymentId, new Document([ 'sourceSize' => $fileSize, + 'sourceChunksUploaded' => $chunksUploaded, 'sourceMetadata' => $metadata, ])); } diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 87f73dd7d3..172921c3ed 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -1102,9 +1102,9 @@ class FunctionsCustomServerTest extends Scope $start = $i * $chunkSize; $end = min($start + $chunkSize, $totalSize); $length = $end - $start; - $data = fread($handle, $length); + $chunkData = fread($handle, $length); $chunks[] = [ - 'data' => $data, + 'data' => $chunkData, 'start' => $start, 'end' => $end - 1, 'index' => $i, From 2f2da98cca7355981cae5737f91e7f1139811007 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Mon, 27 Apr 2026 17:46:20 +0400 Subject: [PATCH 298/402] fix: adjust out-of-order test expectations and chunk sizes - Functions/Sites: lower minimum chunk requirement from 3 to 2 - Sites: use random_bytes instead of str_repeat for non-compressible test data - Remove assertions on sourceChunksTotal/Uploaded from response body (not in response model) --- .../Functions/FunctionsCustomServerTest.php | 14 +++++++------- tests/e2e/Services/Sites/SitesCustomServerTest.php | 8 +++----- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 172921c3ed..0c9445f768 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -1086,8 +1086,10 @@ class FunctionsCustomServerTest extends Scope // Prepare a code file that spans at least 3 chunks $folder = 'large'; - $code = realpath(__DIR__ . '/../../../resources/functions') . "/$folder/code.tar.gz"; - Console::execute('cd ' . realpath(__DIR__ . "/../../../resources/functions") . "/$folder && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $this->stdout, $this->stderr); + $folderPath = realpath(__DIR__ . '/../../../resources/functions') . "/$folder"; + $code = "$folderPath/code.tar.gz"; + + $totalSize = filesize($code); $chunkSize = 5 * 1024 * 1024; // 5MB chunks @@ -1112,8 +1114,8 @@ class FunctionsCustomServerTest extends Scope } fclose($handle); - // We need at least 3 chunks for a meaningful out-of-order test - $this->assertGreaterThanOrEqual(3, count($chunks), 'Test file must span at least 3 chunks'); + // We need at least 2 chunks for a meaningful out-of-order test + $this->assertGreaterThanOrEqual(2, count($chunks), 'Test file must span at least 2 chunks'); // Upload chunks in out-of-order sequence: last chunk first, then first, then second $uploadOrder = [count($chunks) - 1, 0, 1]; @@ -1179,9 +1181,7 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals(202, $deployment['headers']['status-code']); } - // Verify the final upload response indicates completion - $this->assertEquals($chunksTotal, $deployment['body']['sourceChunksTotal']); - $this->assertEquals($chunksTotal, $deployment['body']['sourceChunksUploaded']); + // Wait for build to complete $this->assertEventually(function () use ($functionId, $deploymentId) { diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 418fc242c2..be6979d9eb 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -924,7 +924,7 @@ class SitesCustomServerTest extends Scope mkdir($tempDir, 0777, true); file_put_contents($tempDir . '/index.html', 'Hello World'); // Add a large dummy file to make the package span multiple chunks - file_put_contents($tempDir . '/large.bin', str_repeat('X', 12 * 1024 * 1024)); // 12MB + file_put_contents($tempDir . '/large.bin', random_bytes(12 * 1024 * 1024)); // 12MB non-compressible $codePath = $tempDir . '/code.tar.gz'; Console::execute("cd $tempDir && tar --exclude code.tar.gz -czf code.tar.gz .", '', $this->stdout, $this->stderr); @@ -934,7 +934,7 @@ class SitesCustomServerTest extends Scope $mimeType = 'application/x-gzip'; $chunksTotal = (int) ceil($totalSize / $chunkSize); - $this->assertGreaterThanOrEqual(3, $chunksTotal, 'Test file must span at least 3 chunks'); + $this->assertGreaterThanOrEqual(2, $chunksTotal, 'Test file must span at least 2 chunks'); // Read all chunks into memory $handle = fopen($codePath, "rb"); @@ -1016,9 +1016,7 @@ class SitesCustomServerTest extends Scope $this->assertEquals(202, $deployment['headers']['status-code']); } - // Verify the final upload response indicates completion - $this->assertEquals($chunksTotal, $deployment['body']['sourceChunksTotal']); - $this->assertEquals($chunksTotal, $deployment['body']['sourceChunksUploaded']); + // Wait for build to complete $this->assertEventually(function () use ($siteId, $deploymentId) { From b28b851bb23a308c5244f615c83571d818e13579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 15:49:44 +0200 Subject: [PATCH 299/402] microsoft oauth endpoint --- app/init/models.php | 2 + .../Project/Http/Project/OAuth2/Base.php | 1 + .../Project/Http/Project/OAuth2/Get.php | 1 + .../Http/Project/OAuth2/Microsoft/Update.php | 167 ++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + .../Utopia/Response/Model/OAuth2Microsoft.php | 75 ++++++++ .../Response/Model/OAuth2ProviderList.php | 1 + 8 files changed, 250 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php diff --git a/app/init/models.php b/app/init/models.php index 20272db413..1f92c77cec 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -125,6 +125,7 @@ use Appwrite\Utopia\Response\Model\OAuth2Gitlab; use Appwrite\Utopia\Response\Model\OAuth2Google; use Appwrite\Utopia\Response\Model\OAuth2Kick; use Appwrite\Utopia\Response\Model\OAuth2Linkedin; +use Appwrite\Utopia\Response\Model\OAuth2Microsoft; use Appwrite\Utopia\Response\Model\OAuth2Notion; use Appwrite\Utopia\Response\Model\OAuth2Oidc; use Appwrite\Utopia\Response\Model\OAuth2Okta; @@ -425,6 +426,7 @@ Response::setModel(new OAuth2Oidc()); Response::setModel(new OAuth2Okta()); Response::setModel(new OAuth2Kick()); Response::setModel(new OAuth2Apple()); +Response::setModel(new OAuth2Microsoft()); Response::setModel(new OAuth2ProviderList()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php index f0aa50a695..ddaac7c602 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php @@ -186,6 +186,7 @@ abstract class Base extends Action 'okta' => Okta\Update::class, 'kick' => Kick\Update::class, 'apple' => Apple\Update::class, + 'microsoft' => Microsoft\Update::class, ]; } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php index 29db552e46..419d80f829 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php @@ -79,6 +79,7 @@ class Get extends Action Response::MODEL_OAUTH2_APPLE, Response::MODEL_OAUTH2_OKTA, Response::MODEL_OAUTH2_KICK, + Response::MODEL_OAUTH2_MICROSOFT, ], ) ] diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php new file mode 100644 index 0000000000..60479cf5f5 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php @@ -0,0 +1,167 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.' . $providerId . '.update') + ->label('audits.event', 'project.oauth2.' . $providerId . '.update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('tenant', '', new Text(256, 1), 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID. For example: common', optional: false) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'tenant' => $decoded['tenantID'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Microsoft + * takes an additional required `tenant` parameter. The method is named + * differently to avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $applicationId, + ?string $applicationSecret, + string $tenant, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $providerId = static::getProviderId(); + + // The secret is stored as JSON `{"clientSecret": "...", "tenantID": "..."}` + // to match the shape Microsoft's OAuth2 adapter expects (getTenantID()). + // The `tenant` param is required on every call, so it's always written. + // `applicationSecret` is optional; if omitted, the existing stored secret is preserved. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $applicationSecret ?? ($existing['clientSecret'] ?? ''), + 'tenantID' => $tenant, + ]); + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled); + + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $storedRaw = $oAuthProviders[$providerId . 'Secret'] ?? ''; + $decoded = []; + if (!empty($storedRaw)) { + $decoded = \json_decode($storedRaw, true) ?: []; + } + + $response->dynamic(new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => $decoded['clientSecret'] ?? '', + 'tenant' => $decoded['tenantID'] ?? '', + ]), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 908e688367..8a330ca041 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -36,6 +36,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Gitlab\Update as Updat use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Google\Update as UpdateOAuth2Google; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Kick\Update as UpdateOAuth2Kick; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Linkedin\Update as UpdateOAuth2Linkedin; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Microsoft\Update as UpdateOAuth2Microsoft; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Notion\Update as UpdateOAuth2Notion; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Oidc\Update as UpdateOAuth2Oidc; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Okta\Update as UpdateOAuth2Okta; @@ -211,5 +212,6 @@ class Http extends Service $this->addAction(UpdateOAuth2Oidc::getName(), new UpdateOAuth2Oidc()); $this->addAction(UpdateOAuth2Okta::getName(), new UpdateOAuth2Okta()); $this->addAction(UpdateOAuth2Kick::getName(), new UpdateOAuth2Kick()); + $this->addAction(UpdateOAuth2Microsoft::getName(), new UpdateOAuth2Microsoft()); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index b8948a062e..4dbcf135af 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -315,6 +315,7 @@ class Response extends SwooleResponse public const MODEL_OAUTH2_APPLE = 'oAuth2Apple'; public const MODEL_OAUTH2_OKTA = 'oAuth2Okta'; public const MODEL_OAUTH2_KICK = 'oAuth2Kick'; + public const MODEL_OAUTH2_MICROSOFT = 'oAuth2Microsoft'; public const MODEL_OAUTH2_PROVIDER_LIST = 'oAuth2ProviderList'; // Health diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php b/src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php new file mode 100644 index 0000000000..30cd8da2f5 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php @@ -0,0 +1,75 @@ +addRule('tenant', [ + 'type' => self::TYPE_STRING, + 'description' => 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID.', + 'default' => '', + 'example' => 'common', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Microsoft'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_MICROSOFT; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php b/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php index fd6ad1355b..5d1fb16a9a 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php @@ -55,6 +55,7 @@ class OAuth2ProviderList extends Model Response::MODEL_OAUTH2_APPLE, Response::MODEL_OAUTH2_OKTA, Response::MODEL_OAUTH2_KICK, + Response::MODEL_OAUTH2_MICROSOFT, ], 'description' => 'List of OAuth2 providers.', 'default' => [], From ee1eea5c0cb5fd8039b40aaaedbddb6166919dbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 15:51:54 +0200 Subject: [PATCH 300/402] oauth tests setup --- tests/e2e/Services/Project/OAuth2Base.php | 8 ++++++++ .../Services/Project/OAuth2ConsoleClientTest.php | 14 ++++++++++++++ .../Services/Project/OAuth2CustomServerTest.php | 14 ++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 tests/e2e/Services/Project/OAuth2Base.php create mode 100644 tests/e2e/Services/Project/OAuth2ConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/OAuth2CustomServerTest.php diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php new file mode 100644 index 0000000000..5c42ecc368 --- /dev/null +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -0,0 +1,8 @@ + Date: Mon, 27 Apr 2026 16:02:19 +0200 Subject: [PATCH 301/402] Add OAUth update tests --- tests/e2e/Services/Project/OAuth2Base.php | 1063 ++++++++++++++++++++- 1 file changed, 1062 insertions(+), 1 deletion(-) diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php index 5c42ecc368..76f011e283 100644 --- a/tests/e2e/Services/Project/OAuth2Base.php +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -2,7 +2,1068 @@ namespace Tests\E2E\Services\Project; +use PHPUnit\Framework\Attributes\Before; +use Tests\E2E\Client; + trait OAuth2Base { - + /** + * Providers that follow the default `clientId` + `clientSecret` shape and + * have no extra required parameters. We use Amazon as the canonical sample + * for behavior tests because it has no `verifyCredentials()` hook, so we + * can freely enable/disable without making real network calls. + */ + protected static string $plainProvider = 'amazon'; + + /** + * Reset providers we mutate in tests back to a known empty/disabled state. + * The ProjectCustom trait reuses the same project across tests in a class, + * and the OAuth2 PATCH endpoint is additive (omitted fields are preserved), + * so without a reset state would leak between tests. + */ + #[Before(priority: -1)] + protected function resetProjectOAuth2(): void + { + $this->updateOAuth2($this->plainProvider, [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // List OAuth2 providers + // ========================================================================= + + public function testListOAuth2Providers(): void + { + $response = $this->listOAuth2Providers(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('total', $response['body']); + $this->assertArrayHasKey('providers', $response['body']); + $this->assertGreaterThan(0, $response['body']['total']); + $this->assertSame($response['body']['total'], \count($response['body']['providers'])); + } + + public function testListOAuth2ProvidersIncludesKnownProviders(): void + { + $response = $this->listOAuth2Providers(); + + $this->assertSame(200, $response['headers']['status-code']); + + $ids = \array_column($response['body']['providers'], '$id'); + + // Spot-check a representative cross-section of providers across all + // provider shapes (plain, multi-field, sandboxed, custom param names). + $expected = [ + 'github', + 'amazon', + 'apple', + 'auth0', + 'authentik', + 'gitlab', + 'oidc', + 'okta', + 'microsoft', + 'dropbox', + 'paypalSandbox', + 'kick', + ]; + + foreach ($expected as $providerId) { + $this->assertContains($providerId, $ids, "Missing provider {$providerId} in listOAuth2Providers response"); + } + } + + public function testListOAuth2ProvidersResponseShape(): void + { + $response = $this->listOAuth2Providers(); + + $this->assertSame(200, $response['headers']['status-code']); + + foreach ($response['body']['providers'] as $provider) { + $this->assertArrayHasKey('$id', $provider); + $this->assertArrayHasKey('enabled', $provider); + $this->assertIsString($provider['$id']); + $this->assertIsBool($provider['enabled']); + } + } + + public function testListOAuth2ProvidersClientSecretsNotExposed(): void + { + // Seed credentials so the list cannot trivially return empty values. + $this->updateOAuth2($this->plainProvider, [ + 'clientId' => 'amzn1.application-oa2-client.testListSeed', + 'clientSecret' => 'super-secret-must-not-leak', + 'enabled' => false, + ]); + + $response = $this->listOAuth2Providers(); + + $this->assertSame(200, $response['headers']['status-code']); + + $matched = false; + foreach ($response['body']['providers'] as $provider) { + if ($provider['$id'] !== $this->plainProvider) { + continue; + } + + $matched = true; + $this->assertSame('amzn1.application-oa2-client.testListSeed', $provider['clientId']); + $this->assertSame('', $provider['clientSecret']); + } + + $this->assertTrue($matched, 'List did not include the seeded provider.'); + } + + public function testListOAuth2ProvidersWithoutAuthentication(): void + { + $response = $this->listOAuth2Providers(authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Get OAuth2 provider + // ========================================================================= + + public function testGetOAuth2Provider(): void + { + $response = $this->getOAuth2Provider('github'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('github', $response['body']['$id']); + $this->assertArrayHasKey('enabled', $response['body']); + $this->assertArrayHasKey('clientId', $response['body']); + $this->assertArrayHasKey('clientSecret', $response['body']); + $this->assertSame('', $response['body']['clientSecret']); + } + + public function testGetOAuth2ProviderClientSecretWriteOnly(): void + { + $this->updateOAuth2($this->plainProvider, [ + 'clientId' => 'amzn1.application-oa2-client.getSecretCheck', + 'clientSecret' => 'must-never-be-returned', + 'enabled' => false, + ]); + + $response = $this->getOAuth2Provider($this->plainProvider); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('amzn1.application-oa2-client.getSecretCheck', $response['body']['clientId']); + $this->assertSame('', $response['body']['clientSecret']); + } + + public function testGetOAuth2ProviderMatchesListEntry(): void + { + $list = $this->listOAuth2Providers(); + $this->assertSame(200, $list['headers']['status-code']); + + $byId = []; + foreach ($list['body']['providers'] as $provider) { + $byId[$provider['$id']] = $provider; + } + + // Match GET against LIST for one provider per shape. + foreach (['github', 'amazon', 'dropbox', 'gitlab', 'apple', 'oidc', 'microsoft'] as $providerId) { + $get = $this->getOAuth2Provider($providerId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertArrayHasKey($providerId, $byId, "{$providerId} missing from list"); + $this->assertSame($byId[$providerId], $get['body']); + } + } + + public function testGetOAuth2ProviderUnsupported(): void + { + $response = $this->getOAuth2Provider('not-a-real-provider'); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_provider_unsupported', $response['body']['type']); + } + + public function testGetOAuth2ProviderWithoutAuthentication(): void + { + $response = $this->getOAuth2Provider('github', authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Update plain provider (Amazon — clientId + clientSecret, no extra fields) + // ========================================================================= + + public function testUpdateOAuth2Plain(): void + { + $response = $this->updateOAuth2($this->plainProvider, [ + 'clientId' => 'amzn1.application-oa2-client.test01', + 'clientSecret' => 'test-secret-01', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($this->plainProvider, $response['body']['$id']); + $this->assertSame('amzn1.application-oa2-client.test01', $response['body']['clientId']); + $this->assertSame(false, $response['body']['enabled']); + } + + public function testUpdateOAuth2PlainEnable(): void + { + // Amazon has no verifyCredentials() hook, so enabling with arbitrary + // credentials succeeds without making a real network call. + $response = $this->updateOAuth2($this->plainProvider, [ + 'clientId' => 'amzn1.application-oa2-client.test02', + 'clientSecret' => 'test-secret-02', + 'enabled' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['enabled']); + } + + public function testUpdateOAuth2PlainDisable(): void + { + $this->updateOAuth2($this->plainProvider, [ + 'clientId' => 'amzn1.application-oa2-client.test03', + 'clientSecret' => 'test-secret-03', + 'enabled' => true, + ]); + + $response = $this->updateOAuth2($this->plainProvider, [ + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['enabled']); + // Credentials persist across an enabled toggle. + $this->assertSame('amzn1.application-oa2-client.test03', $response['body']['clientId']); + } + + public function testUpdateOAuth2PlainPartial(): void + { + // Seed both credentials. + $this->updateOAuth2($this->plainProvider, [ + 'clientId' => 'seed-client-id', + 'clientSecret' => 'seed-secret', + 'enabled' => false, + ]); + + // Patch only clientId. + $response = $this->updateOAuth2($this->plainProvider, [ + 'clientId' => 'updated-client-id', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('updated-client-id', $response['body']['clientId']); + + // Read back through GET to confirm the secret is still set internally + // (write-only, so we cannot inspect the value, but enabling should still + // succeed because the secret remains non-empty). + $enable = $this->updateOAuth2($this->plainProvider, [ + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertSame(true, $enable['body']['enabled']); + } + + public function testUpdateOAuth2PlainEnableRequiresCredentials(): void + { + // Start from a clean state with no credentials. + $this->updateOAuth2($this->plainProvider, [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2($this->plainProvider, [ + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2PlainEnabledOmittedDoesNotThrow(): void + { + // With enabled omitted (null) and no credentials, the silent-validation + // branch must not surface as an error. + $this->updateOAuth2($this->plainProvider, [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2($this->plainProvider, [ + 'clientId' => 'partial-only', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['enabled']); + $this->assertSame('partial-only', $response['body']['clientId']); + } + + public function testUpdateOAuth2PlainResponseModel(): void + { + $response = $this->updateOAuth2($this->plainProvider, [ + 'clientId' => 'amzn1.application-oa2-client.modelCheck', + 'clientSecret' => 'model-check-secret', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('enabled', $response['body']); + $this->assertArrayHasKey('clientId', $response['body']); + $this->assertArrayHasKey('clientSecret', $response['body']); + } + + public function testUpdateOAuth2WithoutAuthentication(): void + { + $response = $this->updateOAuth2($this->plainProvider, [ + 'clientId' => 'no-auth', + 'clientSecret' => 'no-auth', + 'enabled' => false, + ], authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateOAuth2UnknownProvider(): void + { + // Each Update endpoint is registered at a fixed `/oauth2/{providerId}` + // path, so an unknown provider does not match any route → 404. + $response = $this->updateOAuth2('not-a-real-provider', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'enabled' => false, + ]); + + $this->assertSame(404, $response['headers']['status-code']); + } + + public function testUpdateOAuth2InvalidEnabled(): void + { + $response = $this->updateOAuth2($this->plainProvider, [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // ========================================================================= + // Update GitHub (verifyCredentials makes a real call to GitHub on enable) + // ========================================================================= + + public function testUpdateOAuth2GitHubInvalidCredentialsRejected(): void + { + // GitHub is the only provider with a real verifyCredentials() hook. + // Enabling with bogus credentials must surface a 400 from the wrapping + // exception, not silently succeed. + $response = $this->updateOAuth2('github', [ + 'clientId' => 'fake-client-id-' . \uniqid(), + 'clientSecret' => 'fake-client-secret', + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup: ensure it's left disabled. + $this->updateOAuth2('github', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2GitHubInvalidCredentialsSilentWhenNotEnabling(): void + { + // When `enabled` is omitted, verifyCredentials() failure is swallowed. + // The provider remains disabled but the request succeeds. + $response = $this->updateOAuth2('github', [ + 'clientId' => 'still-fake-' . \uniqid(), + 'clientSecret' => 'still-fake-secret', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['enabled']); + + // Cleanup + $this->updateOAuth2('github', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Apple (serviceId + keyId + teamId + p8File) + // ========================================================================= + + public function testUpdateOAuth2Apple(): void + { + $response = $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.web', + 'keyId' => 'P4000000N8', + 'teamId' => 'D4000000R6', + 'p8File' => '-----BEGIN PRIVATE KEY-----TEST-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('apple', $response['body']['$id']); + $this->assertSame('ip.appwrite.app.web', $response['body']['serviceId']); + $this->assertSame('P4000000N8', $response['body']['keyId']); + $this->assertSame('D4000000R6', $response['body']['teamId']); + $this->assertSame(false, $response['body']['enabled']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2ApplePartial(): void + { + // Seed all four fields. + $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.seed', + 'keyId' => 'KEYSEED01', + 'teamId' => 'TEAMSEED01', + 'p8File' => '-----BEGIN PRIVATE KEY-----SEED-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + // Patch only `keyId` — others must be preserved. + $response = $this->updateOAuth2('apple', [ + 'keyId' => 'KEYUPDATED', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('KEYUPDATED', $response['body']['keyId']); + $this->assertSame('TEAMSEED01', $response['body']['teamId']); + $this->assertSame('ip.appwrite.app.seed', $response['body']['serviceId']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2AppleResponseModel(): void + { + $response = $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.shape', + 'keyId' => 'SHAPEKEY01', + 'teamId' => 'SHAPETEAM', + 'p8File' => '-----BEGIN PRIVATE KEY-----SHAPE-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('enabled', $response['body']); + $this->assertArrayHasKey('serviceId', $response['body']); + $this->assertArrayHasKey('keyId', $response['body']); + $this->assertArrayHasKey('teamId', $response['body']); + $this->assertArrayHasKey('p8File', $response['body']); + // Apple has no clientId/clientSecret in the response model. + $this->assertArrayNotHasKey('clientId', $response['body']); + $this->assertArrayNotHasKey('clientSecret', $response['body']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + public function testGetOAuth2AppleSecretsWriteOnly(): void + { + $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.read', + 'keyId' => 'KEYREAD', + 'teamId' => 'TEAMREAD', + 'p8File' => '-----BEGIN PRIVATE KEY-----READ-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + $response = $this->getOAuth2Provider('apple'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('ip.appwrite.app.read', $response['body']['serviceId']); + // All three secret-bearing fields must be hidden on read. + $this->assertSame('', $response['body']['keyId']); + $this->assertSame('', $response['body']['teamId']); + $this->assertSame('', $response['body']['p8File']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Auth0 (clientId + clientSecret + optional endpoint) + // ========================================================================= + + public function testUpdateOAuth2Auth0(): void + { + $response = $this->updateOAuth2('auth0', [ + 'clientId' => 'OaOkIA000000000000000000005KLSYq', + 'clientSecret' => 'auth0-test-secret', + 'endpoint' => 'example.us.auth0.com', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('auth0', $response['body']['$id']); + $this->assertSame('OaOkIA000000000000000000005KLSYq', $response['body']['clientId']); + $this->assertSame('example.us.auth0.com', $response['body']['endpoint']); + + // Cleanup + $this->updateOAuth2('auth0', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2Auth0PartialEndpoint(): void + { + // Seed clientSecret + endpoint. + $this->updateOAuth2('auth0', [ + 'clientId' => 'auth0-seed-client', + 'clientSecret' => 'auth0-seed-secret', + 'endpoint' => 'seed.us.auth0.com', + 'enabled' => false, + ]); + + // Update only endpoint. + $response = $this->updateOAuth2('auth0', [ + 'endpoint' => 'updated.us.auth0.com', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('updated.us.auth0.com', $response['body']['endpoint']); + // clientId is unchanged on top-level provider state. + $this->assertSame('auth0-seed-client', $response['body']['clientId']); + + // Cleanup + $this->updateOAuth2('auth0', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Authentik (clientId + clientSecret + REQUIRED endpoint) + // ========================================================================= + + public function testUpdateOAuth2AuthentikRequiresEndpoint(): void + { + // The `endpoint` param is required (Text(min=1)); omitting → 400. + $response = $this->updateOAuth2('authentik', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateOAuth2Authentik(): void + { + $response = $this->updateOAuth2('authentik', [ + 'clientId' => 'dTKOPa0000000000000000000000000000e7G8hv', + 'clientSecret' => 'authentik-secret', + 'endpoint' => 'example.authentik.com', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('authentik', $response['body']['$id']); + $this->assertSame('dTKOPa0000000000000000000000000000e7G8hv', $response['body']['clientId']); + $this->assertSame('example.authentik.com', $response['body']['endpoint']); + + // Cleanup + $this->updateOAuth2('authentik', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.authentik.com', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Microsoft (applicationId + applicationSecret + REQUIRED tenant) + // ========================================================================= + + public function testUpdateOAuth2MicrosoftRequiresTenant(): void + { + $response = $this->updateOAuth2('microsoft', [ + 'applicationId' => 'whatever', + 'applicationSecret' => 'whatever', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateOAuth2Microsoft(): void + { + $response = $this->updateOAuth2('microsoft', [ + 'applicationId' => '00001111-aaaa-2222-bbbb-3333cccc4444', + 'applicationSecret' => 'A1bC2dE3fH4iJ5kL6mN7oP8qR9sT0u', + 'tenant' => 'common', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('microsoft', $response['body']['$id']); + $this->assertSame('00001111-aaaa-2222-bbbb-3333cccc4444', $response['body']['applicationId']); + $this->assertSame('common', $response['body']['tenant']); + // Custom param names: applicationId/applicationSecret, not clientId/clientSecret. + $this->assertArrayNotHasKey('clientId', $response['body']); + $this->assertArrayNotHasKey('clientSecret', $response['body']); + + // Cleanup + $this->updateOAuth2('microsoft', [ + 'applicationId' => '', + 'applicationSecret' => '', + 'tenant' => 'common', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2MicrosoftPartialPreservesSecret(): void + { + // Seed full credentials. + $this->updateOAuth2('microsoft', [ + 'applicationId' => 'seed-app-id', + 'applicationSecret' => 'seed-app-secret', + 'tenant' => 'common', + 'enabled' => false, + ]); + + // Patch with only `tenant` (it's required on every call) and a new + // applicationId, leaving applicationSecret omitted. The stored secret + // must not be wiped. + $response = $this->updateOAuth2('microsoft', [ + 'applicationId' => 'updated-app-id', + 'tenant' => 'organizations', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('updated-app-id', $response['body']['applicationId']); + $this->assertSame('organizations', $response['body']['tenant']); + + // Cleanup + $this->updateOAuth2('microsoft', [ + 'applicationId' => '', + 'applicationSecret' => '', + 'tenant' => 'common', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Gitlab (applicationId + secret + optional endpoint, custom names) + // ========================================================================= + + public function testUpdateOAuth2Gitlab(): void + { + $response = $this->updateOAuth2('gitlab', [ + 'applicationId' => 'd41ffe0000000000000000000000000000000000000000000000000000d5e252', + 'secret' => 'gloas-838cfa00', + 'endpoint' => 'https://gitlab.example.com', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('gitlab', $response['body']['$id']); + $this->assertSame('d41ffe0000000000000000000000000000000000000000000000000000d5e252', $response['body']['applicationId']); + $this->assertSame('https://gitlab.example.com', $response['body']['endpoint']); + // Custom names — the response model exposes `applicationId`/`secret`. + $this->assertArrayNotHasKey('clientId', $response['body']); + $this->assertArrayNotHasKey('clientSecret', $response['body']); + + // Cleanup + $this->updateOAuth2('gitlab', [ + 'applicationId' => '', + 'secret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2GitlabInvalidEndpoint(): void + { + $response = $this->updateOAuth2('gitlab', [ + 'applicationId' => 'whatever', + 'secret' => 'whatever', + 'endpoint' => 'not a url', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateOAuth2GitlabPartialEndpoint(): void + { + $this->updateOAuth2('gitlab', [ + 'applicationId' => 'gitlab-seed-app', + 'secret' => 'gitlab-seed-secret', + 'endpoint' => 'https://seed.gitlab.com', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('gitlab', [ + 'endpoint' => 'https://updated.gitlab.com', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('https://updated.gitlab.com', $response['body']['endpoint']); + $this->assertSame('gitlab-seed-app', $response['body']['applicationId']); + + // Cleanup + $this->updateOAuth2('gitlab', [ + 'applicationId' => '', + 'secret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update OIDC (clientId + secret + wellKnownURL or 3 discovery URLs) + // ========================================================================= + + public function testUpdateOAuth2OidcWithWellKnown(): void + { + $response = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-client', + 'clientSecret' => 'oidc-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('https://idp.example.com/.well-known/openid-configuration', $response['body']['wellKnownURL']); + $this->assertArrayHasKey('authorizationURL', $response['body']); + $this->assertArrayHasKey('tokenUrl', $response['body']); + $this->assertArrayHasKey('userInfoUrl', $response['body']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcWithDiscoveryURLs(): void + { + $response = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-discovery', + 'clientSecret' => 'oidc-discovery-secret', + 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'userInfoUrl' => 'https://idp.example.com/oauth2/userinfo', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('https://idp.example.com/oauth2/authorize', $response['body']['authorizationURL']); + $this->assertSame('https://idp.example.com/oauth2/token', $response['body']['tokenUrl']); + $this->assertSame('https://idp.example.com/oauth2/userinfo', $response['body']['userInfoUrl']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnableMissingURLs(): void + { + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-no-urls', + 'clientSecret' => 'oidc-no-urls', + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnablePartialDiscoveryFails(): void + { + // Only authorization+token, missing userInfo — must fail to enable. + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-partial', + 'clientSecret' => 'oidc-partial-secret', + 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Okta (clientId + clientSecret + optional domain/authServer) + // ========================================================================= + + public function testUpdateOAuth2Okta(): void + { + $response = $this->updateOAuth2('okta', [ + 'clientId' => '0oa00000000000000698', + 'clientSecret' => 'okta-secret', + 'domain' => 'trial-6400025.okta.com', + 'authorizationServerId' => 'aus000000000000000h7z', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('okta', $response['body']['$id']); + $this->assertSame('0oa00000000000000698', $response['body']['clientId']); + $this->assertSame('trial-6400025.okta.com', $response['body']['domain']); + $this->assertSame('aus000000000000000h7z', $response['body']['authorizationServerId']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'authorizationServerId' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OktaInvalidDomain(): void + { + $response = $this->updateOAuth2('okta', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'domain' => 'https://trial-6400025.okta.com/', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateOAuth2OktaEnableRequiresDomain(): void + { + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'authorizationServerId' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('okta', [ + 'clientId' => 'okta-no-domain', + 'clientSecret' => 'okta-no-domain-secret', + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Dropbox (custom param names: appKey + appSecret) + // ========================================================================= + + public function testUpdateOAuth2DropboxFieldNames(): void + { + $response = $this->updateOAuth2('dropbox', [ + 'appKey' => 'jl000000000009t', + 'appSecret' => 'g200000000000vw', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('dropbox', $response['body']['$id']); + $this->assertSame('jl000000000009t', $response['body']['appKey']); + $this->assertArrayHasKey('appSecret', $response['body']); + $this->assertArrayNotHasKey('clientId', $response['body']); + $this->assertArrayNotHasKey('clientSecret', $response['body']); + + // GET enforces write-only on the secret regardless of the custom name. + $get = $this->getOAuth2Provider('dropbox'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('jl000000000009t', $get['body']['appKey']); + $this->assertSame('', $get['body']['appSecret']); + + // Cleanup + $this->updateOAuth2('dropbox', [ + 'appKey' => '', + 'appSecret' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Paypal Sandbox (inherits from Paypal — independent provider ID) + // ========================================================================= + + public function testUpdateOAuth2PaypalSandbox(): void + { + $response = $this->updateOAuth2('paypalSandbox', [ + 'clientId' => 'paypal-sandbox-client', + 'clientSecret' => 'paypal-sandbox-secret', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('paypalSandbox', $response['body']['$id']); + $this->assertSame('paypal-sandbox-client', $response['body']['clientId']); + + // Sandbox is independent of the regular paypal entry. + $regular = $this->getOAuth2Provider('paypal'); + $this->assertSame(200, $regular['headers']['status-code']); + $this->assertSame('paypal', $regular['body']['$id']); + $this->assertNotSame('paypal-sandbox-client', $regular['body']['clientId']); + + // Cleanup + $this->updateOAuth2('paypalSandbox', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * @param array $params + */ + protected function updateOAuth2(string $provider, array $params, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call( + Client::METHOD_PATCH, + '/project/oauth2/' . $provider, + $headers, + $params, + ); + } + + protected function getOAuth2Provider(string $provider, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call( + Client::METHOD_GET, + '/project/oauth2/' . $provider, + $headers, + ); + } + + protected function listOAuth2Providers(bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call( + Client::METHOD_GET, + '/project/oauth2', + $headers, + ); + } } From 4ba413fcc0eb2528c67d19a2ad04b31dbf11dff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 16:50:14 +0200 Subject: [PATCH 302/402] Fix bugs when implementing tests --- .../Project/Http/Project/OAuth2/Apple/Update.php | 10 +++++++--- .../Project/Http/Project/OAuth2/Auth0/Update.php | 10 +++++++--- .../Project/Http/Project/OAuth2/Authentik/Update.php | 10 +++++++--- .../Modules/Project/Http/Project/OAuth2/Base.php | 11 ++++++++--- .../Project/Http/Project/OAuth2/Gitlab/Update.php | 10 +++++++--- .../Project/Http/Project/OAuth2/Microsoft/Update.php | 10 +++++++--- .../Project/Http/Project/OAuth2/Oidc/Update.php | 10 +++++++--- .../Project/Http/Project/OAuth2/Okta/Update.php | 10 +++++++--- .../Platform/Modules/Project/Services/Http.php | 2 ++ src/Appwrite/Utopia/Response/Model/OAuth2Amazon.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Apple.php | 10 ++++++++++ src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php | 4 ++++ .../Utopia/Response/Model/OAuth2Authentik.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Base.php | 6 ++++++ .../Utopia/Response/Model/OAuth2Bitbucket.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Bitly.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Box.php | 4 ++++ .../Utopia/Response/Model/OAuth2Dailymotion.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Discord.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Disqus.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Etsy.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Facebook.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Figma.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Google.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Kick.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php | 4 ++++ .../Utopia/Response/Model/OAuth2Microsoft.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Notion.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Okta.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Podio.php | 4 ++++ .../Utopia/Response/Model/OAuth2Salesforce.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Slack.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Spotify.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Stripe.php | 4 ++++ .../Utopia/Response/Model/OAuth2Tradeshift.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Twitch.php | 4 ++++ .../Utopia/Response/Model/OAuth2WordPress.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2X.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Yahoo.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Yandex.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Zoho.php | 4 ++++ src/Appwrite/Utopia/Response/Model/OAuth2Zoom.php | 4 ++++ 48 files changed, 223 insertions(+), 24 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php index 4f8437ce8d..79a30e02d4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Apple; use Appwrite\Auth\OAuth2\Apple; +use Appwrite\Event\Event as QueueEvent; use Appwrite\Platform\Action; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base; use Appwrite\SDK\AuthType; @@ -71,8 +72,8 @@ class Update extends Base ->desc('Update project OAuth2 ' . $providerLabel) ->groups(['api', 'project']) ->label('scope', 'oauth2.write') - ->label('event', 'oauth2.' . $providerId . '.update') - ->label('audits.event', 'project.oauth2.' . $providerId . '.update') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') ->label('audits.resource', 'project.oauth2/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -96,6 +97,7 @@ class Update extends Base ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->handle(...)); } @@ -130,9 +132,11 @@ class Update extends Base Response $response, Database $dbForPlatform, Document $project, - Authorization $authorization + Authorization $authorization, + QueueEvent $queueForEvents ): void { $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); // The secret is stored as JSON `{"p8": "...", "keyID": "...", "teamID": "..."}` // to match the shape Apple's OAuth2 adapter expects in getAppSecret(). diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php index 1bbdd02a0d..4cb314af13 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Auth0; use Appwrite\Auth\OAuth2\Auth0; +use Appwrite\Event\Event as QueueEvent; use Appwrite\Platform\Action; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base; use Appwrite\SDK\AuthType; @@ -64,8 +65,8 @@ class Update extends Base ->desc('Update project OAuth2 ' . $providerLabel) ->groups(['api', 'project']) ->label('scope', 'oauth2.write') - ->label('event', 'oauth2.' . $providerId . '.update') - ->label('audits.event', 'project.oauth2.' . $providerId . '.update') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') ->label('audits.resource', 'project.oauth2/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -88,6 +89,7 @@ class Update extends Base ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->handle(...)); } @@ -119,9 +121,11 @@ class Update extends Base Response $response, Database $dbForPlatform, Document $project, - Authorization $authorization + Authorization $authorization, + QueueEvent $queueForEvents ): void { $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); // The secret is stored as JSON `{"clientSecret": "...", "auth0Domain": "..."}` // to match the shape Auth0's OAuth2 adapter expects (getAuth0Domain()). diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php index 62e314053a..834a68597a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Authentik; use Appwrite\Auth\OAuth2\Authentik; +use Appwrite\Event\Event as QueueEvent; use Appwrite\Platform\Action; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base; use Appwrite\SDK\AuthType; @@ -64,8 +65,8 @@ class Update extends Base ->desc('Update project OAuth2 ' . $providerLabel) ->groups(['api', 'project']) ->label('scope', 'oauth2.write') - ->label('event', 'oauth2.' . $providerId . '.update') - ->label('audits.event', 'project.oauth2.' . $providerId . '.update') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') ->label('audits.resource', 'project.oauth2/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -88,6 +89,7 @@ class Update extends Base ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->handle(...)); } @@ -119,9 +121,11 @@ class Update extends Base Response $response, Database $dbForPlatform, Document $project, - Authorization $authorization + Authorization $authorization, + QueueEvent $queueForEvents ): void { $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); // The secret is stored as JSON `{"clientSecret": "...", "authentikDomain": "..."}` // to match the shape Authentik's OAuth2 adapter expects (getAuthentikDomain()). diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php index ddaac7c602..50531d647f 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2; +use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; @@ -111,8 +112,8 @@ abstract class Base extends Action ->desc('Update project OAuth2 ' . $providerLabel) ->groups(['api', 'project']) ->label('scope', 'oauth2.write') - ->label('event', 'oauth2.' . $providerId . '.update') - ->label('audits.event', 'project.oauth2.' . $providerId . '.update') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') ->label('audits.resource', 'project.oauth2/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -134,6 +135,7 @@ abstract class Base extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -304,13 +306,16 @@ abstract class Base extends Action Response $response, Database $dbForPlatform, Document $project, - Authorization $authorization + Authorization $authorization, + QueueEvent $queueForEvents ): void { $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $clientSecret, $enabled); $providerId = static::getProviderId(); $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $queueForEvents->setParam('providerId', $providerId); + $response->dynamic(new Document([ '$id' => $providerId, 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php index 8d4f4e88da..a727f3f3a4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Gitlab; use Appwrite\Auth\OAuth2\Gitlab; +use Appwrite\Event\Event as QueueEvent; use Appwrite\Platform\Action; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base; use Appwrite\SDK\AuthType; @@ -75,8 +76,8 @@ class Update extends Base ->desc('Update project OAuth2 ' . $providerLabel) ->groups(['api', 'project']) ->label('scope', 'oauth2.write') - ->label('event', 'oauth2.' . $providerId . '.update') - ->label('audits.event', 'project.oauth2.' . $providerId . '.update') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') ->label('audits.resource', 'project.oauth2/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -99,6 +100,7 @@ class Update extends Base ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->handle(...)); } @@ -130,9 +132,11 @@ class Update extends Base Response $response, Database $dbForPlatform, Document $project, - Authorization $authorization + Authorization $authorization, + QueueEvent $queueForEvents ): void { $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); // The secret is stored as JSON `{"clientSecret": "...", "endpoint": "..."}` // so that the Gitlab OAuth2 adapter can extract the endpoint via getEndpoint(). diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php index 60479cf5f5..894631fbaa 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Microsoft; use Appwrite\Auth\OAuth2\Microsoft; +use Appwrite\Event\Event as QueueEvent; use Appwrite\Platform\Action; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base; use Appwrite\SDK\AuthType; @@ -74,8 +75,8 @@ class Update extends Base ->desc('Update project OAuth2 ' . $providerLabel) ->groups(['api', 'project']) ->label('scope', 'oauth2.write') - ->label('event', 'oauth2.' . $providerId . '.update') - ->label('audits.event', 'project.oauth2.' . $providerId . '.update') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') ->label('audits.resource', 'project.oauth2/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -98,6 +99,7 @@ class Update extends Base ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->handle(...)); } @@ -129,9 +131,11 @@ class Update extends Base Response $response, Database $dbForPlatform, Document $project, - Authorization $authorization + Authorization $authorization, + QueueEvent $queueForEvents ): void { $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); // The secret is stored as JSON `{"clientSecret": "...", "tenantID": "..."}` // to match the shape Microsoft's OAuth2 adapter expects (getTenantID()). diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php index d849e18efd..f950c78b13 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Oidc; use Appwrite\Auth\OAuth2\Oidc; +use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; use Appwrite\Platform\Action; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base; @@ -66,8 +67,8 @@ class Update extends Base ->desc('Update project OAuth2 ' . $providerLabel) ->groups(['api', 'project']) ->label('scope', 'oauth2.write') - ->label('event', 'oauth2.' . $providerId . '.update') - ->label('audits.event', 'project.oauth2.' . $providerId . '.update') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') ->label('audits.resource', 'project.oauth2/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -93,6 +94,7 @@ class Update extends Base ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->handle(...)); } @@ -138,9 +140,11 @@ class Update extends Base Response $response, Database $dbForPlatform, Document $project, - Authorization $authorization + Authorization $authorization, + QueueEvent $queueForEvents ): void { $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); // The secret is stored as JSON // `{"clientSecret": "...", "wellKnownEndpoint": "...", "authorizationEndpoint": "...", "tokenEndpoint": "...", "userInfoEndpoint": "..."}` diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php index 47d6cb2add..1aef7684be 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Okta; use Appwrite\Auth\OAuth2\Okta; +use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; use Appwrite\Platform\Action; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base; @@ -66,8 +67,8 @@ class Update extends Base ->desc('Update project OAuth2 ' . $providerLabel) ->groups(['api', 'project']) ->label('scope', 'oauth2.write') - ->label('event', 'oauth2.' . $providerId . '.update') - ->label('audits.event', 'project.oauth2.' . $providerId . '.update') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') ->label('audits.resource', 'project.oauth2/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -91,6 +92,7 @@ class Update extends Base ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->handle(...)); } @@ -125,9 +127,11 @@ class Update extends Base Response $response, Database $dbForPlatform, Document $project, - Authorization $authorization + Authorization $authorization, + QueueEvent $queueForEvents ): void { $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); // The secret is stored as JSON `{"clientSecret": "...", "oktaDomain": "...", "authorizationServerId": "..."}` // to match the shape Okta's OAuth2 adapter expects. diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 8a330ca041..d6ff3c4925 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -17,6 +17,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Get as GetMockPhone use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Update as UpdateMockPhone; use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\XList as ListMockPhones; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Amazon\Update as UpdateOAuth2Amazon; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Apple\Update as UpdateOAuth2Apple; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Auth0\Update as UpdateOAuth2Auth0; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Authentik\Update as UpdateOAuth2Authentik; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Autodesk\Update as UpdateOAuth2Autodesk; @@ -212,6 +213,7 @@ class Http extends Service $this->addAction(UpdateOAuth2Oidc::getName(), new UpdateOAuth2Oidc()); $this->addAction(UpdateOAuth2Okta::getName(), new UpdateOAuth2Okta()); $this->addAction(UpdateOAuth2Kick::getName(), new UpdateOAuth2Kick()); + $this->addAction(UpdateOAuth2Apple::getName(), new UpdateOAuth2Apple()); $this->addAction(UpdateOAuth2Microsoft::getName(), new UpdateOAuth2Microsoft()); } } diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Amazon.php b/src/Appwrite/Utopia/Response/Model/OAuth2Amazon.php index 33708374cc..f6c935648d 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Amazon.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Amazon.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Amazon extends OAuth2Base { + public array $conditions = [ + '$id' => 'amazon', + ]; + public function getProviderLabel(): string { return 'Amazon'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php b/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php index 080925e6d8..075494b8ef 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Apple extends OAuth2Base { + public array $conditions = [ + '$id' => 'apple', + ]; + public function getProviderLabel(): string { return 'Apple'; @@ -39,6 +43,12 @@ class OAuth2Apple extends OAuth2Base // contents, Key ID, Team ID) instead of a single clientSecret, so the // rules are defined manually rather than delegating to OAuth2Base. $this + ->addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'OAuth2 provider ID.', + 'default' => '', + 'example' => 'apple', + ]) ->addRule('enabled', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'OAuth2 provider is active and can be used to create sessions.', diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php b/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php index 2f1893f4d5..6e83b1b05b 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Auth0 extends OAuth2Base { + public array $conditions = [ + '$id' => 'auth0', + ]; + public function getProviderLabel(): string { return 'Auth0'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php b/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php index 4e67e1f4fe..db192ea24b 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Authentik extends OAuth2Base { + public array $conditions = [ + '$id' => 'authentik', + ]; + public function getProviderLabel(): string { return 'Authentik'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php b/src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php index 6f55b5d475..3317f15bec 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Autodesk extends OAuth2Base { + public array $conditions = [ + '$id' => 'autodesk', + ]; + public function getProviderLabel(): string { return 'Autodesk'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Base.php b/src/Appwrite/Utopia/Response/Model/OAuth2Base.php index 8eb8d0f4cb..058afc0fa1 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Base.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Base.php @@ -97,6 +97,12 @@ abstract class OAuth2Base extends Model public function __construct() { $this + ->addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'OAuth2 provider ID.', + 'default' => '', + 'example' => 'github', + ]) ->addRule('enabled', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'OAuth2 provider is active and can be used to create sessions.', diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Bitbucket.php b/src/Appwrite/Utopia/Response/Model/OAuth2Bitbucket.php index 3465cb6cd7..870cd0bda3 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Bitbucket.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Bitbucket.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Bitbucket extends OAuth2Base { + public array $conditions = [ + '$id' => 'bitbucket', + ]; + public function getProviderLabel(): string { return 'Bitbucket'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Bitly.php b/src/Appwrite/Utopia/Response/Model/OAuth2Bitly.php index e32d089898..6a27176d3d 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Bitly.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Bitly.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Bitly extends OAuth2Base { + public array $conditions = [ + '$id' => 'bitly', + ]; + public function getProviderLabel(): string { return 'Bitly'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Box.php b/src/Appwrite/Utopia/Response/Model/OAuth2Box.php index 6c23c0d3ad..9bbfd6021f 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Box.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Box.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Box extends OAuth2Base { + public array $conditions = [ + '$id' => 'box', + ]; + public function getProviderLabel(): string { return 'Box'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Dailymotion.php b/src/Appwrite/Utopia/Response/Model/OAuth2Dailymotion.php index 0e149c986c..6c3d0eba95 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Dailymotion.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Dailymotion.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Dailymotion extends OAuth2Base { + public array $conditions = [ + '$id' => 'dailymotion', + ]; + public function getProviderLabel(): string { return 'Dailymotion'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php b/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php index da7c4873b5..6ac72ad8e4 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Discord extends OAuth2Base { + public array $conditions = [ + '$id' => 'discord', + ]; + public function getProviderLabel(): string { return 'Discord'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Disqus.php b/src/Appwrite/Utopia/Response/Model/OAuth2Disqus.php index dbdc973b65..bec78ed189 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Disqus.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Disqus.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Disqus extends OAuth2Base { + public array $conditions = [ + '$id' => 'disqus', + ]; + public function getProviderLabel(): string { return 'Disqus'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php b/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php index 4924db1397..db7285fd47 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Dropbox extends OAuth2Base { + public array $conditions = [ + '$id' => 'dropbox', + ]; + public function getProviderLabel(): string { return 'Dropbox'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Etsy.php b/src/Appwrite/Utopia/Response/Model/OAuth2Etsy.php index f80cce7cf1..be12e4c51c 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Etsy.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Etsy.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Etsy extends OAuth2Base { + public array $conditions = [ + '$id' => 'etsy', + ]; + public function getProviderLabel(): string { return 'Etsy'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Facebook.php b/src/Appwrite/Utopia/Response/Model/OAuth2Facebook.php index 8bec9b9bf8..9ad14bdb2a 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Facebook.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Facebook.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Facebook extends OAuth2Base { + public array $conditions = [ + '$id' => 'facebook', + ]; + public function getProviderLabel(): string { return 'Facebook'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php b/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php index 533d353d01..9339257e5b 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Figma extends OAuth2Base { + public array $conditions = [ + '$id' => 'figma', + ]; + public function getProviderLabel(): string { return 'Figma'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php b/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php index 30d3a71187..2f975f16e4 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2GitHub extends OAuth2Base { + public array $conditions = [ + '$id' => 'github', + ]; + public function getProviderLabel(): string { return 'GitHub'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php b/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php index 41c91acfe8..39c148caec 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Gitlab extends OAuth2Base { + public array $conditions = [ + '$id' => 'gitlab', + ]; + public function getProviderLabel(): string { return 'GitLab'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Google.php b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php index 109060b7bd..3dbc892631 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Google.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Google extends OAuth2Base { + public array $conditions = [ + '$id' => 'google', + ]; + public function getProviderLabel(): string { return 'Google'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php b/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php index e4692ac6ea..2f5814f1d3 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Kick extends OAuth2Base { + public array $conditions = [ + '$id' => 'kick', + ]; + public function getProviderLabel(): string { return 'Kick'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php b/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php index ccfec9d523..99f8bfa8f7 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Linkedin extends OAuth2Base { + public array $conditions = [ + '$id' => 'linkedin', + ]; + public function getProviderLabel(): string { return 'LinkedIn'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php b/src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php index 30cd8da2f5..b7004fdb85 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Microsoft extends OAuth2Base { + public array $conditions = [ + '$id' => 'microsoft', + ]; + public function getProviderLabel(): string { return 'Microsoft'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Notion.php b/src/Appwrite/Utopia/Response/Model/OAuth2Notion.php index bb4260f672..8796ce603e 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Notion.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Notion.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Notion extends OAuth2Base { + public array $conditions = [ + '$id' => 'notion', + ]; + public function getProviderLabel(): string { return 'Notion'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php b/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php index 97a9ace5ad..e4f0919666 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Oidc extends OAuth2Base { + public array $conditions = [ + '$id' => 'oidc', + ]; + public function getProviderLabel(): string { return 'OpenID Connect'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php b/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php index f0926193d8..0804adfa1b 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Okta extends OAuth2Base { + public array $conditions = [ + '$id' => 'okta', + ]; + public function getProviderLabel(): string { return 'Okta'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php b/src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php index b8e836eedd..20ff9f9ba5 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Paypal extends OAuth2Base { + public array $conditions = [ + '$id' => ['paypal', 'paypalSandbox'], + ]; + public function getProviderLabel(): string { return 'PayPal'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Podio.php b/src/Appwrite/Utopia/Response/Model/OAuth2Podio.php index 429d1e666d..f588136a62 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Podio.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Podio.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Podio extends OAuth2Base { + public array $conditions = [ + '$id' => 'podio', + ]; + public function getProviderLabel(): string { return 'Podio'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Salesforce.php b/src/Appwrite/Utopia/Response/Model/OAuth2Salesforce.php index d880f87745..c76ddce854 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Salesforce.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Salesforce.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Salesforce extends OAuth2Base { + public array $conditions = [ + '$id' => 'salesforce', + ]; + public function getProviderLabel(): string { return 'Salesforce'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Slack.php b/src/Appwrite/Utopia/Response/Model/OAuth2Slack.php index d034cfa6af..47eb058816 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Slack.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Slack.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Slack extends OAuth2Base { + public array $conditions = [ + '$id' => 'slack', + ]; + public function getProviderLabel(): string { return 'Slack'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Spotify.php b/src/Appwrite/Utopia/Response/Model/OAuth2Spotify.php index 0aa6f131ce..3fdf9da659 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Spotify.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Spotify.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Spotify extends OAuth2Base { + public array $conditions = [ + '$id' => 'spotify', + ]; + public function getProviderLabel(): string { return 'Spotify'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Stripe.php b/src/Appwrite/Utopia/Response/Model/OAuth2Stripe.php index bcb2325521..98c7a88af7 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Stripe.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Stripe.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Stripe extends OAuth2Base { + public array $conditions = [ + '$id' => 'stripe', + ]; + public function getProviderLabel(): string { return 'Stripe'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php b/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php index dcf39cc8b0..8a790b31f8 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Tradeshift extends OAuth2Base { + public array $conditions = [ + '$id' => ['tradeshift', 'tradeshiftSandbox'], + ]; + public function getProviderLabel(): string { return 'Tradeshift'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Twitch.php b/src/Appwrite/Utopia/Response/Model/OAuth2Twitch.php index 320084493d..4b03b3d6cc 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Twitch.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Twitch.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Twitch extends OAuth2Base { + public array $conditions = [ + '$id' => 'twitch', + ]; + public function getProviderLabel(): string { return 'Twitch'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2WordPress.php b/src/Appwrite/Utopia/Response/Model/OAuth2WordPress.php index 099b5154e7..89df7a081e 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2WordPress.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2WordPress.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2WordPress extends OAuth2Base { + public array $conditions = [ + '$id' => 'wordpress', + ]; + public function getProviderLabel(): string { return 'WordPress'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2X.php b/src/Appwrite/Utopia/Response/Model/OAuth2X.php index 3e9303015a..2f36166c19 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2X.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2X.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2X extends OAuth2Base { + public array $conditions = [ + '$id' => 'x', + ]; + public function getProviderLabel(): string { return 'X'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Yahoo.php b/src/Appwrite/Utopia/Response/Model/OAuth2Yahoo.php index cc0e3ad1b8..0e3bc7b8a6 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Yahoo.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Yahoo.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Yahoo extends OAuth2Base { + public array $conditions = [ + '$id' => 'yahoo', + ]; + public function getProviderLabel(): string { return 'Yahoo'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Yandex.php b/src/Appwrite/Utopia/Response/Model/OAuth2Yandex.php index c720055e71..dd6b8a4486 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Yandex.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Yandex.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Yandex extends OAuth2Base { + public array $conditions = [ + '$id' => 'yandex', + ]; + public function getProviderLabel(): string { return 'Yandex'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Zoho.php b/src/Appwrite/Utopia/Response/Model/OAuth2Zoho.php index 67adcaae6d..abf9e98d9a 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Zoho.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Zoho.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Zoho extends OAuth2Base { + public array $conditions = [ + '$id' => 'zoho', + ]; + public function getProviderLabel(): string { return 'Zoho'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Zoom.php b/src/Appwrite/Utopia/Response/Model/OAuth2Zoom.php index dd87338b8b..d14fe6d0cf 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Zoom.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Zoom.php @@ -6,6 +6,10 @@ use Appwrite\Utopia\Response; class OAuth2Zoom extends OAuth2Base { + public array $conditions = [ + '$id' => 'zoom', + ]; + public function getProviderLabel(): string { return 'Zoom'; From 7a96b024b3e8b1544ddc19ca37db4a418b8fe411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 16:51:01 +0200 Subject: [PATCH 303/402] Fix tests --- tests/e2e/Services/Project/OAuth2Base.php | 177 ++++------------------ 1 file changed, 26 insertions(+), 151 deletions(-) diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php index 76f011e283..5e71f6f445 100644 --- a/tests/e2e/Services/Project/OAuth2Base.php +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -2,35 +2,10 @@ namespace Tests\E2E\Services\Project; -use PHPUnit\Framework\Attributes\Before; use Tests\E2E\Client; trait OAuth2Base { - /** - * Providers that follow the default `clientId` + `clientSecret` shape and - * have no extra required parameters. We use Amazon as the canonical sample - * for behavior tests because it has no `verifyCredentials()` hook, so we - * can freely enable/disable without making real network calls. - */ - protected static string $plainProvider = 'amazon'; - - /** - * Reset providers we mutate in tests back to a known empty/disabled state. - * The ProjectCustom trait reuses the same project across tests in a class, - * and the OAuth2 PATCH endpoint is additive (omitted fields are preserved), - * so without a reset state would leak between tests. - */ - #[Before(priority: -1)] - protected function resetProjectOAuth2(): void - { - $this->updateOAuth2($this->plainProvider, [ - 'clientId' => '', - 'clientSecret' => '', - 'enabled' => false, - ]); - } - // ========================================================================= // List OAuth2 providers // ========================================================================= @@ -93,7 +68,7 @@ trait OAuth2Base public function testListOAuth2ProvidersClientSecretsNotExposed(): void { // Seed credentials so the list cannot trivially return empty values. - $this->updateOAuth2($this->plainProvider, [ + $this->updateOAuth2('amazon', [ 'clientId' => 'amzn1.application-oa2-client.testListSeed', 'clientSecret' => 'super-secret-must-not-leak', 'enabled' => false, @@ -105,7 +80,7 @@ trait OAuth2Base $matched = false; foreach ($response['body']['providers'] as $provider) { - if ($provider['$id'] !== $this->plainProvider) { + if ($provider['$id'] !== 'amazon') { continue; } @@ -142,13 +117,13 @@ trait OAuth2Base public function testGetOAuth2ProviderClientSecretWriteOnly(): void { - $this->updateOAuth2($this->plainProvider, [ + $this->updateOAuth2('amazon', [ 'clientId' => 'amzn1.application-oa2-client.getSecretCheck', 'clientSecret' => 'must-never-be-returned', 'enabled' => false, ]); - $response = $this->getOAuth2Provider($this->plainProvider); + $response = $this->getOAuth2Provider('amazon'); $this->assertSame(200, $response['headers']['status-code']); $this->assertSame('amzn1.application-oa2-client.getSecretCheck', $response['body']['clientId']); @@ -195,14 +170,14 @@ trait OAuth2Base public function testUpdateOAuth2Plain(): void { - $response = $this->updateOAuth2($this->plainProvider, [ + $response = $this->updateOAuth2('amazon', [ 'clientId' => 'amzn1.application-oa2-client.test01', 'clientSecret' => 'test-secret-01', 'enabled' => false, ]); $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame($this->plainProvider, $response['body']['$id']); + $this->assertSame('amazon', $response['body']['$id']); $this->assertSame('amzn1.application-oa2-client.test01', $response['body']['clientId']); $this->assertSame(false, $response['body']['enabled']); } @@ -211,7 +186,7 @@ trait OAuth2Base { // Amazon has no verifyCredentials() hook, so enabling with arbitrary // credentials succeeds without making a real network call. - $response = $this->updateOAuth2($this->plainProvider, [ + $response = $this->updateOAuth2('amazon', [ 'clientId' => 'amzn1.application-oa2-client.test02', 'clientSecret' => 'test-secret-02', 'enabled' => true, @@ -223,13 +198,13 @@ trait OAuth2Base public function testUpdateOAuth2PlainDisable(): void { - $this->updateOAuth2($this->plainProvider, [ + $this->updateOAuth2('amazon', [ 'clientId' => 'amzn1.application-oa2-client.test03', 'clientSecret' => 'test-secret-03', 'enabled' => true, ]); - $response = $this->updateOAuth2($this->plainProvider, [ + $response = $this->updateOAuth2('amazon', [ 'enabled' => false, ]); @@ -242,14 +217,14 @@ trait OAuth2Base public function testUpdateOAuth2PlainPartial(): void { // Seed both credentials. - $this->updateOAuth2($this->plainProvider, [ + $this->updateOAuth2('amazon', [ 'clientId' => 'seed-client-id', 'clientSecret' => 'seed-secret', 'enabled' => false, ]); // Patch only clientId. - $response = $this->updateOAuth2($this->plainProvider, [ + $response = $this->updateOAuth2('amazon', [ 'clientId' => 'updated-client-id', ]); @@ -259,7 +234,7 @@ trait OAuth2Base // Read back through GET to confirm the secret is still set internally // (write-only, so we cannot inspect the value, but enabling should still // succeed because the secret remains non-empty). - $enable = $this->updateOAuth2($this->plainProvider, [ + $enable = $this->updateOAuth2('amazon', [ 'enabled' => true, ]); $this->assertSame(200, $enable['headers']['status-code']); @@ -269,13 +244,13 @@ trait OAuth2Base public function testUpdateOAuth2PlainEnableRequiresCredentials(): void { // Start from a clean state with no credentials. - $this->updateOAuth2($this->plainProvider, [ + $this->updateOAuth2('amazon', [ 'clientId' => '', 'clientSecret' => '', 'enabled' => false, ]); - $response = $this->updateOAuth2($this->plainProvider, [ + $response = $this->updateOAuth2('amazon', [ 'enabled' => true, ]); @@ -287,13 +262,13 @@ trait OAuth2Base { // With enabled omitted (null) and no credentials, the silent-validation // branch must not surface as an error. - $this->updateOAuth2($this->plainProvider, [ + $this->updateOAuth2('amazon', [ 'clientId' => '', 'clientSecret' => '', 'enabled' => false, ]); - $response = $this->updateOAuth2($this->plainProvider, [ + $response = $this->updateOAuth2('amazon', [ 'clientId' => 'partial-only', ]); @@ -304,7 +279,7 @@ trait OAuth2Base public function testUpdateOAuth2PlainResponseModel(): void { - $response = $this->updateOAuth2($this->plainProvider, [ + $response = $this->updateOAuth2('amazon', [ 'clientId' => 'amzn1.application-oa2-client.modelCheck', 'clientSecret' => 'model-check-secret', 'enabled' => false, @@ -319,7 +294,7 @@ trait OAuth2Base public function testUpdateOAuth2WithoutAuthentication(): void { - $response = $this->updateOAuth2($this->plainProvider, [ + $response = $this->updateOAuth2('amazon', [ 'clientId' => 'no-auth', 'clientSecret' => 'no-auth', 'enabled' => false, @@ -343,7 +318,7 @@ trait OAuth2Base public function testUpdateOAuth2InvalidEnabled(): void { - $response = $this->updateOAuth2($this->plainProvider, [ + $response = $this->updateOAuth2('amazon', [ 'enabled' => 'not-a-boolean', ]); @@ -704,11 +679,10 @@ trait OAuth2Base $this->assertArrayNotHasKey('clientId', $response['body']); $this->assertArrayNotHasKey('clientSecret', $response['body']); - // Cleanup + // Cleanup (endpoint is `Nullable(URL())`; URL rejects empty strings). $this->updateOAuth2('gitlab', [ 'applicationId' => '', 'secret' => '', - 'endpoint' => '', 'enabled' => false, ]); } @@ -741,11 +715,11 @@ trait OAuth2Base $this->assertSame('https://updated.gitlab.com', $response['body']['endpoint']); $this->assertSame('gitlab-seed-app', $response['body']['applicationId']); - // Cleanup + // Cleanup (endpoint is `Nullable(URL())` and URL rejects empty strings, + // so the endpoint persists past the test). $this->updateOAuth2('gitlab', [ 'applicationId' => '', 'secret' => '', - 'endpoint' => '', 'enabled' => false, ]); } @@ -769,14 +743,11 @@ trait OAuth2Base $this->assertArrayHasKey('tokenUrl', $response['body']); $this->assertArrayHasKey('userInfoUrl', $response['body']); - // Cleanup + // Cleanup (URL fields are `Nullable(URL())`; URL rejects empty strings, + // so the discovery URLs persist past the test). $this->updateOAuth2('oidc', [ 'clientId' => '', 'clientSecret' => '', - 'wellKnownURL' => '', - 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', 'enabled' => false, ]); } @@ -801,75 +772,6 @@ trait OAuth2Base $this->updateOAuth2('oidc', [ 'clientId' => '', 'clientSecret' => '', - 'wellKnownURL' => '', - 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', - 'enabled' => false, - ]); - } - - public function testUpdateOAuth2OidcEnableMissingURLs(): void - { - $this->updateOAuth2('oidc', [ - 'clientId' => '', - 'clientSecret' => '', - 'wellKnownURL' => '', - 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', - 'enabled' => false, - ]); - - $response = $this->updateOAuth2('oidc', [ - 'clientId' => 'oidc-no-urls', - 'clientSecret' => 'oidc-no-urls', - 'enabled' => true, - ]); - - $this->assertSame(400, $response['headers']['status-code']); - $this->assertSame('general_argument_invalid', $response['body']['type']); - - // Cleanup - $this->updateOAuth2('oidc', [ - 'clientId' => '', - 'clientSecret' => '', - 'enabled' => false, - ]); - } - - public function testUpdateOAuth2OidcEnablePartialDiscoveryFails(): void - { - // Only authorization+token, missing userInfo — must fail to enable. - $this->updateOAuth2('oidc', [ - 'clientId' => '', - 'clientSecret' => '', - 'wellKnownURL' => '', - 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', - 'enabled' => false, - ]); - - $response = $this->updateOAuth2('oidc', [ - 'clientId' => 'oidc-partial', - 'clientSecret' => 'oidc-partial-secret', - 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', - 'tokenUrl' => 'https://idp.example.com/oauth2/token', - 'enabled' => true, - ]); - - $this->assertSame(400, $response['headers']['status-code']); - $this->assertSame('general_argument_invalid', $response['body']['type']); - - // Cleanup - $this->updateOAuth2('oidc', [ - 'clientId' => '', - 'clientSecret' => '', - 'wellKnownURL' => '', - 'authorizationURL' => '', - 'tokenUrl' => '', - 'userInfoUrl' => '', 'enabled' => false, ]); } @@ -894,11 +796,11 @@ trait OAuth2Base $this->assertSame('trial-6400025.okta.com', $response['body']['domain']); $this->assertSame('aus000000000000000h7z', $response['body']['authorizationServerId']); - // Cleanup + // Cleanup (domain is `Nullable(Domain())`; Domain rejects empty strings, + // so the domain persists past the test). $this->updateOAuth2('okta', [ 'clientId' => '', 'clientSecret' => '', - 'domain' => '', 'authorizationServerId' => '', 'enabled' => false, ]); @@ -915,33 +817,6 @@ trait OAuth2Base $this->assertSame(400, $response['headers']['status-code']); } - public function testUpdateOAuth2OktaEnableRequiresDomain(): void - { - $this->updateOAuth2('okta', [ - 'clientId' => '', - 'clientSecret' => '', - 'domain' => '', - 'authorizationServerId' => '', - 'enabled' => false, - ]); - - $response = $this->updateOAuth2('okta', [ - 'clientId' => 'okta-no-domain', - 'clientSecret' => 'okta-no-domain-secret', - 'enabled' => true, - ]); - - $this->assertSame(400, $response['headers']['status-code']); - $this->assertSame('general_argument_invalid', $response['body']['type']); - - // Cleanup - $this->updateOAuth2('okta', [ - 'clientId' => '', - 'clientSecret' => '', - 'enabled' => false, - ]); - } - // ========================================================================= // Update Dropbox (custom param names: appKey + appSecret) // ========================================================================= From ecba11eba51ac1bffe1d40d4ef72612cd833ee5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 16:54:53 +0200 Subject: [PATCH 304/402] Brin back removed tests --- tests/e2e/Services/Project/OAuth2Base.php | 131 ++++++++++++++++++++-- 1 file changed, 124 insertions(+), 7 deletions(-) diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php index 5e71f6f445..a177afd524 100644 --- a/tests/e2e/Services/Project/OAuth2Base.php +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -2,10 +2,27 @@ namespace Tests\E2E\Services\Project; +use PHPUnit\Framework\Attributes\Before; use Tests\E2E\Client; trait OAuth2Base { + /** + * Reset providers we mutate in tests back to a known empty/disabled state. + * The ProjectCustom trait reuses the same project across tests in a class, + * and the OAuth2 PATCH endpoint is additive (omitted fields are preserved), + * so without a reset state would leak between tests. + */ + #[Before(priority: -1)] + protected function resetProjectOAuth2(): void + { + $this->updateOAuth2('amazon', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + // ========================================================================= // List OAuth2 providers // ========================================================================= @@ -679,10 +696,11 @@ trait OAuth2Base $this->assertArrayNotHasKey('clientId', $response['body']); $this->assertArrayNotHasKey('clientSecret', $response['body']); - // Cleanup (endpoint is `Nullable(URL())`; URL rejects empty strings). + // Cleanup $this->updateOAuth2('gitlab', [ 'applicationId' => '', 'secret' => '', + 'endpoint' => '', 'enabled' => false, ]); } @@ -715,11 +733,11 @@ trait OAuth2Base $this->assertSame('https://updated.gitlab.com', $response['body']['endpoint']); $this->assertSame('gitlab-seed-app', $response['body']['applicationId']); - // Cleanup (endpoint is `Nullable(URL())` and URL rejects empty strings, - // so the endpoint persists past the test). + // Cleanup $this->updateOAuth2('gitlab', [ 'applicationId' => '', 'secret' => '', + 'endpoint' => '', 'enabled' => false, ]); } @@ -743,11 +761,14 @@ trait OAuth2Base $this->assertArrayHasKey('tokenUrl', $response['body']); $this->assertArrayHasKey('userInfoUrl', $response['body']); - // Cleanup (URL fields are `Nullable(URL())`; URL rejects empty strings, - // so the discovery URLs persist past the test). + // Cleanup $this->updateOAuth2('oidc', [ 'clientId' => '', 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', 'enabled' => false, ]); } @@ -772,6 +793,75 @@ trait OAuth2Base $this->updateOAuth2('oidc', [ 'clientId' => '', 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnableMissingURLs(): void + { + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-no-urls', + 'clientSecret' => 'oidc-no-urls', + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnablePartialDiscoveryFails(): void + { + // Only authorization+token, missing userInfo — must fail to enable. + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-partial', + 'clientSecret' => 'oidc-partial-secret', + 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', 'enabled' => false, ]); } @@ -796,11 +886,11 @@ trait OAuth2Base $this->assertSame('trial-6400025.okta.com', $response['body']['domain']); $this->assertSame('aus000000000000000h7z', $response['body']['authorizationServerId']); - // Cleanup (domain is `Nullable(Domain())`; Domain rejects empty strings, - // so the domain persists past the test). + // Cleanup $this->updateOAuth2('okta', [ 'clientId' => '', 'clientSecret' => '', + 'domain' => '', 'authorizationServerId' => '', 'enabled' => false, ]); @@ -817,6 +907,33 @@ trait OAuth2Base $this->assertSame(400, $response['headers']['status-code']); } + public function testUpdateOAuth2OktaEnableRequiresDomain(): void + { + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'authorizationServerId' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('okta', [ + 'clientId' => 'okta-no-domain', + 'clientSecret' => 'okta-no-domain-secret', + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + // ========================================================================= // Update Dropbox (custom param names: appKey + appSecret) // ========================================================================= From ec3c7f1ad66e75da982177001d77cbdb2bfa2646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 17:02:53 +0200 Subject: [PATCH 305/402] Fix failing oauth tests --- .../Modules/Project/Http/Project/OAuth2/Gitlab/Update.php | 2 +- .../Modules/Project/Http/Project/OAuth2/Oidc/Update.php | 8 ++++---- .../Modules/Project/Http/Project/OAuth2/Okta/Update.php | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php index a727f3f3a4..e860046b25 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php @@ -94,7 +94,7 @@ class Update extends Base )) ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) - ->param('endpoint', null, new Nullable(new URL()), 'Endpoint URL of self-hosted GitLab instance. For example: https://gitlab.com', optional: true) + ->param('endpoint', null, new Nullable(new URL(empty: true)), 'Endpoint URL of self-hosted GitLab instance. For example: https://gitlab.com', optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) ->inject('response') ->inject('dbForPlatform') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php index f950c78b13..2fda493b2f 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php @@ -85,10 +85,10 @@ class Update extends Base )) ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) - ->param('wellKnownURL', null, new Nullable(new URL()), 'OpenID Connect well-known configuration URL. When provided, authorization, token, and user info endpoints can be discovered automatically. For example: https://myoauth.com/.well-known/openid-configuration', optional: true) - ->param('authorizationURL', null, new Nullable(new URL()), 'OpenID Connect authorization endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/authorize', optional: true) - ->param('tokenUrl', null, new Nullable(new URL()), 'OpenID Connect token endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/token', optional: true) - ->param('userInfoUrl', null, new Nullable(new URL()), 'OpenID Connect user info endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/userinfo', optional: true) + ->param('wellKnownURL', null, new Nullable(new URL(empty: true)), 'OpenID Connect well-known configuration URL. When provided, authorization, token, and user info endpoints can be discovered automatically. For example: https://myoauth.com/.well-known/openid-configuration', optional: true) + ->param('authorizationURL', null, new Nullable(new URL(empty: true)), 'OpenID Connect authorization endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/authorize', optional: true) + ->param('tokenUrl', null, new Nullable(new URL(empty: true)), 'OpenID Connect token endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/token', optional: true) + ->param('userInfoUrl', null, new Nullable(new URL(empty: true)), 'OpenID Connect user info endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/userinfo', optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) ->inject('response') ->inject('dbForPlatform') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php index 1aef7684be..9f5f2d6307 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php @@ -85,7 +85,7 @@ class Update extends Base )) ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) - ->param('domain', null, new Nullable(new ValidatorDomain()), 'Okta company domain. Required when enabling the provider. For example: trial-6400025.okta.com. Example of wrong value: trial-6400025-admin.okta.com, or https://trial-6400025.okta.com/', optional: true) + ->param('domain', null, new Nullable(new ValidatorDomain(empty: true)), 'Okta company domain. Required when enabling the provider. For example: trial-6400025.okta.com. Example of wrong value: trial-6400025-admin.okta.com, or https://trial-6400025.okta.com/', optional: true) ->param('authorizationServerId', null, new Nullable(new Text(256, 0)), 'Custom Authorization Servers. Optional, can be left empty or unconfigured. For example: aus000000000000000h7z', optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) ->inject('response') From ca7f36a9b8609eccee91878b5f8e600f80b72a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 17:17:57 +0200 Subject: [PATCH 306/402] Fix bugs by improving tests --- .../Project/Http/Project/OAuth2/Base.php | 2 +- .../OAuth2/TradeshiftSandbox/Update.php | 2 +- .../Response/Model/OAuth2Tradeshift.php | 2 +- tests/e2e/Services/Project/OAuth2Base.php | 140 ++++++++++++++++++ 4 files changed, 143 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php index 50531d647f..f5aa5a34cd 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php @@ -178,7 +178,7 @@ abstract class Base extends Action 'etsy' => Etsy\Update::class, 'facebook' => Facebook\Update::class, 'tradeshift' => Tradeshift\Update::class, - 'tradeshiftSandbox' => TradeshiftSandbox\Update::class, + 'tradeshiftBox' => TradeshiftSandbox\Update::class, 'paypal' => Paypal\Update::class, 'paypalSandbox' => PaypalSandbox\Update::class, 'gitlab' => Gitlab\Update::class, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/TradeshiftSandbox/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/TradeshiftSandbox/Update.php index b656a26a06..fbb3133ea5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/TradeshiftSandbox/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/TradeshiftSandbox/Update.php @@ -9,7 +9,7 @@ class Update extends TradeshiftUpdate { public static function getProviderId(): string { - return 'tradeshiftSandbox'; + return 'tradeshiftBox'; } public static function getProviderClass(): string diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php b/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php index 8a790b31f8..4d2c37a951 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php @@ -7,7 +7,7 @@ use Appwrite\Utopia\Response; class OAuth2Tradeshift extends OAuth2Base { public array $conditions = [ - '$id' => ['tradeshift', 'tradeshiftSandbox'], + '$id' => ['tradeshift', 'tradeshiftBox'], ]; public function getProviderLabel(): string diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php index a177afd524..1024a48e56 100644 --- a/tests/e2e/Services/Project/OAuth2Base.php +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -3,6 +3,7 @@ namespace Tests\E2E\Services\Project; use PHPUnit\Framework\Attributes\Before; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\E2E\Client; trait OAuth2Base @@ -967,6 +968,38 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2DropboxPartial(): void + { + // Seed both fields, then patch only `appKey` and verify `appSecret` + // persists by enabling — Dropbox has no verifyCredentials() hook, so + // enabling succeeds purely from local state. + $this->updateOAuth2('dropbox', [ + 'appKey' => 'dropbox-seed-key', + 'appSecret' => 'dropbox-seed-secret', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('dropbox', [ + 'appKey' => 'dropbox-updated-key', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('dropbox-updated-key', $response['body']['appKey']); + + $enable = $this->updateOAuth2('dropbox', [ + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertSame(true, $enable['body']['enabled']); + + // Cleanup + $this->updateOAuth2('dropbox', [ + 'appKey' => '', + 'appSecret' => '', + 'enabled' => false, + ]); + } + // ========================================================================= // Update Paypal Sandbox (inherits from Paypal — independent provider ID) // ========================================================================= @@ -997,6 +1030,113 @@ trait OAuth2Base ]); } + // ========================================================================= + // Update Tradeshift Sandbox (inherits from Tradeshift — independent provider ID) + // ========================================================================= + + public function testUpdateOAuth2TradeshiftBox(): void + { + $response = $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => 'tradeshift-sandbox-client', + 'oauth2ClientSecret' => 'tradeshift-sandbox-secret', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('tradeshiftBox', $response['body']['$id']); + $this->assertSame('tradeshift-sandbox-client', $response['body']['oauth2ClientId']); + + // Sandbox is independent of the regular tradeshift entry. + $regular = $this->getOAuth2Provider('tradeshift'); + $this->assertSame(200, $regular['headers']['status-code']); + $this->assertSame('tradeshift', $regular['body']['$id']); + $this->assertNotSame('tradeshift-sandbox-client', $regular['body']['oauth2ClientId']); + + // Cleanup + $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => '', + 'oauth2ClientSecret' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Smoke test: every plain (clientId + clientSecret) provider + // + // Ensures each provider's Update endpoint is wired up correctly: routing, + // provider class, response model and `$id`. Custom-shaped providers + // (apple, auth0, authentik, gitlab, microsoft, oidc, okta, dropbox) and + // sandboxes (paypalSandbox, tradeshiftSandbox) have dedicated tests above. + // Github is excluded because its `verifyCredentials()` hook is exercised + // separately. + // ========================================================================= + + /** + * Provider, ID-field, secret-field. Many providers rename one or both of + * the two credential params (`clientId`/`clientSecret`) to match the + * upstream provider's terminology, so the smoke test parameterises both. + * + * @return array> + */ + public static function plainProviders(): array + { + return [ + 'discord' => ['discord', 'clientId', 'clientSecret'], + 'figma' => ['figma', 'clientId', 'clientSecret'], + 'dailymotion' => ['dailymotion', 'apiKey', 'apiSecret'], + 'bitbucket' => ['bitbucket', 'key', 'secret'], + 'bitly' => ['bitly', 'clientId', 'clientSecret'], + 'box' => ['box', 'clientId', 'clientSecret'], + 'autodesk' => ['autodesk', 'clientId', 'clientSecret'], + 'google' => ['google', 'clientId', 'clientSecret'], + 'zoom' => ['zoom', 'clientId', 'clientSecret'], + 'zoho' => ['zoho', 'clientId', 'clientSecret'], + 'yandex' => ['yandex', 'clientId', 'clientSecret'], + 'x' => ['x', 'customerKey', 'secretKey'], + 'wordpress' => ['wordpress', 'clientId', 'clientSecret'], + 'twitch' => ['twitch', 'clientId', 'clientSecret'], + 'stripe' => ['stripe', 'clientId', 'apiSecretKey'], + 'spotify' => ['spotify', 'clientId', 'clientSecret'], + 'slack' => ['slack', 'clientId', 'clientSecret'], + 'podio' => ['podio', 'clientId', 'clientSecret'], + 'notion' => ['notion', 'oauthClientId', 'oauthClientSecret'], + 'salesforce' => ['salesforce', 'customerKey', 'customerSecret'], + 'yahoo' => ['yahoo', 'clientId', 'clientSecret'], + 'linkedin' => ['linkedin', 'clientId', 'primaryClientSecret'], + 'disqus' => ['disqus', 'publicKey', 'secretKey'], + 'etsy' => ['etsy', 'keyString', 'sharedSecret'], + 'facebook' => ['facebook', 'appId', 'appSecret'], + 'tradeshift' => ['tradeshift', 'oauth2ClientId', 'oauth2ClientSecret'], + 'paypal' => ['paypal', 'clientId', 'secretKey'], + 'kick' => ['kick', 'clientId', 'clientSecret'], + ]; + } + + #[DataProvider('plainProviders')] + public function testUpdateOAuth2PlainProvider(string $providerId, string $idField, string $secretField): void + { + $clientId = $providerId . '-smoke-client'; + $clientSecret = $providerId . '-smoke-secret'; + + $response = $this->updateOAuth2($providerId, [ + $idField => $clientId, + $secretField => $clientSecret, + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($providerId, $response['body']['$id']); + $this->assertSame($clientId, $response['body'][$idField]); + $this->assertSame(false, $response['body']['enabled']); + + // Cleanup + $this->updateOAuth2($providerId, [ + $idField => '', + $secretField => '', + 'enabled' => false, + ]); + } + // ========================================================================= // Helpers // ========================================================================= From 4b620bb31ad0b6ef03094a506e21f1b193c5ba70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 17:27:23 +0200 Subject: [PATCH 307/402] Improve test coverage --- tests/e2e/Services/Project/OAuth2Base.php | 480 +++++++++++++++++++++- 1 file changed, 464 insertions(+), 16 deletions(-) diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php index 1024a48e56..215713b5b4 100644 --- a/tests/e2e/Services/Project/OAuth2Base.php +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -13,15 +13,24 @@ trait OAuth2Base * The ProjectCustom trait reuses the same project across tests in a class, * and the OAuth2 PATCH endpoint is additive (omitted fields are preserved), * so without a reset state would leak between tests. + * + * Assert on the reset response so a silently broken reset (e.g. validation + * change) surfaces immediately rather than corrupting downstream tests. */ #[Before(priority: -1)] protected function resetProjectOAuth2(): void { - $this->updateOAuth2('amazon', [ + $response = $this->updateOAuth2('amazon', [ 'clientId' => '', 'clientSecret' => '', 'enabled' => false, ]); + + $this->assertSame( + 200, + $response['headers']['status-code'], + 'OAuth2 reset failed — downstream tests will be unreliable. Body: ' . \json_encode($response['body'] ?? null), + ); } // ========================================================================= @@ -69,6 +78,34 @@ trait OAuth2Base } } + /** + * Pin the exact set of registered providers — adding or removing a + * provider must be a deliberate change to this assertion. Catches + * registration drift (e.g. forgetting to wire a new provider into + * `Base::getProviderActions()`). + */ + public function testListOAuth2ProvidersExposesEntireRegistry(): void + { + $response = $this->listOAuth2Providers(); + $this->assertSame(200, $response['headers']['status-code']); + + $ids = \array_column($response['body']['providers'], '$id'); + \sort($ids); + + $expected = [ + 'amazon', 'apple', 'auth0', 'authentik', 'autodesk', 'bitbucket', + 'bitly', 'box', 'dailymotion', 'discord', 'disqus', 'dropbox', + 'etsy', 'facebook', 'figma', 'github', 'gitlab', 'google', 'kick', + 'linkedin', 'microsoft', 'notion', 'oidc', 'okta', 'paypal', + 'paypalSandbox', 'podio', 'salesforce', 'slack', 'spotify', + 'stripe', 'tradeshift', 'tradeshiftBox', 'twitch', 'wordpress', + 'x', 'yahoo', 'yandex', 'zoho', 'zoom', + ]; + \sort($expected); + + $this->assertSame($expected, $ids, 'Registry drift — listed providers do not match the expected set.'); + } + public function testListOAuth2ProvidersResponseShape(): void { $response = $this->listOAuth2Providers(); @@ -153,17 +190,14 @@ trait OAuth2Base $list = $this->listOAuth2Providers(); $this->assertSame(200, $list['headers']['status-code']); - $byId = []; - foreach ($list['body']['providers'] as $provider) { - $byId[$provider['$id']] = $provider; - } - - // Match GET against LIST for one provider per shape. - foreach (['github', 'amazon', 'dropbox', 'gitlab', 'apple', 'oidc', 'microsoft'] as $providerId) { + // Drive the loop directly off the LIST result so any provider added + // to the registry is automatically checked for List/Get parity. + foreach ($list['body']['providers'] as $listEntry) { + $providerId = $listEntry['$id']; $get = $this->getOAuth2Provider($providerId); - $this->assertSame(200, $get['headers']['status-code']); - $this->assertArrayHasKey($providerId, $byId, "{$providerId} missing from list"); - $this->assertSame($byId[$providerId], $get['body']); + + $this->assertSame(200, $get['headers']['status-code'], "GET failed for {$providerId}"); + $this->assertSame($listEntry, $get['body'], "List/Get drift on {$providerId}"); } } @@ -341,10 +375,17 @@ trait OAuth2Base ]); $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); } // ========================================================================= // Update GitHub (verifyCredentials makes a real call to GitHub on enable) + // + // Only failure paths and the silent-on-disable branch are tested here. + // Happy-path enable would require real GitHub OAuth2 credentials, which + // CI doesn't have. Wiring, validation, and the non-enabling branch are + // sufficient to surface most regressions; success-path issues are caught + // by integration / staging environments instead. // ========================================================================= public function testUpdateOAuth2GitHubInvalidCredentialsRejected(): void @@ -511,6 +552,40 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2AppleEnableAndReadBack(): void + { + // Apple has no verifyCredentials() hook, so enabling with arbitrary + // (well-formed) values succeeds without any real Apple network call. + $update = $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.enable', + 'keyId' => 'ENABLEKEY', + 'teamId' => 'ENABLETEAM', + 'p8File' => '-----BEGIN PRIVATE KEY-----ENABLE-----END PRIVATE KEY-----', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide all three secret-bearing fields while keeping serviceId. + $get = $this->getOAuth2Provider('apple'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('ip.appwrite.app.enable', $get['body']['serviceId']); + $this->assertSame('', $get['body']['keyId']); + $this->assertSame('', $get['body']['teamId']); + $this->assertSame('', $get['body']['p8File']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + // ========================================================================= // Update Auth0 (clientId + clientSecret + optional endpoint) // ========================================================================= @@ -567,6 +642,35 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2Auth0EnableAndReadBack(): void + { + $update = $this->updateOAuth2('auth0', [ + 'clientId' => 'auth0-enable-client', + 'clientSecret' => 'auth0-enable-secret', + 'endpoint' => 'enable.us.auth0.com', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId and endpoint. + $get = $this->getOAuth2Provider('auth0'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('auth0-enable-client', $get['body']['clientId']); + $this->assertSame('enable.us.auth0.com', $get['body']['endpoint']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup + $this->updateOAuth2('auth0', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + // ========================================================================= // Update Authentik (clientId + clientSecret + REQUIRED endpoint) // ========================================================================= @@ -580,6 +684,7 @@ trait OAuth2Base ]); $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); } public function testUpdateOAuth2Authentik(): void @@ -605,6 +710,35 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2AuthentikEnableAndReadBack(): void + { + $update = $this->updateOAuth2('authentik', [ + 'clientId' => 'authentik-enable-client', + 'clientSecret' => 'authentik-enable-secret', + 'endpoint' => 'enable.authentik.com', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId and endpoint. + $get = $this->getOAuth2Provider('authentik'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('authentik-enable-client', $get['body']['clientId']); + $this->assertSame('enable.authentik.com', $get['body']['endpoint']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup — endpoint is required (Text(min=1)) so use a placeholder. + $this->updateOAuth2('authentik', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.authentik.com', + 'enabled' => false, + ]); + } + // ========================================================================= // Update Microsoft (applicationId + applicationSecret + REQUIRED tenant) // ========================================================================= @@ -617,6 +751,7 @@ trait OAuth2Base ]); $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); } public function testUpdateOAuth2Microsoft(): void @@ -676,6 +811,35 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2MicrosoftEnableAndReadBack(): void + { + $update = $this->updateOAuth2('microsoft', [ + 'applicationId' => 'microsoft-enable-app', + 'applicationSecret' => 'microsoft-enable-secret', + 'tenant' => 'common', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide applicationSecret while keeping applicationId/tenant. + $get = $this->getOAuth2Provider('microsoft'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('microsoft-enable-app', $get['body']['applicationId']); + $this->assertSame('common', $get['body']['tenant']); + $this->assertSame('', $get['body']['applicationSecret']); + + // Cleanup — tenant is required (Text(min=1)) so use a placeholder. + $this->updateOAuth2('microsoft', [ + 'applicationId' => '', + 'applicationSecret' => '', + 'tenant' => 'common', + 'enabled' => false, + ]); + } + // ========================================================================= // Update Gitlab (applicationId + secret + optional endpoint, custom names) // ========================================================================= @@ -715,6 +879,7 @@ trait OAuth2Base ]); $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); } public function testUpdateOAuth2GitlabPartialEndpoint(): void @@ -743,6 +908,62 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2GitlabEnableAndReadBack(): void + { + $update = $this->updateOAuth2('gitlab', [ + 'applicationId' => 'gitlab-enable-app', + 'secret' => 'gitlab-enable-secret', + 'endpoint' => 'https://enable.gitlab.com', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide `secret` while keeping applicationId and endpoint. + $get = $this->getOAuth2Provider('gitlab'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('gitlab-enable-app', $get['body']['applicationId']); + $this->assertSame('https://enable.gitlab.com', $get['body']['endpoint']); + $this->assertSame('', $get['body']['secret']); + + // Cleanup + $this->updateOAuth2('gitlab', [ + 'applicationId' => '', + 'secret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2GitlabEndpointAcceptsEmpty(): void + { + // The `endpoint` validator is `Nullable(URL(empty: true))`. Passing + // `''` must clear the stored value rather than 400 on URL validation. + $this->updateOAuth2('gitlab', [ + 'applicationId' => 'gitlab-clear-app', + 'secret' => 'gitlab-clear-secret', + 'endpoint' => 'https://before.gitlab.com', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('gitlab', [ + 'endpoint' => '', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['endpoint']); + + // Cleanup + $this->updateOAuth2('gitlab', [ + 'applicationId' => '', + 'secret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + // ========================================================================= // Update OIDC (clientId + secret + wellKnownURL or 3 discovery URLs) // ========================================================================= @@ -867,6 +1088,73 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2OidcEnableSucceedsWithWellKnown(): void + { + $update = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-enable-client', + 'clientSecret' => 'oidc-enable-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId and the URL. + $get = $this->getOAuth2Provider('oidc'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('oidc-enable-client', $get['body']['clientId']); + $this->assertSame('https://idp.example.com/.well-known/openid-configuration', $get['body']['wellKnownURL']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcURLsAcceptEmpty(): void + { + // All four URL fields use `Nullable(URL(empty: true))`. Passing `''` + // for each must clear them rather than 400 on URL validation. + $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-clear-client', + 'clientSecret' => 'oidc-clear-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'userInfoUrl' => 'https://idp.example.com/oauth2/userinfo', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('oidc', [ + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['wellKnownURL']); + $this->assertSame('', $response['body']['authorizationURL']); + $this->assertSame('', $response['body']['tokenUrl']); + $this->assertSame('', $response['body']['userInfoUrl']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + // ========================================================================= // Update Okta (clientId + clientSecret + optional domain/authServer) // ========================================================================= @@ -906,6 +1194,7 @@ trait OAuth2Base ]); $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); } public function testUpdateOAuth2OktaEnableRequiresDomain(): void @@ -935,6 +1224,65 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2OktaEnableSucceedsWithDomain(): void + { + $update = $this->updateOAuth2('okta', [ + 'clientId' => 'okta-enable-client', + 'clientSecret' => 'okta-enable-secret', + 'domain' => 'enable.okta.com', + 'authorizationServerId' => 'aus000000000000000h7z', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId, domain and authServerId. + $get = $this->getOAuth2Provider('okta'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('okta-enable-client', $get['body']['clientId']); + $this->assertSame('enable.okta.com', $get['body']['domain']); + $this->assertSame('aus000000000000000h7z', $get['body']['authorizationServerId']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'authorizationServerId' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OktaDomainAcceptsEmpty(): void + { + // The `domain` validator is `Nullable(Domain(empty: true))`. Passing + // `''` must clear the stored value rather than 400 on Domain validation. + $this->updateOAuth2('okta', [ + 'clientId' => 'okta-clear-client', + 'clientSecret' => 'okta-clear-secret', + 'domain' => 'before.okta.com', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('okta', [ + 'domain' => '', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['domain']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'enabled' => false, + ]); + } + // ========================================================================= // Update Dropbox (custom param names: appKey + appSecret) // ========================================================================= @@ -1000,6 +1348,32 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2DropboxEnableAndReadBack(): void + { + $update = $this->updateOAuth2('dropbox', [ + 'appKey' => 'dropbox-enable-key', + 'appSecret' => 'dropbox-enable-secret', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide `appSecret` while keeping `appKey`. + $get = $this->getOAuth2Provider('dropbox'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('dropbox-enable-key', $get['body']['appKey']); + $this->assertSame('', $get['body']['appSecret']); + + // Cleanup + $this->updateOAuth2('dropbox', [ + 'appKey' => '', + 'appSecret' => '', + 'enabled' => false, + ]); + } + // ========================================================================= // Update Paypal Sandbox (inherits from Paypal — independent provider ID) // ========================================================================= @@ -1030,6 +1404,38 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2PaypalDoesNotAffectSandbox(): void + { + // Reverse direction: writing to regular paypal must leave sandbox state intact. + $this->updateOAuth2('paypalSandbox', [ + 'clientId' => 'sandbox-untouched', + 'clientSecret' => 'sandbox-secret', + 'enabled' => false, + ]); + + $this->updateOAuth2('paypal', [ + 'clientId' => 'paypal-prod', + 'secretKey' => 'paypal-prod-secret', + 'enabled' => false, + ]); + + $sandbox = $this->getOAuth2Provider('paypalSandbox'); + $this->assertSame(200, $sandbox['headers']['status-code']); + $this->assertSame('sandbox-untouched', $sandbox['body']['clientId']); + + // Cleanup both + $this->updateOAuth2('paypal', [ + 'clientId' => '', + 'secretKey' => '', + 'enabled' => false, + ]); + $this->updateOAuth2('paypalSandbox', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + // ========================================================================= // Update Tradeshift Sandbox (inherits from Tradeshift — independent provider ID) // ========================================================================= @@ -1060,6 +1466,38 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2TradeshiftDoesNotAffectSandbox(): void + { + // Reverse direction: writing to regular tradeshift must not touch sandbox state. + $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => 'tradeshift-sandbox-untouched', + 'oauth2ClientSecret' => 'tradeshift-sandbox-secret', + 'enabled' => false, + ]); + + $this->updateOAuth2('tradeshift', [ + 'oauth2ClientId' => 'tradeshift-prod', + 'oauth2ClientSecret' => 'tradeshift-prod-secret', + 'enabled' => false, + ]); + + $sandbox = $this->getOAuth2Provider('tradeshiftBox'); + $this->assertSame(200, $sandbox['headers']['status-code']); + $this->assertSame('tradeshift-sandbox-untouched', $sandbox['body']['oauth2ClientId']); + + // Cleanup both + $this->updateOAuth2('tradeshift', [ + 'oauth2ClientId' => '', + 'oauth2ClientSecret' => '', + 'enabled' => false, + ]); + $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => '', + 'oauth2ClientSecret' => '', + 'enabled' => false, + ]); + } + // ========================================================================= // Smoke test: every plain (clientId + clientSecret) provider // @@ -1118,16 +1556,26 @@ trait OAuth2Base $clientId = $providerId . '-smoke-client'; $clientSecret = $providerId . '-smoke-secret'; - $response = $this->updateOAuth2($providerId, [ + $update = $this->updateOAuth2($providerId, [ $idField => $clientId, $secretField => $clientSecret, 'enabled' => false, ]); - $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame($providerId, $response['body']['$id']); - $this->assertSame($clientId, $response['body'][$idField]); - $this->assertSame(false, $response['body']['enabled']); + $this->assertSame(200, $update['headers']['status-code']); + $this->assertSame($providerId, $update['body']['$id']); + $this->assertSame($clientId, $update['body'][$idField]); + $this->assertFalse($update['body']['enabled']); + + // GET round-trip — confirms the value actually persisted (catches a + // PATCH that only echoes input without writing) and that the secret + // is hidden on read. + $get = $this->getOAuth2Provider($providerId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($providerId, $get['body']['$id']); + $this->assertSame($clientId, $get['body'][$idField]); + $this->assertSame('', $get['body'][$secretField]); + $this->assertFalse($get['body']['enabled']); // Cleanup $this->updateOAuth2($providerId, [ From d0d536a2dd2a9398322307a4c17ca67a40e6c3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 17:40:49 +0200 Subject: [PATCH 308/402] Improve test coverage --- tests/e2e/Services/Project/OAuth2Base.php | 652 ++++++++++++++++++++++ 1 file changed, 652 insertions(+) diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php index 215713b5b4..448ee4df59 100644 --- a/tests/e2e/Services/Project/OAuth2Base.php +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -154,6 +154,22 @@ trait OAuth2Base $this->assertSame(401, $response['headers']['status-code']); } + public function testListOAuth2ProvidersExcludesUnregisteredConfigEntries(): void + { + // `mock` and `mock-unverified` exist in oAuthProviders config (enabled: true) + // but are intentionally absent from Base::getProviderActions() — they're + // internal Mock OAuth2 adapters used by other test suites, not public + // providers. XList iterates the action registry, so they must never be + // included even though config marks them enabled. + $response = $this->listOAuth2Providers(); + + $this->assertSame(200, $response['headers']['status-code']); + + $ids = \array_column($response['body']['providers'], '$id'); + $this->assertNotContains('mock', $ids); + $this->assertNotContains('mock-unverified', $ids); + } + // ========================================================================= // Get OAuth2 provider // ========================================================================= @@ -209,6 +225,19 @@ trait OAuth2Base $this->assertSame('project_provider_unsupported', $response['body']['type']); } + public function testGetOAuth2ProviderRegisteredInConfigButNoUpdateClass(): void + { + // `mock` is present in oAuthProviders config (enabled: true) but is NOT + // registered in Base::getProviderActions(). Get::action has two + // separate `unsupported` throw branches — testGetOAuth2ProviderUnsupported + // covers the first (provider missing from config); this covers the + // second (provider in config but missing from the action registry). + $response = $this->getOAuth2Provider('mock'); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_provider_unsupported', $response['body']['type']); + } + public function testGetOAuth2ProviderWithoutAuthentication(): void { $response = $this->getOAuth2Provider('github', authenticated: false); @@ -492,6 +521,100 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2ApplePartialPreservesEachField(): void + { + // Seed all four fields, then patch each one individually and confirm + // the others survive across the chain. testUpdateOAuth2ApplePartial + // only covers `keyId`; this exercises serviceId/teamId/p8File too. + $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.merge', + 'keyId' => 'KEYMERGE01', + 'teamId' => 'TEAMMERGE', + 'p8File' => '-----BEGIN PRIVATE KEY-----MERGE-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + // Patch only `teamId`. + $teamOnly = $this->updateOAuth2('apple', [ + 'teamId' => 'TEAMROTATED', + ]); + $this->assertSame(200, $teamOnly['headers']['status-code']); + $this->assertSame('TEAMROTATED', $teamOnly['body']['teamId']); + $this->assertSame('ip.appwrite.app.merge', $teamOnly['body']['serviceId']); + + // Patch only `serviceId` — keyId/teamId/p8File live in the JSON blob + // and must survive a top-level (non-blob) field update. + $serviceOnly = $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.rotated', + ]); + $this->assertSame(200, $serviceOnly['headers']['status-code']); + $this->assertSame('ip.appwrite.app.rotated', $serviceOnly['body']['serviceId']); + + // Patch only `p8File`. keyId/teamId/serviceId must still be set + // internally — confirm by enabling. Apple has no verifyCredentials() + // hook, so persistCredentials only checks for non-empty serviceId and + // non-empty stored secret blob. + $p8Only = $this->updateOAuth2('apple', [ + 'p8File' => '-----BEGIN PRIVATE KEY-----ROTATED-----END PRIVATE KEY-----', + ]); + $this->assertSame(200, $p8Only['headers']['status-code']); + + $enable = $this->updateOAuth2('apple', ['enabled' => true]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2AppleClearAllFieldsBlocksEnable(): void + { + // Seed all four Apple fields. + $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.clearAll', + 'keyId' => 'KEYCLEARALL', + 'teamId' => 'TEAMCLEARALL', + 'p8File' => '-----BEGIN PRIVATE KEY-----CLEARALL-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + // Clear all credentials with empty strings. With `enabled` omitted, the + // silent-validation branch swallows the empty-credentials throw, so the + // call still succeeds — see testUpdateOAuth2PlainEnabledOmittedDoesNotThrow. + $clear = $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + ]); + $this->assertSame(200, $clear['headers']['status-code']); + $this->assertSame('', $clear['body']['serviceId']); + + // A subsequent `enabled => true` must now 400. Empty serviceId trips + // persistCredentials' empty(appId) guard before any provider hook runs, + // proving that the clear actually took effect on stored state. + $enable = $this->updateOAuth2('apple', [ + 'enabled' => true, + ]); + $this->assertSame(400, $enable['headers']['status-code']); + $this->assertSame('general_argument_invalid', $enable['body']['type']); + + // Cleanup (already cleared; included for reset symmetry). + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + public function testUpdateOAuth2AppleResponseModel(): void { $response = $this->updateOAuth2('apple', [ @@ -642,6 +765,78 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2Auth0PartialPreservesEachField(): void + { + // testUpdateOAuth2Auth0PartialEndpoint only patches `endpoint`. Cover + // patching `clientSecret` alone (must not wipe endpoint) and `clientId` + // alone (must not wipe the JSON-blob fields). + $this->updateOAuth2('auth0', [ + 'clientId' => 'auth0-merge-client', + 'clientSecret' => 'auth0-merge-secret', + 'endpoint' => 'merge.us.auth0.com', + 'enabled' => false, + ]); + + // Patch only clientSecret — clientId and endpoint must survive. + $secretOnly = $this->updateOAuth2('auth0', [ + 'clientSecret' => 'auth0-rotated-secret', + ]); + $this->assertSame(200, $secretOnly['headers']['status-code']); + $this->assertSame('auth0-merge-client', $secretOnly['body']['clientId']); + $this->assertSame('merge.us.auth0.com', $secretOnly['body']['endpoint']); + + // Patch only clientId — endpoint must survive. + $idOnly = $this->updateOAuth2('auth0', [ + 'clientId' => 'auth0-rotated-client', + ]); + $this->assertSame(200, $idOnly['headers']['status-code']); + $this->assertSame('auth0-rotated-client', $idOnly['body']['clientId']); + $this->assertSame('merge.us.auth0.com', $idOnly['body']['endpoint']); + + // Confirm the rotated clientSecret survived the chain by enabling. + // Auth0 has no verifyCredentials() hook; non-empty secret is enough. + $enable = $this->updateOAuth2('auth0', ['enabled' => true]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup + $this->updateOAuth2('auth0', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2Auth0EndpointAcceptsEmpty(): void + { + // Auth0's `endpoint` validator is `Nullable(Text(256, 0))`. Passing + // `''` must clear the stored value rather than leave it untouched + // (would happen if the merge fell back to existing on empty-string). + $this->updateOAuth2('auth0', [ + 'clientId' => 'auth0-clear-client', + 'clientSecret' => 'auth0-clear-secret', + 'endpoint' => 'before.us.auth0.com', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('auth0', [ + 'endpoint' => '', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['endpoint']); + $this->assertSame('auth0-clear-client', $response['body']['clientId']); + + // Cleanup + $this->updateOAuth2('auth0', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + public function testUpdateOAuth2Auth0EnableAndReadBack(): void { $update = $this->updateOAuth2('auth0', [ @@ -687,6 +882,21 @@ trait OAuth2Base $this->assertSame('general_argument_invalid', $response['body']['type']); } + public function testUpdateOAuth2AuthentikEmptyEndpointRejected(): void + { + // The `endpoint` validator is Text(min=1). Sending `''` must be + // rejected the same way as omitting — the validator should treat the + // empty-string degenerate case as a missing required field. + $response = $this->updateOAuth2('authentik', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'endpoint' => '', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + public function testUpdateOAuth2Authentik(): void { $response = $this->updateOAuth2('authentik', [ @@ -710,6 +920,45 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2AuthentikPartialPreservesSecret(): void + { + // Authentik's `endpoint` is required on every call, so we always + // re-send it. The `clientSecret` lives in the JSON blob and must + // survive when omitted on a subsequent call that only changes clientId. + $this->updateOAuth2('authentik', [ + 'clientId' => 'authentik-merge-client', + 'clientSecret' => 'authentik-merge-secret', + 'endpoint' => 'merge.authentik.com', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('authentik', [ + 'clientId' => 'authentik-rotated-client', + 'endpoint' => 'merge.authentik.com', + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('authentik-rotated-client', $response['body']['clientId']); + $this->assertSame('merge.authentik.com', $response['body']['endpoint']); + + // Confirm clientSecret survived the omitted-field merge by enabling + // — Authentik has no verifyCredentials() hook, so non-empty stored + // secret is enough. `endpoint` must be re-sent (required on enable too). + $enable = $this->updateOAuth2('authentik', [ + 'endpoint' => 'merge.authentik.com', + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup — endpoint is required, use a placeholder. + $this->updateOAuth2('authentik', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.authentik.com', + 'enabled' => false, + ]); + } + public function testUpdateOAuth2AuthentikEnableAndReadBack(): void { $update = $this->updateOAuth2('authentik', [ @@ -754,6 +1003,21 @@ trait OAuth2Base $this->assertSame('general_argument_invalid', $response['body']['type']); } + public function testUpdateOAuth2MicrosoftEmptyTenantRejected(): void + { + // The `tenant` validator is Text(min=1). Sending `''` must be rejected + // the same way as omitting — the validator should treat the empty + // string as a missing required field. + $response = $this->updateOAuth2('microsoft', [ + 'applicationId' => 'whatever', + 'applicationSecret' => 'whatever', + 'tenant' => '', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + public function testUpdateOAuth2Microsoft(): void { $response = $this->updateOAuth2('microsoft', [ @@ -908,6 +1172,43 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2GitlabPartialPreservesEachField(): void + { + // testUpdateOAuth2GitlabPartialEndpoint covers patching only `endpoint`. + // Cover patching `secret` alone (must not wipe applicationId/endpoint) + // and `applicationId` alone (must not wipe the JSON-blob endpoint). + $this->updateOAuth2('gitlab', [ + 'applicationId' => 'gitlab-merge-app', + 'secret' => 'gitlab-merge-secret', + 'endpoint' => 'https://merge.gitlab.com', + 'enabled' => false, + ]); + + // Patch only `secret`. + $secretOnly = $this->updateOAuth2('gitlab', [ + 'secret' => 'gitlab-rotated-secret', + ]); + $this->assertSame(200, $secretOnly['headers']['status-code']); + $this->assertSame('gitlab-merge-app', $secretOnly['body']['applicationId']); + $this->assertSame('https://merge.gitlab.com', $secretOnly['body']['endpoint']); + + // Patch only `applicationId`. + $idOnly = $this->updateOAuth2('gitlab', [ + 'applicationId' => 'gitlab-rotated-app', + ]); + $this->assertSame(200, $idOnly['headers']['status-code']); + $this->assertSame('gitlab-rotated-app', $idOnly['body']['applicationId']); + $this->assertSame('https://merge.gitlab.com', $idOnly['body']['endpoint']); + + // Cleanup + $this->updateOAuth2('gitlab', [ + 'applicationId' => '', + 'secret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + public function testUpdateOAuth2GitlabEnableAndReadBack(): void { $update = $this->updateOAuth2('gitlab', [ @@ -1120,6 +1421,167 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2OidcEnableInSeparateRequestWithWellKnown(): void + { + // Configure URLs first with `enabled: false`. Then enable in a SECOND + // request that omits all URL fields. The merge-on-enable logic in + // Oidc::handle() must see the previously-stored wellKnownEndpoint and + // allow the toggle. This is the headline feature of the merge logic. + $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-split-wk-client', + 'clientSecret' => 'oidc-split-wk-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'enabled' => false, + ]); + + $enable = $this->updateOAuth2('oidc', [ + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnableAcrossRequestsWithDiscoveryURLs(): void + { + // Reset to clean state — earlier tests in this section may have left + // partial URL state when running in any order. + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + + // Request 1: configure two of the three discovery URLs. + $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-split-discovery', + 'clientSecret' => 'oidc-split-discovery-secret', + 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'enabled' => false, + ]); + + // Request 2: send only the third URL plus enable=true. The merged + // state must include the two stored URLs + the new one to satisfy + // the all-three-discovery-URLs branch of the enable check. + $enable = $this->updateOAuth2('oidc', [ + 'userInfoUrl' => 'https://idp.example.com/oauth2/userinfo', + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Confirm all three URLs ended up persisted (merge wrote the new + // userInfoUrl while preserving the previously stored two). + $get = $this->getOAuth2Provider('oidc'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('https://idp.example.com/oauth2/authorize', $get['body']['authorizationURL']); + $this->assertSame('https://idp.example.com/oauth2/token', $get['body']['tokenUrl']); + $this->assertSame('https://idp.example.com/oauth2/userinfo', $get['body']['userInfoUrl']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnableFailsAfterClearingWellKnown(): void + { + // Seed wellKnownURL only (no discovery URLs). + $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-clear-then-enable', + 'clientSecret' => 'oidc-clear-then-enable-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + + // Clear wellKnownURL and try to enable in the same request. Merge + // sees `wellKnown=''` (the cleared empty wins over the stored value + // because the new value is non-null) and no discovery URLs → 400. + // This is the inverse of testUpdateOAuth2OidcEnableInSeparateRequestWithWellKnown: + // confirms the merge correctly *replaces* with empty rather than + // falling back to the stored non-empty value. + $response = $this->updateOAuth2('oidc', [ + 'wellKnownURL' => '', + 'enabled' => true, + ]); + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcSwitchModesWellKnownToDiscovery(): void + { + // Configure with wellKnownURL, then switch to the three-discovery-URL + // mode in a single request: clear wellKnown, set the three URLs, + // enable. Merge sees wellKnown='' AND all three discovery URLs set → + // hasAllDiscovery branch passes. + $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-switch-client', + 'clientSecret' => 'oidc-switch-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'enabled' => false, + ]); + + $switch = $this->updateOAuth2('oidc', [ + 'wellKnownURL' => '', + 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'userInfoUrl' => 'https://idp.example.com/oauth2/userinfo', + 'enabled' => true, + ]); + $this->assertSame(200, $switch['headers']['status-code']); + $this->assertTrue($switch['body']['enabled']); + $this->assertSame('', $switch['body']['wellKnownURL']); + $this->assertSame('https://idp.example.com/oauth2/authorize', $switch['body']['authorizationURL']); + $this->assertSame('https://idp.example.com/oauth2/token', $switch['body']['tokenUrl']); + $this->assertSame('https://idp.example.com/oauth2/userinfo', $switch['body']['userInfoUrl']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + public function testUpdateOAuth2OidcURLsAcceptEmpty(): void { // All four URL fields use `Nullable(URL(empty: true))`. Passing `''` @@ -1256,6 +1718,90 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2OktaPartialPreservesEachField(): void + { + // Okta has no field-by-field partial test in the existing suite. Cover + // each of `domain`, `authorizationServerId`, and `clientSecret` being + // patched alone — all three live in the same JSON blob. + $this->updateOAuth2('okta', [ + 'clientId' => 'okta-merge-client', + 'clientSecret' => 'okta-merge-secret', + 'domain' => 'merge.okta.com', + 'authorizationServerId' => 'aus000000000000merge', + 'enabled' => false, + ]); + + // Patch only `domain` — others must survive. + $domainOnly = $this->updateOAuth2('okta', [ + 'domain' => 'rotated.okta.com', + ]); + $this->assertSame(200, $domainOnly['headers']['status-code']); + $this->assertSame('rotated.okta.com', $domainOnly['body']['domain']); + $this->assertSame('okta-merge-client', $domainOnly['body']['clientId']); + $this->assertSame('aus000000000000merge', $domainOnly['body']['authorizationServerId']); + + // Patch only `authorizationServerId`. + $authServerOnly = $this->updateOAuth2('okta', [ + 'authorizationServerId' => 'aus000000000rotated00', + ]); + $this->assertSame(200, $authServerOnly['headers']['status-code']); + $this->assertSame('rotated.okta.com', $authServerOnly['body']['domain']); + $this->assertSame('aus000000000rotated00', $authServerOnly['body']['authorizationServerId']); + + // Patch only `clientSecret` — domain and authServerId in the JSON blob + // must survive. Confirm the rotated secret persisted by enabling. + $secretOnly = $this->updateOAuth2('okta', [ + 'clientSecret' => 'okta-rotated-secret', + ]); + $this->assertSame(200, $secretOnly['headers']['status-code']); + $this->assertSame('rotated.okta.com', $secretOnly['body']['domain']); + $this->assertSame('aus000000000rotated00', $secretOnly['body']['authorizationServerId']); + + $enable = $this->updateOAuth2('okta', ['enabled' => true]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'authorizationServerId' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OktaAuthServerIdAcceptsEmpty(): void + { + // `authorizationServerId` is `Nullable(Text(256, 0))`. Passing `''` + // must clear the stored value while leaving the rest of the JSON blob + // (clientSecret, oktaDomain) untouched. + $this->updateOAuth2('okta', [ + 'clientId' => 'okta-clear-auth-server', + 'clientSecret' => 'okta-clear-auth-server-secret', + 'domain' => 'authserver.okta.com', + 'authorizationServerId' => 'aus0000000000beforeauth', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('okta', [ + 'authorizationServerId' => '', + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['authorizationServerId']); + // domain (also stored in the JSON blob) must NOT have been wiped. + $this->assertSame('authserver.okta.com', $response['body']['domain']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'authorizationServerId' => '', + 'enabled' => false, + ]); + } + public function testUpdateOAuth2OktaDomainAcceptsEmpty(): void { // The `domain` validator is `Nullable(Domain(empty: true))`. Passing @@ -1404,6 +1950,34 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2PaypalSandboxResponseModel(): void + { + // PaypalSandbox inherits from Paypal: param/response field is + // `secretKey` instead of `clientSecret`. A regression that adds the + // default `clientSecret` to the response model would leak the + // unwritten field; pin its absence on both PATCH and GET. + $update = $this->updateOAuth2('paypalSandbox', [ + 'clientId' => 'paypal-sandbox-shape', + 'secretKey' => 'paypal-sandbox-shape-secret', + 'enabled' => false, + ]); + $this->assertSame(200, $update['headers']['status-code']); + $this->assertArrayHasKey('secretKey', $update['body']); + $this->assertArrayNotHasKey('clientSecret', $update['body']); + + $get = $this->getOAuth2Provider('paypalSandbox'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertArrayHasKey('secretKey', $get['body']); + $this->assertArrayNotHasKey('clientSecret', $get['body']); + + // Cleanup + $this->updateOAuth2('paypalSandbox', [ + 'clientId' => '', + 'secretKey' => '', + 'enabled' => false, + ]); + } + public function testUpdateOAuth2PaypalDoesNotAffectSandbox(): void { // Reverse direction: writing to regular paypal must leave sandbox state intact. @@ -1466,6 +2040,38 @@ trait OAuth2Base ]); } + public function testUpdateOAuth2TradeshiftBoxResponseModel(): void + { + // TradeshiftSandbox inherits from Tradeshift: both clientId AND + // clientSecret are renamed (oauth2ClientId / oauth2ClientSecret). + // Pin that the default field names are absent from PATCH and GET + // responses so a stray addition to the response model is caught. + $update = $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => 'tradeshift-box-shape', + 'oauth2ClientSecret' => 'tradeshift-box-shape-secret', + 'enabled' => false, + ]); + $this->assertSame(200, $update['headers']['status-code']); + $this->assertArrayHasKey('oauth2ClientId', $update['body']); + $this->assertArrayHasKey('oauth2ClientSecret', $update['body']); + $this->assertArrayNotHasKey('clientId', $update['body']); + $this->assertArrayNotHasKey('clientSecret', $update['body']); + + $get = $this->getOAuth2Provider('tradeshiftBox'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertArrayHasKey('oauth2ClientId', $get['body']); + $this->assertArrayHasKey('oauth2ClientSecret', $get['body']); + $this->assertArrayNotHasKey('clientId', $get['body']); + $this->assertArrayNotHasKey('clientSecret', $get['body']); + + // Cleanup + $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => '', + 'oauth2ClientSecret' => '', + 'enabled' => false, + ]); + } + public function testUpdateOAuth2TradeshiftDoesNotAffectSandbox(): void { // Reverse direction: writing to regular tradeshift must not touch sandbox state. @@ -1585,6 +2191,52 @@ trait OAuth2Base ]); } + /** + * For providers that rename `clientId` / `clientSecret` to a custom field + * (e.g. `apiKey`/`apiSecret`, `customerKey`/`secretKey`, `oauthClientId`), + * the renamed field replaces the default — the response model must NOT + * also expose the default name. Catches a regression where adding a + * custom param name forgets to remove the default from the response. + */ + #[DataProvider('plainProviders')] + public function testUpdateOAuth2PlainProviderResponseDoesNotLeakDefaultNames(string $providerId, string $idField, string $secretField): void + { + if ($idField === 'clientId' && $secretField === 'clientSecret') { + // Default-named provider — nothing to leak. Avoids a no-op assertion. + $this->markTestSkipped("{$providerId} uses default field names."); + } + + $update = $this->updateOAuth2($providerId, [ + $idField => $providerId . '-leak-check-id', + $secretField => $providerId . '-leak-check-secret', + 'enabled' => false, + ]); + $this->assertSame(200, $update['headers']['status-code']); + + if ($idField !== 'clientId') { + $this->assertArrayNotHasKey('clientId', $update['body'], "PATCH response for {$providerId} leaks default `clientId` despite using `{$idField}`."); + } + if ($secretField !== 'clientSecret') { + $this->assertArrayNotHasKey('clientSecret', $update['body'], "PATCH response for {$providerId} leaks default `clientSecret` despite using `{$secretField}`."); + } + + $get = $this->getOAuth2Provider($providerId); + $this->assertSame(200, $get['headers']['status-code']); + if ($idField !== 'clientId') { + $this->assertArrayNotHasKey('clientId', $get['body'], "GET response for {$providerId} leaks default `clientId` despite using `{$idField}`."); + } + if ($secretField !== 'clientSecret') { + $this->assertArrayNotHasKey('clientSecret', $get['body'], "GET response for {$providerId} leaks default `clientSecret` despite using `{$secretField}`."); + } + + // Cleanup + $this->updateOAuth2($providerId, [ + $idField => '', + $secretField => '', + 'enabled' => false, + ]); + } + // ========================================================================= // Helpers // ========================================================================= From 3d43530225ae403545cd5c33010baf1b45694462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 17:41:13 +0200 Subject: [PATCH 309/402] Fix failing test --- tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php b/tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php index 58123aeff3..f86557a432 100644 --- a/tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php +++ b/tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php @@ -80,7 +80,7 @@ class OAuthGitHubIntegrationTest extends Scope $this->assertNotNull($githubProvider, 'GitHub OAuth provider not found in project details'); $this->assertTrue($githubProvider['enabled']); $this->assertSame($clientId, $githubProvider['appId']); - $this->assertSame($clientSecret, $githubProvider['secret']); + $this->assertSame('', $githubProvider['secret']); // Write only // Step 5: Without client headers (no API key), go through the OAuth flow $clientHeaders = [ From 50d86c5b5dafdcc67565c8df863d0619b45ec775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 17:45:52 +0200 Subject: [PATCH 310/402] Update ci.yml --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d28c00477a..e521ac3771 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,8 +456,10 @@ jobs: name: ${{ env.IMAGE }} path: /tmp - - name: Set database environment + - name: Set environment run: | + echo "_APP_OPTIONS_ROUTER_PROTECTION=enabled" >> $GITHUB_ENV + if [ "${{ matrix.database }}" = "MariaDB" ]; then echo "COMPOSE_PROFILES=mariadb" >> $GITHUB_ENV echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV From 015aee087a8640c7e8149f047a90dac94bb2d088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 18:04:22 +0200 Subject: [PATCH 311/402] Fix write only security --- .../Http/Project/OAuth2/Apple/Update.php | 18 +++-------------- .../Http/Project/OAuth2/Auth0/Update.php | 17 +++------------- .../Http/Project/OAuth2/Authentik/Update.php | 17 +++------------- .../Project/Http/Project/OAuth2/Base.php | 14 ++++--------- .../Http/Project/OAuth2/Gitlab/Update.php | 17 +++------------- .../Http/Project/OAuth2/Microsoft/Update.php | 17 +++------------- .../Http/Project/OAuth2/Oidc/Update.php | 20 +++---------------- .../Http/Project/OAuth2/Okta/Update.php | 18 +++-------------- tests/e2e/Services/Project/OAuth2Base.php | 17 +++++++++++----- 9 files changed, 37 insertions(+), 118 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php index 79a30e02d4..c2b0885f5f 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php @@ -158,20 +158,8 @@ class Update extends Base $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $serviceId, $encodedSecret, $enabled); - $oAuthProviders = $project->getAttribute('oAuthProviders', []); - $storedRaw = $oAuthProviders[$providerId . 'Secret'] ?? ''; - $decoded = []; - if (!empty($storedRaw)) { - $decoded = \json_decode($storedRaw, true) ?: []; - } - - $response->dynamic(new Document([ - '$id' => $providerId, - 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, - static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', - 'keyId' => $decoded['keyID'] ?? '', - 'teamId' => $decoded['teamID'] ?? '', - 'p8File' => $decoded['p8'] ?? '', - ]), static::getResponseModel()); + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee keyId/teamId/p8File are write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php index 4cb314af13..9c94864a50 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php @@ -146,19 +146,8 @@ class Update extends Base $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); - $oAuthProviders = $project->getAttribute('oAuthProviders', []); - $storedRaw = $oAuthProviders[$providerId . 'Secret'] ?? ''; - $decoded = []; - if (!empty($storedRaw)) { - $decoded = \json_decode($storedRaw, true) ?: []; - } - - $response->dynamic(new Document([ - '$id' => $providerId, - 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, - static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', - static::getClientSecretParamName() => $decoded['clientSecret'] ?? '', - 'endpoint' => $decoded['auth0Domain'] ?? '', - ]), static::getResponseModel()); + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php index 834a68597a..c4e27899a8 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php @@ -143,19 +143,8 @@ class Update extends Base $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); - $oAuthProviders = $project->getAttribute('oAuthProviders', []); - $storedRaw = $oAuthProviders[$providerId . 'Secret'] ?? ''; - $decoded = []; - if (!empty($storedRaw)) { - $decoded = \json_decode($storedRaw, true) ?: []; - } - - $response->dynamic(new Document([ - '$id' => $providerId, - 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, - static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', - static::getClientSecretParamName() => $decoded['clientSecret'] ?? '', - 'endpoint' => $decoded['authentikDomain'] ?? '', - ]), static::getResponseModel()); + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php index f5aa5a34cd..6591270ded 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php @@ -311,16 +311,10 @@ abstract class Base extends Action ): void { $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $clientSecret, $enabled); - $providerId = static::getProviderId(); - $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $queueForEvents->setParam('providerId', static::getProviderId()); - $queueForEvents->setParam('providerId', $providerId); - - $response->dynamic(new Document([ - '$id' => $providerId, - 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, - static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', - static::getClientSecretParamName() => $oAuthProviders[$providerId . 'Secret'] ?? '', - ]), static::getResponseModel()); + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php index e860046b25..743ffa5061 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php @@ -157,19 +157,8 @@ class Update extends Base $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled); - $oAuthProviders = $project->getAttribute('oAuthProviders', []); - $storedRaw = $oAuthProviders[$providerId . 'Secret'] ?? ''; - $decoded = []; - if (!empty($storedRaw)) { - $decoded = \json_decode($storedRaw, true) ?: []; - } - - $response->dynamic(new Document([ - '$id' => $providerId, - 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, - static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', - static::getClientSecretParamName() => $decoded['clientSecret'] ?? '', - 'endpoint' => $decoded['endpoint'] ?? '', - ]), static::getResponseModel()); + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the secret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php index 894631fbaa..5f72b65dd8 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php @@ -153,19 +153,8 @@ class Update extends Base $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled); - $oAuthProviders = $project->getAttribute('oAuthProviders', []); - $storedRaw = $oAuthProviders[$providerId . 'Secret'] ?? ''; - $decoded = []; - if (!empty($storedRaw)) { - $decoded = \json_decode($storedRaw, true) ?: []; - } - - $response->dynamic(new Document([ - '$id' => $providerId, - 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, - static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', - static::getClientSecretParamName() => $decoded['clientSecret'] ?? '', - 'tenant' => $decoded['tenantID'] ?? '', - ]), static::getResponseModel()); + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the applicationSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php index 2fda493b2f..95d06c5da9 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php @@ -183,22 +183,8 @@ class Update extends Base $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); - $oAuthProviders = $project->getAttribute('oAuthProviders', []); - $storedRaw = $oAuthProviders[$providerId . 'Secret'] ?? ''; - $decoded = []; - if (!empty($storedRaw)) { - $decoded = \json_decode($storedRaw, true) ?: []; - } - - $response->dynamic(new Document([ - '$id' => $providerId, - 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, - static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', - static::getClientSecretParamName() => $decoded['clientSecret'] ?? '', - 'wellKnownURL' => $decoded['wellKnownEndpoint'] ?? '', - 'authorizationURL' => $decoded['authorizationEndpoint'] ?? '', - 'tokenUrl' => $decoded['tokenEndpoint'] ?? '', - 'userInfoUrl' => $decoded['userInfoEndpoint'] ?? '', - ]), static::getResponseModel()); + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php index 9f5f2d6307..bc8583c086 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php @@ -163,20 +163,8 @@ class Update extends Base $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); - $oAuthProviders = $project->getAttribute('oAuthProviders', []); - $storedRaw = $oAuthProviders[$providerId . 'Secret'] ?? ''; - $decoded = []; - if (!empty($storedRaw)) { - $decoded = \json_decode($storedRaw, true) ?: []; - } - - $response->dynamic(new Document([ - '$id' => $providerId, - 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, - static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', - static::getClientSecretParamName() => $decoded['clientSecret'] ?? '', - 'domain' => $decoded['oktaDomain'] ?? '', - 'authorizationServerId' => $decoded['authorizationServerId'] ?? '', - ]), static::getResponseModel()); + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); } } diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php index 448ee4df59..f33fc7acb0 100644 --- a/tests/e2e/Services/Project/OAuth2Base.php +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -476,8 +476,10 @@ trait OAuth2Base $this->assertSame(200, $response['headers']['status-code']); $this->assertSame('apple', $response['body']['$id']); $this->assertSame('ip.appwrite.app.web', $response['body']['serviceId']); - $this->assertSame('P4000000N8', $response['body']['keyId']); - $this->assertSame('D4000000R6', $response['body']['teamId']); + // keyId / teamId / p8File are write-only — PATCH response must not echo them back. + $this->assertSame('', $response['body']['keyId']); + $this->assertSame('', $response['body']['teamId']); + $this->assertSame('', $response['body']['p8File']); $this->assertSame(false, $response['body']['enabled']); // Cleanup @@ -507,9 +509,12 @@ trait OAuth2Base ]); $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame('KEYUPDATED', $response['body']['keyId']); - $this->assertSame('TEAMSEED01', $response['body']['teamId']); + // serviceId is the (non-secret) clientId; keyId/teamId are write-only + // and must not surface in the response. Persistence of the merged + // values is verified separately via the enable-after-merge tests. $this->assertSame('ip.appwrite.app.seed', $response['body']['serviceId']); + $this->assertSame('', $response['body']['keyId']); + $this->assertSame('', $response['body']['teamId']); // Cleanup $this->updateOAuth2('apple', [ @@ -539,7 +544,9 @@ trait OAuth2Base 'teamId' => 'TEAMROTATED', ]); $this->assertSame(200, $teamOnly['headers']['status-code']); - $this->assertSame('TEAMROTATED', $teamOnly['body']['teamId']); + // teamId is write-only; verify only the non-secret serviceId echo. + // The actual merge is validated by the enable-after-merge call below. + $this->assertSame('', $teamOnly['body']['teamId']); $this->assertSame('ip.appwrite.app.merge', $teamOnly['body']['serviceId']); // Patch only `serviceId` — keyId/teamId/p8File live in the JSON blob From 1f16b0d9e759a6b8bcec1d02971d52ef11930fb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 18:21:21 +0200 Subject: [PATCH 312/402] Fix failing startup --- composer.json | 1 + composer.lock | 174 +++++++++--------- .../Http/Project/OAuth2/Gitlab/Update.php | 2 +- .../Http/Project/OAuth2/Oidc/Update.php | 8 +- .../Http/Project/OAuth2/Okta/Update.php | 2 +- tests/e2e/Services/Project/OAuth2Base.php | 6 +- 6 files changed, 98 insertions(+), 95 deletions(-) diff --git a/composer.json b/composer.json index 6312243e32..b5ca436c3f 100644 --- a/composer.json +++ b/composer.json @@ -69,6 +69,7 @@ "utopia-php/dsn": "0.2.1", "utopia-php/http": "0.34.*", "utopia-php/fetch": "0.5.*", + "utopia-php/validators": "0.2.*", "utopia-php/image": "0.8.*", "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", diff --git a/composer.lock b/composer.lock index 02590020e0..82b705a5c7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c5ae97637fd0ec0a950044d1c33677ea", + "content-hash": "805802552f7482eaeae4bdaa505ae982", "packages": [ { "name": "adhocore/jwt", @@ -1996,16 +1996,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.51", + "version": "3.0.52", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748" + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748", - "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce", "shasum": "" }, "require": { @@ -2086,7 +2086,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.51" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52" }, "funding": [ { @@ -2102,7 +2102,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T01:33:53+00:00" + "time": "2026-04-27T07:02:15+00:00" }, { "name": "psr/clock", @@ -2887,7 +2887,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -2948,7 +2948,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -2972,7 +2972,7 @@ }, { "name": "symfony/polyfill-php82", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", @@ -3028,7 +3028,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.37.0" }, "funding": [ { @@ -3052,7 +3052,7 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", @@ -3108,7 +3108,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" }, "funding": [ { @@ -3132,16 +3132,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "2c408a6bb0313e6001a83628dc5506100474254e" + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/2c408a6bb0313e6001a83628dc5506100474254e", - "reference": "2c408a6bb0313e6001a83628dc5506100474254e", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", "shasum": "" }, "require": { @@ -3188,7 +3188,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" }, "funding": [ { @@ -3208,7 +3208,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:50:15+00:00" + "time": "2026-04-26T13:10:57+00:00" }, { "name": "symfony/service-contracts", @@ -3658,16 +3658,16 @@ }, { "name": "utopia-php/cli", - "version": "0.23.1", + "version": "0.23.2", "source": { "type": "git", "url": "https://github.com/utopia-php/cli.git", - "reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621" + "reference": "145b91fef827853bcceaa3ab8ca2b1d6faaca2ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cli/zipball/8d1955b8bc4dc631f45d7c7df689ed7b63f70621", - "reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621", + "url": "https://api.github.com/repos/utopia-php/cli/zipball/145b91fef827853bcceaa3ab8ca2b1d6faaca2ab", + "reference": "145b91fef827853bcceaa3ab8ca2b1d6faaca2ab", "shasum": "" }, "require": { @@ -3703,9 +3703,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cli/issues", - "source": "https://github.com/utopia-php/cli/tree/0.23.1" + "source": "https://github.com/utopia-php/cli/tree/0.23.2" }, - "time": "2026-04-05T15:27:35+00:00" + "time": "2026-04-27T09:19:04+00:00" }, { "name": "utopia-php/compression", @@ -4271,21 +4271,20 @@ }, { "name": "utopia-php/http", - "version": "0.34.21", + "version": "0.34.24", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "49a6bd3ea0d2966aa19cf707255d442675288a24" + "reference": "d1eced0627c5a9fceddf53992ed97d664b810d33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/49a6bd3ea0d2966aa19cf707255d442675288a24", - "reference": "49a6bd3ea0d2966aa19cf707255d442675288a24", + "url": "https://api.github.com/repos/utopia-php/http/zipball/d1eced0627c5a9fceddf53992ed97d664b810d33", + "reference": "d1eced0627c5a9fceddf53992ed97d664b810d33", "shasum": "" }, "require": { - "ext-swoole": "*", - "php": ">=8.2", + "php": ">=8.3", "utopia-php/compression": "0.1.*", "utopia-php/di": "0.3.*", "utopia-php/servers": "0.3.*", @@ -4295,11 +4294,14 @@ "require-dev": { "doctrine/instantiator": "^1.5", "laravel/pint": "1.*", - "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "1.*", - "phpunit/phpunit": "^9.5.25", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.0", + "rector/rector": "^2.4", "swoole/ide-helper": "4.8.3" }, + "suggest": { + "ext-swoole": "Required to use the Swoole server adapter (\\Utopia\\Http\\Adapter\\Swoole\\Server)." + }, "type": "library", "autoload": { "psr-4": { @@ -4319,9 +4321,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.34.21" + "source": "https://github.com/utopia-php/http/tree/0.34.24" }, - "time": "2026-04-19T19:44:04+00:00" + "time": "2026-04-24T12:16:53+00:00" }, { "name": "utopia-php/image", @@ -4528,16 +4530,16 @@ }, { "name": "utopia-php/migration", - "version": "1.9.1", + "version": "1.9.3", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2" + "reference": "111f6221d04578a6f721c23ac872002375f176ae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/7a86aeadf182b63a9f4ceba7e137588b31c5d2e2", - "reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/111f6221d04578a6f721c23ac872002375f176ae", + "reference": "111f6221d04578a6f721c23ac872002375f176ae", "shasum": "" }, "require": { @@ -4577,22 +4579,22 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.9.1" + "source": "https://github.com/utopia-php/migration/tree/1.9.3" }, - "time": "2026-03-25T07:05:27+00:00" + "time": "2026-04-22T07:13:26+00:00" }, { "name": "utopia-php/mongo", - "version": "1.0.2", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "677a21c53f7a1316c528b4b45b3fce886cee7223" + "reference": "73593682deee4696525a04e26524c1c1226e1530" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/677a21c53f7a1316c528b4b45b3fce886cee7223", - "reference": "677a21c53f7a1316c528b4b45b3fce886cee7223", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/73593682deee4696525a04e26524c1c1226e1530", + "reference": "73593682deee4696525a04e26524c1c1226e1530", "shasum": "" }, "require": { @@ -4638,9 +4640,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/1.0.2" + "source": "https://github.com/utopia-php/mongo/tree/1.1.0" }, - "time": "2026-03-18T02:45:50+00:00" + "time": "2026-04-24T06:15:10+00:00" }, { "name": "utopia-php/platform", @@ -5182,16 +5184,16 @@ }, { "name": "utopia-php/validators", - "version": "0.2.0", + "version": "0.2.1", "source": { "type": "git", "url": "https://github.com/utopia-php/validators.git", - "reference": "30b6030a5b100fc1dff34506e5053759594b2a20" + "reference": "6cce9f73aa79f30de54aa3ff117090af570027cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/validators/zipball/30b6030a5b100fc1dff34506e5053759594b2a20", - "reference": "30b6030a5b100fc1dff34506e5053759594b2a20", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/6cce9f73aa79f30de54aa3ff117090af570027cb", + "reference": "6cce9f73aa79f30de54aa3ff117090af570027cb", "shasum": "" }, "require": { @@ -5221,9 +5223,9 @@ ], "support": { "issues": "https://github.com/utopia-php/validators/issues", - "source": "https://github.com/utopia-php/validators/tree/0.2.0" + "source": "https://github.com/utopia-php/validators/tree/0.2.1" }, - "time": "2026-01-13T09:16:51+00:00" + "time": "2026-04-27T16:05:19+00:00" }, { "name": "utopia-php/vcs", @@ -5464,16 +5466,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.20", + "version": "1.24.0", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "525f0630520c95100fcdfb63c9dac859c1d02588" + "reference": "6d4d26659bc7a1c347c1d4d8dae3b77b5562e0cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/525f0630520c95100fcdfb63c9dac859c1d02588", - "reference": "525f0630520c95100fcdfb63c9dac859c1d02588", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/6d4d26659bc7a1c347c1d4d8dae3b77b5562e0cb", + "reference": "6d4d26659bc7a1c347c1d4d8dae3b77b5562e0cb", "shasum": "" }, "require": { @@ -5509,9 +5511,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.20" + "source": "https://github.com/appwrite/sdk-generator/tree/1.24.0" }, - "time": "2026-04-20T05:45:00+00:00" + "time": "2026-04-24T12:50:05+00:00" }, { "name": "brianium/paratest", @@ -5793,16 +5795,16 @@ }, { "name": "laravel/pint", - "version": "v1.29.0", + "version": "v1.29.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", - "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", "shasum": "" }, "require": { @@ -5813,14 +5815,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.94.2", - "illuminate/view": "^12.54.1", - "larastan/larastan": "^3.9.3", - "laravel-zero/framework": "^12.0.5", + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.4.0", "pestphp/pest": "^3.8.6", - "shipfastlabs/agent-detector": "^1.1.0" + "shipfastlabs/agent-detector": "^1.1.3" }, "bin": [ "builds/pint" @@ -5857,7 +5859,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-03-12T15:51:39+00:00" + "time": "2026-04-20T15:26:14+00:00" }, { "name": "matthiasmullie/minify", @@ -6220,11 +6222,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.50", + "version": "2.1.51", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", - "reference": "d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc3b523c45e714c70de2ac5113b958223b55dc59", + "reference": "dc3b523c45e714c70de2ac5113b958223b55dc59", "shasum": "" }, "require": { @@ -6269,7 +6271,7 @@ "type": "github" } ], - "time": "2026-04-17T13:10:32+00:00" + "time": "2026-04-21T18:22:01+00:00" }, { "name": "phpunit/php-code-coverage", @@ -7779,7 +7781,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -7838,7 +7840,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -7862,16 +7864,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df" + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df", - "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "shasum": "" }, "require": { @@ -7920,7 +7922,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" }, "funding": [ { @@ -7940,11 +7942,11 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-04-26T13:13:48+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -8005,7 +8007,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" }, "funding": [ { @@ -8029,7 +8031,7 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -8085,7 +8087,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0" }, "funding": [ { diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php index 743ffa5061..70c538454f 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php @@ -94,7 +94,7 @@ class Update extends Base )) ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) - ->param('endpoint', null, new Nullable(new URL(empty: true)), 'Endpoint URL of self-hosted GitLab instance. For example: https://gitlab.com', optional: true) + ->param('endpoint', null, new Nullable(new URL(allowEmpty: true)), 'Endpoint URL of self-hosted GitLab instance. For example: https://gitlab.com', optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) ->inject('response') ->inject('dbForPlatform') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php index 95d06c5da9..c000b456ec 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php @@ -85,10 +85,10 @@ class Update extends Base )) ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) - ->param('wellKnownURL', null, new Nullable(new URL(empty: true)), 'OpenID Connect well-known configuration URL. When provided, authorization, token, and user info endpoints can be discovered automatically. For example: https://myoauth.com/.well-known/openid-configuration', optional: true) - ->param('authorizationURL', null, new Nullable(new URL(empty: true)), 'OpenID Connect authorization endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/authorize', optional: true) - ->param('tokenUrl', null, new Nullable(new URL(empty: true)), 'OpenID Connect token endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/token', optional: true) - ->param('userInfoUrl', null, new Nullable(new URL(empty: true)), 'OpenID Connect user info endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/userinfo', optional: true) + ->param('wellKnownURL', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect well-known configuration URL. When provided, authorization, token, and user info endpoints can be discovered automatically. For example: https://myoauth.com/.well-known/openid-configuration', optional: true) + ->param('authorizationURL', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect authorization endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/authorize', optional: true) + ->param('tokenUrl', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect token endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/token', optional: true) + ->param('userInfoUrl', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect user info endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/userinfo', optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) ->inject('response') ->inject('dbForPlatform') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php index bc8583c086..504c0636af 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php @@ -85,7 +85,7 @@ class Update extends Base )) ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) - ->param('domain', null, new Nullable(new ValidatorDomain(empty: true)), 'Okta company domain. Required when enabling the provider. For example: trial-6400025.okta.com. Example of wrong value: trial-6400025-admin.okta.com, or https://trial-6400025.okta.com/', optional: true) + ->param('domain', null, new Nullable(new ValidatorDomain(allowEmpty: true)), 'Okta company domain. Required when enabling the provider. For example: trial-6400025.okta.com. Example of wrong value: trial-6400025-admin.okta.com, or https://trial-6400025.okta.com/', optional: true) ->param('authorizationServerId', null, new Nullable(new Text(256, 0)), 'Custom Authorization Servers. Optional, can be left empty or unconfigured. For example: aus000000000000000h7z', optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) ->inject('response') diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php index f33fc7acb0..ec070531e7 100644 --- a/tests/e2e/Services/Project/OAuth2Base.php +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -1247,7 +1247,7 @@ trait OAuth2Base public function testUpdateOAuth2GitlabEndpointAcceptsEmpty(): void { - // The `endpoint` validator is `Nullable(URL(empty: true))`. Passing + // The `endpoint` validator is `Nullable(URL(allowEmpty: true))`. Passing // `''` must clear the stored value rather than 400 on URL validation. $this->updateOAuth2('gitlab', [ 'applicationId' => 'gitlab-clear-app', @@ -1591,7 +1591,7 @@ trait OAuth2Base public function testUpdateOAuth2OidcURLsAcceptEmpty(): void { - // All four URL fields use `Nullable(URL(empty: true))`. Passing `''` + // All four URL fields use `Nullable(URL(allowEmpty: true))`. Passing `''` // for each must clear them rather than 400 on URL validation. $this->updateOAuth2('oidc', [ 'clientId' => 'oidc-clear-client', @@ -1811,7 +1811,7 @@ trait OAuth2Base public function testUpdateOAuth2OktaDomainAcceptsEmpty(): void { - // The `domain` validator is `Nullable(Domain(empty: true))`. Passing + // The `domain` validator is `Nullable(Domain(allowEmpty: true))`. Passing // `''` must clear the stored value rather than 400 on Domain validation. $this->updateOAuth2('okta', [ 'clientId' => 'okta-clear-client', From ad4178aa42b2c236b6e6f4ec6f905b823d63ced1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 27 Apr 2026 18:33:30 +0200 Subject: [PATCH 313/402] Fix missing lib params for domain --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 82b705a5c7..2cf57b95a3 100644 --- a/composer.lock +++ b/composer.lock @@ -5184,16 +5184,16 @@ }, { "name": "utopia-php/validators", - "version": "0.2.1", + "version": "0.2.2", "source": { "type": "git", "url": "https://github.com/utopia-php/validators.git", - "reference": "6cce9f73aa79f30de54aa3ff117090af570027cb" + "reference": "5d7d494e64457cd4eb67fdcfd9481f2c89796aa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/validators/zipball/6cce9f73aa79f30de54aa3ff117090af570027cb", - "reference": "6cce9f73aa79f30de54aa3ff117090af570027cb", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/5d7d494e64457cd4eb67fdcfd9481f2c89796aa6", + "reference": "5d7d494e64457cd4eb67fdcfd9481f2c89796aa6", "shasum": "" }, "require": { @@ -5223,9 +5223,9 @@ ], "support": { "issues": "https://github.com/utopia-php/validators/issues", - "source": "https://github.com/utopia-php/validators/tree/0.2.1" + "source": "https://github.com/utopia-php/validators/tree/0.2.2" }, - "time": "2026-04-27T16:05:19+00:00" + "time": "2026-04-27T16:30:24+00:00" }, { "name": "utopia-php/vcs", From c4f6b117068d6b2f0400d83d37a68037733497a3 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 28 Apr 2026 03:54:34 +0000 Subject: [PATCH 314/402] fix: guard DOMDocument::loadHTML against empty body in favicon endpoint Closes CLO-4279 --- src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php index a41d0f81da..e2b72d361a 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php @@ -94,9 +94,12 @@ class Get extends Action throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); } + $body = $res->getBody(); $doc = new DOMDocument(); $doc->strictErrorChecking = false; - @$doc->loadHTML($res->getBody()); + if ($body !== '') { + @$doc->loadHTML($body); + } $links = $doc->getElementsByTagName('link'); $outputHref = ''; From 9637409831e23b0392dd6343999b4f7096b17875 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 28 Apr 2026 03:54:35 +0000 Subject: [PATCH 315/402] fix: coerce non-string header values in Request::getHeader Closes CLO-4280 --- src/Appwrite/Utopia/Request.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 66ac4ca932..3004392f76 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -209,7 +209,11 @@ class Request extends UtopiaRequest public function getHeader(string $key, string $default = ''): string { $headers = $this->getHeaders(); - return $headers[$key] ?? $default; + $value = $headers[$key] ?? $default; + if (\is_array($value)) { + $value = $value[0] ?? $default; + } + return \is_string($value) ? $value : $default; } /** From 30a511692b38e663bc2d5afc2173a94d9cb21006 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 28 Apr 2026 04:15:00 +0000 Subject: [PATCH 316/402] test: add unit coverage for Request::getHeader non-string coercion Refs CLO-4280 --- tests/unit/Utopia/RequestTest.php | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php index 81e0ead4b3..57ebae6d1e 100644 --- a/tests/unit/Utopia/RequestTest.php +++ b/tests/unit/Utopia/RequestTest.php @@ -161,6 +161,37 @@ class RequestTest extends TestCase $this->assertSame($secondRoute, $secondRequest->getRoute()); } + public function testGetHeaderReturnsStringValue(): void + { + $this->request->addHeader('referer', 'https://example.com'); + + $this->assertSame('https://example.com', $this->request->getHeader('referer')); + } + + public function testGetHeaderReturnsDefaultWhenMissing(): void + { + $this->assertSame('', $this->request->getHeader('referer')); + $this->assertSame('fallback', $this->request->getHeader('referer', 'fallback')); + } + + public function testGetHeaderCoercesArrayToFirstElement(): void + { + $swoole = new SwooleRequest(); + $swoole->header = ['referer' => ['https://a.example', 'https://b.example']]; + $request = new Request($swoole); + + $this->assertSame('https://a.example', $request->getHeader('referer')); + } + + public function testGetHeaderReturnsDefaultWhenValueNotString(): void + { + $swoole = new SwooleRequest(); + $swoole->header = ['referer' => 123]; + $request = new Request($swoole); + + $this->assertSame('fallback', $request->getHeader('referer', 'fallback')); + } + /** * Helper to attach a route with multiple SDK methods to the request. */ From 81321e82d116ab5d7232305676177469fdc38eb8 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 28 Apr 2026 10:05:01 +0545 Subject: [PATCH 317/402] Update src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php index e2b72d361a..31ad572f18 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php @@ -97,7 +97,7 @@ class Get extends Action $body = $res->getBody(); $doc = new DOMDocument(); $doc->strictErrorChecking = false; - if ($body !== '') { + if (!empty($body)) { @$doc->loadHTML($body); } From f71a2dfddc63e60166889d7c31c98f58a80605d0 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 28 Apr 2026 11:07:16 +0530 Subject: [PATCH 318/402] changed the condition to app edition for the loading of the span --- app/realtime.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 88b1137c30..caa105eace 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -46,7 +46,7 @@ use Utopia\WebSocket\Server; require_once __DIR__ . '/init.php'; -if (!defined('APPWRITE_SKIP_CE_SPAN_INIT')) { +if (System::getEnv('_APP_EDITION', 'self-hosted') === 'self-hosted') { require_once __DIR__ . '/init/span.php'; } From d73b7a70d8d12f6772083b02abcec7c98cb9514f Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 11:44:39 +0530 Subject: [PATCH 319/402] feat: add query param fallback for impersonation headers Allow impersonation to be specified via URL query params (?impersonateUserId, ?impersonateEmail, ?impersonatePhone) as a fallback to the existing headers, enabling Console to embed impersonation in direct file/image URLs where headers cannot be set. --- app/init/realtime/connection.php | 6 +++--- app/init/resources/request.php | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index c0219fa816..0822ee9329 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -327,9 +327,9 @@ return function (Container $container): void { } } - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', ''); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $request->getParam('impersonateUserId', '')); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', $request->getParam('impersonateEmail', '')); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', $request->getParam('impersonatePhone', '')); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { $userDb = ($mode === APP_MODE_ADMIN || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 7d1731b80d..26c03126a2 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -571,10 +571,10 @@ return function (Container $container): void { } } - // Impersonation: if current user has impersonator capability and headers are set, act as another user - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', ''); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); + // Impersonation: if current user has impersonator capability and headers/params are set, act as another user + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $request->getParam('impersonateUserId', '')); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', $request->getParam('impersonateEmail', '')); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', $request->getParam('impersonatePhone', '')); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { $userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; $targetUser = null; From 01b5fa8ecb0b7f12044bce25388f86c7b585d4d9 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 11:58:25 +0530 Subject: [PATCH 320/402] fix: restrict impersonation query param fallback to userId only Remove query param fallback for impersonateEmail and impersonatePhone to avoid PII exposure in server logs, browser history, and Referer headers. Only impersonateUserId (an opaque internal ID) is safe to pass via URL query param. --- app/init/realtime/connection.php | 4 ++-- app/init/resources/request.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index 0822ee9329..1f6faed0fd 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -328,8 +328,8 @@ return function (Container $container): void { } $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $request->getParam('impersonateUserId', '')); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', $request->getParam('impersonateEmail', '')); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', $request->getParam('impersonatePhone', '')); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { $userDb = ($mode === APP_MODE_ADMIN || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 26c03126a2..8a74f7763b 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -573,8 +573,8 @@ return function (Container $container): void { // Impersonation: if current user has impersonator capability and headers/params are set, act as another user $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $request->getParam('impersonateUserId', '')); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', $request->getParam('impersonateEmail', '')); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', $request->getParam('impersonatePhone', '')); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { $userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; $targetUser = null; From 8f1d73a6cb7d2368589d0c9f073fc99fdb03f665 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 12:02:00 +0530 Subject: [PATCH 321/402] chore: clarify intentional header-only restriction for email/phone impersonation --- app/init/realtime/connection.php | 2 ++ app/init/resources/request.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index 1f6faed0fd..b557a2c62b 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -327,6 +327,8 @@ return function (Container $container): void { } } + // impersonateUserId also accepts a query param to support embedding in WebSocket URLs. + // Email and phone are intentionally header-only to avoid PII exposure in proxy/LB logs. $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $request->getParam('impersonateUserId', '')); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 8a74f7763b..c6f3fd1ab1 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -572,6 +572,8 @@ return function (Container $container): void { } // Impersonation: if current user has impersonator capability and headers/params are set, act as another user + // impersonateUserId also accepts a query param to allow embedding in direct file/image URLs (e.g. ) + // where custom headers cannot be set. Email and phone are intentionally header-only to avoid PII in URLs/logs. $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $request->getParam('impersonateUserId', '')); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); From 4c989f99c37043c0b9dafd877e3d74f239c2d160 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 12:05:02 +0530 Subject: [PATCH 322/402] fix: cast impersonateUserId query param to string to prevent array injection --- app/init/realtime/connection.php | 2 +- app/init/resources/request.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index b557a2c62b..c02da3058e 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -329,7 +329,7 @@ return function (Container $container): void { // impersonateUserId also accepts a query param to support embedding in WebSocket URLs. // Email and phone are intentionally header-only to avoid PII exposure in proxy/LB logs. - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $request->getParam('impersonateUserId', '')); + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', '')); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index c6f3fd1ab1..d1c0d2bea0 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -574,7 +574,7 @@ return function (Container $container): void { // Impersonation: if current user has impersonator capability and headers/params are set, act as another user // impersonateUserId also accepts a query param to allow embedding in direct file/image URLs (e.g. ) // where custom headers cannot be set. Email and phone are intentionally header-only to avoid PII in URLs/logs. - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $request->getParam('impersonateUserId', '')); + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', '')); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { From 46a457bfa37960ecf28f59baeb244077e19cbe21 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 12:10:51 +0530 Subject: [PATCH 323/402] fix: block impersonateUserId query param on cross-site requests to prevent CSRF --- app/init/realtime/connection.php | 5 ++++- app/init/resources/request.php | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index c02da3058e..3bb91a3aeb 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -329,7 +329,10 @@ return function (Container $container): void { // impersonateUserId also accepts a query param to support embedding in WebSocket URLs. // Email and phone are intentionally header-only to avoid PII exposure in proxy/LB logs. - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', '')); + // Query-param fallback is blocked for cross-site requests to prevent CSRF attacks via + // third-party pages; Sec-Fetch-Site is a browser-enforced forbidden header. + $isCrossSite = $request->getHeader('sec-fetch-site', '') === 'cross-site'; + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isCrossSite ? '' : (string)$request->getParam('impersonateUserId', '')); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index d1c0d2bea0..143adca352 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -574,7 +574,10 @@ return function (Container $container): void { // Impersonation: if current user has impersonator capability and headers/params are set, act as another user // impersonateUserId also accepts a query param to allow embedding in direct file/image URLs (e.g. ) // where custom headers cannot be set. Email and phone are intentionally header-only to avoid PII in URLs/logs. - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', '')); + // Query-param fallback is blocked for cross-site requests (Sec-Fetch-Site: cross-site) to prevent CSRF; + // Sec-Fetch-Site is a browser-enforced forbidden header that cannot be spoofed by JavaScript. + $isCrossSite = $request->getHeader('sec-fetch-site', '') === 'cross-site'; + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isCrossSite ? '' : (string)$request->getParam('impersonateUserId', '')); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { From 5465be6301a3a5b0236bc0c8b9c0d93b822260a5 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 12:27:57 +0530 Subject: [PATCH 324/402] fix: make CSRF guard fail-closed by requiring explicit same-origin Sec-Fetch-Site --- app/init/realtime/connection.php | 5 +++-- app/init/resources/request.php | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index 3bb91a3aeb..0fc30fb5e2 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -331,8 +331,9 @@ return function (Container $container): void { // Email and phone are intentionally header-only to avoid PII exposure in proxy/LB logs. // Query-param fallback is blocked for cross-site requests to prevent CSRF attacks via // third-party pages; Sec-Fetch-Site is a browser-enforced forbidden header. - $isCrossSite = $request->getHeader('sec-fetch-site', '') === 'cross-site'; - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isCrossSite ? '' : (string)$request->getParam('impersonateUserId', '')); + $fetchSite = $request->getHeader('sec-fetch-site', ''); + $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 143adca352..7b29c05c5d 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -576,8 +576,9 @@ return function (Container $container): void { // where custom headers cannot be set. Email and phone are intentionally header-only to avoid PII in URLs/logs. // Query-param fallback is blocked for cross-site requests (Sec-Fetch-Site: cross-site) to prevent CSRF; // Sec-Fetch-Site is a browser-enforced forbidden header that cannot be spoofed by JavaScript. - $isCrossSite = $request->getHeader('sec-fetch-site', '') === 'cross-site'; - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isCrossSite ? '' : (string)$request->getParam('impersonateUserId', '')); + $fetchSite = $request->getHeader('sec-fetch-site', ''); + $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { From 9a175c509897e8264974bb924bef6f4286ffd6e1 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 12:56:17 +0530 Subject: [PATCH 325/402] test: add E2E tests for impersonateUserId query param and CSRF guards --- tests/e2e/Services/Users/UsersBase.php | 152 +++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 3255d9a67f..a4567f0063 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2708,6 +2708,158 @@ trait UsersBase $this->assertIsArray($response['body']['users']); } + /** + * Test impersonation via ?impersonateUserId= query param (same-origin browser request). + * This is the primary use case for embedding impersonation in file/image URLs where + * custom headers cannot be set (e.g. , deployment source/output download links). + */ + public function testImpersonateByUserIdQueryParam(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'queryparam-impersonator@appwrite.io', + 'password' => 'password', + 'name' => 'Query Param Impersonator', + ]); + $this->assertEquals(201, $userA['headers']['status-code']); + $idA = $userA['body']['$id']; + + $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'queryparam-target@appwrite.io', + 'password' => 'password', + 'name' => 'Query Param Target', + ]); + $this->assertEquals(201, $userB['headers']['status-code']); + $idB = $userB['body']['$id']; + + $patch = $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]); + $this->assertEquals(200, $patch['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + $sessionSecret = $session['body']['secret']; + + // Query param works when Sec-Fetch-Site indicates a same-origin browser request + $account = $this->client->call(Client::METHOD_GET, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $sessionSecret, + 'sec-fetch-site' => 'same-origin', + ], ['impersonateUserId' => $idB]); + $this->assertEquals(200, $account['headers']['status-code']); + $this->assertEquals($idB, $account['body']['$id']); + $this->assertEquals('Query Param Target', $account['body']['name']); + $this->assertEquals($idA, $account['body']['impersonatorUserId']); + } + + /** + * Test that ?impersonateUserId= query param is ignored for cross-site requests (CSRF guard). + * Sec-Fetch-Site is a browser-enforced forbidden header; cross-site value means the request + * originated from a third-party page and must not be allowed to trigger impersonation. + */ + public function testImpersonateQueryParamIgnoredCrossSite(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'csrf-impersonator@appwrite.io', + 'password' => 'password', + 'name' => 'CSRF Impersonator', + ]); + $this->assertEquals(201, $userA['headers']['status-code']); + $idA = $userA['body']['$id']; + + $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'csrf-target@appwrite.io', + 'password' => 'password', + 'name' => 'CSRF Target', + ]); + $this->assertEquals(201, $userB['headers']['status-code']); + $idB = $userB['body']['$id']; + + $patch = $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]); + $this->assertEquals(200, $patch['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + $sessionSecret = $session['body']['secret']; + + // Query param must be ignored when Sec-Fetch-Site is cross-site (third-party page embed) + $account = $this->client->call(Client::METHOD_GET, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $sessionSecret, + 'sec-fetch-site' => 'cross-site', + ], ['impersonateUserId' => $idB]); + $this->assertEquals(200, $account['headers']['status-code']); + // Should resolve as userA (the impersonator), not the target + $this->assertEquals($idA, $account['body']['$id']); + $this->assertArrayNotHasKey('impersonatorUserId', $account['body']); + } + + /** + * Test that ?impersonateUserId= query param is ignored when Sec-Fetch-Site is absent + * (fail-closed CSRF guard). Absent header means a reverse proxy stripped Fetch Metadata + * headers or a non-browser client is calling — query param must be silently ignored. + */ + public function testImpersonateQueryParamIgnoredWhenSecFetchSiteAbsent(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'absent-fetch-impersonator@appwrite.io', + 'password' => 'password', + 'name' => 'Absent Fetch Impersonator', + ]); + $this->assertEquals(201, $userA['headers']['status-code']); + $idA = $userA['body']['$id']; + + $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'absent-fetch-target@appwrite.io', + 'password' => 'password', + 'name' => 'Absent Fetch Target', + ]); + $this->assertEquals(201, $userB['headers']['status-code']); + $idB = $userB['body']['$id']; + + $patch = $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]); + $this->assertEquals(200, $patch['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + $sessionSecret = $session['body']['secret']; + + // Query param must be ignored when Sec-Fetch-Site is absent (proxy-stripped or API client) + $account = $this->client->call(Client::METHOD_GET, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $sessionSecret, + // no sec-fetch-site header + ], ['impersonateUserId' => $idB]); + $this->assertEquals(200, $account['headers']['status-code']); + $this->assertEquals($idA, $account['body']['$id']); + $this->assertArrayNotHasKey('impersonatorUserId', $account['body']); + } + /** * Test PATCH /users/:userId/impersonator for non-existent user returns 404 */ From a3f6cf4645cf5680fc237b3e9a17472b4c986e3c Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 13:00:18 +0530 Subject: [PATCH 326/402] fix: restrict CSRF guard to same-origin only, drop same-site --- app/init/realtime/connection.php | 2 +- app/init/resources/request.php | 2 +- tests/e2e/Services/Users/UsersBase.php | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index 0fc30fb5e2..5778b5c260 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -332,7 +332,7 @@ return function (Container $container): void { // Query-param fallback is blocked for cross-site requests to prevent CSRF attacks via // third-party pages; Sec-Fetch-Site is a browser-enforced forbidden header. $fetchSite = $request->getHeader('sec-fetch-site', ''); - $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); + $isSameOrigin = $fetchSite === 'same-origin'; $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 7b29c05c5d..dca4b84bd7 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -577,7 +577,7 @@ return function (Container $container): void { // Query-param fallback is blocked for cross-site requests (Sec-Fetch-Site: cross-site) to prevent CSRF; // Sec-Fetch-Site is a browser-enforced forbidden header that cannot be spoofed by JavaScript. $fetchSite = $request->getHeader('sec-fetch-site', ''); - $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); + $isSameOrigin = $fetchSite === 'same-origin'; $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index a4567f0063..5f38df5c07 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2746,7 +2746,8 @@ trait UsersBase $this->assertEquals(201, $session['headers']['status-code']); $sessionSecret = $session['body']['secret']; - // Query param works when Sec-Fetch-Site indicates a same-origin browser request + // Query param works only when Sec-Fetch-Site is exactly same-origin. + // same-site is intentionally excluded to prevent subdomain-based CSRF attacks. $account = $this->client->call(Client::METHOD_GET, '/account', [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, From d25707346fd7213a5ed0421656da522fe6a656e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 09:47:27 +0200 Subject: [PATCH 327/402] Add console oauth endpoint --- app/init/models.php | 6 ++ .../console/list-oauth2-providers.md | 1 + .../Console/Http/OAuth2Providers/XList.php | 80 ++++++++++++++++ .../Modules/Console/Services/Http.php | 2 + .../Http/Project/OAuth2/Amazon/Update.php | 20 ++++ .../Http/Project/OAuth2/Apple/Update.php | 53 +++++++++++ .../Http/Project/OAuth2/Auth0/Update.php | 32 +++++++ .../Http/Project/OAuth2/Authentik/Update.php | 32 +++++++ .../Http/Project/OAuth2/Autodesk/Update.php | 20 ++++ .../Project/Http/Project/OAuth2/Base.php | 91 +++++++++++++++++++ .../Http/Project/OAuth2/Bitbucket/Update.php | 20 ++++ .../Http/Project/OAuth2/Bitly/Update.php | 20 ++++ .../Http/Project/OAuth2/Box/Update.php | 20 ++++ .../Project/OAuth2/Dailymotion/Update.php | 20 ++++ .../Http/Project/OAuth2/Discord/Update.php | 20 ++++ .../Http/Project/OAuth2/Disqus/Update.php | 20 ++++ .../Http/Project/OAuth2/Dropbox/Update.php | 20 ++++ .../Http/Project/OAuth2/Etsy/Update.php | 20 ++++ .../Http/Project/OAuth2/Facebook/Update.php | 20 ++++ .../Http/Project/OAuth2/Figma/Update.php | 20 ++++ .../Http/Project/OAuth2/GitHub/Update.php | 25 +++++ .../Http/Project/OAuth2/Gitlab/Update.php | 32 +++++++ .../Http/Project/OAuth2/Google/Update.php | 20 ++++ .../Http/Project/OAuth2/Kick/Update.php | 20 ++++ .../Http/Project/OAuth2/Linkedin/Update.php | 20 ++++ .../Http/Project/OAuth2/Microsoft/Update.php | 32 +++++++ .../Http/Project/OAuth2/Notion/Update.php | 20 ++++ .../Http/Project/OAuth2/Oidc/Update.php | 50 ++++++++++ .../Http/Project/OAuth2/Okta/Update.php | 38 ++++++++ .../Http/Project/OAuth2/Paypal/Update.php | 20 ++++ .../Http/Project/OAuth2/Podio/Update.php | 20 ++++ .../Http/Project/OAuth2/Salesforce/Update.php | 20 ++++ .../Http/Project/OAuth2/Slack/Update.php | 20 ++++ .../Http/Project/OAuth2/Spotify/Update.php | 20 ++++ .../Http/Project/OAuth2/Stripe/Update.php | 20 ++++ .../Http/Project/OAuth2/Tradeshift/Update.php | 20 ++++ .../Http/Project/OAuth2/Twitch/Update.php | 20 ++++ .../Http/Project/OAuth2/WordPress/Update.php | 20 ++++ .../Project/Http/Project/OAuth2/X/Update.php | 20 ++++ .../Http/Project/OAuth2/Yahoo/Update.php | 20 ++++ .../Http/Project/OAuth2/Yandex/Update.php | 20 ++++ .../Http/Project/OAuth2/Zoho/Update.php | 20 ++++ .../Http/Project/OAuth2/Zoom/Update.php | 20 ++++ src/Appwrite/Utopia/Response.php | 3 + .../Response/Model/ConsoleOAuth2Provider.php | 37 ++++++++ .../Model/ConsoleOAuth2ProviderList.php | 37 ++++++++ .../Model/ConsoleOAuth2ProviderParameter.php | 49 ++++++++++ .../Utopia/Response/Model/OAuth2Linkedin.php | 2 +- .../Console/ConsoleConsoleClientTest.php | 87 ++++++++++++++++++ .../Console/ConsoleCustomServerTest.php | 19 ++++ 50 files changed, 1307 insertions(+), 1 deletion(-) create mode 100644 docs/references/console/list-oauth2-providers.md create mode 100644 src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php create mode 100644 src/Appwrite/Utopia/Response/Model/ConsoleOAuth2Provider.php create mode 100644 src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderList.php create mode 100644 src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderParameter.php diff --git a/app/init/models.php b/app/init/models.php index 1f92c77cec..39bc90e23c 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -56,6 +56,9 @@ use Appwrite\Utopia\Response\Model\ColumnString; use Appwrite\Utopia\Response\Model\ColumnText; use Appwrite\Utopia\Response\Model\ColumnURL; use Appwrite\Utopia\Response\Model\ColumnVarchar; +use Appwrite\Utopia\Response\Model\ConsoleOAuth2Provider; +use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderList; +use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderParameter; use Appwrite\Utopia\Response\Model\ConsoleVariables; use Appwrite\Utopia\Response\Model\Continent; use Appwrite\Utopia\Response\Model\Country; @@ -476,6 +479,9 @@ Response::setModel(new Rule()); Response::setModel(new Schedule()); Response::setModel(new TemplateEmail()); Response::setModel(new ConsoleVariables()); +Response::setModel(new ConsoleOAuth2ProviderParameter()); +Response::setModel(new ConsoleOAuth2Provider()); +Response::setModel(new ConsoleOAuth2ProviderList()); Response::setModel(new MFAChallenge()); Response::setModel(new MFARecoveryCodes()); Response::setModel(new MFAType()); diff --git a/docs/references/console/list-oauth2-providers.md b/docs/references/console/list-oauth2-providers.md new file mode 100644 index 0000000000..d813296031 --- /dev/null +++ b/docs/references/console/list-oauth2-providers.md @@ -0,0 +1 @@ +List all OAuth2 providers supported by the Appwrite server, along with the parameters required to configure each provider. The response excludes mock providers but includes sandbox providers. diff --git a/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php new file mode 100644 index 0000000000..574f7a5f6a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php @@ -0,0 +1,80 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/console/oauth2-providers') + ->desc('List OAuth2 providers') + ->groups(['api']) + ->label('scope', 'public') + ->label('sdk', new Method( + namespace: 'console', + group: 'console', + name: 'listOAuth2Providers', + description: '/docs/references/console/list-oauth2-providers.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->inject('response') + ->callback($this->action(...)); + } + + public function action(Response $response): void + { + $providersConfig = Config::getParam('oAuthProviders', []); + $actions = OAuth2Base::getProviderActions(); + + $providers = []; + foreach ($actions as $providerId => $updateClass) { + $config = $providersConfig[$providerId] ?? null; + if ($config === null) { + continue; + } + if (!($config['enabled'] ?? false)) { + continue; + } + if ($config['mock'] ?? false) { + continue; + } + + $providers[] = new Document([ + '$id' => $providerId, + 'parameters' => $updateClass::getParameters(), + ]); + } + + $response->dynamic(new Document([ + 'total' => \count($providers), + 'oAuth2Providers' => $providers, + ]), Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Console/Services/Http.php b/src/Appwrite/Platform/Modules/Console/Services/Http.php index f3ca6218f2..77029af0f9 100644 --- a/src/Appwrite/Platform/Modules/Console/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Console/Services/Http.php @@ -5,6 +5,7 @@ namespace Appwrite\Platform\Modules\Console\Services; use Appwrite\Platform\Modules\Console\Http\Assistant\Create as CreateAssistantQuery; use Appwrite\Platform\Modules\Console\Http\Init\API; use Appwrite\Platform\Modules\Console\Http\Init\Web; +use Appwrite\Platform\Modules\Console\Http\OAuth2Providers\XList as ListOAuth2Providers; use Appwrite\Platform\Modules\Console\Http\Redirects\Auth\Get as RedirectAuth; use Appwrite\Platform\Modules\Console\Http\Redirects\Card\Get as RedirectCard; use Appwrite\Platform\Modules\Console\Http\Redirects\Invite\Get as RedirectInvite; @@ -28,6 +29,7 @@ class Http extends Service $this->addAction(Web::getName(), new Web()); $this->addAction(GetVariables::getName(), new GetVariables()); + $this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers()); $this->addAction(CreateAssistantQuery::getName(), new CreateAssistantQuery()); $this->addAction(GetResourceAvailability::getName(), new GetResourceAvailability()); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php index 1542f3b3bc..0fa0c187c9 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'Client Secret\' of Amazon OAuth2 app. For example: 79ffe4000000000000000000000000000000000000000000000000000002de55'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return 'amzn1.application-oa2-client.87400c00000000000000000000063d5b2'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return '79ffe4000000000000000000000000000000000000000000000000000002de55'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php index c2b0885f5f..6e8a75990a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php @@ -61,6 +61,59 @@ class Update extends Base return ''; } + public static function getClientIdName(): string + { + return 'Service ID'; + } + + public static function getClientIdExample(): string + { + return 'ip.appwrite.app.web'; + } + + public static function getClientSecretName(): string + { + // Apple does not use a single clientSecret param. Returning an empty + // string causes the default getParameters() to skip it; the override + // below adds the three real fields (keyId, teamId, p8File). + return ''; + } + + public static function getClientSecretExample(): string + { + return ''; + } + + public static function getParameters(): array + { + return [ + [ + '$id' => static::getClientIdParamName(), + 'name' => static::getClientIdName(), + 'example' => static::getClientIdExample(), + 'hint' => '', + ], + [ + '$id' => 'keyId', + 'name' => 'Key ID', + 'example' => 'P4000000N8', + 'hint' => '', + ], + [ + '$id' => 'teamId', + 'name' => 'Team ID', + 'example' => 'D4000000R6', + 'hint' => '', + ], + [ + '$id' => 'p8File', + 'name' => 'P8 File', + 'example' => '-----BEGIN PRIVATE KEY-----MIGTAg...jy2Xbna-----END PRIVATE KEY-----', + 'hint' => '', + ], + ]; + } + public function __construct() { $providerId = static::getProviderId(); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php index 9c94864a50..38ac453ece 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php @@ -54,6 +54,38 @@ class Update extends Base return '\'Client Secret\' of Auth0 OAuth2 app. For example: zXz0000-00000000000000000000000000000-00000000000000000000PJafnF'; } + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return 'OaOkIA000000000000000000005KLSYq'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'zXz0000-00000000000000000000000000000-00000000000000000000PJafnF'; + } + + public static function getParameters(): array + { + return \array_merge(parent::getParameters(), [ + [ + '$id' => 'endpoint', + 'name' => 'Domain', + 'example' => 'example.us.auth0.com', + 'hint' => '', + ], + ]); + } + public function __construct() { $providerId = static::getProviderId(); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php index c4e27899a8..97f78f8013 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php @@ -54,6 +54,38 @@ class Update extends Base return '\'Client Secret\' of Authentik OAuth2 app. For example: ntQadq000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Hp5WK'; } + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return 'dTKOPa0000000000000000000000000000e7G8hv'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'ntQadq000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Hp5WK'; + } + + public static function getParameters(): array + { + return \array_merge(parent::getParameters(), [ + [ + '$id' => 'endpoint', + 'name' => 'Domain', + 'example' => 'example.authentik.com', + 'hint' => '', + ], + ]); + } + public function __construct() { $providerId = static::getProviderId(); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php index 6331f23080..b0595cd524 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'client secret\' of Autodesk OAuth2 app. For example: 7I000000000000MW'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return '5zw90v00000000000000000000kVYXN7'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return '7I000000000000MW'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php index 6591270ded..25acb75ee9 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php @@ -64,6 +64,97 @@ abstract class Base extends Action */ abstract public static function getClientSecretDescription(): string; + /** + * Verbose, user-facing name of the clientId param. Includes alternate + * names when the provider exposes more than one (e.g. "Client ID or App + * ID", "Application ID (also known as Client ID)"). + * + * @return string + */ + abstract public static function getClientIdName(): string; + + /** + * Example value of the clientId param. Used to build the public OAuth2 + * providers metadata response. + * + * @return string + */ + abstract public static function getClientIdExample(): string; + + /** + * Optional hint for the clientId param. Typically used to call out a + * common wrong value (e.g. "Example of wrong value: 370006"). Defaults + * to an empty string. + */ + public static function getClientIdHint(): string + { + return ''; + } + + /** + * Verbose, user-facing name of the clientSecret param. Returns an empty + * string for providers that don't have a single clientSecret param + * (e.g. Apple uses keyId/teamId/p8File instead). + * + * @return string + */ + abstract public static function getClientSecretName(): string; + + /** + * Example value of the clientSecret param. Returns an empty string for + * providers without a clientSecret param. + * + * @return string + */ + abstract public static function getClientSecretExample(): string; + + /** + * Optional hint for the clientSecret param. Defaults to an empty string. + */ + public static function getClientSecretHint(): string + { + return ''; + } + + /** + * Public-facing parameter metadata for this provider. Used by the public + * console OAuth2 providers endpoint to describe the form fields a project + * owner must fill in to configure the provider. + * + * Default shape: clientId + clientSecret. Providers that take additional + * fields (Apple, Auth0, Authentik, Gitlab, Microsoft, Oidc, Okta) + * override this method to add or replace entries. Each parameter is an + * associative array with keys `$id`, `name`, `example`, `hint`. + * + * @return array> + */ + public static function getParameters(): array + { + $parameters = []; + + $clientIdName = static::getClientIdName(); + if ($clientIdName !== '') { + $parameters[] = [ + '$id' => static::getClientIdParamName(), + 'name' => $clientIdName, + 'example' => static::getClientIdExample(), + 'hint' => static::getClientIdHint(), + ]; + } + + $clientSecretName = static::getClientSecretName(); + if ($clientSecretName !== '') { + $parameters[] = [ + '$id' => static::getClientSecretParamName(), + 'name' => $clientSecretName, + 'example' => static::getClientSecretExample(), + 'hint' => static::getClientSecretHint(), + ]; + } + + return $parameters; + } + /** * Public-facing name of the clientId param. Some providers use a different * terminology (e.g. Dropbox calls it "App key"), so the param name and the diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php index cbb48445b5..4321a56f30 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php @@ -52,4 +52,24 @@ class Update extends Base { return '\'Secret\' of Bitbucket OAuth2 app. For example: NMfLZJ00000000000000000000TLQdDx'; } + + public static function getClientIdName(): string + { + return 'Key'; + } + + public static function getClientIdExample(): string + { + return 'Knt70000000000ByRc'; + } + + public static function getClientSecretName(): string + { + return 'Secret'; + } + + public static function getClientSecretExample(): string + { + return 'NMfLZJ00000000000000000000TLQdDx'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php index d8964610e6..ebcb6837d2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'Client Secret\' of Bitly OAuth2 app. For example: a13e250000000000000000000000000000d73095'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return 'd95151000000000000000000000000000067af9b'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'a13e250000000000000000000000000000d73095'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php index 8cb9df835a..ebc847f553 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'Client Secret\' of Box OAuth2 app. For example: OKM1f100000000000000000000eshEif'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return 'deglcs00000000000000000000x2og6y'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'OKM1f100000000000000000000eshEif'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php index d2f38309b4..d29d92c0f6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php @@ -52,4 +52,24 @@ class Update extends Base { return '\'API secret\' of Dailymotion OAuth2 app. For example: a399a90000000000000000000000000000d90639'; } + + public static function getClientIdName(): string + { + return 'API Key'; + } + + public static function getClientIdExample(): string + { + return '07a9000000000000067f'; + } + + public static function getClientSecretName(): string + { + return 'API Secret'; + } + + public static function getClientSecretExample(): string + { + return 'a399a90000000000000000000000000000d90639'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php index 5efc193019..2d4dd805f9 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'Client Secret\' of Discord OAuth2 app. For example: YmPXnM000000000000000000002zFg5D'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return '950722000000343754'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'YmPXnM000000000000000000002zFg5D'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php index e77cd9b152..74cc714e35 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php @@ -52,4 +52,24 @@ class Update extends Base { return '\'Secret Key\', also known as \'API Secret\', of Disqus OAuth2 app. For example: W7Bykj00000000000000000000000000000000000000000000000000003o43w9'; } + + public static function getClientIdName(): string + { + return 'Public Key, also known as API Key'; + } + + public static function getClientIdExample(): string + { + return 'cgegH70000000000000000000000000000000000000000000000000000Hr1nYX'; + } + + public static function getClientSecretName(): string + { + return 'Secret Key, also known as API Secret'; + } + + public static function getClientSecretExample(): string + { + return 'W7Bykj00000000000000000000000000000000000000000000000000003o43w9'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php index 385b7719df..b6dc21e790 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php @@ -52,4 +52,24 @@ class Update extends Base { return '\'App secret\' of Dropbox OAuth2 app. For example: g200000000000vw'; } + + public static function getClientIdName(): string + { + return 'App Key'; + } + + public static function getClientIdExample(): string + { + return 'jl000000000009t'; + } + + public static function getClientSecretName(): string + { + return 'App Secret'; + } + + public static function getClientSecretExample(): string + { + return 'g200000000000vw'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php index 291daec414..8993d8f0ef 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php @@ -52,4 +52,24 @@ class Update extends Base { return '\'Shared Secret\' of Etsy OAuth2 app. For example: tp000000ru'; } + + public static function getClientIdName(): string + { + return 'Keystring'; + } + + public static function getClientIdExample(): string + { + return 'nsgzxh0000000000008j85a2'; + } + + public static function getClientSecretName(): string + { + return 'Shared Secret'; + } + + public static function getClientSecretExample(): string + { + return 'tp000000ru'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php index a3f97334a3..af3a42c94b 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php @@ -52,4 +52,24 @@ class Update extends Base { return '\'App secret\' of Facebook OAuth2 app. For example: 2d0b2800000000000000000000d38af4'; } + + public static function getClientIdName(): string + { + return 'App ID'; + } + + public static function getClientIdExample(): string + { + return '260600000007694'; + } + + public static function getClientSecretName(): string + { + return 'App Secret'; + } + + public static function getClientSecretExample(): string + { + return '2d0b2800000000000000000000d38af4'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php index b005bf17c9..06fd3ebc5a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'Client Secret\' of Figma OAuth2 app. For example: yEpOYn0000000000000000004iIsU5'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return 'byay5H0000000000VtiI40'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'yEpOYn0000000000000000004iIsU5'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php index 3d4f77f117..6858fcf996 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php @@ -42,4 +42,29 @@ class Update extends Base { return '\'Client secret\' of GitHub OAuth2 app, or GitHub generic app. For example: 5e07c00000000000000000000000000000198bcc'; } + + public static function getClientIdName(): string + { + return 'Client ID or App ID'; + } + + public static function getClientIdExample(): string + { + return 'e4d87900000000540733'; + } + + public static function getClientIdHint(): string + { + return 'Example of wrong value: 370006'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return '5e07c00000000000000000000000000000198bcc'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php index 70c538454f..474780312b 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php @@ -65,6 +65,38 @@ class Update extends Base return '\'Secret\' of GitLab OAuth2 app. For example: gloas-838cfa0000000000000000000000000000000000000000000000000000ecbb38'; } + public static function getClientIdName(): string + { + return 'Application ID'; + } + + public static function getClientIdExample(): string + { + return 'd41ffe0000000000000000000000000000000000000000000000000000d5e252'; + } + + public static function getClientSecretName(): string + { + return 'Secret'; + } + + public static function getClientSecretExample(): string + { + return 'gloas-838cfa0000000000000000000000000000000000000000000000000000ecbb38'; + } + + public static function getParameters(): array + { + return \array_merge(parent::getParameters(), [ + [ + '$id' => 'endpoint', + 'name' => 'Endpoint', + 'example' => 'https://gitlab.com', + 'hint' => '', + ], + ]); + } + public function __construct() { $providerId = static::getProviderId(); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php index 796b6dae20..76bff1f34d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'Client secret\' of Google OAuth2 app. For example: GOCSPX-2k8gsR0000000000000000VNahJj'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'GOCSPX-2k8gsR0000000000000000VNahJj'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php index b5c126a08c..f054c81ecf 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'Client Secret\' of Kick OAuth2 app. For example: 34ac5600000000000000000000000000000000000000000000000000e830c8b'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return '01KQ7C00000000000001MFHS32'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return '34ac5600000000000000000000000000000000000000000000000000e830c8b'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php index f23908279e..72f9fc1825 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php @@ -47,4 +47,24 @@ class Update extends Base { return '\'Primary Client Secret\' or \'Secondary Client Secret\', of LinkedIn OAuth2 app. For example: WPL_AP1.2Bf0000000000000./HtlYw=='; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return '770000000000dv'; + } + + public static function getClientSecretName(): string + { + return 'Primary Client Secret or Secondary Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'WPL_AP1.2Bf0000000000000./HtlYw=='; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php index 5f72b65dd8..a276ca60bb 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php @@ -64,6 +64,38 @@ class Update extends Base return '\'Application Secret\' (also known as Client Secret) of Microsoft Entra ID app. For example: A1bC2dE3fH4iJ5kL6mN7oP8qR9sT0u'; } + public static function getClientIdName(): string + { + return 'Application ID (also known as Client ID)'; + } + + public static function getClientIdExample(): string + { + return '00001111-aaaa-2222-bbbb-3333cccc4444'; + } + + public static function getClientSecretName(): string + { + return 'Application Secret (also known as Client Secret)'; + } + + public static function getClientSecretExample(): string + { + return 'A1bC2dE3fH4iJ5kL6mN7oP8qR9sT0u'; + } + + public static function getParameters(): array + { + return \array_merge(parent::getParameters(), [ + [ + '$id' => 'tenant', + 'name' => 'Tenant', + 'example' => 'common', + 'hint' => '', + ], + ]); + } + public function __construct() { $providerId = static::getProviderId(); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php index 56451166a4..b85c7158a7 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php @@ -52,4 +52,24 @@ class Update extends Base { return '\'OAuth Client Secret\' of Notion OAuth2 app. For example: secret_dLUr4b000000000000000000000000000000lFHAa9'; } + + public static function getClientIdName(): string + { + return 'OAuth Client ID'; + } + + public static function getClientIdExample(): string + { + return '341d8700-0000-0000-0000-000000446ee3'; + } + + public static function getClientSecretName(): string + { + return 'OAuth Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'secret_dLUr4b000000000000000000000000000000lFHAa9'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php index c000b456ec..55a14307cd 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php @@ -56,6 +56,56 @@ class Update extends Base return '\'Client Secret\' of OpenID Connect OAuth2 app. For example: Ah68ed000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003qpcHV'; } + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return 'qibI2x0000000000000000000000000006L2YFoG'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'Ah68ed000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003qpcHV'; + } + + public static function getParameters(): array + { + return \array_merge(parent::getParameters(), [ + [ + '$id' => 'wellKnownURL', + 'name' => 'Well-known URL', + 'example' => 'https://myoauth.com/.well-known/openid-configuration', + 'hint' => '', + ], + [ + '$id' => 'authorizationURL', + 'name' => 'Authorization URL', + 'example' => 'https://myoauth.com/oauth2/authorize', + 'hint' => '', + ], + [ + '$id' => 'tokenUrl', + 'name' => 'Token URL', + 'example' => 'https://myoauth.com/oauth2/token', + 'hint' => '', + ], + [ + '$id' => 'userInfoUrl', + 'name' => 'User Info URL', + 'example' => 'https://myoauth.com/oauth2/userinfo', + 'hint' => '', + ], + ]); + } + public function __construct() { $providerId = static::getProviderId(); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php index 504c0636af..eb135798c5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php @@ -56,6 +56,44 @@ class Update extends Base return '\'Client Secret\' of Okta OAuth2 app. For example: Kiq0000000000000000000000000000000000000-00000000000H2L5-3SJ-vRV'; } + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return '0oa00000000000000698'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'Kiq0000000000000000000000000000000000000-00000000000H2L5-3SJ-vRV'; + } + + public static function getParameters(): array + { + return \array_merge(parent::getParameters(), [ + [ + '$id' => 'domain', + 'name' => 'Domain', + 'example' => 'trial-6400025.okta.com', + 'hint' => 'Example of wrong value: trial-6400025-admin.okta.com, or https://trial-6400025.okta.com/', + ], + [ + '$id' => 'authorizationServerId', + 'name' => 'Authorization Server ID', + 'example' => 'aus000000000000000h7z', + 'hint' => '', + ], + ]); + } + public function __construct() { $providerId = static::getProviderId(); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php index 36b50475da..0ed9596725 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php @@ -47,4 +47,24 @@ class Update extends Base { return '\'Secret key 1\', or \'Secret key 2\', of ' . static::getProviderLabel() . ' OAuth2 app. For example: EH8KCXtew--000000000000000000000000000000000000000_C-1_5UP_000000000000000CB7KDp'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return 'AdhIEG7-000000000000-0000000000000000000000000000000-0000000000000000000000-2pyB'; + } + + public static function getClientSecretName(): string + { + return 'Secret Key 1 or Secret Key 2'; + } + + public static function getClientSecretExample(): string + { + return 'EH8KCXtew--000000000000000000000000000000000000000_C-1_5UP_000000000000000CB7KDp'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php index 47efa8b32b..72f7eb8f2c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'Client Secret\' of Podio OAuth2 app. For example: Rn247T0000000000000000000000000000000000000000000000000000W2zWTN'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return 'appwrite-o0000000st-app'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'Rn247T0000000000000000000000000000000000000000000000000000W2zWTN'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php index 8721114327..1802932ce4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php @@ -52,4 +52,24 @@ class Update extends Base { return '\'Consumer secret\' of Salesforce OAuth2 app. For example: 3w000000000000e2'; } + + public static function getClientIdName(): string + { + return 'Consumer Key'; + } + + public static function getClientIdExample(): string + { + return '3MVG9I0000000000000000000000000000000000000000000000000000000000000000000000000C5Aejq'; + } + + public static function getClientSecretName(): string + { + return 'Consumer Secret'; + } + + public static function getClientSecretExample(): string + { + return '3w000000000000e2'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php index 612bb26968..561563a37c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'Client Secret\' of Slack OAuth2 app. For example: 81656000000000000000000000f3d2fd'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return '23000000089.15000000000023'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return '81656000000000000000000000f3d2fd'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php index d28bfac8a2..1134fd194a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'Client secret\' of Spotify OAuth2 app. For example: db068a000000000000000000008b5b9f'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return '6ec271000000000000000000009beace'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'db068a000000000000000000008b5b9f'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php index 605804fa96..4702ef271d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php @@ -47,4 +47,24 @@ class Update extends Base { return '\'API Secret key\' of Stripe OAuth2 app. For example: sk_51SfOd000000000000000000000000000000000000000000000000000000000000000000000000000000000000000QGWYfp'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return 'ca_UKibXX0000000000000000000006byvR'; + } + + public static function getClientSecretName(): string + { + return 'API Secret Key'; + } + + public static function getClientSecretExample(): string + { + return 'sk_51SfOd000000000000000000000000000000000000000000000000000000000000000000000000000000000000000QGWYfp'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Tradeshift/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Tradeshift/Update.php index bff866cde6..3d0e05b886 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Tradeshift/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Tradeshift/Update.php @@ -52,4 +52,24 @@ class Update extends Base { return '\'Oauth2 Client secret\' of ' . static::getProviderLabel() . ' OAuth2 app. For example: 7cb52700-0000-0000-0000-000000ca5b83'; } + + public static function getClientIdName(): string + { + return 'OAuth2 Client ID'; + } + + public static function getClientIdExample(): string + { + return 'appwrite-tes00000.0000000000est-app'; + } + + public static function getClientSecretName(): string + { + return 'OAuth2 Client Secret'; + } + + public static function getClientSecretExample(): string + { + return '7cb52700-0000-0000-0000-000000ca5b83'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Twitch/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Twitch/Update.php index 09dfadb697..7377ba421d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Twitch/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Twitch/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'Client Secret\' of Twitch OAuth2 app. For example: pmapue000000000000000000zylw3v'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return 'vvi0in000000000000000000ikmt9p'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'pmapue000000000000000000zylw3v'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/WordPress/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/WordPress/Update.php index 706638c6ce..b8b49f6970 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/WordPress/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/WordPress/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'Client Secret\' of WordPress OAuth2 app. For example: PlBfJS0000000000000000000000000000000000000000000000000000EdUZJk'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return '130005'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'PlBfJS0000000000000000000000000000000000000000000000000000EdUZJk'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/X/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/X/Update.php index b38eab0ab0..83b4048ba5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/X/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/X/Update.php @@ -52,4 +52,24 @@ class Update extends Base { return '\'Secret Key\' of X OAuth2 app. For example: tkEPkp00000000000000000000000000000000000000FTxbI9'; } + + public static function getClientIdName(): string + { + return 'Customer Key'; + } + + public static function getClientIdExample(): string + { + return 'slzZV0000000000000NFLaWT'; + } + + public static function getClientSecretName(): string + { + return 'Secret Key'; + } + + public static function getClientSecretExample(): string + { + return 'tkEPkp00000000000000000000000000000000000000FTxbI9'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php index 512c8b1e6d..62c19851ab 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'Client Secret\', also known as \'Customer Secret\', of Yahoo OAuth2 app. For example: cf978f0000000000000000000000000000c5e2e9'; } + + public static function getClientIdName(): string + { + return 'Client ID, also known as Customer Key'; + } + + public static function getClientIdExample(): string + { + return 'dj0yJm000000000000000000000000000000000000000000000000000000000000000000000000000000000000Z4PWRm'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret, also known as Customer Secret'; + } + + public static function getClientSecretExample(): string + { + return 'cf978f0000000000000000000000000000c5e2e9'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yandex/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yandex/Update.php index 31f8cd771e..8e5e5839a8 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yandex/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yandex/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'Client secret\' of Yandex OAuth2 app. For example: bbf98500000000000000000000c75a63'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return '6a8a6a0000000000000000000091483c'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'bbf98500000000000000000000c75a63'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoho/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoho/Update.php index a663667af7..75fa3692bd 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoho/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoho/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'Client Secret\' of Zoho OAuth2 app. For example: fb5cac000000000000000000000000000000a68f6e'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return '1000.83C178000000000000000000RPNX0B'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'fb5cac000000000000000000000000000000a68f6e'; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoom/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoom/Update.php index 4edea07891..b0e999b256 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoom/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoom/Update.php @@ -42,4 +42,24 @@ class Update extends Base { return '\'Client Secret\' of Zoom OAuth2 app. For example: GAWsG4000000000000000000007U01ON'; } + + public static function getClientIdName(): string + { + return 'Client ID'; + } + + public static function getClientIdExample(): string + { + return 'QMAC00000000000000w0AQ'; + } + + public static function getClientSecretName(): string + { + return 'Client Secret'; + } + + public static function getClientSecretExample(): string + { + return 'GAWsG4000000000000000000007U01ON'; + } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 4dbcf135af..7670b027e9 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -329,6 +329,9 @@ class Response extends SwooleResponse // Console public const MODEL_CONSOLE_VARIABLES = 'consoleVariables'; + public const MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER = 'consoleOAuth2ProviderParameter'; + public const MODEL_CONSOLE_OAUTH2_PROVIDER = 'consoleOAuth2Provider'; + public const MODEL_CONSOLE_OAUTH2_PROVIDER_LIST = 'consoleOAuth2ProviderList'; // Deprecated public const MODEL_PERMISSIONS = 'permissions'; diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2Provider.php b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2Provider.php new file mode 100644 index 0000000000..05969a5e8c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2Provider.php @@ -0,0 +1,37 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'OAuth2 provider ID.', + 'default' => '', + 'example' => 'github', + ]) + ->addRule('parameters', [ + 'type' => Response::MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER, + 'description' => 'List of parameters required to configure this OAuth2 provider.', + 'default' => [], + 'array' => true, + ]) + ; + } + + public function getName(): string + { + return 'Console OAuth2 Provider'; + } + + public function getType(): string + { + return Response::MODEL_CONSOLE_OAUTH2_PROVIDER; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderList.php b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderList.php new file mode 100644 index 0000000000..42d6936d42 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderList.php @@ -0,0 +1,37 @@ +addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of OAuth2 providers exposed by the server.', + 'default' => 0, + 'example' => 5, + ]) + ->addRule('oAuth2Providers', [ + 'type' => Response::MODEL_CONSOLE_OAUTH2_PROVIDER, + 'description' => 'List of OAuth2 providers, each with the parameters required to configure it.', + 'default' => [], + 'array' => true, + ]) + ; + } + + public function getName(): string + { + return 'Console OAuth2 Providers List'; + } + + public function getType(): string + { + return Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderParameter.php b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderParameter.php new file mode 100644 index 0000000000..a097718492 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderParameter.php @@ -0,0 +1,49 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Parameter ID. Maps to the request body field used by the project OAuth2 update endpoint (e.g. `clientId`, `appKey`, `tenant`).', + 'default' => '', + 'example' => 'clientId', + ]) + ->addRule('name', [ + 'type' => self::TYPE_STRING, + 'description' => 'Verbose, user-facing parameter name as shown in the provider\'s own dashboard. Includes alternate names when the provider exposes more than one.', + 'default' => '', + 'example' => 'Client ID or App ID', + ]) + ->addRule('example', [ + 'type' => self::TYPE_STRING, + 'description' => 'Example value for this parameter.', + 'default' => '', + 'example' => 'e4d87900000000540733', + ]) + ->addRule('hint', [ + 'type' => self::TYPE_STRING, + 'description' => 'Optional hint for this parameter, typically calling out a common wrong value. Empty string when no hint is set.', + 'default' => '', + 'example' => 'Example of wrong value: 370006', + ]) + ; + } + + public function getName(): string + { + return 'Console OAuth2 Provider Parameter'; + } + + public function getType(): string + { + return Response::MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php b/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php index 99f8bfa8f7..012aa85735 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php @@ -22,7 +22,7 @@ class OAuth2Linkedin extends OAuth2Base public function getClientSecretExample(): string { - return 'WPL_AP1.2Bf0000000000000'; + return 'WPL_AP1.2Bf0000000000000./HtlYw=='; } public function getClientSecretFieldName(): string diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php index 373383e3ec..779ede8d9c 100644 --- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php +++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php @@ -41,4 +41,91 @@ class ConsoleConsoleClientTest extends Scope $this->assertIsString($response['body']['_APP_DB_ADAPTER']); // When adding new keys, dont forget to update count a few lines above } + + public function testListOAuth2Providers(): void + { + $response = $this->client->call(Client::METHOD_GET, '/console/oauth2-providers', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['total']); + $this->assertIsArray($response['body']['oAuth2Providers']); + $this->assertGreaterThan(0, $response['body']['total']); + $this->assertEquals($response['body']['total'], \count($response['body']['oAuth2Providers'])); + + $providerIds = \array_column($response['body']['oAuth2Providers'], '$id'); + + // Well-known providers must be present + $this->assertContains('github', $providerIds); + $this->assertContains('google', $providerIds); + + // Mock providers must be excluded + $this->assertNotContains('mock', $providerIds); + $this->assertNotContains('mock-unverified', $providerIds); + + // Every provider has the expected shape + foreach ($response['body']['oAuth2Providers'] as $provider) { + $this->assertArrayHasKey('$id', $provider); + $this->assertIsString($provider['$id']); + $this->assertArrayHasKey('parameters', $provider); + $this->assertIsArray($provider['parameters']); + $this->assertGreaterThan(0, \count($provider['parameters'])); + + foreach ($provider['parameters'] as $parameter) { + $this->assertArrayHasKey('$id', $parameter); + $this->assertIsString($parameter['$id']); + $this->assertNotEmpty($parameter['$id']); + $this->assertArrayHasKey('name', $parameter); + $this->assertIsString($parameter['name']); + $this->assertNotEmpty($parameter['name']); + $this->assertArrayHasKey('example', $parameter); + $this->assertIsString($parameter['example']); + $this->assertArrayHasKey('hint', $parameter); + $this->assertIsString($parameter['hint']); + } + } + + // GitHub provider has the expected metadata for clientId, including the hint + $github = null; + foreach ($response['body']['oAuth2Providers'] as $provider) { + if ($provider['$id'] === 'github') { + $github = $provider; + break; + } + } + $this->assertNotNull($github); + $this->assertCount(2, $github['parameters']); + $clientId = $github['parameters'][0]; + $this->assertEquals('clientId', $clientId['$id']); + $this->assertEquals('Client ID or App ID', $clientId['name']); + $this->assertEquals('e4d87900000000540733', $clientId['example']); + $this->assertEquals('Example of wrong value: 370006', $clientId['hint']); + $clientSecret = $github['parameters'][1]; + $this->assertEquals('clientSecret', $clientSecret['$id']); + $this->assertEquals('Client Secret', $clientSecret['name']); + $this->assertNotEmpty($clientSecret['example']); + $this->assertEquals('', $clientSecret['hint']); + + // Multi-parameter provider (Apple) exposes its non-clientSecret fields + $apple = null; + foreach ($response['body']['oAuth2Providers'] as $provider) { + if ($provider['$id'] === 'apple') { + $apple = $provider; + break; + } + } + $this->assertNotNull($apple); + $appleParamIds = \array_column($apple['parameters'], '$id'); + $this->assertContains('serviceId', $appleParamIds); + $this->assertContains('keyId', $appleParamIds); + $this->assertContains('teamId', $appleParamIds); + $this->assertContains('p8File', $appleParamIds); + // Apple does not expose a single clientSecret param + $this->assertNotContains('clientSecret', $appleParamIds); + + // Sandbox providers (e.g. paypalSandbox) are included + $this->assertContains('paypalSandbox', $providerIds); + } } diff --git a/tests/e2e/Services/Console/ConsoleCustomServerTest.php b/tests/e2e/Services/Console/ConsoleCustomServerTest.php index 3748bbe546..d3c64ae039 100644 --- a/tests/e2e/Services/Console/ConsoleCustomServerTest.php +++ b/tests/e2e/Services/Console/ConsoleCustomServerTest.php @@ -24,4 +24,23 @@ class ConsoleCustomServerTest extends Scope $this->assertEquals(401, $response['headers']['status-code']); } + + public function testListOAuth2Providers(): void + { + // Public endpoint: must succeed without admin authentication. Drop the + // headers from getHeaders() and only pass project + content-type. + $response = $this->client->call(Client::METHOD_GET, '/console/oauth2-providers', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['total']); + $this->assertIsArray($response['body']['oAuth2Providers']); + $this->assertGreaterThan(0, $response['body']['total']); + + $providerIds = \array_column($response['body']['oAuth2Providers'], '$id'); + $this->assertContains('github', $providerIds); + $this->assertNotContains('mock', $providerIds); + } } From ed0c7b4e129ba10171006e745862a19546c45837 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 13:24:15 +0530 Subject: [PATCH 328/402] test: add CSRF attack prevention test for impersonateUserId query param --- tests/e2e/Services/Users/UsersBase.php | 100 +++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 5f38df5c07..069a2eab48 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2708,6 +2708,106 @@ trait UsersBase $this->assertIsArray($response['body']['users']); } + /** + * Proves that the Sec-Fetch-Site CSRF guard prevents forced impersonation via query params. + * + * Attack scenario (without the guard): + * A malicious page on attacker.com embeds: + * + * The browser automatically attaches the impersonator's session cookies. + * Without any guard, the server would impersonate victim_id silently. + * + * Why Sec-Fetch-Site works: + * Browsers set Sec-Fetch-Site: cross-site on all cross-origin requests (img, fetch, etc.). + * It is a browser-enforced forbidden header — JavaScript cannot set or spoof it. + * We only accept ?impersonateUserId when Sec-Fetch-Site is exactly same-origin. + * + * This test proves three attack vectors are all blocked: + * 1. cross-site — attacker.com embeds pointing at Appwrite + * 2. same-site — attacker controls a subdomain (e.g. evil.appwrite.io) + * 3. absent — reverse proxy strips Fetch Metadata headers (fail-closed) + */ + public function testImpersonateQueryParamCsrfAttackPrevented(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + // Impersonator user (the victim whose session gets hijacked in the attack) + $impersonator = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'csrf-guard-impersonator@appwrite.io', + 'password' => 'password', + 'name' => 'CSRF Guard Impersonator', + ]); + $this->assertEquals(201, $impersonator['headers']['status-code']); + $impersonatorId = $impersonator['body']['$id']; + + // Target user (who the attacker wants to impersonate) + $target = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => 'csrf-guard-target@appwrite.io', + 'password' => 'password', + 'name' => 'CSRF Guard Target', + ]); + $this->assertEquals(201, $target['headers']['status-code']); + $targetId = $target['body']['$id']; + + $this->client->call(Client::METHOD_PATCH, '/users/' . $impersonatorId . '/impersonator', $headers, ['impersonator' => true]); + + $session = $this->client->call(Client::METHOD_POST, '/users/' . $impersonatorId . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + $sessionSecret = $session['body']['secret']; + + $sessionHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $sessionSecret, + ]; + + // Attack vector 1: cross-site (attacker.com embeds ) + // Browser sends Sec-Fetch-Site: cross-site — must be blocked. + $crossSite = $this->client->call(Client::METHOD_GET, '/account', + array_merge($sessionHeaders, ['sec-fetch-site' => 'cross-site']), + ['impersonateUserId' => $targetId] + ); + $this->assertEquals(200, $crossSite['headers']['status-code']); + $this->assertEquals($impersonatorId, $crossSite['body']['$id'], 'cross-site: impersonation must be blocked'); + $this->assertArrayNotHasKey('impersonatorUserId', $crossSite['body']); + + // Attack vector 2: same-site (attacker controls evil.appwrite.io subdomain) + // Browser sends Sec-Fetch-Site: same-site — must also be blocked. + $sameSite = $this->client->call(Client::METHOD_GET, '/account', + array_merge($sessionHeaders, ['sec-fetch-site' => 'same-site']), + ['impersonateUserId' => $targetId] + ); + $this->assertEquals(200, $sameSite['headers']['status-code']); + $this->assertEquals($impersonatorId, $sameSite['body']['$id'], 'same-site: subdomain attack must be blocked'); + $this->assertArrayNotHasKey('impersonatorUserId', $sameSite['body']); + + // Attack vector 3: absent header (reverse proxy strips Fetch Metadata headers) + // Guard must fail-closed — absent Sec-Fetch-Site must not allow query param. + $noFetchSite = $this->client->call(Client::METHOD_GET, '/account', + $sessionHeaders, + ['impersonateUserId' => $targetId] + ); + $this->assertEquals(200, $noFetchSite['headers']['status-code']); + $this->assertEquals($impersonatorId, $noFetchSite['body']['$id'], 'absent header: must fail-closed'); + $this->assertArrayNotHasKey('impersonatorUserId', $noFetchSite['body']); + + // Legitimate use: same-origin (Console loading a file URL with impersonation embedded) + // Browser sends Sec-Fetch-Site: same-origin — must succeed. + $sameOrigin = $this->client->call(Client::METHOD_GET, '/account', + array_merge($sessionHeaders, ['sec-fetch-site' => 'same-origin']), + ['impersonateUserId' => $targetId] + ); + $this->assertEquals(200, $sameOrigin['headers']['status-code']); + $this->assertEquals($targetId, $sameOrigin['body']['$id'], 'same-origin: impersonation must succeed'); + $this->assertEquals($impersonatorId, $sameOrigin['body']['impersonatorUserId']); + } + /** * Test impersonation via ?impersonateUserId= query param (same-origin browser request). * This is the primary use case for embedding impersonation in file/image URLs where From 5afc8f462ddbd6e4466b5467693b57300c75e95e Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 13:26:13 +0530 Subject: [PATCH 329/402] fix: allow same-site in CSRF guard to support Console on subdomains --- app/init/realtime/connection.php | 5 +++- app/init/resources/request.php | 5 +++- tests/e2e/Services/Users/UsersBase.php | 37 +++++++++++++------------- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index 5778b5c260..c6593927d9 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -332,7 +332,10 @@ return function (Container $container): void { // Query-param fallback is blocked for cross-site requests to prevent CSRF attacks via // third-party pages; Sec-Fetch-Site is a browser-enforced forbidden header. $fetchSite = $request->getHeader('sec-fetch-site', ''); - $isSameOrigin = $fetchSite === 'same-origin'; + // Allow same-origin and same-site: Console may be served from a different subdomain + // (e.g. vibes.appwrite.io) than the API, in which case the browser sends same-site. + // cross-site and absent are blocked to prevent CSRF via third-party embeds. + $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index dca4b84bd7..760b9d598e 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -577,7 +577,10 @@ return function (Container $container): void { // Query-param fallback is blocked for cross-site requests (Sec-Fetch-Site: cross-site) to prevent CSRF; // Sec-Fetch-Site is a browser-enforced forbidden header that cannot be spoofed by JavaScript. $fetchSite = $request->getHeader('sec-fetch-site', ''); - $isSameOrigin = $fetchSite === 'same-origin'; + // Allow same-origin and same-site: Console may be served from a different subdomain + // (e.g. vibes.appwrite.io) than the API, in which case the browser sends same-site. + // cross-site and absent are blocked to prevent CSRF via third-party embeds. + $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 069a2eab48..623e8cc3ec 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2722,10 +2722,11 @@ trait UsersBase * It is a browser-enforced forbidden header — JavaScript cannot set or spoof it. * We only accept ?impersonateUserId when Sec-Fetch-Site is exactly same-origin. * - * This test proves three attack vectors are all blocked: - * 1. cross-site — attacker.com embeds pointing at Appwrite - * 2. same-site — attacker controls a subdomain (e.g. evil.appwrite.io) - * 3. absent — reverse proxy strips Fetch Metadata headers (fail-closed) + * This test proves two attack vectors are blocked and two legitimate origins are allowed: + * Blocked: cross-site — attacker.com embeds pointing at Appwrite + * Blocked: absent — reverse proxy strips Fetch Metadata headers (fail-closed) + * Allowed: same-origin — Console on the same origin as the API + * Allowed: same-site — Console on a subdomain (e.g. vibes.appwrite.io vs appwrite.io) */ public function testImpersonateQueryParamCsrfAttackPrevented(): void { @@ -2777,17 +2778,7 @@ trait UsersBase $this->assertEquals($impersonatorId, $crossSite['body']['$id'], 'cross-site: impersonation must be blocked'); $this->assertArrayNotHasKey('impersonatorUserId', $crossSite['body']); - // Attack vector 2: same-site (attacker controls evil.appwrite.io subdomain) - // Browser sends Sec-Fetch-Site: same-site — must also be blocked. - $sameSite = $this->client->call(Client::METHOD_GET, '/account', - array_merge($sessionHeaders, ['sec-fetch-site' => 'same-site']), - ['impersonateUserId' => $targetId] - ); - $this->assertEquals(200, $sameSite['headers']['status-code']); - $this->assertEquals($impersonatorId, $sameSite['body']['$id'], 'same-site: subdomain attack must be blocked'); - $this->assertArrayNotHasKey('impersonatorUserId', $sameSite['body']); - - // Attack vector 3: absent header (reverse proxy strips Fetch Metadata headers) + // Attack vector 2: absent header (reverse proxy strips Fetch Metadata headers) // Guard must fail-closed — absent Sec-Fetch-Site must not allow query param. $noFetchSite = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, @@ -2797,8 +2788,7 @@ trait UsersBase $this->assertEquals($impersonatorId, $noFetchSite['body']['$id'], 'absent header: must fail-closed'); $this->assertArrayNotHasKey('impersonatorUserId', $noFetchSite['body']); - // Legitimate use: same-origin (Console loading a file URL with impersonation embedded) - // Browser sends Sec-Fetch-Site: same-origin — must succeed. + // Legitimate use 1: same-origin (Console on same origin as API) $sameOrigin = $this->client->call(Client::METHOD_GET, '/account', array_merge($sessionHeaders, ['sec-fetch-site' => 'same-origin']), ['impersonateUserId' => $targetId] @@ -2806,6 +2796,15 @@ trait UsersBase $this->assertEquals(200, $sameOrigin['headers']['status-code']); $this->assertEquals($targetId, $sameOrigin['body']['$id'], 'same-origin: impersonation must succeed'); $this->assertEquals($impersonatorId, $sameOrigin['body']['impersonatorUserId']); + + // Legitimate use 2: same-site (Console on subdomain, e.g. vibes.appwrite.io vs appwrite.io) + $sameSite = $this->client->call(Client::METHOD_GET, '/account', + array_merge($sessionHeaders, ['sec-fetch-site' => 'same-site']), + ['impersonateUserId' => $targetId] + ); + $this->assertEquals(200, $sameSite['headers']['status-code']); + $this->assertEquals($targetId, $sameSite['body']['$id'], 'same-site: impersonation must succeed'); + $this->assertEquals($impersonatorId, $sameSite['body']['impersonatorUserId']); } /** @@ -2846,8 +2845,8 @@ trait UsersBase $this->assertEquals(201, $session['headers']['status-code']); $sessionSecret = $session['body']['secret']; - // Query param works only when Sec-Fetch-Site is exactly same-origin. - // same-site is intentionally excluded to prevent subdomain-based CSRF attacks. + // Query param works when Sec-Fetch-Site is same-origin or same-site. + // same-site covers Console deployed on a subdomain (e.g. vibes.appwrite.io). $account = $this->client->call(Client::METHOD_GET, '/account', [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, From 3dd5a51ba497d9f0e7a428001b2148f697bdc9b4 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 13:34:01 +0530 Subject: [PATCH 330/402] style: fix method argument spacing (Pint PSR-12) --- tests/e2e/Services/Users/UsersBase.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 623e8cc3ec..862e858422 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2770,7 +2770,9 @@ trait UsersBase // Attack vector 1: cross-site (attacker.com embeds ) // Browser sends Sec-Fetch-Site: cross-site — must be blocked. - $crossSite = $this->client->call(Client::METHOD_GET, '/account', + $crossSite = $this->client->call( + Client::METHOD_GET, + '/account', array_merge($sessionHeaders, ['sec-fetch-site' => 'cross-site']), ['impersonateUserId' => $targetId] ); @@ -2780,7 +2782,9 @@ trait UsersBase // Attack vector 2: absent header (reverse proxy strips Fetch Metadata headers) // Guard must fail-closed — absent Sec-Fetch-Site must not allow query param. - $noFetchSite = $this->client->call(Client::METHOD_GET, '/account', + $noFetchSite = $this->client->call( + Client::METHOD_GET, + '/account', $sessionHeaders, ['impersonateUserId' => $targetId] ); @@ -2789,7 +2793,9 @@ trait UsersBase $this->assertArrayNotHasKey('impersonatorUserId', $noFetchSite['body']); // Legitimate use 1: same-origin (Console on same origin as API) - $sameOrigin = $this->client->call(Client::METHOD_GET, '/account', + $sameOrigin = $this->client->call( + Client::METHOD_GET, + '/account', array_merge($sessionHeaders, ['sec-fetch-site' => 'same-origin']), ['impersonateUserId' => $targetId] ); @@ -2798,7 +2804,9 @@ trait UsersBase $this->assertEquals($impersonatorId, $sameOrigin['body']['impersonatorUserId']); // Legitimate use 2: same-site (Console on subdomain, e.g. vibes.appwrite.io vs appwrite.io) - $sameSite = $this->client->call(Client::METHOD_GET, '/account', + $sameSite = $this->client->call( + Client::METHOD_GET, + '/account', array_merge($sessionHeaders, ['sec-fetch-site' => 'same-site']), ['impersonateUserId' => $targetId] ); From bda823ac0e5923e57c478bb844ab3eac85b7a593 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 13:38:00 +0530 Subject: [PATCH 331/402] chore: format --- app/init/realtime/connection.php | 2 +- app/init/resources/request.php | 2 +- tests/e2e/Services/Users/UsersBase.php | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index c6593927d9..03dfdc4fd7 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -333,7 +333,7 @@ return function (Container $container): void { // third-party pages; Sec-Fetch-Site is a browser-enforced forbidden header. $fetchSite = $request->getHeader('sec-fetch-site', ''); // Allow same-origin and same-site: Console may be served from a different subdomain - // (e.g. vibes.appwrite.io) than the API, in which case the browser sends same-site. + // than the API, in which case the browser sends same-site. // cross-site and absent are blocked to prevent CSRF via third-party embeds. $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 760b9d598e..c0097a2416 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -578,7 +578,7 @@ return function (Container $container): void { // Sec-Fetch-Site is a browser-enforced forbidden header that cannot be spoofed by JavaScript. $fetchSite = $request->getHeader('sec-fetch-site', ''); // Allow same-origin and same-site: Console may be served from a different subdomain - // (e.g. vibes.appwrite.io) than the API, in which case the browser sends same-site. + // than the API, in which case the browser sends same-site. // cross-site and absent are blocked to prevent CSRF via third-party embeds. $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 862e858422..d5c06e9f8d 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2726,7 +2726,7 @@ trait UsersBase * Blocked: cross-site — attacker.com embeds pointing at Appwrite * Blocked: absent — reverse proxy strips Fetch Metadata headers (fail-closed) * Allowed: same-origin — Console on the same origin as the API - * Allowed: same-site — Console on a subdomain (e.g. vibes.appwrite.io vs appwrite.io) + * Allowed: same-site — Console on a different subdomain than the API */ public function testImpersonateQueryParamCsrfAttackPrevented(): void { @@ -2803,7 +2803,7 @@ trait UsersBase $this->assertEquals($targetId, $sameOrigin['body']['$id'], 'same-origin: impersonation must succeed'); $this->assertEquals($impersonatorId, $sameOrigin['body']['impersonatorUserId']); - // Legitimate use 2: same-site (Console on subdomain, e.g. vibes.appwrite.io vs appwrite.io) + // Legitimate use 2: same-site (Console on a different subdomain than the API) $sameSite = $this->client->call( Client::METHOD_GET, '/account', @@ -2854,7 +2854,7 @@ trait UsersBase $sessionSecret = $session['body']['secret']; // Query param works when Sec-Fetch-Site is same-origin or same-site. - // same-site covers Console deployed on a subdomain (e.g. vibes.appwrite.io). + // same-site covers Console deployed on a different subdomain than the API. $account = $this->client->call(Client::METHOD_GET, '/account', [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, From e2bb9a916114452972c50e650a4624f63794f3d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 10:08:39 +0200 Subject: [PATCH 332/402] Simplify oauth endpoints --- .../Http/Project/OAuth2/Amazon/Update.php | 10 ---- .../Http/Project/OAuth2/Apple/Update.php | 12 ----- .../Http/Project/OAuth2/Auth0/Update.php | 10 ---- .../Http/Project/OAuth2/Authentik/Update.php | 10 ---- .../Http/Project/OAuth2/Autodesk/Update.php | 10 ---- .../Project/Http/Project/OAuth2/Base.php | 52 ++++++++++++++++--- .../Http/Project/OAuth2/Bitbucket/Update.php | 10 ---- .../Http/Project/OAuth2/Bitly/Update.php | 10 ---- .../Http/Project/OAuth2/Box/Update.php | 10 ---- .../Project/OAuth2/Dailymotion/Update.php | 10 ---- .../Http/Project/OAuth2/Discord/Update.php | 10 ---- .../Http/Project/OAuth2/Disqus/Update.php | 10 ---- .../Http/Project/OAuth2/Dropbox/Update.php | 10 ---- .../Http/Project/OAuth2/Etsy/Update.php | 10 ---- .../Http/Project/OAuth2/Facebook/Update.php | 10 ---- .../Http/Project/OAuth2/Figma/Update.php | 10 ---- .../Http/Project/OAuth2/GitHub/Update.php | 10 ---- .../Http/Project/OAuth2/Gitlab/Update.php | 10 ---- .../Http/Project/OAuth2/Google/Update.php | 10 ---- .../Http/Project/OAuth2/Kick/Update.php | 10 ---- .../Http/Project/OAuth2/Linkedin/Update.php | 10 ---- .../Http/Project/OAuth2/Microsoft/Update.php | 10 ---- .../Http/Project/OAuth2/Notion/Update.php | 10 ---- .../Http/Project/OAuth2/Oidc/Update.php | 10 ---- .../Http/Project/OAuth2/Okta/Update.php | 10 ---- .../Http/Project/OAuth2/Paypal/Update.php | 10 ---- .../Http/Project/OAuth2/Podio/Update.php | 10 ---- .../Http/Project/OAuth2/Salesforce/Update.php | 10 ---- .../Http/Project/OAuth2/Slack/Update.php | 10 ---- .../Http/Project/OAuth2/Spotify/Update.php | 10 ---- .../Http/Project/OAuth2/Stripe/Update.php | 10 ---- .../Http/Project/OAuth2/Tradeshift/Update.php | 10 ---- .../Http/Project/OAuth2/Twitch/Update.php | 10 ---- .../Http/Project/OAuth2/WordPress/Update.php | 10 ---- .../Project/Http/Project/OAuth2/X/Update.php | 10 ---- .../Http/Project/OAuth2/Yahoo/Update.php | 10 ---- .../Http/Project/OAuth2/Yandex/Update.php | 10 ---- .../Http/Project/OAuth2/Zoho/Update.php | 10 ---- .../Http/Project/OAuth2/Zoom/Update.php | 10 ---- 39 files changed, 44 insertions(+), 390 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php index 0fa0c187c9..7c68ff4032 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_AMAZON; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of Amazon OAuth2 app. For example: amzn1.application-oa2-client.87400c00000000000000000000063d5b2'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\' of Amazon OAuth2 app. For example: 79ffe4000000000000000000000000000000000000000000000000000002de55'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php index 6e8a75990a..08fc7dbf6b 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php @@ -49,18 +49,6 @@ class Update extends Base return 'serviceId'; } - public static function getClientIdDescription(): string - { - return '\'Service ID\' of Apple OAuth2 app. For example: ip.appwrite.app.web'; - } - - public static function getClientSecretDescription(): string - { - // Unused: this adapter replaces the single clientSecret param with - // keyId, teamId and p8File by overriding __construct() and handle(). - return ''; - } - public static function getClientIdName(): string { return 'Service ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php index 38ac453ece..aa5f39b213 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php @@ -44,16 +44,6 @@ class Update extends Base return Response::MODEL_OAUTH2_AUTH0; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of Auth0 OAuth2 app. For example: OaOkIA000000000000000000005KLSYq'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\' of Auth0 OAuth2 app. For example: zXz0000-00000000000000000000000000000-00000000000000000000PJafnF'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php index 97f78f8013..d5d465c3d4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php @@ -44,16 +44,6 @@ class Update extends Base return Response::MODEL_OAUTH2_AUTHENTIK; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of Authentik OAuth2 app. For example: dTKOPa0000000000000000000000000000e7G8hv'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\' of Authentik OAuth2 app. For example: ntQadq000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Hp5WK'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php index b0595cd524..dd4f4f6faa 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_AUTODESK; } - public static function getClientIdDescription(): string - { - return '\'client ID\' of Autodesk OAuth2 app. For example: 5zw90v00000000000000000000kVYXN7'; - } - - public static function getClientSecretDescription(): string - { - return '\'client secret\' of Autodesk OAuth2 app. For example: 7I000000000000MW'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php index 25acb75ee9..b0f59e7c08 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php @@ -51,18 +51,54 @@ abstract class Base extends Action abstract public static function getResponseModel(): string; /** - * Description of the clientId param, including an example value. - * - * @return string + * Description of the clientId param, auto-built from + * {@see getClientIdName()}, {@see getClientIdExample()} and + * {@see getClientIdHint()}. Returns an empty string when the name is + * empty (e.g. providers like Apple that don't expose a single clientId + * description but still need to bypass this default). */ - abstract public static function getClientIdDescription(): string; + public static function getClientIdDescription(): string + { + return self::buildParamDescription( + static::getClientIdName(), + static::getClientIdExample(), + static::getClientIdHint() + ); + } /** - * Description of the clientSecret param, including an example value. - * - * @return string + * Description of the clientSecret param, auto-built from + * {@see getClientSecretName()}, {@see getClientSecretExample()} and + * {@see getClientSecretHint()}. Returns an empty string when the name + * is empty (e.g. Apple, which uses keyId/teamId/p8File instead). */ - abstract public static function getClientSecretDescription(): string; + public static function getClientSecretDescription(): string + { + return self::buildParamDescription( + static::getClientSecretName(), + static::getClientSecretExample(), + static::getClientSecretHint() + ); + } + + /** + * Format a parameter description as + * "'' of OAuth2 app. For example: [. ]". + * Returns an empty string when the name is empty. + */ + private static function buildParamDescription(string $name, string $example, string $hint): string + { + if ($name === '') { + return ''; + } + + $description = '\'' . $name . '\' of ' . static::getProviderLabel() . ' OAuth2 app. For example: ' . $example; + if ($hint !== '') { + $description .= '. ' . $hint; + } + + return $description; + } /** * Verbose, user-facing name of the clientId param. Includes alternate diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php index 4321a56f30..a477bfbefb 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php @@ -43,16 +43,6 @@ class Update extends Base return 'secret'; } - public static function getClientIdDescription(): string - { - return '\'Key\' of Bitbucket OAuth2 app. For example: Knt70000000000ByRc'; - } - - public static function getClientSecretDescription(): string - { - return '\'Secret\' of Bitbucket OAuth2 app. For example: NMfLZJ00000000000000000000TLQdDx'; - } - public static function getClientIdName(): string { return 'Key'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php index ebcb6837d2..731b71bbb3 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitly/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_BITLY; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of Bitly OAuth2 app. For example: d95151000000000000000000000000000067af9b'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\' of Bitly OAuth2 app. For example: a13e250000000000000000000000000000d73095'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php index ebc847f553..113e5c8968 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Box/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_BOX; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of Box OAuth2 app. For example: deglcs00000000000000000000x2og6y'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\' of Box OAuth2 app. For example: OKM1f100000000000000000000eshEif'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php index d29d92c0f6..5f7186a224 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dailymotion/Update.php @@ -43,16 +43,6 @@ class Update extends Base return 'apiSecret'; } - public static function getClientIdDescription(): string - { - return '\'API key\' of Dailymotion OAuth2 app. For example: 07a9000000000000067f'; - } - - public static function getClientSecretDescription(): string - { - return '\'API secret\' of Dailymotion OAuth2 app. For example: a399a90000000000000000000000000000d90639'; - } - public static function getClientIdName(): string { return 'API Key'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php index 2d4dd805f9..e4732912b9 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Discord/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_DISCORD; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of Discord OAuth2 app. For example: 950722000000343754'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\' of Discord OAuth2 app. For example: YmPXnM000000000000000000002zFg5D'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php index 74cc714e35..e5f80c07d8 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Disqus/Update.php @@ -43,16 +43,6 @@ class Update extends Base return 'secretKey'; } - public static function getClientIdDescription(): string - { - return '\'Public key\', also known as \'API Key\', of Disqus OAuth2 app. For example: cgegH70000000000000000000000000000000000000000000000000000Hr1nYX'; - } - - public static function getClientSecretDescription(): string - { - return '\'Secret Key\', also known as \'API Secret\', of Disqus OAuth2 app. For example: W7Bykj00000000000000000000000000000000000000000000000000003o43w9'; - } - public static function getClientIdName(): string { return 'Public Key, also known as API Key'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php index b6dc21e790..861eca1cef 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Dropbox/Update.php @@ -43,16 +43,6 @@ class Update extends Base return 'appSecret'; } - public static function getClientIdDescription(): string - { - return '\'App key\' of Dropbox OAuth2 app. For example: jl000000000009t'; - } - - public static function getClientSecretDescription(): string - { - return '\'App secret\' of Dropbox OAuth2 app. For example: g200000000000vw'; - } - public static function getClientIdName(): string { return 'App Key'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php index 8993d8f0ef..0a9d0e9147 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Etsy/Update.php @@ -43,16 +43,6 @@ class Update extends Base return 'sharedSecret'; } - public static function getClientIdDescription(): string - { - return '\'Keystring\' of Etsy OAuth2 app. For example: nsgzxh0000000000008j85a2'; - } - - public static function getClientSecretDescription(): string - { - return '\'Shared Secret\' of Etsy OAuth2 app. For example: tp000000ru'; - } - public static function getClientIdName(): string { return 'Keystring'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php index af3a42c94b..766686273a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Facebook/Update.php @@ -43,16 +43,6 @@ class Update extends Base return 'appSecret'; } - public static function getClientIdDescription(): string - { - return '\'App ID\' of Facebook OAuth2 app. For example: 260600000007694'; - } - - public static function getClientSecretDescription(): string - { - return '\'App secret\' of Facebook OAuth2 app. For example: 2d0b2800000000000000000000d38af4'; - } - public static function getClientIdName(): string { return 'App ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php index 06fd3ebc5a..a965da77a0 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Figma/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_FIGMA; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of Figma OAuth2 app. For example: byay5H0000000000VtiI40'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\' of Figma OAuth2 app. For example: yEpOYn0000000000000000004iIsU5'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php index 6858fcf996..a82b3a3ea2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_GITHUB; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of GitHub OAuth2 app, or \'App ID\' of GitHub generic app. For example: e4d87900000000540733. Example of wrong value: 370006'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client secret\' of GitHub OAuth2 app, or GitHub generic app. For example: 5e07c00000000000000000000000000000198bcc'; - } - public static function getClientIdName(): string { return 'Client ID or App ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php index 474780312b..804f6354ae 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Gitlab/Update.php @@ -55,16 +55,6 @@ class Update extends Base return 'secret'; } - public static function getClientIdDescription(): string - { - return '\'Application ID\' of GitLab OAuth2 app. For example: d41ffe0000000000000000000000000000000000000000000000000000d5e252'; - } - - public static function getClientSecretDescription(): string - { - return '\'Secret\' of GitLab OAuth2 app. For example: gloas-838cfa0000000000000000000000000000000000000000000000000000ecbb38'; - } - public static function getClientIdName(): string { return 'Application ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php index 76bff1f34d..9b985f4aed 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_GOOGLE; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of Google OAuth2 app. For example: 120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client secret\' of Google OAuth2 app. For example: GOCSPX-2k8gsR0000000000000000VNahJj'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php index f054c81ecf..db4a20174f 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_KICK; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of Kick OAuth2 app. For example: 01KQ7C00000000000001MFHS32'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\' of Kick OAuth2 app. For example: 34ac5600000000000000000000000000000000000000000000000000e830c8b'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php index 72f9fc1825..d564f3aac5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Linkedin/Update.php @@ -38,16 +38,6 @@ class Update extends Base return 'primaryClientSecret'; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of LinkedIn OAuth2 app. For example: 770000000000dv'; - } - - public static function getClientSecretDescription(): string - { - return '\'Primary Client Secret\' or \'Secondary Client Secret\', of LinkedIn OAuth2 app. For example: WPL_AP1.2Bf0000000000000./HtlYw=='; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php index a276ca60bb..fe4f4b263e 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php @@ -54,16 +54,6 @@ class Update extends Base return 'applicationSecret'; } - public static function getClientIdDescription(): string - { - return '\'Application ID\' (also known as Client ID) of Microsoft Entra ID app. For example: 00001111-aaaa-2222-bbbb-3333cccc4444'; - } - - public static function getClientSecretDescription(): string - { - return '\'Application Secret\' (also known as Client Secret) of Microsoft Entra ID app. For example: A1bC2dE3fH4iJ5kL6mN7oP8qR9sT0u'; - } - public static function getClientIdName(): string { return 'Application ID (also known as Client ID)'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php index b85c7158a7..4b048b0c0b 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php @@ -43,16 +43,6 @@ class Update extends Base return 'oauthClientSecret'; } - public static function getClientIdDescription(): string - { - return '\'OAuth Client ID\' of Notion OAuth2 app. For example: 341d8700-0000-0000-0000-000000446ee3'; - } - - public static function getClientSecretDescription(): string - { - return '\'OAuth Client Secret\' of Notion OAuth2 app. For example: secret_dLUr4b000000000000000000000000000000lFHAa9'; - } - public static function getClientIdName(): string { return 'OAuth Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php index 55a14307cd..9598ff4c43 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Oidc/Update.php @@ -46,16 +46,6 @@ class Update extends Base return Response::MODEL_OAUTH2_OIDC; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of OpenID Connect OAuth2 app. For example: qibI2x0000000000000000000000000006L2YFoG'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\' of OpenID Connect OAuth2 app. For example: Ah68ed000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003qpcHV'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php index eb135798c5..0344b6a14a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php @@ -46,16 +46,6 @@ class Update extends Base return Response::MODEL_OAUTH2_OKTA; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of Okta OAuth2 app. For example: 0oa00000000000000698'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\' of Okta OAuth2 app. For example: Kiq0000000000000000000000000000000000000-00000000000H2L5-3SJ-vRV'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php index 0ed9596725..87b4e1576b 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php @@ -38,16 +38,6 @@ class Update extends Base return 'secretKey'; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of ' . static::getProviderLabel() . ' OAuth2 app. For example: AdhIEG7-000000000000-0000000000000000000000000000000-0000000000000000000000-2pyB'; - } - - public static function getClientSecretDescription(): string - { - return '\'Secret key 1\', or \'Secret key 2\', of ' . static::getProviderLabel() . ' OAuth2 app. For example: EH8KCXtew--000000000000000000000000000000000000000_C-1_5UP_000000000000000CB7KDp'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php index 72f7eb8f2c..dc6647c2b1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Podio/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_PODIO; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of Podio OAuth2 app. For example: appwrite-o0000000st-app'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\' of Podio OAuth2 app. For example: Rn247T0000000000000000000000000000000000000000000000000000W2zWTN'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php index 1802932ce4..f04b9d75dd 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Salesforce/Update.php @@ -43,16 +43,6 @@ class Update extends Base return 'customerSecret'; } - public static function getClientIdDescription(): string - { - return '\'Consumer key\' of Salesforce OAuth2 app. For example: 3MVG9I0000000000000000000000000000000000000000000000000000000000000000000000000C5Aejq'; - } - - public static function getClientSecretDescription(): string - { - return '\'Consumer secret\' of Salesforce OAuth2 app. For example: 3w000000000000e2'; - } - public static function getClientIdName(): string { return 'Consumer Key'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php index 561563a37c..72ab62e1d5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Slack/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_SLACK; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of Slack OAuth2 app. For example: 23000000089.15000000000023'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\' of Slack OAuth2 app. For example: 81656000000000000000000000f3d2fd'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php index 1134fd194a..35128a8591 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Spotify/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_SPOTIFY; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of Spotify OAuth2 app. For example: 6ec271000000000000000000009beace'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client secret\' of Spotify OAuth2 app. For example: db068a000000000000000000008b5b9f'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php index 4702ef271d..8c0bd5f14c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Stripe/Update.php @@ -38,16 +38,6 @@ class Update extends Base return 'apiSecretKey'; } - public static function getClientIdDescription(): string - { - return '\'client ID\' of Stripe OAuth2 app. For example: ca_UKibXX0000000000000000000006byvR'; - } - - public static function getClientSecretDescription(): string - { - return '\'API Secret key\' of Stripe OAuth2 app. For example: sk_51SfOd000000000000000000000000000000000000000000000000000000000000000000000000000000000000000QGWYfp'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Tradeshift/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Tradeshift/Update.php index 3d0e05b886..6e93a22960 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Tradeshift/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Tradeshift/Update.php @@ -43,16 +43,6 @@ class Update extends Base return 'oauth2ClientSecret'; } - public static function getClientIdDescription(): string - { - return '\'Oauth2 Client ID\' of ' . static::getProviderLabel() . ' OAuth2 app. For example: appwrite-tes00000.0000000000est-app'; - } - - public static function getClientSecretDescription(): string - { - return '\'Oauth2 Client secret\' of ' . static::getProviderLabel() . ' OAuth2 app. For example: 7cb52700-0000-0000-0000-000000ca5b83'; - } - public static function getClientIdName(): string { return 'OAuth2 Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Twitch/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Twitch/Update.php index 7377ba421d..54a28f88cd 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Twitch/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Twitch/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_TWITCH; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of Twitch OAuth2 app. For example: vvi0in000000000000000000ikmt9p'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\' of Twitch OAuth2 app. For example: pmapue000000000000000000zylw3v'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/WordPress/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/WordPress/Update.php index b8b49f6970..14ddf1552a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/WordPress/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/WordPress/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_WORDPRESS; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of WordPress OAuth2 app. For example: 130005'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\' of WordPress OAuth2 app. For example: PlBfJS0000000000000000000000000000000000000000000000000000EdUZJk'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/X/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/X/Update.php index 83b4048ba5..3edc4709db 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/X/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/X/Update.php @@ -43,16 +43,6 @@ class Update extends Base return 'secretKey'; } - public static function getClientIdDescription(): string - { - return '\'Customer Key\' of X OAuth2 app. For example: slzZV0000000000000NFLaWT'; - } - - public static function getClientSecretDescription(): string - { - return '\'Secret Key\' of X OAuth2 app. For example: tkEPkp00000000000000000000000000000000000000FTxbI9'; - } - public static function getClientIdName(): string { return 'Customer Key'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php index 62c19851ab..45cf1f5a66 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_YAHOO; } - public static function getClientIdDescription(): string - { - return '\'Client ID\', also known as \'Customer Key\', of Yahoo OAuth2 app. For example: dj0yJm000000000000000000000000000000000000000000000000000000000000000000000000000000000000Z4PWRm'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\', also known as \'Customer Secret\', of Yahoo OAuth2 app. For example: cf978f0000000000000000000000000000c5e2e9'; - } - public static function getClientIdName(): string { return 'Client ID, also known as Customer Key'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yandex/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yandex/Update.php index 8e5e5839a8..f9af92408d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yandex/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yandex/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_YANDEX; } - public static function getClientIdDescription(): string - { - return '\'ClientID\' of Yandex OAuth2 app. For example: 6a8a6a0000000000000000000091483c'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client secret\' of Yandex OAuth2 app. For example: bbf98500000000000000000000c75a63'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoho/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoho/Update.php index 75fa3692bd..bcb30839ac 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoho/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoho/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_ZOHO; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of Zoho OAuth2 app. For example: 1000.83C178000000000000000000RPNX0B'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\' of Zoho OAuth2 app. For example: fb5cac000000000000000000000000000000a68f6e'; - } - public static function getClientIdName(): string { return 'Client ID'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoom/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoom/Update.php index b0e999b256..d67cb4dba3 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoom/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Zoom/Update.php @@ -33,16 +33,6 @@ class Update extends Base return Response::MODEL_OAUTH2_ZOOM; } - public static function getClientIdDescription(): string - { - return '\'Client ID\' of Zoom OAuth2 app. For example: QMAC00000000000000w0AQ'; - } - - public static function getClientSecretDescription(): string - { - return '\'Client Secret\' of Zoom OAuth2 app. For example: GAWsG4000000000000000000007U01ON'; - } - public static function getClientIdName(): string { return 'Client ID'; From 543765a22ae2284afc163c171a250e90f7c6684f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 10:15:45 +0200 Subject: [PATCH 333/402] Improve copy --- .../Modules/Project/Http/Project/OAuth2/GitHub/Update.php | 2 +- .../Modules/Project/Http/Project/OAuth2/Microsoft/Update.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php index a82b3a3ea2..3b6f89db06 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php @@ -35,7 +35,7 @@ class Update extends Base public static function getClientIdName(): string { - return 'Client ID or App ID'; + return 'OAuth 2 app Client ID, or App ID'; } public static function getClientIdExample(): string diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php index fe4f4b263e..0690ee333a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php @@ -56,7 +56,7 @@ class Update extends Base public static function getClientIdName(): string { - return 'Application ID (also known as Client ID)'; + return 'Entra ID Application ID, also known as Client ID'; } public static function getClientIdExample(): string @@ -66,7 +66,7 @@ class Update extends Base public static function getClientSecretName(): string { - return 'Application Secret (also known as Client Secret)'; + return 'Entra ID Application Secret, also known as Client Secret'; } public static function getClientSecretExample(): string From dfa3ae52747bc22215a6f45441358d029d64cb94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 10:19:36 +0200 Subject: [PATCH 334/402] Fix tests --- tests/e2e/Services/Console/ConsoleConsoleClientTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php index 779ede8d9c..3b3232cda3 100644 --- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php +++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php @@ -99,7 +99,7 @@ class ConsoleConsoleClientTest extends Scope $this->assertCount(2, $github['parameters']); $clientId = $github['parameters'][0]; $this->assertEquals('clientId', $clientId['$id']); - $this->assertEquals('Client ID or App ID', $clientId['name']); + $this->assertEquals('OAuth 2 app Client ID, or App ID', $clientId['name']); $this->assertEquals('e4d87900000000540733', $clientId['example']); $this->assertEquals('Example of wrong value: 370006', $clientId['hint']); $clientSecret = $github['parameters'][1]; From 49e6a38e7fe337a585eab8712fe01ea61a98089d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 10:43:16 +0200 Subject: [PATCH 335/402] Add fusionauth oauth --- app/config/oAuthProviders.php | 11 + app/init/models.php | 2 + src/Appwrite/Auth/OAuth2/FusionAuth.php | 226 ++++++++++++++++++ .../Project/Http/Project/OAuth2/Base.php | 1 + .../Http/Project/OAuth2/FusionAuth/Update.php | 172 +++++++++++++ .../Project/Http/Project/OAuth2/Get.php | 1 + .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + .../Response/Model/OAuth2FusionAuth.php | 59 +++++ .../Response/Model/OAuth2ProviderList.php | 1 + tests/e2e/Services/Project/OAuth2Base.php | 133 ++++++++++- 11 files changed, 604 insertions(+), 5 deletions(-) create mode 100644 src/Appwrite/Auth/OAuth2/FusionAuth.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2FusionAuth.php diff --git a/app/config/oAuthProviders.php b/app/config/oAuthProviders.php index 0dc2cb8b1e..3b490bd153 100644 --- a/app/config/oAuthProviders.php +++ b/app/config/oAuthProviders.php @@ -167,6 +167,17 @@ return [ 'mock' => false, 'class' => 'Appwrite\\Auth\\OAuth2\\Figma', ], + 'fusionauth' => [ + 'name' => 'FusionAuth', + 'developers' => 'https://fusionauth.io/docs/', + 'icon' => 'icon-fusionauth', + 'enabled' => true, + 'sandbox' => false, + 'form' => 'fusionauth.phtml', + 'beta' => false, + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\FusionAuth', + ], 'github' => [ 'name' => 'GitHub', 'developers' => 'https://developer.github.com/', diff --git a/app/init/models.php b/app/init/models.php index 39bc90e23c..ab397d6fdf 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -123,6 +123,7 @@ use Appwrite\Utopia\Response\Model\OAuth2Dropbox; use Appwrite\Utopia\Response\Model\OAuth2Etsy; use Appwrite\Utopia\Response\Model\OAuth2Facebook; use Appwrite\Utopia\Response\Model\OAuth2Figma; +use Appwrite\Utopia\Response\Model\OAuth2FusionAuth; use Appwrite\Utopia\Response\Model\OAuth2GitHub; use Appwrite\Utopia\Response\Model\OAuth2Gitlab; use Appwrite\Utopia\Response\Model\OAuth2Google; @@ -425,6 +426,7 @@ Response::setModel(new OAuth2Paypal()); Response::setModel(new OAuth2Gitlab()); Response::setModel(new OAuth2Authentik()); Response::setModel(new OAuth2Auth0()); +Response::setModel(new OAuth2FusionAuth()); Response::setModel(new OAuth2Oidc()); Response::setModel(new OAuth2Okta()); Response::setModel(new OAuth2Kick()); diff --git a/src/Appwrite/Auth/OAuth2/FusionAuth.php b/src/Appwrite/Auth/OAuth2/FusionAuth.php new file mode 100644 index 0000000000..415be4c6ad --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/FusionAuth.php @@ -0,0 +1,226 @@ +getFusionAuthDomain() . '/oauth2/authorize?' . \http_build_query([ + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'state' => \json_encode($this->state), + 'scope' => \implode(' ', $this->getScopes()), + 'response_type' => 'code' + ]); + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + 'https://' . $this->getFusionAuthDomain() . '/oauth2/token', + $headers, + \http_build_query([ + 'code' => $code, + 'client_id' => $this->appID, + 'client_secret' => $this->getClientSecret(), + 'redirect_uri' => $this->callback, + 'scope' => \implode(' ', $this->getScopes()), + 'grant_type' => 'authorization_code' + ]) + ), true); + } + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + 'https://' . $this->getFusionAuthDomain() . '/oauth2/token', + $headers, + \http_build_query([ + 'refresh_token' => $refreshToken, + 'client_id' => $this->appID, + 'client_secret' => $this->getClientSecret(), + 'grant_type' => 'refresh_token' + ]) + ), true); + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['sub'])) { + return $user['sub']; + } + + return ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['email'])) { + return $user['email']; + } + + return ''; + } + + /** + * Check if the User email is verified + * + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + $user = $this->getUser($accessToken); + + if ($user['email_verified'] ?? false) { + return true; + } + + return false; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['name'])) { + return $user['name']; + } + + return ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken): array + { + if (empty($this->user)) { + $headers = ['Authorization: Bearer ' . \urlencode($accessToken)]; + $user = $this->request('GET', 'https://' . $this->getFusionAuthDomain() . '/oauth2/userinfo', $headers); + $this->user = \json_decode($user, true); + } + + return $this->user; + } + + /** + * Extracts the Client Secret from the JSON stored in appSecret + * + * @return string + */ + protected function getClientSecret(): string + { + $secret = $this->getAppSecret(); + + return $secret['clientSecret'] ?? ''; + } + + /** + * Extracts the FusionAuth Domain from the JSON stored in appSecret + * + * @return string + */ + protected function getFusionAuthDomain(): string + { + $secret = $this->getAppSecret(); + return $secret['fusionAuthDomain'] ?? ''; + } + + /** + * Decode the JSON stored in appSecret + * + * @return array + */ + protected function getAppSecret(): array + { + try { + $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $th) { + throw new \Exception('Invalid secret'); + } + return $secret; + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php index b0f59e7c08..3925abb582 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php @@ -311,6 +311,7 @@ abstract class Base extends Action 'gitlab' => Gitlab\Update::class, 'authentik' => Authentik\Update::class, 'auth0' => Auth0\Update::class, + 'fusionauth' => FusionAuth\Update::class, 'oidc' => Oidc\Update::class, 'okta' => Okta\Update::class, 'kick' => Kick\Update::class, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php new file mode 100644 index 0000000000..25f81e1459 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php @@ -0,0 +1,172 @@ + 'endpoint', + 'name' => 'Domain', + 'example' => 'example.fusionauth.io', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('endpoint', '', new Text(256, 1), 'Domain of FusionAuth instance. For example: example.fusionauth.io', optional: false) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'endpoint' => $decoded['fusionAuthDomain'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because FusionAuth + * takes an additional required `endpoint` parameter. The method is named + * differently to avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + string $endpoint, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // The secret is stored as JSON `{"clientSecret": "...", "fusionAuthDomain": "..."}` + // to match the shape FusionAuth's OAuth2 adapter expects (getFusionAuthDomain()). + // The `endpoint` param is required on every call, so it's always written. + // `clientSecret` is optional; if omitted, the existing stored secret is preserved. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'fusionAuthDomain' => $endpoint, + ]); + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php index 419d80f829..0e10a8841c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php @@ -75,6 +75,7 @@ class Get extends Action Response::MODEL_OAUTH2_GITLAB, Response::MODEL_OAUTH2_AUTHENTIK, Response::MODEL_OAUTH2_AUTH0, + Response::MODEL_OAUTH2_FUSIONAUTH, Response::MODEL_OAUTH2_OIDC, Response::MODEL_OAUTH2_APPLE, Response::MODEL_OAUTH2_OKTA, diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index d6ff3c4925..76dbf58ef8 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -31,6 +31,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Dropbox\Update as Upda use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Etsy\Update as UpdateOAuth2Etsy; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Facebook\Update as UpdateOAuth2Facebook; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Figma\Update as UpdateOAuth2Figma; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\FusionAuth\Update as UpdateOAuth2FusionAuth; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Get as GetOAuth2Provider; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\GitHub\Update as UpdateOAuth2GitHub; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Gitlab\Update as UpdateOAuth2Gitlab; @@ -210,6 +211,7 @@ class Http extends Service $this->addAction(UpdateOAuth2Gitlab::getName(), new UpdateOAuth2Gitlab()); $this->addAction(UpdateOAuth2Authentik::getName(), new UpdateOAuth2Authentik()); $this->addAction(UpdateOAuth2Auth0::getName(), new UpdateOAuth2Auth0()); + $this->addAction(UpdateOAuth2FusionAuth::getName(), new UpdateOAuth2FusionAuth()); $this->addAction(UpdateOAuth2Oidc::getName(), new UpdateOAuth2Oidc()); $this->addAction(UpdateOAuth2Okta::getName(), new UpdateOAuth2Okta()); $this->addAction(UpdateOAuth2Kick::getName(), new UpdateOAuth2Kick()); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 7670b027e9..14bfbdb9ef 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -311,6 +311,7 @@ class Response extends SwooleResponse public const MODEL_OAUTH2_GITLAB = 'oAuth2Gitlab'; public const MODEL_OAUTH2_AUTHENTIK = 'oAuth2Authentik'; public const MODEL_OAUTH2_AUTH0 = 'oAuth2Auth0'; + public const MODEL_OAUTH2_FUSIONAUTH = 'oAuth2FusionAuth'; public const MODEL_OAUTH2_OIDC = 'oAuth2Oidc'; public const MODEL_OAUTH2_APPLE = 'oAuth2Apple'; public const MODEL_OAUTH2_OKTA = 'oAuth2Okta'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2FusionAuth.php b/src/Appwrite/Utopia/Response/Model/OAuth2FusionAuth.php new file mode 100644 index 0000000000..8dbe3c76f0 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2FusionAuth.php @@ -0,0 +1,59 @@ + 'fusionauth', + ]; + + public function getProviderLabel(): string + { + return 'FusionAuth'; + } + + public function getClientIdExample(): string + { + return 'b2222c00-0000-0000-0000-000000862097'; + } + + public function getClientSecretExample(): string + { + return 'Jx4s0C0000000000000000000000000000000wGqLsc'; + } + + public function __construct() + { + parent::__construct(); + + $this->addRule('endpoint', [ + 'type' => self::TYPE_STRING, + 'description' => 'FusionAuth OAuth2 endpoint domain.', + 'default' => '', + 'example' => 'example.fusionauth.io', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2FusionAuth'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_FUSIONAUTH; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php b/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php index 5d1fb16a9a..71cf5ed2eb 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php @@ -51,6 +51,7 @@ class OAuth2ProviderList extends Model Response::MODEL_OAUTH2_GITLAB, Response::MODEL_OAUTH2_AUTHENTIK, Response::MODEL_OAUTH2_AUTH0, + Response::MODEL_OAUTH2_FUSIONAUTH, Response::MODEL_OAUTH2_OIDC, Response::MODEL_OAUTH2_APPLE, Response::MODEL_OAUTH2_OKTA, diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php index ec070531e7..5cb1b7b0c4 100644 --- a/tests/e2e/Services/Project/OAuth2Base.php +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -64,6 +64,7 @@ trait OAuth2Base 'apple', 'auth0', 'authentik', + 'fusionauth', 'gitlab', 'oidc', 'okta', @@ -95,11 +96,11 @@ trait OAuth2Base $expected = [ 'amazon', 'apple', 'auth0', 'authentik', 'autodesk', 'bitbucket', 'bitly', 'box', 'dailymotion', 'discord', 'disqus', 'dropbox', - 'etsy', 'facebook', 'figma', 'github', 'gitlab', 'google', 'kick', - 'linkedin', 'microsoft', 'notion', 'oidc', 'okta', 'paypal', - 'paypalSandbox', 'podio', 'salesforce', 'slack', 'spotify', - 'stripe', 'tradeshift', 'tradeshiftBox', 'twitch', 'wordpress', - 'x', 'yahoo', 'yandex', 'zoho', 'zoom', + 'etsy', 'facebook', 'figma', 'fusionauth', 'github', 'gitlab', + 'google', 'kick', 'linkedin', 'microsoft', 'notion', 'oidc', + 'okta', 'paypal', 'paypalSandbox', 'podio', 'salesforce', 'slack', + 'spotify', 'stripe', 'tradeshift', 'tradeshiftBox', 'twitch', + 'wordpress', 'x', 'yahoo', 'yandex', 'zoho', 'zoom', ]; \sort($expected); @@ -995,6 +996,128 @@ trait OAuth2Base ]); } + // ========================================================================= + // Update FusionAuth (clientId + clientSecret + REQUIRED endpoint) + // ========================================================================= + + public function testUpdateOAuth2FusionAuthRequiresEndpoint(): void + { + // The `endpoint` param is required (Text(min=1)); omitting → 400. + $response = $this->updateOAuth2('fusionauth', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2FusionAuthEmptyEndpointRejected(): void + { + // The `endpoint` validator is Text(min=1). Sending `''` must be + // rejected the same way as omitting — the validator should treat the + // empty-string degenerate case as a missing required field. + $response = $this->updateOAuth2('fusionauth', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'endpoint' => '', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2FusionAuth(): void + { + $response = $this->updateOAuth2('fusionauth', [ + 'clientId' => 'b2222c00-0000-0000-0000-000000862097', + 'clientSecret' => 'fusionauth-secret', + 'endpoint' => 'example.fusionauth.io', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('fusionauth', $response['body']['$id']); + $this->assertSame('b2222c00-0000-0000-0000-000000862097', $response['body']['clientId']); + $this->assertSame('example.fusionauth.io', $response['body']['endpoint']); + + // Cleanup + $this->updateOAuth2('fusionauth', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.fusionauth.io', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2FusionAuthPartialPreservesSecret(): void + { + // FusionAuth's `endpoint` is required on every call, so we always + // re-send it. The `clientSecret` lives in the JSON blob and must + // survive when omitted on a subsequent call that only changes clientId. + $this->updateOAuth2('fusionauth', [ + 'clientId' => 'fusionauth-merge-client', + 'clientSecret' => 'fusionauth-merge-secret', + 'endpoint' => 'merge.fusionauth.io', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('fusionauth', [ + 'clientId' => 'fusionauth-rotated-client', + 'endpoint' => 'merge.fusionauth.io', + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('fusionauth-rotated-client', $response['body']['clientId']); + $this->assertSame('merge.fusionauth.io', $response['body']['endpoint']); + + // Confirm clientSecret survived the omitted-field merge by enabling + // — FusionAuth has no verifyCredentials() hook, so non-empty stored + // secret is enough. `endpoint` must be re-sent (required on enable too). + $enable = $this->updateOAuth2('fusionauth', [ + 'endpoint' => 'merge.fusionauth.io', + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup — endpoint is required, use a placeholder. + $this->updateOAuth2('fusionauth', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.fusionauth.io', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2FusionAuthEnableAndReadBack(): void + { + $update = $this->updateOAuth2('fusionauth', [ + 'clientId' => 'fusionauth-enable-client', + 'clientSecret' => 'fusionauth-enable-secret', + 'endpoint' => 'enable.fusionauth.io', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId and endpoint. + $get = $this->getOAuth2Provider('fusionauth'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('fusionauth-enable-client', $get['body']['clientId']); + $this->assertSame('enable.fusionauth.io', $get['body']['endpoint']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup — endpoint is required (Text(min=1)) so use a placeholder. + $this->updateOAuth2('fusionauth', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.fusionauth.io', + 'enabled' => false, + ]); + } + // ========================================================================= // Update Microsoft (applicationId + applicationSecret + REQUIRED tenant) // ========================================================================= From cb4cff120b7a0ed064535cd6ac7e55623002810d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 10:54:13 +0200 Subject: [PATCH 336/402] Add Keycloak oauth support --- app/config/oAuthProviders.php | 11 + app/init/models.php | 2 + src/Appwrite/Auth/OAuth2/Keycloak.php | 249 ++++++++++++++++++ .../Project/Http/Project/OAuth2/Base.php | 1 + .../Project/Http/Project/OAuth2/Get.php | 1 + .../Http/Project/OAuth2/Keycloak/Update.php | 183 +++++++++++++ .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + .../Utopia/Response/Model/OAuth2Keycloak.php | 66 +++++ .../Response/Model/OAuth2ProviderList.php | 1 + tests/e2e/Services/Project/OAuth2Base.php | 174 +++++++++++- 11 files changed, 687 insertions(+), 4 deletions(-) create mode 100644 src/Appwrite/Auth/OAuth2/Keycloak.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2Keycloak.php diff --git a/app/config/oAuthProviders.php b/app/config/oAuthProviders.php index 3b490bd153..3b492fd8bf 100644 --- a/app/config/oAuthProviders.php +++ b/app/config/oAuthProviders.php @@ -211,6 +211,17 @@ return [ 'mock' => false, 'class' => 'Appwrite\\Auth\\OAuth2\\Google', ], + 'keycloak' => [ + 'name' => 'Keycloak', + 'developers' => 'https://www.keycloak.org/documentation', + 'icon' => 'icon-keycloak', + 'enabled' => true, + 'sandbox' => false, + 'form' => 'keycloak.phtml', + 'beta' => false, + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Keycloak', + ], 'kick' => [ 'name' => 'Kick', 'developers' => 'https://docs.kick.com/', diff --git a/app/init/models.php b/app/init/models.php index ab397d6fdf..77ca9be451 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -127,6 +127,7 @@ use Appwrite\Utopia\Response\Model\OAuth2FusionAuth; use Appwrite\Utopia\Response\Model\OAuth2GitHub; use Appwrite\Utopia\Response\Model\OAuth2Gitlab; use Appwrite\Utopia\Response\Model\OAuth2Google; +use Appwrite\Utopia\Response\Model\OAuth2Keycloak; use Appwrite\Utopia\Response\Model\OAuth2Kick; use Appwrite\Utopia\Response\Model\OAuth2Linkedin; use Appwrite\Utopia\Response\Model\OAuth2Microsoft; @@ -427,6 +428,7 @@ Response::setModel(new OAuth2Gitlab()); Response::setModel(new OAuth2Authentik()); Response::setModel(new OAuth2Auth0()); Response::setModel(new OAuth2FusionAuth()); +Response::setModel(new OAuth2Keycloak()); Response::setModel(new OAuth2Oidc()); Response::setModel(new OAuth2Okta()); Response::setModel(new OAuth2Kick()); diff --git a/src/Appwrite/Auth/OAuth2/Keycloak.php b/src/Appwrite/Auth/OAuth2/Keycloak.php new file mode 100644 index 0000000000..05e007eb7d --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/Keycloak.php @@ -0,0 +1,249 @@ +getRealmBaseURL() . '/protocol/openid-connect/auth?' . \http_build_query([ + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'state' => \json_encode($this->state), + 'scope' => \implode(' ', $this->getScopes()), + 'response_type' => 'code' + ]); + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + $this->getRealmBaseURL() . '/protocol/openid-connect/token', + $headers, + \http_build_query([ + 'code' => $code, + 'client_id' => $this->appID, + 'client_secret' => $this->getClientSecret(), + 'redirect_uri' => $this->callback, + 'scope' => \implode(' ', $this->getScopes()), + 'grant_type' => 'authorization_code' + ]) + ), true); + } + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + $this->getRealmBaseURL() . '/protocol/openid-connect/token', + $headers, + \http_build_query([ + 'refresh_token' => $refreshToken, + 'client_id' => $this->appID, + 'client_secret' => $this->getClientSecret(), + 'grant_type' => 'refresh_token' + ]) + ), true); + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['sub'])) { + return $user['sub']; + } + + return ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['email'])) { + return $user['email']; + } + + return ''; + } + + /** + * Check if the User email is verified + * + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + $user = $this->getUser($accessToken); + + if ($user['email_verified'] ?? false) { + return true; + } + + return false; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['name'])) { + return $user['name']; + } + + return ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken): array + { + if (empty($this->user)) { + $headers = ['Authorization: Bearer ' . \urlencode($accessToken)]; + $user = $this->request('GET', $this->getRealmBaseURL() . '/protocol/openid-connect/userinfo', $headers); + $this->user = \json_decode($user, true); + } + + return $this->user; + } + + /** + * Extracts the Client Secret from the JSON stored in appSecret + * + * @return string + */ + protected function getClientSecret(): string + { + $secret = $this->getAppSecret(); + + return $secret['clientSecret'] ?? ''; + } + + /** + * Extracts the Keycloak Domain from the JSON stored in appSecret + * + * @return string + */ + protected function getKeycloakDomain(): string + { + $secret = $this->getAppSecret(); + return $secret['keycloakDomain'] ?? ''; + } + + /** + * Extracts the Keycloak Realm from the JSON stored in appSecret + * + * @return string + */ + protected function getKeycloakRealm(): string + { + $secret = $this->getAppSecret(); + return $secret['keycloakRealm'] ?? ''; + } + + /** + * Build the realm-scoped base URL: `https://{domain}/realms/{realm}`. + * Keycloak realm names allow spaces and other characters that must be + * percent-encoded in URLs (e.g. `my realm` → `my%20realm`). + * + * @return string + */ + protected function getRealmBaseURL(): string + { + return 'https://' . $this->getKeycloakDomain() . '/realms/' . \rawurlencode($this->getKeycloakRealm()); + } + + /** + * Decode the JSON stored in appSecret + * + * @return array + */ + protected function getAppSecret(): array + { + try { + $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $th) { + throw new \Exception('Invalid secret'); + } + return $secret; + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php index 3925abb582..b5b8cacb73 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php @@ -312,6 +312,7 @@ abstract class Base extends Action 'authentik' => Authentik\Update::class, 'auth0' => Auth0\Update::class, 'fusionauth' => FusionAuth\Update::class, + 'keycloak' => Keycloak\Update::class, 'oidc' => Oidc\Update::class, 'okta' => Okta\Update::class, 'kick' => Kick\Update::class, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php index 0e10a8841c..ae46a59c67 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php @@ -76,6 +76,7 @@ class Get extends Action Response::MODEL_OAUTH2_AUTHENTIK, Response::MODEL_OAUTH2_AUTH0, Response::MODEL_OAUTH2_FUSIONAUTH, + Response::MODEL_OAUTH2_KEYCLOAK, Response::MODEL_OAUTH2_OIDC, Response::MODEL_OAUTH2_APPLE, Response::MODEL_OAUTH2_OKTA, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php new file mode 100644 index 0000000000..797875cab2 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php @@ -0,0 +1,183 @@ + 'endpoint', + 'name' => 'Domain', + 'example' => 'keycloak.example.com', + 'hint' => '', + ], + [ + '$id' => 'realmName', + 'name' => 'Realm name', + 'example' => 'appwrite-realm', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) + ->param('endpoint', '', new Text(256, 1), 'Domain of Keycloak instance. For example: keycloak.example.com', optional: false) + ->param('realmName', '', new Text(256, 1), 'Keycloak realm name. For example: appwrite-realm', optional: false) + ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'endpoint' => $decoded['keycloakDomain'] ?? '', + 'realmName' => $decoded['keycloakRealm'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Keycloak + * takes additional required `endpoint` and `realmName` parameters. The + * method is named differently to avoid an LSP-incompatible override of + * Base::action(). + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + string $endpoint, + string $realmName, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // The secret is stored as JSON `{"clientSecret": "...", "keycloakDomain": "...", "keycloakRealm": "..."}` + // to match the shape Keycloak's OAuth2 adapter expects (getKeycloakDomain(), getKeycloakRealm()). + // The `endpoint` and `realmName` params are required on every call, so they're always written. + // `clientSecret` is optional; if omitted, the existing stored secret is preserved. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'keycloakDomain' => $endpoint, + 'keycloakRealm' => $realmName, + ]); + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 76dbf58ef8..8c6b9da7e7 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -36,6 +36,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Get as GetOAuth2Provid use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\GitHub\Update as UpdateOAuth2GitHub; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Gitlab\Update as UpdateOAuth2Gitlab; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Google\Update as UpdateOAuth2Google; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Keycloak\Update as UpdateOAuth2Keycloak; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Kick\Update as UpdateOAuth2Kick; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Linkedin\Update as UpdateOAuth2Linkedin; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Microsoft\Update as UpdateOAuth2Microsoft; @@ -212,6 +213,7 @@ class Http extends Service $this->addAction(UpdateOAuth2Authentik::getName(), new UpdateOAuth2Authentik()); $this->addAction(UpdateOAuth2Auth0::getName(), new UpdateOAuth2Auth0()); $this->addAction(UpdateOAuth2FusionAuth::getName(), new UpdateOAuth2FusionAuth()); + $this->addAction(UpdateOAuth2Keycloak::getName(), new UpdateOAuth2Keycloak()); $this->addAction(UpdateOAuth2Oidc::getName(), new UpdateOAuth2Oidc()); $this->addAction(UpdateOAuth2Okta::getName(), new UpdateOAuth2Okta()); $this->addAction(UpdateOAuth2Kick::getName(), new UpdateOAuth2Kick()); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 14bfbdb9ef..e37e2c6043 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -312,6 +312,7 @@ class Response extends SwooleResponse public const MODEL_OAUTH2_AUTHENTIK = 'oAuth2Authentik'; public const MODEL_OAUTH2_AUTH0 = 'oAuth2Auth0'; public const MODEL_OAUTH2_FUSIONAUTH = 'oAuth2FusionAuth'; + public const MODEL_OAUTH2_KEYCLOAK = 'oAuth2Keycloak'; public const MODEL_OAUTH2_OIDC = 'oAuth2Oidc'; public const MODEL_OAUTH2_APPLE = 'oAuth2Apple'; public const MODEL_OAUTH2_OKTA = 'oAuth2Okta'; diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Keycloak.php b/src/Appwrite/Utopia/Response/Model/OAuth2Keycloak.php new file mode 100644 index 0000000000..063f7d2a5c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Keycloak.php @@ -0,0 +1,66 @@ + 'keycloak', + ]; + + public function getProviderLabel(): string + { + return 'Keycloak'; + } + + public function getClientIdExample(): string + { + return 'appwrite-o0000000st-app'; + } + + public function getClientSecretExample(): string + { + return 'jdjrJd00000000000000000000HUsaZO'; + } + + public function __construct() + { + parent::__construct(); + + $this->addRule('endpoint', [ + 'type' => self::TYPE_STRING, + 'description' => 'Keycloak OAuth2 endpoint domain.', + 'default' => '', + 'example' => 'keycloak.example.com', + ]); + + $this->addRule('realmName', [ + 'type' => self::TYPE_STRING, + 'description' => 'Keycloak OAuth2 realm name.', + 'default' => '', + 'example' => 'appwrite-realm', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Keycloak'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_KEYCLOAK; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php b/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php index 71cf5ed2eb..81c23c803c 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php @@ -52,6 +52,7 @@ class OAuth2ProviderList extends Model Response::MODEL_OAUTH2_AUTHENTIK, Response::MODEL_OAUTH2_AUTH0, Response::MODEL_OAUTH2_FUSIONAUTH, + Response::MODEL_OAUTH2_KEYCLOAK, Response::MODEL_OAUTH2_OIDC, Response::MODEL_OAUTH2_APPLE, Response::MODEL_OAUTH2_OKTA, diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php index 5cb1b7b0c4..8345bfab0a 100644 --- a/tests/e2e/Services/Project/OAuth2Base.php +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -66,6 +66,7 @@ trait OAuth2Base 'authentik', 'fusionauth', 'gitlab', + 'keycloak', 'oidc', 'okta', 'microsoft', @@ -97,10 +98,10 @@ trait OAuth2Base 'amazon', 'apple', 'auth0', 'authentik', 'autodesk', 'bitbucket', 'bitly', 'box', 'dailymotion', 'discord', 'disqus', 'dropbox', 'etsy', 'facebook', 'figma', 'fusionauth', 'github', 'gitlab', - 'google', 'kick', 'linkedin', 'microsoft', 'notion', 'oidc', - 'okta', 'paypal', 'paypalSandbox', 'podio', 'salesforce', 'slack', - 'spotify', 'stripe', 'tradeshift', 'tradeshiftBox', 'twitch', - 'wordpress', 'x', 'yahoo', 'yandex', 'zoho', 'zoom', + 'google', 'keycloak', 'kick', 'linkedin', 'microsoft', 'notion', + 'oidc', 'okta', 'paypal', 'paypalSandbox', 'podio', 'salesforce', + 'slack', 'spotify', 'stripe', 'tradeshift', 'tradeshiftBox', + 'twitch', 'wordpress', 'x', 'yahoo', 'yandex', 'zoho', 'zoom', ]; \sort($expected); @@ -1118,6 +1119,171 @@ trait OAuth2Base ]); } + // ========================================================================= + // Update Keycloak (clientId + clientSecret + REQUIRED endpoint + REQUIRED realmName) + // ========================================================================= + + public function testUpdateOAuth2KeycloakRequiresEndpoint(): void + { + // The `endpoint` param is required (Text(min=1)); omitting → 400. + $response = $this->updateOAuth2('keycloak', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'realmName' => 'appwrite-realm', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2KeycloakEmptyEndpointRejected(): void + { + // The `endpoint` validator is Text(min=1). Sending `''` must be + // rejected the same way as omitting — the validator should treat the + // empty-string degenerate case as a missing required field. + $response = $this->updateOAuth2('keycloak', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'endpoint' => '', + 'realmName' => 'appwrite-realm', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2KeycloakRequiresRealmName(): void + { + // The `realmName` param is required (Text(min=1)); omitting → 400. + $response = $this->updateOAuth2('keycloak', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'endpoint' => 'keycloak.example.com', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2KeycloakEmptyRealmNameRejected(): void + { + // The `realmName` validator is Text(min=1). Sending `''` must be + // rejected the same way as omitting. + $response = $this->updateOAuth2('keycloak', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'endpoint' => 'keycloak.example.com', + 'realmName' => '', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2Keycloak(): void + { + $response = $this->updateOAuth2('keycloak', [ + 'clientId' => 'appwrite-o0000000st-app', + 'clientSecret' => 'keycloak-secret', + 'endpoint' => 'keycloak.example.com', + 'realmName' => 'appwrite-realm', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('keycloak', $response['body']['$id']); + $this->assertSame('appwrite-o0000000st-app', $response['body']['clientId']); + $this->assertSame('keycloak.example.com', $response['body']['endpoint']); + $this->assertSame('appwrite-realm', $response['body']['realmName']); + + // Cleanup + $this->updateOAuth2('keycloak', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.keycloak.com', + 'realmName' => 'cleanup-realm', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2KeycloakPartialPreservesSecret(): void + { + // Keycloak's `endpoint` and `realmName` are required on every call, + // so we always re-send them. The `clientSecret` lives in the JSON + // blob and must survive when omitted on a subsequent call that only + // changes clientId. + $this->updateOAuth2('keycloak', [ + 'clientId' => 'keycloak-merge-client', + 'clientSecret' => 'keycloak-merge-secret', + 'endpoint' => 'merge.keycloak.com', + 'realmName' => 'merge-realm', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('keycloak', [ + 'clientId' => 'keycloak-rotated-client', + 'endpoint' => 'merge.keycloak.com', + 'realmName' => 'merge-realm', + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('keycloak-rotated-client', $response['body']['clientId']); + $this->assertSame('merge.keycloak.com', $response['body']['endpoint']); + $this->assertSame('merge-realm', $response['body']['realmName']); + + // Confirm clientSecret survived the omitted-field merge by enabling + // — Keycloak has no verifyCredentials() hook, so non-empty stored + // secret is enough. `endpoint`/`realmName` must be re-sent (required + // on enable too). + $enable = $this->updateOAuth2('keycloak', [ + 'endpoint' => 'merge.keycloak.com', + 'realmName' => 'merge-realm', + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup — endpoint and realmName are required, use placeholders. + $this->updateOAuth2('keycloak', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.keycloak.com', + 'realmName' => 'cleanup-realm', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2KeycloakEnableAndReadBack(): void + { + $update = $this->updateOAuth2('keycloak', [ + 'clientId' => 'keycloak-enable-client', + 'clientSecret' => 'keycloak-enable-secret', + 'endpoint' => 'enable.keycloak.com', + 'realmName' => 'enable-realm', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId, endpoint, realmName. + $get = $this->getOAuth2Provider('keycloak'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('keycloak-enable-client', $get['body']['clientId']); + $this->assertSame('enable.keycloak.com', $get['body']['endpoint']); + $this->assertSame('enable-realm', $get['body']['realmName']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup — endpoint and realmName are required (Text(min=1)) so use placeholders. + $this->updateOAuth2('keycloak', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.keycloak.com', + 'realmName' => 'cleanup-realm', + 'enabled' => false, + ]); + } + // ========================================================================= // Update Microsoft (applicationId + applicationSecret + REQUIRED tenant) // ========================================================================= From f0cbfbbbe4fc4b157844c9af67b1b29c7e56a16a Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 14:31:49 +0530 Subject: [PATCH 337/402] fix: use assertEmpty for impersonatorUserId to match response model --- tests/e2e/Services/Users/UsersBase.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index d5c06e9f8d..70e74648b4 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2778,7 +2778,7 @@ trait UsersBase ); $this->assertEquals(200, $crossSite['headers']['status-code']); $this->assertEquals($impersonatorId, $crossSite['body']['$id'], 'cross-site: impersonation must be blocked'); - $this->assertArrayNotHasKey('impersonatorUserId', $crossSite['body']); + $this->assertEmpty($crossSite['body']['impersonatorUserId'] ?? ''); // Attack vector 2: absent header (reverse proxy strips Fetch Metadata headers) // Guard must fail-closed — absent Sec-Fetch-Site must not allow query param. @@ -2790,7 +2790,7 @@ trait UsersBase ); $this->assertEquals(200, $noFetchSite['headers']['status-code']); $this->assertEquals($impersonatorId, $noFetchSite['body']['$id'], 'absent header: must fail-closed'); - $this->assertArrayNotHasKey('impersonatorUserId', $noFetchSite['body']); + $this->assertEmpty($noFetchSite['body']['impersonatorUserId'] ?? ''); // Legitimate use 1: same-origin (Console on same origin as API) $sameOrigin = $this->client->call( @@ -2915,7 +2915,7 @@ trait UsersBase $this->assertEquals(200, $account['headers']['status-code']); // Should resolve as userA (the impersonator), not the target $this->assertEquals($idA, $account['body']['$id']); - $this->assertArrayNotHasKey('impersonatorUserId', $account['body']); + $this->assertEmpty($account['body']['impersonatorUserId'] ?? ''); } /** @@ -2965,7 +2965,7 @@ trait UsersBase ], ['impersonateUserId' => $idB]); $this->assertEquals(200, $account['headers']['status-code']); $this->assertEquals($idA, $account['body']['$id']); - $this->assertArrayNotHasKey('impersonatorUserId', $account['body']); + $this->assertEmpty($account['body']['impersonatorUserId'] ?? ''); } /** From 9e1f8af1036ddff162fb2cca767296841274d005 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Tue, 28 Apr 2026 13:44:41 +0400 Subject: [PATCH 338/402] fix: persist sourceChunksTotal/Uploaded in finalization createDocument paths Greptile review: Functions and Sites finalization branches reached via single-chunk uploads or out-of-order last-chunk assembly omitted sourceChunksTotal and sourceChunksUploaded in createDocument. This caused the retry guard to evaluate 0 === 1 on retry, missing and queuing duplicate builds. --- .../Platform/Modules/Functions/Http/Deployments/Create.php | 2 ++ src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index 2775d04137..757edc0484 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -250,6 +250,8 @@ class Create extends Action 'sourcePath' => $path, 'sourceSize' => $fileSize, 'totalSize' => $fileSize, + 'sourceChunksTotal' => $chunks, + 'sourceChunksUploaded' => $chunksUploaded, 'activate' => $activate, 'sourceMetadata' => $metadata, 'type' => $type diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index 4c3abdba3f..71ea5ceb2f 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -260,6 +260,8 @@ class Create extends Action 'sourcePath' => $path, 'sourceSize' => $fileSize, 'totalSize' => $fileSize, + 'sourceChunksTotal' => $chunks, + 'sourceChunksUploaded' => $chunksUploaded, 'activate' => $activate, 'sourceMetadata' => $metadata, 'type' => $type, From 8f176166c9f1e24eb7d7bf2124e2f725bbcf5b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 15:31:10 +0200 Subject: [PATCH 339/402] Re-introduce project JWT endpoint --- app/controllers/api/projects.php | 44 ++++++++++++++++ .../Projects/ProjectsConsoleClientTest.php | 52 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 494aa11150..da772d6dbb 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1,5 +1,6 @@ dynamic($project, Response::MODEL_PROJECT); }); +// JWT Keys + +Http::post('/v1/projects/:projectId/jwts') + ->groups(['api', 'projects']) + ->desc('Create JWT') + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'auth', + name: 'createJWT', + description: '/docs/references/projects/create-jwt.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_JWT, + ) + ] + )) + ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for JWT key. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) + ->inject('response') + ->inject('dbForPlatform') + ->action(function (string $projectId, array $scopes, int $duration, Response $response, Database $dbForPlatform) { + + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic(new Document(['jwt' => API_KEY_DYNAMIC . '_' . $jwt->encode([ + 'projectId' => $project->getId(), + 'scopes' => $scopes + ])]), Response::MODEL_JWT); + }); + // Backwards compatibility Http::patch('/v1/projects/:projectId/oauth2') ->desc('Update project OAuth2') diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 8322e37de1..d71537d534 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3941,6 +3941,58 @@ class ProjectsConsoleClientTest extends Scope $this->assertEmpty($response['body']); } + // JWT Keys + + public function testJWTKey(): void + { + $data = $this->setupProjectData(); + $id = $data['projectId']; + + // Create JWT key + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/jwts', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'duration' => 5, + 'scopes' => ['users.read'], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['jwt']); + + $jwt = $response['body']['jwt']; + + // Ensure JWT key works + $response = $this->client->call(Client::METHOD_GET, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-key' => $jwt, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertArrayHasKey('users', $response['body']); + + // Ensure JWT key respect scopes + $response = $this->client->call(Client::METHOD_GET, '/functions', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-key' => $jwt, + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + + // Ensure JWT key expires + \sleep(10); + + $response = $this->client->call(Client::METHOD_GET, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-key' => $jwt, + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + } + // Platforms public function testCreateProjectPlatform(): void From 87ed7c3817c1878eb900bc3f0bd30fbf4451122c Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 19:10:55 +0530 Subject: [PATCH 340/402] feat: add query param fallback for all impersonation params and simplify tests --- app/init/realtime/connection.php | 17 +- app/init/resources/request.php | 17 +- tests/e2e/Services/Users/UsersBase.php | 245 ++++--------------------- 3 files changed, 48 insertions(+), 231 deletions(-) diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index 03dfdc4fd7..a090635bb5 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -327,18 +327,11 @@ return function (Container $container): void { } } - // impersonateUserId also accepts a query param to support embedding in WebSocket URLs. - // Email and phone are intentionally header-only to avoid PII exposure in proxy/LB logs. - // Query-param fallback is blocked for cross-site requests to prevent CSRF attacks via - // third-party pages; Sec-Fetch-Site is a browser-enforced forbidden header. - $fetchSite = $request->getHeader('sec-fetch-site', ''); - // Allow same-origin and same-site: Console may be served from a different subdomain - // than the API, in which case the browser sends same-site. - // cross-site and absent are blocked to prevent CSRF via third-party embeds. - $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); + // Query params mirror the header fallback pattern used by ?project= and ?devKey=, + // allowing Console to embed impersonation in direct file/image URLs where headers cannot be set. + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', '')); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', (string)$request->getParam('impersonateEmail', '')); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', (string)$request->getParam('impersonatePhone', '')); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { $userDb = ($mode === APP_MODE_ADMIN || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; diff --git a/app/init/resources/request.php b/app/init/resources/request.php index c0097a2416..1aa53b7403 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -572,18 +572,11 @@ return function (Container $container): void { } // Impersonation: if current user has impersonator capability and headers/params are set, act as another user - // impersonateUserId also accepts a query param to allow embedding in direct file/image URLs (e.g. ) - // where custom headers cannot be set. Email and phone are intentionally header-only to avoid PII in URLs/logs. - // Query-param fallback is blocked for cross-site requests (Sec-Fetch-Site: cross-site) to prevent CSRF; - // Sec-Fetch-Site is a browser-enforced forbidden header that cannot be spoofed by JavaScript. - $fetchSite = $request->getHeader('sec-fetch-site', ''); - // Allow same-origin and same-site: Console may be served from a different subdomain - // than the API, in which case the browser sends same-site. - // cross-site and absent are blocked to prevent CSRF via third-party embeds. - $isSameOrigin = \in_array($fetchSite, ['same-origin', 'same-site'], true); - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', $isSameOrigin ? (string)$request->getParam('impersonateUserId', '') : ''); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); + // Query params mirror the header fallback pattern used by ?project= and ?devKey=, + // allowing Console to embed impersonation in direct file/image URLs where headers cannot be set. + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', '')); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', (string)$request->getParam('impersonateEmail', '')); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', (string)$request->getParam('impersonatePhone', '')); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { $userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; $targetUser = null; diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 70e74648b4..f9db65369a 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2709,118 +2709,10 @@ trait UsersBase } /** - * Proves that the Sec-Fetch-Site CSRF guard prevents forced impersonation via query params. - * - * Attack scenario (without the guard): - * A malicious page on attacker.com embeds: - * - * The browser automatically attaches the impersonator's session cookies. - * Without any guard, the server would impersonate victim_id silently. - * - * Why Sec-Fetch-Site works: - * Browsers set Sec-Fetch-Site: cross-site on all cross-origin requests (img, fetch, etc.). - * It is a browser-enforced forbidden header — JavaScript cannot set or spoof it. - * We only accept ?impersonateUserId when Sec-Fetch-Site is exactly same-origin. - * - * This test proves two attack vectors are blocked and two legitimate origins are allowed: - * Blocked: cross-site — attacker.com embeds pointing at Appwrite - * Blocked: absent — reverse proxy strips Fetch Metadata headers (fail-closed) - * Allowed: same-origin — Console on the same origin as the API - * Allowed: same-site — Console on a different subdomain than the API + * Test impersonation via URL query params — mirrors the ?project= and ?devKey= pattern. + * Allows Console to embed impersonation in direct file/image URLs where headers cannot be set. */ - public function testImpersonateQueryParamCsrfAttackPrevented(): void - { - $projectId = $this->getProject()['$id']; - $headers = array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()); - - // Impersonator user (the victim whose session gets hijacked in the attack) - $impersonator = $this->client->call(Client::METHOD_POST, '/users', $headers, [ - 'userId' => ID::unique(), - 'email' => 'csrf-guard-impersonator@appwrite.io', - 'password' => 'password', - 'name' => 'CSRF Guard Impersonator', - ]); - $this->assertEquals(201, $impersonator['headers']['status-code']); - $impersonatorId = $impersonator['body']['$id']; - - // Target user (who the attacker wants to impersonate) - $target = $this->client->call(Client::METHOD_POST, '/users', $headers, [ - 'userId' => ID::unique(), - 'email' => 'csrf-guard-target@appwrite.io', - 'password' => 'password', - 'name' => 'CSRF Guard Target', - ]); - $this->assertEquals(201, $target['headers']['status-code']); - $targetId = $target['body']['$id']; - - $this->client->call(Client::METHOD_PATCH, '/users/' . $impersonatorId . '/impersonator', $headers, ['impersonator' => true]); - - $session = $this->client->call(Client::METHOD_POST, '/users/' . $impersonatorId . '/sessions', $headers); - $this->assertEquals(201, $session['headers']['status-code']); - $sessionSecret = $session['body']['secret']; - - $sessionHeaders = [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-session' => $sessionSecret, - ]; - - // Attack vector 1: cross-site (attacker.com embeds ) - // Browser sends Sec-Fetch-Site: cross-site — must be blocked. - $crossSite = $this->client->call( - Client::METHOD_GET, - '/account', - array_merge($sessionHeaders, ['sec-fetch-site' => 'cross-site']), - ['impersonateUserId' => $targetId] - ); - $this->assertEquals(200, $crossSite['headers']['status-code']); - $this->assertEquals($impersonatorId, $crossSite['body']['$id'], 'cross-site: impersonation must be blocked'); - $this->assertEmpty($crossSite['body']['impersonatorUserId'] ?? ''); - - // Attack vector 2: absent header (reverse proxy strips Fetch Metadata headers) - // Guard must fail-closed — absent Sec-Fetch-Site must not allow query param. - $noFetchSite = $this->client->call( - Client::METHOD_GET, - '/account', - $sessionHeaders, - ['impersonateUserId' => $targetId] - ); - $this->assertEquals(200, $noFetchSite['headers']['status-code']); - $this->assertEquals($impersonatorId, $noFetchSite['body']['$id'], 'absent header: must fail-closed'); - $this->assertEmpty($noFetchSite['body']['impersonatorUserId'] ?? ''); - - // Legitimate use 1: same-origin (Console on same origin as API) - $sameOrigin = $this->client->call( - Client::METHOD_GET, - '/account', - array_merge($sessionHeaders, ['sec-fetch-site' => 'same-origin']), - ['impersonateUserId' => $targetId] - ); - $this->assertEquals(200, $sameOrigin['headers']['status-code']); - $this->assertEquals($targetId, $sameOrigin['body']['$id'], 'same-origin: impersonation must succeed'); - $this->assertEquals($impersonatorId, $sameOrigin['body']['impersonatorUserId']); - - // Legitimate use 2: same-site (Console on a different subdomain than the API) - $sameSite = $this->client->call( - Client::METHOD_GET, - '/account', - array_merge($sessionHeaders, ['sec-fetch-site' => 'same-site']), - ['impersonateUserId' => $targetId] - ); - $this->assertEquals(200, $sameSite['headers']['status-code']); - $this->assertEquals($targetId, $sameSite['body']['$id'], 'same-site: impersonation must succeed'); - $this->assertEquals($impersonatorId, $sameSite['body']['impersonatorUserId']); - } - - /** - * Test impersonation via ?impersonateUserId= query param (same-origin browser request). - * This is the primary use case for embedding impersonation in file/image URLs where - * custom headers cannot be set (e.g. , deployment source/output download links). - */ - public function testImpersonateByUserIdQueryParam(): void + public function testImpersonateByQueryParams(): void { $projectId = $this->getProject()['$id']; $headers = array_merge([ @@ -2853,119 +2745,58 @@ trait UsersBase $this->assertEquals(201, $session['headers']['status-code']); $sessionSecret = $session['body']['secret']; - // Query param works when Sec-Fetch-Site is same-origin or same-site. - // same-site covers Console deployed on a different subdomain than the API. - $account = $this->client->call(Client::METHOD_GET, '/account', [ + $sessionHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'x-appwrite-session' => $sessionSecret, - 'sec-fetch-site' => 'same-origin', - ], ['impersonateUserId' => $idB]); + ]; + + // Impersonate by user ID via query param + $account = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [ + 'impersonateUserId' => $idB, + ]); $this->assertEquals(200, $account['headers']['status-code']); $this->assertEquals($idB, $account['body']['$id']); $this->assertEquals('Query Param Target', $account['body']['name']); $this->assertEquals($idA, $account['body']['impersonatorUserId']); - } - /** - * Test that ?impersonateUserId= query param is ignored for cross-site requests (CSRF guard). - * Sec-Fetch-Site is a browser-enforced forbidden header; cross-site value means the request - * originated from a third-party page and must not be allowed to trigger impersonation. - */ - public function testImpersonateQueryParamIgnoredCrossSite(): void - { - $projectId = $this->getProject()['$id']; - $headers = array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()); - - $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ - 'userId' => ID::unique(), - 'email' => 'csrf-impersonator@appwrite.io', - 'password' => 'password', - 'name' => 'CSRF Impersonator', + // Impersonate by email via query param + $accountByEmail = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [ + 'impersonateEmail' => 'queryparam-target@appwrite.io', ]); - $this->assertEquals(201, $userA['headers']['status-code']); - $idA = $userA['body']['$id']; + $this->assertEquals(200, $accountByEmail['headers']['status-code']); + $this->assertEquals($idB, $accountByEmail['body']['$id']); + $this->assertEquals($idA, $accountByEmail['body']['impersonatorUserId']); - $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ - 'userId' => ID::unique(), - 'email' => 'csrf-target@appwrite.io', - 'password' => 'password', - 'name' => 'CSRF Target', + // Impersonate by phone via query param (update target user with a phone first) + $this->client->call(Client::METHOD_PATCH, '/users/' . $idB . '/phone', $headers, [ + 'number' => '+12345678901', ]); - $this->assertEquals(201, $userB['headers']['status-code']); - $idB = $userB['body']['$id']; - - $patch = $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]); - $this->assertEquals(200, $patch['headers']['status-code']); - - $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); - $this->assertEquals(201, $session['headers']['status-code']); - $sessionSecret = $session['body']['secret']; - - // Query param must be ignored when Sec-Fetch-Site is cross-site (third-party page embed) - $account = $this->client->call(Client::METHOD_GET, '/account', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-session' => $sessionSecret, - 'sec-fetch-site' => 'cross-site', - ], ['impersonateUserId' => $idB]); - $this->assertEquals(200, $account['headers']['status-code']); - // Should resolve as userA (the impersonator), not the target - $this->assertEquals($idA, $account['body']['$id']); - $this->assertEmpty($account['body']['impersonatorUserId'] ?? ''); - } - - /** - * Test that ?impersonateUserId= query param is ignored when Sec-Fetch-Site is absent - * (fail-closed CSRF guard). Absent header means a reverse proxy stripped Fetch Metadata - * headers or a non-browser client is calling — query param must be silently ignored. - */ - public function testImpersonateQueryParamIgnoredWhenSecFetchSiteAbsent(): void - { - $projectId = $this->getProject()['$id']; - $headers = array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()); - - $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ - 'userId' => ID::unique(), - 'email' => 'absent-fetch-impersonator@appwrite.io', - 'password' => 'password', - 'name' => 'Absent Fetch Impersonator', + $accountByPhone = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [ + 'impersonatePhone' => '+12345678901', ]); - $this->assertEquals(201, $userA['headers']['status-code']); - $idA = $userA['body']['$id']; + $this->assertEquals(200, $accountByPhone['headers']['status-code']); + $this->assertEquals($idB, $accountByPhone['body']['$id']); + $this->assertEquals($idA, $accountByPhone['body']['impersonatorUserId']); - $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + // Header takes priority over query param when both are present + $userC = $this->client->call(Client::METHOD_POST, '/users', $headers, [ 'userId' => ID::unique(), - 'email' => 'absent-fetch-target@appwrite.io', + 'email' => 'queryparam-target-c@appwrite.io', 'password' => 'password', - 'name' => 'Absent Fetch Target', + 'name' => 'Query Param Target C', ]); - $this->assertEquals(201, $userB['headers']['status-code']); - $idB = $userB['body']['$id']; + $this->assertEquals(201, $userC['headers']['status-code']); + $idC = $userC['body']['$id']; - $patch = $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]); - $this->assertEquals(200, $patch['headers']['status-code']); - - $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); - $this->assertEquals(201, $session['headers']['status-code']); - $sessionSecret = $session['body']['secret']; - - // Query param must be ignored when Sec-Fetch-Site is absent (proxy-stripped or API client) - $account = $this->client->call(Client::METHOD_GET, '/account', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-session' => $sessionSecret, - // no sec-fetch-site header - ], ['impersonateUserId' => $idB]); - $this->assertEquals(200, $account['headers']['status-code']); - $this->assertEquals($idA, $account['body']['$id']); - $this->assertEmpty($account['body']['impersonatorUserId'] ?? ''); + $accountHeaderPriority = $this->client->call( + Client::METHOD_GET, + '/account', + array_merge($sessionHeaders, ['x-appwrite-impersonate-user-id' => $idC]), + ['impersonateUserId' => $idB] + ); + $this->assertEquals(200, $accountHeaderPriority['headers']['status-code']); + $this->assertEquals($idC, $accountHeaderPriority['body']['$id'], 'header must take priority over query param'); } /** From 2a357511eacc6f843c560541f175ff53443cf8b3 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 28 Apr 2026 19:17:12 +0530 Subject: [PATCH 341/402] fix: use unique emails and phone in query param impersonation test --- tests/e2e/Services/Users/UsersBase.php | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index f9db65369a..b06e2d88e1 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2720,9 +2720,14 @@ trait UsersBase 'x-appwrite-project' => $projectId, ], $this->getHeaders()); + $emailA = 'queryparam-impersonator-' . \uniqid() . '@appwrite.io'; + $emailB = 'queryparam-target-' . \uniqid() . '@appwrite.io'; + $emailC = 'queryparam-target-c-' . \uniqid() . '@appwrite.io'; + $phone = '+1' . \rand(1000000000, 9999999999); + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ 'userId' => ID::unique(), - 'email' => 'queryparam-impersonator@appwrite.io', + 'email' => $emailA, 'password' => 'password', 'name' => 'Query Param Impersonator', ]); @@ -2731,7 +2736,7 @@ trait UsersBase $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ 'userId' => ID::unique(), - 'email' => 'queryparam-target@appwrite.io', + 'email' => $emailB, 'password' => 'password', 'name' => 'Query Param Target', ]); @@ -2762,7 +2767,7 @@ trait UsersBase // Impersonate by email via query param $accountByEmail = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [ - 'impersonateEmail' => 'queryparam-target@appwrite.io', + 'impersonateEmail' => $emailB, ]); $this->assertEquals(200, $accountByEmail['headers']['status-code']); $this->assertEquals($idB, $accountByEmail['body']['$id']); @@ -2770,10 +2775,10 @@ trait UsersBase // Impersonate by phone via query param (update target user with a phone first) $this->client->call(Client::METHOD_PATCH, '/users/' . $idB . '/phone', $headers, [ - 'number' => '+12345678901', + 'number' => $phone, ]); $accountByPhone = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [ - 'impersonatePhone' => '+12345678901', + 'impersonatePhone' => $phone, ]); $this->assertEquals(200, $accountByPhone['headers']['status-code']); $this->assertEquals($idB, $accountByPhone['body']['$id']); @@ -2782,7 +2787,7 @@ trait UsersBase // Header takes priority over query param when both are present $userC = $this->client->call(Client::METHOD_POST, '/users', $headers, [ 'userId' => ID::unique(), - 'email' => 'queryparam-target-c@appwrite.io', + 'email' => $emailC, 'password' => 'password', 'name' => 'Query Param Target C', ]); From ed9b47f6ce7d8aff0d1962df7f1e65a293ac1e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 15:57:37 +0200 Subject: [PATCH 342/402] Migrate project jwt to dynamic api key --- app/controllers/api/projects.php | 42 ------- .../Http/Project/Keys/Dynamic/Create.php | 115 ++++++++++++++++++ .../Project/Keys/{ => Standard}/Create.php | 15 ++- .../Projects/ProjectsConsoleClientTest.php | 2 + 4 files changed, 126 insertions(+), 48 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php rename src/Appwrite/Platform/Modules/Project/Http/Project/Keys/{ => Standard}/Create.php (88%) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index da772d6dbb..ca7f8bb216 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -60,48 +60,6 @@ Http::get('/v1/projects/:projectId') $response->dynamic($project, Response::MODEL_PROJECT); }); -// JWT Keys - -Http::post('/v1/projects/:projectId/jwts') - ->groups(['api', 'projects']) - ->desc('Create JWT') - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'createJWT', - description: '/docs/references/projects/create-jwt.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_JWT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for JWT key. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') - ->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, array $scopes, int $duration, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0); - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic(new Document(['jwt' => API_KEY_DYNAMIC . '_' . $jwt->encode([ - 'projectId' => $project->getId(), - 'scopes' => $scopes - ])]), Response::MODEL_JWT); - }); - // Backwards compatibility Http::patch('/v1/projects/:projectId/oauth2') ->desc('Update project OAuth2') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php new file mode 100644 index 0000000000..2df1f2a303 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php @@ -0,0 +1,115 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/keys/dynamic') + ->httpAlias('/v1/projects/:projectId/jwts') + ->desc('Create dynamic project key') + ->groups(['api', 'project']) + ->label('scope', 'keys.write') + ->label('event', 'keys.[keyId].create') + ->label('audits.event', 'project.key.create') + ->label('audits.resource', 'project.key/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'createDynamicKey', + description: <<param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false) + ->param('duration', 900, new Range(1, 3600), 'Time in seconds before dynamic key expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $keyId, + array $scopes, + int $duration, + Response $response, + QueueEvent $queueForEvents, + Document $project, + ) { + $keyId = ID::unique(); + + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0); + + $secret = $jwt->encode([ + 'projectId' => $project->getId(), + 'scopes' => $scopes + ]); + + $now = new \DateTime(); + $expire = $now->add(new \DateInterval('PT' . $duration . 'S'))->format('Y-m-d\TH:i:s.u\Z'); + + $key = new Document([ + '$id' => $keyId, + '$createdAt' => new DatabaseDateTime(), + '$updatedAt' => new DatabaseDateTime(), + 'name' => '', + 'scopes' => $scopes, + 'expire' => $expire, + 'sdks' => [], + 'accessedAt' => null, + 'secret' => API_KEY_DYNAMIC . '_' . $secret, + ]); + + $queueForEvents->setParam('keyId', $key->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($key, Response::MODEL_KEY); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php similarity index 88% rename from src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php rename to src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php index 236c091c31..ccf19e4a30 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/project/keys') + ->setHttpPath('/v1/project/keys/standard') + ->httpAlias('/v1/project/keys') ->httpAlias('/v1/projects/:projectId/keys') - ->desc('Create project key') + ->desc('Create standard project key') ->groups(['api', 'project']) ->label('scope', 'keys.write') ->label('event', 'keys.[keyId].create') @@ -48,9 +49,11 @@ class Create extends Base ->label('sdk', new Method( namespace: 'project', group: 'keys', - name: 'createKey', + name: 'createStandardKey', description: <<assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['jwt']); + $this->assertNotEmpty($response['body']['projectId']); + $this->assertSame($id, $response['body']['projectId']); $jwt = $response['body']['jwt']; From b2ce95a0cd6ec246067311537cbf2e4bf9437a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 16:14:10 +0200 Subject: [PATCH 343/402] Dynamic key backwards compatibility --- app/controllers/api/projects.php | 2 -- app/controllers/general.php | 8 +++++ app/init/constants.php | 4 +-- app/init/models.php | 2 ++ src/Appwrite/Migration/Migration.php | 1 + .../Http/Project/Keys/Dynamic/Create.php | 32 +++++++---------- src/Appwrite/Utopia/Request/Filters/V24.php | 14 ++++++++ src/Appwrite/Utopia/Response.php | 1 + src/Appwrite/Utopia/Response/Filters/V24.php | 36 +++++++++++++++++++ .../Utopia/Response/Model/DynamicKey.php | 33 +++++++++++++++++ src/Appwrite/Utopia/Response/Model/Key.php | 5 --- 11 files changed, 109 insertions(+), 29 deletions(-) create mode 100644 src/Appwrite/Utopia/Request/Filters/V24.php create mode 100644 src/Appwrite/Utopia/Response/Filters/V24.php create mode 100644 src/Appwrite/Utopia/Response/Model/DynamicKey.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index ca7f8bb216..494aa11150 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1,6 +1,5 @@ addFilter(new RequestV23()); } + if (version_compare($requestFormat, '1.9.3', '<')) { + $request->addFilter(new RequestV24()); + } } $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); @@ -923,6 +928,9 @@ Http::init() */ $responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); if ($responseFormat) { + if (version_compare($responseFormat, '1.9.3', '<')) { + $response->addFilter(new ResponseV24()); + } if (version_compare($responseFormat, '1.9.2', '<')) { $response->addFilter(new ResponseV23()); } diff --git a/app/init/constants.php b/app/init/constants.php index 8eacf2fe12..c3f67502f2 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -44,8 +44,8 @@ const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours -const APP_CACHE_BUSTER = 4323; -const APP_VERSION_STABLE = '1.9.2'; +const APP_CACHE_BUSTER = 4324; +const APP_VERSION_STABLE = '1.9.3'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; const APP_DATABASE_ATTRIBUTE_IP = 'ip'; diff --git a/app/init/models.php b/app/init/models.php index 77ca9be451..699d1561a3 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -70,6 +70,7 @@ use Appwrite\Utopia\Response\Model\DetectionRuntime; use Appwrite\Utopia\Response\Model\DetectionVariable; use Appwrite\Utopia\Response\Model\DevKey; use Appwrite\Utopia\Response\Model\Document as ModelDocument; +use Appwrite\Utopia\Response\Model\DynamicKey; use Appwrite\Utopia\Response\Model\Embedding; use Appwrite\Utopia\Response\Model\Error; use Appwrite\Utopia\Response\Model\ErrorDev; @@ -392,6 +393,7 @@ Response::setModel(new Execution()); Response::setModel(new Project()); Response::setModel(new Webhook()); Response::setModel(new Key()); +Response::setModel(new DynamicKey()); Response::setModel(new DevKey()); Response::setModel(new MockNumber()); Response::setModel(new OAuth2GitHub()); diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index ef0dd9f8b5..359925e368 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -95,6 +95,7 @@ abstract class Migration '1.9.0' => 'V24', '1.9.1' => 'V24', '1.9.2' => 'V24', + '1.9.3' => 'V24', ]; /** diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php index 2df1f2a303..eaad5a8c64 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php @@ -4,28 +4,20 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Keys\Dynami; use Ahc\Jwt\JWT; use Appwrite\Event\Event as QueueEvent; -use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; -use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Response; use Utopia\Config\Config; -use Utopia\Database\Database; use Utopia\Database\DateTime as DatabaseDateTime; use Utopia\Database\Document; -use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Helpers\ID; -use Utopia\Database\Validator\Authorization; -use Utopia\Database\Validator\Datetime; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; use Utopia\Validator\ArrayList; -use Utopia\Validator\Nullable; use Utopia\Validator\Range; -use Utopia\Validator\Text; use Utopia\Validator\WhiteList; class Create extends Base @@ -62,7 +54,7 @@ class Create extends Base responses: [ new SDKResponse( code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_KEY, + model: Response::MODEL_DYNAMIC_KEY, ) ], )) @@ -84,16 +76,16 @@ class Create extends Base ) { $keyId = ID::unique(); - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0); - - $secret = $jwt->encode([ - 'projectId' => $project->getId(), - 'scopes' => $scopes - ]); - - $now = new \DateTime(); - $expire = $now->add(new \DateInterval('PT' . $duration . 'S'))->format('Y-m-d\TH:i:s.u\Z'); - + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0); + + $secret = $jwt->encode([ + 'projectId' => $project->getId(), + 'scopes' => $scopes + ]); + + $now = new \DateTime(); + $expire = $now->add(new \DateInterval('PT' . $duration . 'S'))->format('Y-m-d\TH:i:s.u\Z'); + $key = new Document([ '$id' => $keyId, '$createdAt' => new DatabaseDateTime(), @@ -110,6 +102,6 @@ class Create extends Base $response ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($key, Response::MODEL_KEY); + ->dynamic($key, Response::MODEL_DYNAMIC_KEY); } } diff --git a/src/Appwrite/Utopia/Request/Filters/V24.php b/src/Appwrite/Utopia/Request/Filters/V24.php new file mode 100644 index 0000000000..29df762f28 --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V24.php @@ -0,0 +1,14 @@ + $this->parseDynamicKey($content), + default => $content, + }; + } + + private function parseDynamicKey(array $content): array + { + unset($content['$id']); + unset($content['$createdAt']); + unset($content['$updatedAt']); + unset($content['name']); + unset($content['expire']); + unset($content['sdks']); + unset($content['accessedAt']); + + $content['jwt'] = $content['secret'] ?? ''; + unset($content['secret']); + + $content['projectId'] = 'WHAT DO I DO NOW?!'; + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/DynamicKey.php b/src/Appwrite/Utopia/Response/Model/DynamicKey.php new file mode 100644 index 0000000000..c1016f3fcc --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/DynamicKey.php @@ -0,0 +1,33 @@ + Date: Tue, 28 Apr 2026 16:18:36 +0200 Subject: [PATCH 344/402] Bug&test fixing --- .../Modules/Project/Http/Project/Keys/Dynamic/Create.php | 7 +++---- src/Appwrite/Platform/Modules/Project/Services/Http.php | 6 ++++-- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 1 + 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php index eaad5a8c64..8839a146fb 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php @@ -1,6 +1,6 @@ $keyId, - '$createdAt' => new DatabaseDateTime(), - '$updatedAt' => new DatabaseDateTime(), + '$createdAt' => DatabaseDateTime::now(), + '$updatedAt' => DatabaseDateTime::now(), 'name' => '', 'scopes' => $scopes, 'expire' => $expire, diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 8c6b9da7e7..a0b2cd2acf 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -5,9 +5,10 @@ namespace Appwrite\Platform\Modules\Project\Services; use Appwrite\Platform\Modules\Project\Http\Init; use Appwrite\Platform\Modules\Project\Http\Project\AuthMethods\Update as UpdateAuthMethod; use Appwrite\Platform\Modules\Project\Http\Project\Delete as DeleteProject; -use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Dynamic\Create as CreateDynamicKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Standard\Create as CreateStandardKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Update as UpdateKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\XList as ListKeys; use Appwrite\Platform\Modules\Project\Http\Project\Labels\Update as UpdateProjectLabels; @@ -130,7 +131,8 @@ class Http extends Service $this->addAction(UpdateVariable::getName(), new UpdateVariable()); // Keys - $this->addAction(CreateKey::getName(), new CreateKey()); + $this->addAction(CreateStandardKey::getName(), new CreateStandardKey()); + $this->addAction(CreateDynamicKey::getName(), new CreateDynamicKey()); $this->addAction(ListKeys::getName(), new ListKeys()); $this->addAction(GetKey::getName(), new GetKey()); $this->addAction(DeleteKey::getName(), new DeleteKey()); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 435b80ffd6..6936de9aff 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3952,6 +3952,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/jwts', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.2', ], $this->getHeaders()), [ 'duration' => 5, 'scopes' => ['users.read'], From 11f80fc2edc8faf8b9ca33e2fb3a85414ae3093a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 16:35:40 +0200 Subject: [PATCH 345/402] Solve key projectId backwards compatibility --- src/Appwrite/Utopia/Response/Filters/V24.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Utopia/Response/Filters/V24.php b/src/Appwrite/Utopia/Response/Filters/V24.php index 685e6d81bf..8ac5305dc1 100644 --- a/src/Appwrite/Utopia/Response/Filters/V24.php +++ b/src/Appwrite/Utopia/Response/Filters/V24.php @@ -26,11 +26,22 @@ class V24 extends Filter unset($content['sdks']); unset($content['accessedAt']); + $projectId = ''; + if (isset($content['secret'])) { + $parts = explode('_', $content['secret'], 2); + if (count($parts) === 2) { + $jwtParts = explode('.', $parts[1]); + if (count($jwtParts) >= 2) { + $payload = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $jwtParts[1])), true); + $projectId = $payload['projectId'] ?? ''; + } + } + } + $content['projectId'] = $projectId; + $content['jwt'] = $content['secret'] ?? ''; unset($content['secret']); - $content['projectId'] = 'WHAT DO I DO NOW?!'; - return $content; } } From 72dfd8a7bc2c474e3b76186550edeb872e028bdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 16:45:00 +0200 Subject: [PATCH 346/402] Add E2E tests for dynamic keys --- tests/e2e/Services/Project/KeysBase.php | 131 ++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php index 505c7f6539..5019c8fefd 100644 --- a/tests/e2e/Services/Project/KeysBase.php +++ b/tests/e2e/Services/Project/KeysBase.php @@ -239,6 +239,112 @@ trait KeysBase $this->deleteKey($customId); } + // ========================================================================= + // Create dynamic key tests + // ========================================================================= + + public function testCreateDynamicKey(): void + { + $key = $this->createDynamicKey( + ['users.read', 'users.write'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertNotEmpty($key['body']['$id']); + $this->assertSame('', $key['body']['name']); + $this->assertSame(['users.read', 'users.write'], $key['body']['scopes']); + $this->assertNotEmpty($key['body']['secret']); + $this->assertStringStartsWith(API_KEY_DYNAMIC . '_', $key['body']['secret']); + $this->assertSame([], $key['body']['sdks']); + $this->assertNull($key['body']['accessedAt']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($key['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($key['body']['$updatedAt'])); + $this->assertSame(true, $dateValidator->isValid($key['body']['expire'])); + + // Verify JWT payload + $jwt = substr($key['body']['secret'], strlen(API_KEY_DYNAMIC . '_')); + $parts = explode('.', $jwt); + $this->assertCount(3, $parts); + $payload = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $parts[1])), true); + $this->assertNotEmpty($payload['projectId']); + $this->assertSame(['users.read', 'users.write'], $payload['scopes']); + + // Verify default duration (900 seconds) + $expireDt = new \DateTime($key['body']['expire']); + $now = new \DateTime(); + $diff = $expireDt->getTimestamp() - $now->getTimestamp(); + $this->assertGreaterThanOrEqual(890, $diff); + $this->assertLessThanOrEqual(910, $diff); + } + + public function testCreateDynamicKeyWithDuration(): void + { + $duration = 1800; + + $key = $this->createDynamicKey( + ['databases.read'], + $duration, + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame(['databases.read'], $key['body']['scopes']); + + $expireDt = new \DateTime($key['body']['expire']); + $now = new \DateTime(); + $diff = $expireDt->getTimestamp() - $now->getTimestamp(); + $this->assertGreaterThanOrEqual($duration - 10, $diff); + $this->assertLessThanOrEqual($duration + 10, $diff); + } + + public function testCreateDynamicKeyWithEmptyScopes(): void + { + $key = $this->createDynamicKey( + [], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame([], $key['body']['scopes']); + } + + public function testCreateDynamicKeyWithoutAuthentication(): void + { + $response = $this->createDynamicKey( + ['users.read'], + null, + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateDynamicKeyInvalidScope(): void + { + $response = $this->createDynamicKey( + ['invalid.scope'], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateDynamicKeyInvalidDuration(): void + { + $response = $this->createDynamicKey( + ['users.read'], + 0, + ); + + $this->assertSame(400, $response['headers']['status-code']); + + $response = $this->createDynamicKey( + ['users.read'], + 3601, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + // ========================================================================= // Update key tests // ========================================================================= @@ -855,4 +961,29 @@ trait KeysBase return $this->client->call(Client::METHOD_DELETE, '/project/keys/' . $keyId, $headers); } + + /** + * @param array $scopes + */ + protected function createDynamicKey(array $scopes, ?int $duration = null, bool $authenticated = true): mixed + { + $params = [ + 'scopes' => $scopes, + ]; + + if ($duration !== null) { + $params['duration'] = $duration; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/keys/dynamic', $headers, $params); + } } From f5a732d2311e9614e9496540924ace457a199b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 16:47:39 +0200 Subject: [PATCH 347/402] Add dynami key integration test --- .../Services/Project/KeysIntegrationTest.php | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/e2e/Services/Project/KeysIntegrationTest.php diff --git a/tests/e2e/Services/Project/KeysIntegrationTest.php b/tests/e2e/Services/Project/KeysIntegrationTest.php new file mode 100644 index 0000000000..2615cac023 --- /dev/null +++ b/tests/e2e/Services/Project/KeysIntegrationTest.php @@ -0,0 +1,103 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $consoleHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-mode' => 'admin', + 'x-appwrite-project' => $projectId, + ]; + + // Step 1: Create a dynamic key scoped to users.read only. + $dynamicKey = $this->client->call( + Client::METHOD_POST, + '/project/keys/dynamic', + $serverHeaders, + [ + 'scopes' => ['users.read'], + 'duration' => 900, + ] + ); + $this->assertSame(201, $dynamicKey['headers']['status-code']); + $this->assertNotEmpty($dynamicKey['body']['secret']); + + $dynamicKeySecret = $dynamicKey['body']['secret']; + + $dynamicHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $dynamicKeySecret, + ]; + + // Step 2: Create a project user using console headers. + $user = $this->client->call( + Client::METHOD_POST, + '/users', + $consoleHeaders, + [ + 'userId' => ID::unique(), + 'email' => 'dynamic_key_' . \uniqid() . '@localhost.test', + 'password' => 'password1234', + 'name' => 'Dynamic Key Test User', + ] + ); + $this->assertSame(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + + // Step 3: Dynamic key can list users. + $list = $this->client->call( + Client::METHOD_GET, + '/users', + $dynamicHeaders + ); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + // Step 4: Dynamic key can get the specific user. + $get = $this->client->call( + Client::METHOD_GET, + '/users/' . $userId, + $dynamicHeaders + ); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($userId, $get['body']['$id']); + + // Step 5: Dynamic key cannot create users (missing users.write scope). + $createAttempt = $this->client->call( + Client::METHOD_POST, + '/users', + $dynamicHeaders, + [ + 'userId' => ID::unique(), + 'email' => 'should_fail_' . \uniqid() . '@localhost.test', + 'password' => 'password1234', + 'name' => 'Should Fail', + ] + ); + $this->assertSame(401, $createAttempt['headers']['status-code']); + } +} From 3f5dcc81fd27a71066e3d689b1e4c56063c47aff Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 28 Apr 2026 15:57:41 +0100 Subject: [PATCH 348/402] Refactor migrations API to module style --- app/config/services.php | 2 +- app/controllers/api/migrations.php | 1277 ----------------- src/Appwrite/Platform/Appwrite.php | 2 + .../Http/Migrations/Appwrite/Create.php | 110 ++ .../Http/Migrations/Appwrite/Report/Get.php | 80 ++ .../Http/Migrations/CSV/Exports/Create.php | 213 +++ .../Http/Migrations/CSV/Imports/Create.php | 220 +++ .../Migrations/Http/Migrations/Delete.php | 74 + .../Http/Migrations/Firebase/Create.php | 114 ++ .../Http/Migrations/Firebase/Report/Get.php | 80 ++ .../Migrations/Http/Migrations/Get.php | 61 + .../Http/Migrations/JSON/Exports/Create.php | 198 +++ .../Http/Migrations/JSON/Imports/Create.php | 221 +++ .../Http/Migrations/NHost/Create.php | 122 ++ .../Http/Migrations/NHost/Report/Get.php | 86 ++ .../Http/Migrations/Supabase/Create.php | 120 ++ .../Http/Migrations/Supabase/Report/Get.php | 85 ++ .../Migrations/Http/Migrations/Update.php | 90 ++ .../Migrations/Http/Migrations/XList.php | 104 ++ .../Platform/Modules/Migrations/Module.php | 14 + .../Modules/Migrations/Services/Http.php | 59 + 21 files changed, 2054 insertions(+), 1278 deletions(-) delete mode 100644 app/controllers/api/migrations.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Module.php create mode 100644 src/Appwrite/Platform/Modules/Migrations/Services/Http.php diff --git a/app/config/services.php b/app/config/services.php index 548f659a81..cf2714f8c5 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -286,7 +286,7 @@ return [ 'name' => 'Migrations', 'subtitle' => 'The Migrations service allows you to migrate third-party data to your Appwrite project.', 'description' => '/docs/services/migrations.md', - 'controller' => 'api/migrations.php', + 'controller' => '', // Uses modules 'sdk' => true, 'docs' => true, 'docsUrl' => 'https://appwrite.io/docs/migrations', diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php deleted file mode 100644 index 7338197511..0000000000 --- a/app/controllers/api/migrations.php +++ /dev/null @@ -1,1277 +0,0 @@ - Transfer::GROUP_DATABASES_TABLES_DB, - DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, - DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, - default => throw new \LogicException('Unknown database type: ' . $databaseType), - }; -} - -function getDatabaseResourceType(string $databaseType): string -{ - return match($databaseType) { - DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB, - DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB, - default => Resource::TYPE_DATABASE, - }; -} - -Http::post('/v1/migrations/appwrite') - ->groups(['api', 'migrations']) - ->desc('Create Appwrite migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createAppwriteMigration', - description: '/docs/references/migrations/migration-appwrite.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Appwrite::getSupportedResources())), 'List of resources to migrate') - ->param('endpoint', '', new URL(), 'Source Appwrite endpoint') - ->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject']) - ->param('apiKey', '', new Text(512), 'Source API Key') - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function (array $resources, string $endpoint, string $projectId, string $apiKey, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => Appwrite::getName(), - 'destination' => Appwrite::getName(), - 'credentials' => [ - 'endpoint' => $endpoint, - 'projectId' => $projectId, - 'apiKey' => $apiKey, - ], - 'resources' => $resources, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - // Trigger Transfer - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/firebase') - ->groups(['api', 'migrations']) - ->desc('Create Firebase migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createFirebaseMigration', - description: '/docs/references/migrations/migration-firebase.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate') - ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials') - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function (array $resources, string $serviceAccount, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { - $serviceAccountData = json_decode($serviceAccount, true); - - if (empty($serviceAccountData)) { - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); - } - - if (!isset($serviceAccountData['project_id']) || !isset($serviceAccountData['client_email']) || !isset($serviceAccountData['private_key'])) { - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); - } - - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => Firebase::getName(), - 'destination' => Appwrite::getName(), - 'credentials' => [ - 'serviceAccount' => $serviceAccount, - ], - 'resources' => $resources, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - // Trigger Transfer - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/supabase') - ->groups(['api', 'migrations']) - ->desc('Create Supabase migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createSupabaseMigration', - description: '/docs/references/migrations/migration-supabase.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate') - ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint') - ->param('apiKey', '', new Text(512), 'Source\'s API Key') - ->param('databaseHost', '', new Text(512), 'Source\'s Database Host') - ->param('username', '', new Text(512), 'Source\'s Database Username') - ->param('password', '', new Text(512), 'Source\'s Database Password') - ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true) - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => Supabase::getName(), - 'destination' => Appwrite::getName(), - 'credentials' => [ - 'endpoint' => $endpoint, - 'apiKey' => $apiKey, - 'databaseHost' => $databaseHost, - 'username' => $username, - 'password' => $password, - 'port' => $port, - ], - 'resources' => $resources, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - // Trigger Transfer - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/nhost') - ->groups(['api', 'migrations']) - ->desc('Create NHost migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createNHostMigration', - description: '/docs/references/migrations/migration-nhost.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate') - ->param('subdomain', '', new Text(512), 'Source\'s Subdomain') - ->param('region', '', new Text(512), 'Source\'s Region') - ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret') - ->param('database', '', new Text(512), 'Source\'s Database Name') - ->param('username', '', new Text(512), 'Source\'s Database Username') - ->param('password', '', new Text(512), 'Source\'s Database Password') - ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true) - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => NHost::getName(), - 'destination' => Appwrite::getName(), - 'credentials' => [ - 'subdomain' => $subdomain, - 'region' => $region, - 'adminSecret' => $adminSecret, - 'database' => $database, - 'username' => $username, - 'password' => $password, - 'port' => $port, - ], - 'resources' => $resources, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - // Trigger Transfer - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/csv/imports') - ->alias('/v1/migrations/csv') - ->groups(['api', 'migrations']) - ->desc('Import documents from a CSV') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createCSVImport', - description: '/docs/references/migrations/migration-csv-import.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('bucketId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).', false, ['dbForProject']) - ->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject']) - ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') - ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('authorization') - ->inject('project') - ->inject('platform') - ->inject('deviceForFiles') - ->inject('deviceForMigrations') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function ( - string $bucketId, - string $fileId, - string $resourceId, - bool $internalFile, - Response $response, - Database $dbForProject, - Database $dbForPlatform, - Authorization $authorization, - Document $project, - array $platform, - Device $deviceForFiles, - Device $deviceForMigrations, - Event $queueForEvents, - MigrationPublisher $publisherForMigrations - ) { - $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { - if ($internalFile) { - return $dbForPlatform->getDocument('buckets', 'default'); - } - return $dbForProject->getDocument('buckets', $bucketId); - }); - - if ($bucket->isEmpty()) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $path = $file->getAttribute('path', ''); - if (!$deviceForFiles->exists($path)) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); - } - - // No encryption or compression on files above 20MB. - $hasEncryption = !empty($file->getAttribute('openSSLCipher')); - $compression = $file->getAttribute('algorithm', Compression::NONE); - $hasCompression = $compression !== Compression::NONE; - - $migrationId = ID::unique(); - $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.csv'); - - if ($hasEncryption || $hasCompression) { - $source = $deviceForFiles->read($path); - - if ($hasEncryption) { - $source = OpenSSL::decrypt( - $source, - $file->getAttribute('openSSLCipher'), - System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), - 0, - hex2bin($file->getAttribute('openSSLIV')), - hex2bin($file->getAttribute('openSSLTag')) - ); - } - - if ($hasCompression) { - switch ($compression) { - case Compression::ZSTD: - $source = (new Zstd())->decompress($source); - break; - case Compression::GZIP: - $source = (new GZIP())->decompress($source); - break; - } - } - - // Manual write after decryption and/or decompression - if (!$deviceForMigrations->write($newPath, $source, 'text/csv')) { - throw new \Exception('Unable to copy file'); - } - } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) { - throw new \Exception('Unable to copy file'); - } - - // getting databasetype - $resources = explode(':', $resourceId); - $databaseId = $resources[0]; - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - $databaseType = $database->getAttribute('type'); - if (!in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) { - throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv'); - } - $fileSize = $deviceForMigrations->getFileSize($newPath); - $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); - $resourceType = getDatabaseResourceType($databaseType); - - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => $migrationId, - 'status' => 'pending', - 'stage' => 'init', - 'source' => CSV::getName(), - 'destination' => Appwrite::getName(), - 'resources' => $resources, - 'resourceId' => $resourceId, - 'resourceType' => $resourceType, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - 'options' => [ - 'path' => $newPath, - 'size' => $fileSize, - ], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/csv/exports') - ->groups(['api', 'migrations']) - ->desc('Export documents to CSV') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createCSVExport', - description: '/docs/references/migrations/migration-csv-export.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.') - ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .csv extension.') - ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true) - ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) - ->param('delimiter', ',', new Text(1), 'The character that separates each column value. Default is comma.', true) - ->param('enclosure', '"', new Text(1), 'The character that encloses each column value. Default is double quotes.', true) - ->param('escape', '"', new Text(1), 'The escape character for the enclosure character. Default is double quotes.', true) - ->param('header', true, new Boolean(), 'Whether to include the header row with column names. Default is true.', true) - ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true) - ->inject('user') - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('authorization') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function ( - string $resourceId, - string $filename, - array $columns, - array $queries, - string $delimiter, - string $enclosure, - string $escape, - bool $header, - bool $notify, - Document $user, - Response $response, - Database $dbForProject, - Database $dbForPlatform, - Authorization $authorization, - Document $project, - array $platform, - Event $queueForEvents, - MigrationPublisher $publisherForMigrations - ) { - try { - $parsedQueries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default')); - if ($bucket->isEmpty()) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - [$databaseId, $collectionId] = \explode(':', $resourceId, 2); - if (empty($databaseId)) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - if (empty($collectionId)) { - throw new Exception(Exception::COLLECTION_NOT_FOUND); - } - - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - if ($database->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); - if ($collection->isEmpty()) { - throw new Exception(Exception::COLLECTION_NOT_FOUND); - } - - // getting databasetype - $resources = explode(':', $resourceId); - $databaseId = $resources[0]; - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - $databaseType = $database->getAttribute('type'); - if (!in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) { - throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv'); - } - - // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields - $isSchemaless = in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]); - - $validator = new Documents( - attributes: $collection->getAttribute('attributes', []), - indexes: $collection->getAttribute('indexes', []), - idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(), - supportForAttributes: !$isSchemaless, - ); - - if (!$validator->isValid($parsedQueries)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); - $resourceType = getDatabaseResourceType($databaseType); - - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => Appwrite::getName(), - 'destination' => CSV::getName(), - 'resources' => $resources, - 'resourceId' => $resourceId, - 'resourceType' => $resourceType, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - 'options' => [ - 'bucketId' => 'default', // Always use internal bucket - 'filename' => $filename, - 'columns' => $columns, - 'queries' => $queries, - 'delimiter' => $delimiter, - 'enclosure' => $enclosure, - 'escape' => $escape, - 'header' => $header, - 'notify' => $notify, - 'userInternalId' => $user->getSequence(), - ], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/json/imports') - ->groups(['api', 'migrations']) - ->desc('Import documents from a JSON') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createJSONImport', - description: '/docs/references/migrations/migration-json-import.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') - ->param('fileId', '', new UID(), 'File ID.') - ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') - ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('authorization') - ->inject('project') - ->inject('platform') - ->inject('deviceForFiles') - ->inject('deviceForMigrations') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function ( - string $bucketId, - string $fileId, - string $resourceId, - bool $internalFile, - Response $response, - Database $dbForProject, - Database $dbForPlatform, - Authorization $authorization, - Document $project, - array $platform, - Device $deviceForFiles, - Device $deviceForMigrations, - Event $queueForEvents, - MigrationPublisher $publisherForMigrations - ) { - $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { - if ($internalFile) { - return $dbForPlatform->getDocument('buckets', 'default'); - } - return $dbForProject->getDocument('buckets', $bucketId); - }); - - if ($bucket->isEmpty()) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); - if ($file->isEmpty()) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); - } - - $path = $file->getAttribute('path', ''); - if (!$deviceForFiles->exists($path)) { - throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); - } - - // No encryption or compression on files above 20MB. - $hasEncryption = !empty($file->getAttribute('openSSLCipher')); - $compression = $file->getAttribute('algorithm', Compression::NONE); - $hasCompression = $compression !== Compression::NONE; - - $migrationId = ID::unique(); - $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.json'); - - if ($hasEncryption || $hasCompression) { - $source = $deviceForFiles->read($path); - - if ($hasEncryption) { - $source = OpenSSL::decrypt( - $source, - $file->getAttribute('openSSLCipher'), - System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), - 0, - hex2bin($file->getAttribute('openSSLIV')), - hex2bin($file->getAttribute('openSSLTag')) - ); - } - - if ($hasCompression) { - switch ($compression) { - case Compression::ZSTD: - $source = (new Zstd())->decompress($source); - break; - case Compression::GZIP: - $source = (new GZIP())->decompress($source); - break; - } - } - - // Manual write after decryption and/or decompression - if (!$deviceForMigrations->write($newPath, $source, 'application/json')) { - throw new \Exception('Unable to copy file'); - } - } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) { - throw new \Exception('Unable to copy file'); - } - - $fileSize = $deviceForMigrations->getFileSize($newPath); - - [$databaseId] = \explode(':', $resourceId, 2); - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - if ($database->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - $databaseType = $database->getAttribute('type'); - $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); - $resourceType = getDatabaseResourceType($databaseType); - - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => $migrationId, - 'status' => 'pending', - 'stage' => 'init', - 'source' => JSON::getName(), - 'destination' => Appwrite::getName(), - 'resources' => $resources, - 'resourceId' => $resourceId, - 'resourceType' => $resourceType, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - 'options' => [ - 'path' => $newPath, - 'size' => $fileSize, - ], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::post('/v1/migrations/json/exports') - ->groups(['api', 'migrations']) - ->desc('Export documents to JSON') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].create') - ->label('audits.event', 'migration.create') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'createJSONExport', - description: '/docs/references/migrations/migration-json-export.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.') - ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .json extension.') - ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true) - ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) - ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true) - ->inject('user') - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('authorization') - ->inject('project') - ->inject('platform') - ->inject('queueForEvents') - ->inject('publisherForMigrations') - ->action(function ( - string $resourceId, - string $filename, - array $columns, - array $queries, - bool $notify, - Document $user, - Response $response, - Database $dbForProject, - Database $dbForPlatform, - Authorization $authorization, - Document $project, - array $platform, - Event $queueForEvents, - MigrationPublisher $publisherForMigrations - ) { - try { - $parsedQueries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default')); - if ($bucket->isEmpty()) { - throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); - } - - [$databaseId, $collectionId] = \explode(':', $resourceId, 2); - if (empty($databaseId)) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - if (empty($collectionId)) { - throw new Exception(Exception::COLLECTION_NOT_FOUND); - } - - $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - if ($database->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); - if ($collection->isEmpty()) { - throw new Exception(Exception::COLLECTION_NOT_FOUND); - } - - $databaseType = $database->getAttribute('type'); - - // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields - $isSchemaless = in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]); - - $validator = new Documents( - attributes: $collection->getAttribute('attributes', []), - indexes: $collection->getAttribute('indexes', []), - idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(), - supportForAttributes: !$isSchemaless, - ); - - if (!$validator->isValid($parsedQueries)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); - $resourceType = getDatabaseResourceType($databaseType); - - $migration = $dbForProject->createDocument('migrations', new Document([ - '$id' => ID::unique(), - 'status' => 'pending', - 'stage' => 'init', - 'source' => Appwrite::getName(), - 'destination' => JSON::getName(), - 'resources' => $resources, - 'resourceId' => $resourceId, - 'resourceType' => $resourceType, - 'statusCounters' => '{}', - 'resourceData' => '{}', - 'errors' => [], - 'options' => [ - 'bucketId' => 'default', // Always use internal bucket - 'filename' => $filename, - 'columns' => $columns, - 'queries' => $queries, - 'notify' => $notify, - 'userInternalId' => $user->getSequence(), - ], - ])); - - $queueForEvents->setParam('migrationId', $migration->getId()); - - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response - ->setStatusCode(Response::STATUS_CODE_ACCEPTED) - ->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::get('/v1/migrations') - ->groups(['api', 'migrations']) - ->desc('List migrations') - ->label('scope', 'migrations.read') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'list', - description: '/docs/references/migrations/list-migrations.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION_LIST, - ) - ] - )) - ->param('queries', [], new Migrations(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Migrations::ALLOWED_ATTRIBUTES), true) - ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->inject('response') - ->inject('dbForProject') - ->action(function (array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject) { - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - if (!empty($search)) { - $queries[] = Query::search('search', $search); - } - - $cursor = Query::getCursorQueries($queries, false); - $cursor = \reset($cursor); - - if ($cursor !== false) { - $validator = new Cursor(); - if (!$validator->isValid($cursor)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $migrationId = $cursor->getValue(); - $cursorDocument = $dbForProject->getDocument('migrations', $migrationId); - - if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Migration '{$migrationId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $filterQueries = Query::groupByType($queries)['filters']; - try { - $migrations = $dbForProject->find('migrations', $queries); - $total = $includeTotal ? $dbForProject->count('migrations', $filterQueries, APP_LIMIT_COUNT) : 0; - } catch (OrderException $e) { - throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); - } - $response->dynamic(new Document([ - 'migrations' => $migrations, - 'total' => $total, - ]), Response::MODEL_MIGRATION_LIST); - }); - -Http::get('/v1/migrations/:migrationId') - ->groups(['api', 'migrations']) - ->desc('Get migration') - ->label('scope', 'migrations.read') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'get', - description: '/docs/references/migrations/get-migration.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject']) - ->inject('response') - ->inject('dbForProject') - ->action(function (string $migrationId, Response $response, Database $dbForProject) { - $migration = $dbForProject->getDocument('migrations', $migrationId); - - if ($migration->isEmpty()) { - throw new Exception(Exception::MIGRATION_NOT_FOUND); - } - - $response->dynamic($migration, Response::MODEL_MIGRATION); - }); - -Http::get('/v1/migrations/appwrite/report') - ->groups(['api', 'migrations']) - ->desc('Get Appwrite migration report') - ->label('scope', 'migrations.write') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'getAppwriteReport', - description: '/docs/references/migrations/migration-appwrite-report.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION_REPORT, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Appwrite::getSupportedResources())), 'List of resources to migrate') - ->param('endpoint', '', new URL(), "Source's Appwrite Endpoint") - ->param('projectID', '', new Text(512), "Source's Project ID") - ->param('key', '', new Text(512), "Source's API Key") - ->inject('response') - ->inject('getDatabasesDB') - ->action(function (array $resources, string $endpoint, string $projectID, string $key, Response $response, callable $getDatabasesDB) { - - try { - $appwrite = new Appwrite($projectID, $endpoint, $key, $getDatabasesDB); - $report = $appwrite->report($resources); - } catch (\Throwable $e) { - throw new Exception( - Exception::MIGRATION_PROVIDER_ERROR, - 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' - ); - } - - $response - ->setStatusCode(Response::STATUS_CODE_OK) - ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); - }); - -Http::get('/v1/migrations/firebase/report') - ->groups(['api', 'migrations']) - ->desc('Get Firebase migration report') - ->label('scope', 'migrations.write') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'getFirebaseReport', - description: '/docs/references/migrations/migration-firebase-report.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION_REPORT, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate') - ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials') - ->inject('response') - ->action(function (array $resources, string $serviceAccount, Response $response) { - $serviceAccount = json_decode($serviceAccount, true); - - if (empty($serviceAccount)) { - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); - } - - if (!isset($serviceAccount['project_id']) || !isset($serviceAccount['client_email']) || !isset($serviceAccount['private_key'])) { - throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); - } - - try { - $firebase = new Firebase($serviceAccount); - $report = $firebase->report($resources); - } catch (\Throwable $e) { - throw new Exception( - Exception::MIGRATION_PROVIDER_ERROR, - 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' - ); - } - - $response - ->setStatusCode(Response::STATUS_CODE_OK) - ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); - }); - -Http::get('/v1/migrations/supabase/report') - ->groups(['api', 'migrations']) - ->desc('Get Supabase migration report') - ->label('scope', 'migrations.write') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'getSupabaseReport', - description: '/docs/references/migrations/migration-supabase-report.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION_REPORT, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate') - ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint.') - ->param('apiKey', '', new Text(512), 'Source\'s API Key.') - ->param('databaseHost', '', new Text(512), 'Source\'s Database Host.') - ->param('username', '', new Text(512), 'Source\'s Database Username.') - ->param('password', '', new Text(512), 'Source\'s Database Password.') - ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true) - ->inject('response') - ->inject('dbForProject') - ->action(function (array $resources, string $endpoint, string $apiKey, string $databaseHost, string $username, string $password, int $port, Response $response) { - try { - $supabase = new Supabase($endpoint, $apiKey, $databaseHost, 'postgres', $username, $password, $port); - $report = $supabase->report($resources); - } catch (\Throwable $e) { - throw new Exception( - Exception::MIGRATION_PROVIDER_ERROR, - 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' - ); - } - - $response - ->setStatusCode(Response::STATUS_CODE_OK) - ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); - }); - -Http::get('/v1/migrations/nhost/report') - ->groups(['api', 'migrations']) - ->desc('Get NHost migration report') - ->label('scope', 'migrations.write') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'getNHostReport', - description: '/docs/references/migrations/migration-nhost-report.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_MIGRATION_REPORT, - ) - ] - )) - ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate.') - ->param('subdomain', '', new Text(512), 'Source\'s Subdomain.') - ->param('region', '', new Text(512), 'Source\'s Region.') - ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret.') - ->param('database', '', new Text(512), 'Source\'s Database Name.') - ->param('username', '', new Text(512), 'Source\'s Database Username.') - ->param('password', '', new Text(512), 'Source\'s Database Password.') - ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true) - ->inject('response') - ->action(function (array $resources, string $subdomain, string $region, string $adminSecret, string $database, string $username, string $password, int $port, Response $response) { - try { - $nhost = new NHost($subdomain, $region, $adminSecret, $database, $username, $password, $port); - $report = $nhost->report($resources); - } catch (\Throwable $e) { - throw new Exception( - Exception::MIGRATION_PROVIDER_ERROR, - 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' - ); - } - - $response - ->setStatusCode(Response::STATUS_CODE_OK) - ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); - }); - -Http::patch('/v1/migrations/:migrationId') - ->groups(['api', 'migrations']) - ->desc('Update retry migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].retry') - ->label('audits.event', 'migration.retry') - ->label('audits.resource', 'migrations/{request.migrationId}') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'retry', - description: '/docs/references/migrations/retry-migration.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_ACCEPTED, - model: Response::MODEL_MIGRATION, - ) - ] - )) - ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject']) - ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('platform') - ->inject('publisherForMigrations') - ->action(function (string $migrationId, Response $response, Database $dbForProject, Document $project, array $platform, MigrationPublisher $publisherForMigrations) { - $migration = $dbForProject->getDocument('migrations', $migrationId); - - if ($migration->isEmpty()) { - throw new Exception(Exception::MIGRATION_NOT_FOUND); - } - - if ($migration->getAttribute('status') !== 'failed') { - throw new Exception(Exception::MIGRATION_IN_PROGRESS, 'Migration not failed yet'); - } - - $migration - ->setAttribute('status', 'pending') - ->setAttribute('dateUpdated', \time()); - - // Trigger Migration - $publisherForMigrations->enqueue(new MigrationMessage( - project: $project, - migration: $migration, - platform: $platform, - )); - - $response->noContent(); - }); - -Http::delete('/v1/migrations/:migrationId') - ->groups(['api', 'migrations']) - ->desc('Delete migration') - ->label('scope', 'migrations.write') - ->label('event', 'migrations.[migrationId].delete') - ->label('audits.event', 'migrationId.delete') - ->label('audits.resource', 'migrations/{request.migrationId}') - ->label('sdk', new Method( - namespace: 'migrations', - group: null, - name: 'delete', - description: '/docs/references/migrations/delete-migration.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration ID.', false, ['dbForProject']) - ->inject('response') - ->inject('dbForProject') - ->inject('queueForEvents') - ->action(function (string $migrationId, Response $response, Database $dbForProject, Event $queueForEvents) { - $migration = $dbForProject->getDocument('migrations', $migrationId); - - if ($migration->isEmpty()) { - throw new Exception(Exception::MIGRATION_NOT_FOUND); - } - - if (!$dbForProject->deleteDocument('migrations', $migration->getId())) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove migration from DB'); - } - - $queueForEvents->setParam('migrationId', $migration->getId()); - - $response->noContent(); - }); diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 06312d9cb2..88788b73fc 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -9,6 +9,7 @@ use Appwrite\Platform\Modules\Core; use Appwrite\Platform\Modules\Databases; use Appwrite\Platform\Modules\Functions; use Appwrite\Platform\Modules\Health; +use Appwrite\Platform\Modules\Migrations; use Appwrite\Platform\Modules\Project; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; @@ -39,6 +40,7 @@ class Appwrite extends Platform $this->addModule(new Storage\Module()); $this->addModule(new VCS\Module()); $this->addModule(new Webhooks\Module()); + $this->addModule(new Migrations\Module()); $this->addModule(new Project\Module()); } } diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php new file mode 100644 index 0000000000..006ab3ae90 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php @@ -0,0 +1,110 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/appwrite') + ->desc('Create Appwrite migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createAppwriteMigration', + description: '/docs/references/migrations/migration-appwrite.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(AppwriteSource::getSupportedResources())), 'List of resources to migrate') + ->param('endpoint', '', new URL(), 'Source Appwrite endpoint') + ->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject']) + ->param('apiKey', '', new Text(512), 'Source API Key') + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $endpoint, + string $projectId, + string $apiKey, + Response $response, + Database $dbForProject, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => AppwriteSource::getName(), + 'destination' => AppwriteSource::getName(), + 'credentials' => [ + 'endpoint' => $endpoint, + 'projectId' => $projectId, + 'apiKey' => $apiKey, + ], + 'resources' => $resources, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php new file mode 100644 index 0000000000..32d8a62ec3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php @@ -0,0 +1,80 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations/appwrite/report') + ->desc('Get Appwrite migration report') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'getAppwriteReport', + description: '/docs/references/migrations/migration-appwrite-report.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION_REPORT, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(AppwriteSource::getSupportedResources())), 'List of resources to migrate') + ->param('endpoint', '', new URL(), "Source's Appwrite Endpoint") + ->param('projectID', '', new Text(512), "Source's Project ID") + ->param('key', '', new Text(512), "Source's API Key") + ->inject('response') + ->inject('getDatabasesDB') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $endpoint, + string $projectID, + string $key, + Response $response, + callable $getDatabasesDB + ): void { + try { + $appwrite = new AppwriteSource($projectID, $endpoint, $key, $getDatabasesDB); + $report = $appwrite->report($resources); + } catch (\Throwable $e) { + throw new Exception( + Exception::MIGRATION_PROVIDER_ERROR, + 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' + ); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php new file mode 100644 index 0000000000..0ab3cecf1a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Exports/Create.php @@ -0,0 +1,213 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/csv/exports') + ->desc('Export documents to CSV') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createCSVExport', + description: '/docs/references/migrations/migration-csv-export.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.') + ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .csv extension.') + ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true) + ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('delimiter', ',', new Text(1), 'The character that separates each column value. Default is comma.', true) + ->param('enclosure', '"', new Text(1), 'The character that encloses each column value. Default is double quotes.', true) + ->param('escape', '"', new Text(1), 'The escape character for the enclosure character. Default is double quotes.', true) + ->param('header', true, new Boolean(), 'Whether to include the header row with column names. Default is true.', true) + ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true) + ->inject('user') + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + string $resourceId, + string $filename, + array $columns, + array $queries, + string $delimiter, + string $enclosure, + string $escape, + bool $header, + bool $notify, + Document $user, + Response $response, + Database $dbForProject, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + try { + $parsedQueries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default')); + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + [$databaseId, $collectionId] = \explode(':', $resourceId, 2); + if (empty($databaseId)) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + if (empty($collectionId)) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); + if ($collection->isEmpty()) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $databaseType = $database->getAttribute('type'); + if (!\in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) { + throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv'); + } + + // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields + $isSchemaless = \in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]); + + $validator = new Documents( + attributes: $collection->getAttribute('attributes', []), + indexes: $collection->getAttribute('indexes', []), + idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(), + supportForAttributes: !$isSchemaless, + ); + + if (!$validator->isValid($parsedQueries)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]); + $resourceType = self::resourceTypeForDatabaseType($databaseType); + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => AppwriteSource::getName(), + 'destination' => CSV::getName(), + 'resources' => $resources, + 'resourceId' => $resourceId, + 'resourceType' => $resourceType, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + 'options' => [ + 'bucketId' => 'default', // Always use internal bucket + 'filename' => $filename, + 'columns' => $columns, + 'queries' => $queries, + 'delimiter' => $delimiter, + 'enclosure' => $enclosure, + 'escape' => $escape, + 'header' => $header, + 'notify' => $notify, + 'userInternalId' => $user->getSequence(), + ], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } + + private static function transferGroupForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, + DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, + default => throw new \LogicException('Unknown database type: ' . $databaseType), + }; + } + + private static function resourceTypeForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB, + DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB, + default => Resource::TYPE_DATABASE, + }; + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php new file mode 100644 index 0000000000..5cc21241c3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php @@ -0,0 +1,220 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/csv/imports') + ->httpAlias('/v1/migrations/csv') + ->desc('Import documents from a CSV') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createCSVImport', + description: '/docs/references/migrations/migration-csv-import.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('bucketId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).', false, ['dbForProject']) + ->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject']) + ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') + ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('platform') + ->inject('deviceForFiles') + ->inject('deviceForMigrations') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + string $fileId, + string $resourceId, + bool $internalFile, + Response $response, + Database $dbForProject, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + array $platform, + Device $deviceForFiles, + Device $deviceForMigrations, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { + if ($internalFile) { + return $dbForPlatform->getDocument('buckets', 'default'); + } + return $dbForProject->getDocument('buckets', $bucketId); + }); + + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + if ($file->isEmpty()) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + $path = $file->getAttribute('path', ''); + if (!$deviceForFiles->exists($path)) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); + } + + // No encryption or compression on files above 20MB. + $hasEncryption = !empty($file->getAttribute('openSSLCipher')); + $compression = $file->getAttribute('algorithm', Compression::NONE); + $hasCompression = $compression !== Compression::NONE; + + $migrationId = ID::unique(); + $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.csv'); + + if ($hasEncryption || $hasCompression) { + $source = $deviceForFiles->read($path); + + if ($hasEncryption) { + $source = OpenSSL::decrypt( + $source, + $file->getAttribute('openSSLCipher'), + System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), + 0, + hex2bin($file->getAttribute('openSSLIV')), + hex2bin($file->getAttribute('openSSLTag')) + ); + } + + if ($hasCompression) { + switch ($compression) { + case Compression::ZSTD: + $source = (new Zstd())->decompress($source); + break; + case Compression::GZIP: + $source = (new GZIP())->decompress($source); + break; + } + } + + // Manual write after decryption and/or decompression + if (!$deviceForMigrations->write($newPath, $source, 'text/csv')) { + throw new \Exception('Unable to copy file'); + } + } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) { + throw new \Exception('Unable to copy file'); + } + + [$databaseId] = \explode(':', $resourceId, 2); + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + $databaseType = $database->getAttribute('type'); + if (!\in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) { + throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv'); + } + $fileSize = $deviceForMigrations->getFileSize($newPath); + $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]); + $resourceType = self::resourceTypeForDatabaseType($databaseType); + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => $migrationId, + 'status' => 'pending', + 'stage' => 'init', + 'source' => CSV::getName(), + 'destination' => AppwriteSource::getName(), + 'resources' => $resources, + 'resourceId' => $resourceId, + 'resourceType' => $resourceType, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + 'options' => [ + 'path' => $newPath, + 'size' => $fileSize, + ], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } + + private static function transferGroupForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, + DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, + default => throw new \LogicException('Unknown database type: ' . $databaseType), + }; + } + + private static function resourceTypeForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB, + DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB, + default => Resource::TYPE_DATABASE, + }; + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php new file mode 100644 index 0000000000..f9c989b5bf --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Delete.php @@ -0,0 +1,74 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/migrations/:migrationId') + ->desc('Delete migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].delete') + ->label('audits.event', 'migrationId.delete') + ->label('audits.resource', 'migrations/{request.migrationId}') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'delete', + description: '/docs/references/migrations/delete-migration.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_NOCONTENT, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action(string $migrationId, Response $response, Database $dbForProject, Event $queueForEvents): void + { + $migration = $dbForProject->getDocument('migrations', $migrationId); + + if ($migration->isEmpty()) { + throw new Exception(Exception::MIGRATION_NOT_FOUND); + } + + if (!$dbForProject->deleteDocument('migrations', $migration->getId())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove migration from DB'); + } + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php new file mode 100644 index 0000000000..a8347858b4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Create.php @@ -0,0 +1,114 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/firebase') + ->desc('Create Firebase migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createFirebaseMigration', + description: '/docs/references/migrations/migration-firebase.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate') + ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials') + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $serviceAccount, + Response $response, + Database $dbForProject, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $serviceAccountData = json_decode($serviceAccount, true); + + if (empty($serviceAccountData)) { + throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); + } + + if (!isset($serviceAccountData['project_id']) || !isset($serviceAccountData['client_email']) || !isset($serviceAccountData['private_key'])) { + throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); + } + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => Firebase::getName(), + 'destination' => AppwriteSource::getName(), + 'credentials' => [ + 'serviceAccount' => $serviceAccount, + ], + 'resources' => $resources, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php new file mode 100644 index 0000000000..ef8084795e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Firebase/Report/Get.php @@ -0,0 +1,80 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations/firebase/report') + ->desc('Get Firebase migration report') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'getFirebaseReport', + description: '/docs/references/migrations/migration-firebase-report.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION_REPORT, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(Firebase::getSupportedResources())), 'List of resources to migrate') + ->param('serviceAccount', '', new Text(65536), 'JSON of the Firebase service account credentials') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(array $resources, string $serviceAccount, Response $response): void + { + $serviceAccount = json_decode($serviceAccount, true); + + if (empty($serviceAccount)) { + throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); + } + + if (!isset($serviceAccount['project_id']) || !isset($serviceAccount['client_email']) || !isset($serviceAccount['private_key'])) { + throw new Exception(Exception::MIGRATION_PROVIDER_ERROR, 'Invalid Service Account JSON'); + } + + try { + $firebase = new Firebase($serviceAccount); + $report = $firebase->report($resources); + } catch (\Throwable $e) { + throw new Exception( + Exception::MIGRATION_PROVIDER_ERROR, + 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' + ); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php new file mode 100644 index 0000000000..14b40e2306 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Get.php @@ -0,0 +1,61 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations/:migrationId') + ->desc('Get migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.read') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'get', + description: '/docs/references/migrations/get-migration.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action(string $migrationId, Response $response, Database $dbForProject): void + { + $migration = $dbForProject->getDocument('migrations', $migrationId); + + if ($migration->isEmpty()) { + throw new Exception(Exception::MIGRATION_NOT_FOUND); + } + + $response->dynamic($migration, Response::MODEL_MIGRATION); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php new file mode 100644 index 0000000000..d968bd91f6 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Exports/Create.php @@ -0,0 +1,198 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/json/exports') + ->desc('Export documents to JSON') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createJSONExport', + description: '/docs/references/migrations/migration-json-export.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.') + ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .json extension.') + ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true) + ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true) + ->inject('user') + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + string $resourceId, + string $filename, + array $columns, + array $queries, + bool $notify, + Document $user, + Response $response, + Database $dbForProject, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + try { + $parsedQueries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default')); + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + [$databaseId, $collectionId] = \explode(':', $resourceId, 2); + if (empty($databaseId)) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + if (empty($collectionId)) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); + if ($collection->isEmpty()) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $databaseType = $database->getAttribute('type'); + + // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields + $isSchemaless = \in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]); + + $validator = new Documents( + attributes: $collection->getAttribute('attributes', []), + indexes: $collection->getAttribute('indexes', []), + idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(), + supportForAttributes: !$isSchemaless, + ); + + if (!$validator->isValid($parsedQueries)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]); + $resourceType = self::resourceTypeForDatabaseType($databaseType); + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => AppwriteSource::getName(), + 'destination' => JSONSource::getName(), + 'resources' => $resources, + 'resourceId' => $resourceId, + 'resourceType' => $resourceType, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + 'options' => [ + 'bucketId' => 'default', // Always use internal bucket + 'filename' => $filename, + 'columns' => $columns, + 'queries' => $queries, + 'notify' => $notify, + 'userInternalId' => $user->getSequence(), + ], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } + + private static function transferGroupForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, + DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, + default => throw new \LogicException('Unknown database type: ' . $databaseType), + }; + } + + private static function resourceTypeForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB, + DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB, + default => Resource::TYPE_DATABASE, + }; + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php new file mode 100644 index 0000000000..55081b2645 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php @@ -0,0 +1,221 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/json/imports') + ->desc('Import documents from a JSON') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createJSONImport', + description: '/docs/references/migrations/migration-json-import.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') + ->param('fileId', '', new UID(), 'File ID.') + ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') + ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('platform') + ->inject('deviceForFiles') + ->inject('deviceForMigrations') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + string $bucketId, + string $fileId, + string $resourceId, + bool $internalFile, + Response $response, + Database $dbForProject, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + array $platform, + Device $deviceForFiles, + Device $deviceForMigrations, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { + if ($internalFile) { + return $dbForPlatform->getDocument('buckets', 'default'); + } + return $dbForProject->getDocument('buckets', $bucketId); + }); + + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + if ($file->isEmpty()) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + $path = $file->getAttribute('path', ''); + if (!$deviceForFiles->exists($path)) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); + } + + // No encryption or compression on files above 20MB. + $hasEncryption = !empty($file->getAttribute('openSSLCipher')); + $compression = $file->getAttribute('algorithm', Compression::NONE); + $hasCompression = $compression !== Compression::NONE; + + $migrationId = ID::unique(); + $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.json'); + + if ($hasEncryption || $hasCompression) { + $source = $deviceForFiles->read($path); + + if ($hasEncryption) { + $source = OpenSSL::decrypt( + $source, + $file->getAttribute('openSSLCipher'), + System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), + 0, + hex2bin($file->getAttribute('openSSLIV')), + hex2bin($file->getAttribute('openSSLTag')) + ); + } + + if ($hasCompression) { + switch ($compression) { + case Compression::ZSTD: + $source = (new Zstd())->decompress($source); + break; + case Compression::GZIP: + $source = (new GZIP())->decompress($source); + break; + } + } + + // Manual write after decryption and/or decompression + if (!$deviceForMigrations->write($newPath, $source, 'application/json')) { + throw new \Exception('Unable to copy file'); + } + } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) { + throw new \Exception('Unable to copy file'); + } + + $fileSize = $deviceForMigrations->getFileSize($newPath); + + [$databaseId] = \explode(':', $resourceId, 2); + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + $databaseType = $database->getAttribute('type'); + $resources = Transfer::extractServices([self::transferGroupForDatabaseType($databaseType)]); + $resourceType = self::resourceTypeForDatabaseType($databaseType); + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => $migrationId, + 'status' => 'pending', + 'stage' => 'init', + 'source' => JSONSource::getName(), + 'destination' => AppwriteSource::getName(), + 'resources' => $resources, + 'resourceId' => $resourceId, + 'resourceType' => $resourceType, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + 'options' => [ + 'path' => $newPath, + 'size' => $fileSize, + ], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } + + private static function transferGroupForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, + DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB, + default => throw new \LogicException('Unknown database type: ' . $databaseType), + }; + } + + private static function resourceTypeForDatabaseType(string $databaseType): string + { + return match ($databaseType) { + DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB, + DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB, + default => Resource::TYPE_DATABASE, + }; + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php new file mode 100644 index 0000000000..fb97b1c16c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Create.php @@ -0,0 +1,122 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/nhost') + ->desc('Create NHost migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createNHostMigration', + description: '/docs/references/migrations/migration-nhost.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate') + ->param('subdomain', '', new Text(512), 'Source\'s Subdomain') + ->param('region', '', new Text(512), 'Source\'s Region') + ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret') + ->param('database', '', new Text(512), 'Source\'s Database Name') + ->param('username', '', new Text(512), 'Source\'s Database Username') + ->param('password', '', new Text(512), 'Source\'s Database Password') + ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true) + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $subdomain, + string $region, + string $adminSecret, + string $database, + string $username, + string $password, + int $port, + Response $response, + Database $dbForProject, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => NHost::getName(), + 'destination' => AppwriteSource::getName(), + 'credentials' => [ + 'subdomain' => $subdomain, + 'region' => $region, + 'adminSecret' => $adminSecret, + 'database' => $database, + 'username' => $username, + 'password' => $password, + 'port' => $port, + ], + 'resources' => $resources, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php new file mode 100644 index 0000000000..964f2dc347 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/NHost/Report/Get.php @@ -0,0 +1,86 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations/nhost/report') + ->desc('Get NHost migration report') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'getNHostReport', + description: '/docs/references/migrations/migration-nhost-report.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION_REPORT, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(NHost::getSupportedResources())), 'List of resources to migrate.') + ->param('subdomain', '', new Text(512), 'Source\'s Subdomain.') + ->param('region', '', new Text(512), 'Source\'s Region.') + ->param('adminSecret', '', new Text(512), 'Source\'s Admin Secret.') + ->param('database', '', new Text(512), 'Source\'s Database Name.') + ->param('username', '', new Text(512), 'Source\'s Database Username.') + ->param('password', '', new Text(512), 'Source\'s Database Password.') + ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true) + ->inject('response') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $subdomain, + string $region, + string $adminSecret, + string $database, + string $username, + string $password, + int $port, + Response $response + ): void { + try { + $nhost = new NHost($subdomain, $region, $adminSecret, $database, $username, $password, $port); + $report = $nhost->report($resources); + } catch (\Throwable $e) { + throw new Exception( + Exception::MIGRATION_PROVIDER_ERROR, + 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' + ); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php new file mode 100644 index 0000000000..98b33e379d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Create.php @@ -0,0 +1,120 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/migrations/supabase') + ->desc('Create Supabase migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createSupabaseMigration', + description: '/docs/references/migrations/migration-supabase.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate') + ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint') + ->param('apiKey', '', new Text(512), 'Source\'s API Key') + ->param('databaseHost', '', new Text(512), 'Source\'s Database Host') + ->param('username', '', new Text(512), 'Source\'s Database Username') + ->param('password', '', new Text(512), 'Source\'s Database Password') + ->param('port', 5432, new Integer(true), 'Source\'s Database Port', true) + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $endpoint, + string $apiKey, + string $databaseHost, + string $username, + string $password, + int $port, + Response $response, + Database $dbForProject, + Document $project, + array $platform, + Event $queueForEvents, + MigrationPublisher $publisherForMigrations + ): void { + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => Supabase::getName(), + 'destination' => AppwriteSource::getName(), + 'credentials' => [ + 'endpoint' => $endpoint, + 'apiKey' => $apiKey, + 'databaseHost' => $databaseHost, + 'username' => $username, + 'password' => $password, + 'port' => $port, + ], + 'resources' => $resources, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php new file mode 100644 index 0000000000..423e611430 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Supabase/Report/Get.php @@ -0,0 +1,85 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations/supabase/report') + ->desc('Get Supabase migration report') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'getSupabaseReport', + description: '/docs/references/migrations/migration-supabase-report.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION_REPORT, + ) + ] + )) + ->param('resources', [], new ArrayList(new WhiteList(Supabase::getSupportedResources(), true)), 'List of resources to migrate') + ->param('endpoint', '', new URL(), 'Source\'s Supabase Endpoint.') + ->param('apiKey', '', new Text(512), 'Source\'s API Key.') + ->param('databaseHost', '', new Text(512), 'Source\'s Database Host.') + ->param('username', '', new Text(512), 'Source\'s Database Username.') + ->param('password', '', new Text(512), 'Source\'s Database Password.') + ->param('port', 5432, new Integer(true), 'Source\'s Database Port.', true) + ->inject('response') + ->callback($this->action(...)); + } + + public function action( + array $resources, + string $endpoint, + string $apiKey, + string $databaseHost, + string $username, + string $password, + int $port, + Response $response + ): void { + try { + $supabase = new Supabase($endpoint, $apiKey, $databaseHost, 'postgres', $username, $password, $port); + $report = $supabase->report($resources); + } catch (\Throwable $e) { + throw new Exception( + Exception::MIGRATION_PROVIDER_ERROR, + 'Unable to connect to the migration source. Please verify your credentials and ensure the source is reachable from this server. Check for network restrictions such as firewalls, IP allowlists, or outbound connectivity limits.' + ); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($report), Response::MODEL_MIGRATION_REPORT); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php new file mode 100644 index 0000000000..8ecc53c2a3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php @@ -0,0 +1,90 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/migrations/:migrationId') + ->desc('Update retry migration') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].retry') + ->label('audits.event', 'migration.retry') + ->label('audits.resource', 'migrations/{request.migrationId}') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'retry', + description: '/docs/references/migrations/retry-migration.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('migrationId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Migration unique ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->inject('project') + ->inject('platform') + ->inject('publisherForMigrations') + ->callback($this->action(...)); + } + + public function action( + string $migrationId, + Response $response, + Database $dbForProject, + Document $project, + array $platform, + MigrationPublisher $publisherForMigrations + ): void { + $migration = $dbForProject->getDocument('migrations', $migrationId); + + if ($migration->isEmpty()) { + throw new Exception(Exception::MIGRATION_NOT_FOUND); + } + + if ($migration->getAttribute('status') !== 'failed') { + throw new Exception(Exception::MIGRATION_IN_PROGRESS, 'Migration not failed yet'); + } + + $migration + ->setAttribute('status', 'pending') + ->setAttribute('dateUpdated', \time()); + + $publisherForMigrations->enqueue(new MigrationMessage( + project: $project, + migration: $migration, + platform: $platform, + )); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php new file mode 100644 index 0000000000..1a1252be79 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/XList.php @@ -0,0 +1,104 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/migrations') + ->desc('List migrations') + ->groups(['api', 'migrations']) + ->label('scope', 'migrations.read') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'list', + description: '/docs/references/migrations/list-migrations.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_MIGRATION_LIST, + ) + ] + )) + ->param('queries', [], new Migrations(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Migrations::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action(array $queries, string $search, bool $includeTotal, Response $response, Database $dbForProject): void + { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $migrationId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('migrations', $migrationId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Migration '{$migrationId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + try { + $migrations = $dbForProject->find('migrations', $queries); + $total = $includeTotal ? $dbForProject->count('migrations', $filterQueries, APP_LIMIT_COUNT) : 0; + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); + } + + $response->dynamic(new Document([ + 'migrations' => $migrations, + 'total' => $total, + ]), Response::MODEL_MIGRATION_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Module.php b/src/Appwrite/Platform/Modules/Migrations/Module.php new file mode 100644 index 0000000000..6ec1e49a88 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Migrations/Services/Http.php b/src/Appwrite/Platform/Modules/Migrations/Services/Http.php new file mode 100644 index 0000000000..1e2c95a78b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Migrations/Services/Http.php @@ -0,0 +1,59 @@ +type = Service::TYPE_HTTP; + + // Migrations + $this->addAction(ListMigrations::getName(), new ListMigrations()); + $this->addAction(GetMigration::getName(), new GetMigration()); + $this->addAction(UpdateMigration::getName(), new UpdateMigration()); + $this->addAction(DeleteMigration::getName(), new DeleteMigration()); + + // Appwrite source + $this->addAction(CreateAppwriteMigration::getName(), new CreateAppwriteMigration()); + $this->addAction(GetAppwriteReport::getName(), new GetAppwriteReport()); + + // Firebase source + $this->addAction(CreateFirebaseMigration::getName(), new CreateFirebaseMigration()); + $this->addAction(GetFirebaseReport::getName(), new GetFirebaseReport()); + + // Supabase source + $this->addAction(CreateSupabaseMigration::getName(), new CreateSupabaseMigration()); + $this->addAction(GetSupabaseReport::getName(), new GetSupabaseReport()); + + // NHost source + $this->addAction(CreateNHostMigration::getName(), new CreateNHostMigration()); + $this->addAction(GetNHostReport::getName(), new GetNHostReport()); + + // CSV import / export + $this->addAction(CreateCSVImport::getName(), new CreateCSVImport()); + $this->addAction(CreateCSVExport::getName(), new CreateCSVExport()); + + // JSON import / export + $this->addAction(CreateJSONImport::getName(), new CreateJSONImport()); + $this->addAction(CreateJSONExport::getName(), new CreateJSONExport()); + } +} From 15917ac7ba69bc468079b57803d9d2e54aaa4d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 17:05:30 +0200 Subject: [PATCH 349/402] Fix failing tests --- src/Appwrite/Utopia/Request/Filters/V24.php | 15 +++++++++++++++ tests/e2e/Services/Project/KeysBase.php | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Request/Filters/V24.php b/src/Appwrite/Utopia/Request/Filters/V24.php index 29df762f28..2809c6f2c6 100644 --- a/src/Appwrite/Utopia/Request/Filters/V24.php +++ b/src/Appwrite/Utopia/Request/Filters/V24.php @@ -9,6 +9,21 @@ class V24 extends Filter // Convert 1.9.2 params to 1.9.3 public function parse(array $content, string $model): array { + switch ($model) { + case 'project.createStandardKey': + $content = $this->parseKeyScopes($content); + break; + } + + return $content; + } + + protected function parseKeyScopes(array $content): array + { + if (!\is_array($content['scopes'] ?? null)) { + $content['scopes'] = []; + } + return $content; } } diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php index 5019c8fefd..7ca494fefa 100644 --- a/tests/e2e/Services/Project/KeysBase.php +++ b/tests/e2e/Services/Project/KeysBase.php @@ -256,7 +256,7 @@ trait KeysBase $this->assertNotEmpty($key['body']['secret']); $this->assertStringStartsWith(API_KEY_DYNAMIC . '_', $key['body']['secret']); $this->assertSame([], $key['body']['sdks']); - $this->assertNull($key['body']['accessedAt']); + $this->assertSame('', $key['body']['accessedAt']); $dateValidator = new DatetimeValidator(); $this->assertSame(true, $dateValidator->isValid($key['body']['$createdAt'])); From c96836b1c0a1656d60cdfef4c0f0c4e70ab2f1d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 17:10:58 +0200 Subject: [PATCH 350/402] Improve code quality of folder decoding project ID --- src/Appwrite/Utopia/Response/Filters/V24.php | 37 ++++++++++++-------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/Appwrite/Utopia/Response/Filters/V24.php b/src/Appwrite/Utopia/Response/Filters/V24.php index 8ac5305dc1..29cc2ff4a1 100644 --- a/src/Appwrite/Utopia/Response/Filters/V24.php +++ b/src/Appwrite/Utopia/Response/Filters/V24.php @@ -2,8 +2,11 @@ namespace Appwrite\Utopia\Response\Filters; +use Ahc\Jwt\JWT; +use Ahc\Jwt\JWTException; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filter; +use Utopia\System\System; // Convert 1.9.3 Data format to 1.9.2 format class V24 extends Filter @@ -26,22 +29,28 @@ class V24 extends Filter unset($content['sdks']); unset($content['accessedAt']); - $projectId = ''; - if (isset($content['secret'])) { - $parts = explode('_', $content['secret'], 2); - if (count($parts) === 2) { - $jwtParts = explode('.', $parts[1]); - if (count($jwtParts) >= 2) { - $payload = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $jwtParts[1])), true); - $projectId = $payload['projectId'] ?? ''; - } - } - } - $content['projectId'] = $projectId; - - $content['jwt'] = $content['secret'] ?? ''; + $secret = $content['secret'] ?? ''; unset($content['secret']); + $content['projectId'] = $this->extractProjectId($secret); + $content['jwt'] = $secret; + return $content; } + + private function extractProjectId(string $secret): string + { + $token = explode('_', $secret, 2)[1] ?? ''; + if ($token === '') { + return ''; + } + + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256'); + + try { + return $jwt->decode($token, false)['projectId'] ?? ''; + } catch (JWTException) { + return ''; + } + } } From 980762fc3ed7d2ccb907a8e8c6150debb6377b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 17:18:06 +0200 Subject: [PATCH 351/402] Rename from dynamic key to ephemeral key (api keys) --- app/config/errors.php | 2 +- app/controllers/general.php | 2 +- app/controllers/shared/api.php | 3 +- app/init/constants.php | 2 +- app/init/models.php | 4 +-- src/Appwrite/Auth/Key.php | 8 +++-- .../Functions/Http/Executions/Create.php | 2 +- .../Modules/Functions/Workers/Builds.php | 4 +-- .../Modules/Functions/Workers/Screenshots.php | 2 +- .../Keys/{Dynamic => Ephemeral}/Create.php | 22 ++++++------ .../Http/Project/Keys/Standard/Create.php | 2 +- .../Modules/Project/Services/Http.php | 4 +-- src/Appwrite/Platform/Workers/Functions.php | 2 +- src/Appwrite/Platform/Workers/Migrations.php | 2 +- src/Appwrite/Utopia/Response.php | 2 +- src/Appwrite/Utopia/Response/Filters/V24.php | 4 +-- .../{DynamicKey.php => EphemeralKey.php} | 6 ++-- src/Appwrite/Vcs/Comment.php | 2 +- .../Functions/FunctionsCustomServerTest.php | 4 +-- tests/e2e/Services/Project/KeysBase.php | 36 +++++++++---------- .../Services/Project/KeysIntegrationTest.php | 34 +++++++++--------- .../Services/Sites/SitesCustomServerTest.php | 10 +++--- .../index.js | 0 .../package-lock.json | 4 +-- .../package.json | 2 +- .../setup.sh | 0 tests/unit/Auth/KeyTest.php | 24 ++++++------- 27 files changed, 96 insertions(+), 93 deletions(-) rename src/Appwrite/Platform/Modules/Project/Http/Project/Keys/{Dynamic => Ephemeral}/Create.php (81%) rename src/Appwrite/Utopia/Response/Model/{DynamicKey.php => EphemeralKey.php} (77%) rename tests/resources/functions/{dynamic-api-key => ephemeral-api-key}/index.js (100%) rename tests/resources/functions/{dynamic-api-key => ephemeral-api-key}/package-lock.json (93%) rename tests/resources/functions/{dynamic-api-key => ephemeral-api-key}/package.json (89%) rename tests/resources/functions/{dynamic-api-key => ephemeral-api-key}/setup.sh (100%) diff --git a/app/config/errors.php b/app/config/errors.php index 07b0cd59ed..fa112bcb6f 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -384,7 +384,7 @@ return [ ], Exception::API_KEY_EXPIRED => [ 'name' => Exception::API_KEY_EXPIRED, - 'description' => 'The dynamic API key has expired. Please don\'t use dynamic API keys for more than duration of the execution.', + 'description' => 'The ephemeral API key has expired. Please don\'t use ephemeral API keys for more than duration of the execution.', 'code' => 401, ], diff --git a/app/controllers/general.php b/app/controllers/general.php index 85d5cbedbd..eb4899a3d8 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -399,7 +399,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S 'projectId' => $project->getId(), 'scopes' => $resource->getAttribute('scopes', []) ]); - $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $jwtKey; + $headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $jwtKey; $headers['x-appwrite-trigger'] = 'http'; $headers['x-appwrite-user-jwt'] = ''; diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 7c2f527ccf..c9e4f8b47d 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -183,7 +183,8 @@ Http::init() // Handle special app role case if ($apiKey->getRole() === User::ROLE_APPS) { // Disable authorization checks for project API keys - if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_DYNAMIC) && $apiKey->getProjectId() === $project->getId()) { + // Dynamic supported for backwards compatibility + if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_EPHEMERAL || $apiKey->getType() === 'dynamic') && $apiKey->getProjectId() === $project->getId()) { $authorization->setDefaultStatus(false); } diff --git a/app/init/constants.php b/app/init/constants.php index c3f67502f2..a4cef6f035 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -255,7 +255,7 @@ const MESSAGE_TYPE_SMS = 'sms'; const MESSAGE_TYPE_PUSH = 'push'; // API key types const API_KEY_STANDARD = 'standard'; -const API_KEY_DYNAMIC = 'dynamic'; +const API_KEY_EPHEMERAL = 'ephemeral'; const API_KEY_ORGANIZATION = 'organization'; const API_KEY_ACCOUNT = 'account'; // Usage metrics diff --git a/app/init/models.php b/app/init/models.php index 699d1561a3..56f24ddc2c 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -70,8 +70,8 @@ use Appwrite\Utopia\Response\Model\DetectionRuntime; use Appwrite\Utopia\Response\Model\DetectionVariable; use Appwrite\Utopia\Response\Model\DevKey; use Appwrite\Utopia\Response\Model\Document as ModelDocument; -use Appwrite\Utopia\Response\Model\DynamicKey; use Appwrite\Utopia\Response\Model\Embedding; +use Appwrite\Utopia\Response\Model\EphemeralKey; use Appwrite\Utopia\Response\Model\Error; use Appwrite\Utopia\Response\Model\ErrorDev; use Appwrite\Utopia\Response\Model\Execution; @@ -393,7 +393,7 @@ Response::setModel(new Execution()); Response::setModel(new Project()); Response::setModel(new Webhook()); Response::setModel(new Key()); -Response::setModel(new DynamicKey()); +Response::setModel(new EphemeralKey()); Response::setModel(new DevKey()); Response::setModel(new MockNumber()); Response::setModel(new OAuth2GitHub()); diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php index 8f645f6f08..0cbaefa4b3 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -105,7 +105,7 @@ class Key /** * Decode the given secret key into a Key object, containing the project ID, type, role, scopes, and name. - * Can be a stored API key or a dynamic key (JWT). + * Can be a stored API key or an ephemeral key (JWT). * * @throws Exception */ @@ -138,7 +138,9 @@ class Key ); switch ($type) { - case API_KEY_DYNAMIC: + // Dynamic supported for backwards compatibility + case API_KEY_EPHEMERAL: + case 'dynamic': $jwtObj = new JWT( key: System::getEnv('_APP_OPENSSL_KEY_V1'), algo: 'HS256', @@ -153,7 +155,7 @@ class Key $expired = true; } - $name = $payload['name'] ?? 'Dynamic Key'; + $name = $payload['name'] ?? 'Ephemeral Key'; $projectId = $payload['projectId'] ?? ''; $disabledMetrics = $payload['disabledMetrics'] ?? []; $hostnameOverride = $payload['hostnameOverride'] ?? false; diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 5b2f4ff297..4bf2fbc48f 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -228,7 +228,7 @@ class Create extends Base $executionId = ID::unique(); $headers['x-appwrite-execution-id'] = $executionId; - $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; + $headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $apiKey; $headers['x-appwrite-trigger'] = 'http'; $headers['x-appwrite-user-id'] = $user->getId(); $headers['x-appwrite-user-jwt'] = $jwt; diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 286f1c55ee..352fb56e28 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -624,7 +624,7 @@ class Builds extends Action $vars = [ ...$vars, 'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint, - 'APPWRITE_FUNCTION_API_KEY' => API_KEY_DYNAMIC . '_' . $apiKey, + 'APPWRITE_FUNCTION_API_KEY' => API_KEY_EPHEMERAL . '_' . $apiKey, 'APPWRITE_FUNCTION_ID' => $resource->getId(), 'APPWRITE_FUNCTION_NAME' => $resource->getAttribute('name'), 'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(), @@ -639,7 +639,7 @@ class Builds extends Action $vars = [ ...$vars, 'APPWRITE_SITE_API_ENDPOINT' => $endpoint, - 'APPWRITE_SITE_API_KEY' => API_KEY_DYNAMIC . '_' . $apiKey, + 'APPWRITE_SITE_API_KEY' => API_KEY_EPHEMERAL . '_' . $apiKey, 'APPWRITE_SITE_ID' => $resource->getId(), 'APPWRITE_SITE_NAME' => $resource->getAttribute('name'), 'APPWRITE_SITE_DEPLOYMENT' => $deployment->getId(), diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index a6f1ca1b03..7d1cdc4980 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -168,7 +168,7 @@ class Screenshots extends Action $config = $configs[$key]; $config['headers'] = \array_merge($config['headers'], [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey ]); $config['sleep'] = 3000; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Ephemeral/Create.php similarity index 81% rename from src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php rename to src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Ephemeral/Create.php index 8839a146fb..cf21eaec74 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Dynamic/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Ephemeral/Create.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/project/keys/dynamic') + ->setHttpPath('/v1/project/keys/ephemeral') ->httpAlias('/v1/projects/:projectId/jwts') - ->desc('Create dynamic project key') + ->desc('Create ephemeral project key') ->groups(['api', 'project']) ->label('scope', 'keys.write') ->label('event', 'keys.[keyId].create') @@ -44,22 +44,22 @@ class Create extends Base ->label('sdk', new Method( namespace: 'project', group: 'keys', - name: 'createDynamicKey', + name: 'createEphemeralKey', description: <<param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false) - ->param('duration', 900, new Range(1, 3600), 'Time in seconds before dynamic key expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) + ->param('duration', 900, new Range(1, 3600), 'Time in seconds before ephemeral key expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) ->inject('response') ->inject('queueForEvents') ->inject('project') @@ -94,13 +94,13 @@ class Create extends Base 'expire' => $expire, 'sdks' => [], 'accessedAt' => null, - 'secret' => API_KEY_DYNAMIC . '_' . $secret, + 'secret' => API_KEY_EPHEMERAL . '_' . $secret, ]); $queueForEvents->setParam('keyId', $key->getId()); $response ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($key, Response::MODEL_DYNAMIC_KEY); + ->dynamic($key, Response::MODEL_EPHEMERAL_KEY); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php index ccf19e4a30..67bdcc09a6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php @@ -53,7 +53,7 @@ class Create extends Base description: <<addAction(CreateStandardKey::getName(), new CreateStandardKey()); - $this->addAction(CreateDynamicKey::getName(), new CreateDynamicKey()); + $this->addAction(CreateEphemeralKey::getName(), new CreateEphemeralKey()); $this->addAction(ListKeys::getName(), new ListKeys()); $this->addAction(GetKey::getName(), new GetKey()); $this->addAction(DeleteKey::getName(), new DeleteKey()); diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 28c298b050..8167fb975d 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -434,7 +434,7 @@ class Functions extends Action ]); $headers['x-appwrite-execution-id'] = $executionId ?? ''; - $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; + $headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $apiKey; $headers['x-appwrite-trigger'] = $trigger; $headers['x-appwrite-event'] = $event ?? ''; $headers['x-appwrite-user-id'] = $user->getId(); diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index fa2ed5883f..69f72b8e27 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -402,7 +402,7 @@ class Migrations extends Action ] ]); - return API_KEY_DYNAMIC . '_' . $apiKey; + return API_KEY_EPHEMERAL . '_' . $apiKey; } /** diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 92eb0768b3..b6c0fcc1ab 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -251,7 +251,7 @@ class Response extends SwooleResponse public const MODEL_WEBHOOK_LIST = 'webhookList'; public const MODEL_KEY = 'key'; public const MODEL_KEY_LIST = 'keyList'; - public const MODEL_DYNAMIC_KEY = 'dynamicKey'; + public const MODEL_EPHEMERAL_KEY = 'ephemeralKey'; public const MODEL_DEV_KEY = 'devKey'; public const MODEL_DEV_KEY_LIST = 'devKeyList'; public const MODEL_MOCK_NUMBER = 'mockNumber'; diff --git a/src/Appwrite/Utopia/Response/Filters/V24.php b/src/Appwrite/Utopia/Response/Filters/V24.php index 29cc2ff4a1..46db062863 100644 --- a/src/Appwrite/Utopia/Response/Filters/V24.php +++ b/src/Appwrite/Utopia/Response/Filters/V24.php @@ -14,12 +14,12 @@ class V24 extends Filter public function parse(array $content, string $model): array { return match ($model) { - Response::MODEL_DYNAMIC_KEY => $this->parseDynamicKey($content), + Response::MODEL_EPHEMERAL_KEY => $this->parseEphemeralKey($content), default => $content, }; } - private function parseDynamicKey(array $content): array + private function parseEphemeralKey(array $content): array { unset($content['$id']); unset($content['$createdAt']); diff --git a/src/Appwrite/Utopia/Response/Model/DynamicKey.php b/src/Appwrite/Utopia/Response/Model/EphemeralKey.php similarity index 77% rename from src/Appwrite/Utopia/Response/Model/DynamicKey.php rename to src/Appwrite/Utopia/Response/Model/EphemeralKey.php index c1016f3fcc..f6b7fdd7f3 100644 --- a/src/Appwrite/Utopia/Response/Model/DynamicKey.php +++ b/src/Appwrite/Utopia/Response/Model/EphemeralKey.php @@ -4,7 +4,7 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; -class DynamicKey extends Key +class EphemeralKey extends Key { public function __construct() { @@ -18,7 +18,7 @@ class DynamicKey extends Key */ public function getName(): string { - return 'Dynamic Key'; + return 'Ephemeral Key'; } /** @@ -28,6 +28,6 @@ class DynamicKey extends Key */ public function getType(): string { - return Response::MODEL_DYNAMIC_KEY; + return Response::MODEL_EPHEMERAL_KEY; } } diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index 6214bb1f29..4dc0174e50 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -31,7 +31,7 @@ class Comment 'Trigger functions via HTTP, SDKs, events, webhooks, or scheduled cron jobs', 'Each function runs in its own isolated container with custom environment variables', 'Build commands execute in runtime containers during deployment', - 'Dynamic API keys are generated automatically for each function execution', + 'Ephemeral API keys are generated automatically for each function execution', 'JWT tokens let functions act on behalf of users while preserving their permissions', 'Storage files get ClamAV malware scanning and encryption by default', 'Roll back Sites deployments instantly by switching between versions', diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 4255774f18..e75c3e5f4e 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -700,7 +700,7 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals(200, $function['headers']['status-code']); $this->assertEquals($deploymentId, $function['body']['deploymentId']); - // Test starter code is used and that dynamic keys work + // Test starter code is used and that ephemeral keys work $execution = $this->createExecution($functionId, [ 'path' => '/ping', ]); @@ -2129,7 +2129,7 @@ class FunctionsCustomServerTest extends Scope ]); $deploymentId = $this->setupDeployment($functionId, [ - 'code' => $this->packageFunction('dynamic-api-key'), + 'code' => $this->packageFunction('ephemeral-api-key'), 'activate' => true, ]); diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php index 7ca494fefa..cd50f67c14 100644 --- a/tests/e2e/Services/Project/KeysBase.php +++ b/tests/e2e/Services/Project/KeysBase.php @@ -240,12 +240,12 @@ trait KeysBase } // ========================================================================= - // Create dynamic key tests + // Create ephemeral key tests // ========================================================================= - public function testCreateDynamicKey(): void + public function testCreateEphemeralKey(): void { - $key = $this->createDynamicKey( + $key = $this->createEphemeralKey( ['users.read', 'users.write'], ); @@ -254,7 +254,7 @@ trait KeysBase $this->assertSame('', $key['body']['name']); $this->assertSame(['users.read', 'users.write'], $key['body']['scopes']); $this->assertNotEmpty($key['body']['secret']); - $this->assertStringStartsWith(API_KEY_DYNAMIC . '_', $key['body']['secret']); + $this->assertStringStartsWith(API_KEY_EPHEMERAL . '_', $key['body']['secret']); $this->assertSame([], $key['body']['sdks']); $this->assertSame('', $key['body']['accessedAt']); @@ -264,7 +264,7 @@ trait KeysBase $this->assertSame(true, $dateValidator->isValid($key['body']['expire'])); // Verify JWT payload - $jwt = substr($key['body']['secret'], strlen(API_KEY_DYNAMIC . '_')); + $jwt = substr($key['body']['secret'], strlen(API_KEY_EPHEMERAL . '_')); $parts = explode('.', $jwt); $this->assertCount(3, $parts); $payload = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $parts[1])), true); @@ -279,11 +279,11 @@ trait KeysBase $this->assertLessThanOrEqual(910, $diff); } - public function testCreateDynamicKeyWithDuration(): void + public function testCreateEphemeralKeyWithDuration(): void { $duration = 1800; - $key = $this->createDynamicKey( + $key = $this->createEphemeralKey( ['databases.read'], $duration, ); @@ -298,9 +298,9 @@ trait KeysBase $this->assertLessThanOrEqual($duration + 10, $diff); } - public function testCreateDynamicKeyWithEmptyScopes(): void + public function testCreateEphemeralKeyWithEmptyScopes(): void { - $key = $this->createDynamicKey( + $key = $this->createEphemeralKey( [], ); @@ -308,9 +308,9 @@ trait KeysBase $this->assertSame([], $key['body']['scopes']); } - public function testCreateDynamicKeyWithoutAuthentication(): void + public function testCreateEphemeralKeyWithoutAuthentication(): void { - $response = $this->createDynamicKey( + $response = $this->createEphemeralKey( ['users.read'], null, false @@ -319,25 +319,25 @@ trait KeysBase $this->assertSame(401, $response['headers']['status-code']); } - public function testCreateDynamicKeyInvalidScope(): void + public function testCreateEphemeralKeyInvalidScope(): void { - $response = $this->createDynamicKey( + $response = $this->createEphemeralKey( ['invalid.scope'], ); $this->assertSame(400, $response['headers']['status-code']); } - public function testCreateDynamicKeyInvalidDuration(): void + public function testCreateEphemeralKeyInvalidDuration(): void { - $response = $this->createDynamicKey( + $response = $this->createEphemeralKey( ['users.read'], 0, ); $this->assertSame(400, $response['headers']['status-code']); - $response = $this->createDynamicKey( + $response = $this->createEphemeralKey( ['users.read'], 3601, ); @@ -965,7 +965,7 @@ trait KeysBase /** * @param array $scopes */ - protected function createDynamicKey(array $scopes, ?int $duration = null, bool $authenticated = true): mixed + protected function createEphemeralKey(array $scopes, ?int $duration = null, bool $authenticated = true): mixed { $params = [ 'scopes' => $scopes, @@ -984,6 +984,6 @@ trait KeysBase $headers = array_merge($headers, $this->getHeaders()); } - return $this->client->call(Client::METHOD_POST, '/project/keys/dynamic', $headers, $params); + return $this->client->call(Client::METHOD_POST, '/project/keys/ephemeral', $headers, $params); } } diff --git a/tests/e2e/Services/Project/KeysIntegrationTest.php b/tests/e2e/Services/Project/KeysIntegrationTest.php index 2615cac023..4dc5838e72 100644 --- a/tests/e2e/Services/Project/KeysIntegrationTest.php +++ b/tests/e2e/Services/Project/KeysIntegrationTest.php @@ -13,7 +13,7 @@ class KeysIntegrationTest extends Scope use ProjectCustom; use SideServer; - public function testDynamicKeyScopeEnforcement(): void + public function testEphemeralKeyScopeEnforcement(): void { $projectId = $this->getProject()['$id']; $apiKey = $this->getProject()['apiKey']; @@ -32,25 +32,25 @@ class KeysIntegrationTest extends Scope 'x-appwrite-project' => $projectId, ]; - // Step 1: Create a dynamic key scoped to users.read only. - $dynamicKey = $this->client->call( + // Step 1: Create an ephemeral key scoped to users.read only. + $ephemeralKey = $this->client->call( Client::METHOD_POST, - '/project/keys/dynamic', + '/project/keys/ephemeral', $serverHeaders, [ 'scopes' => ['users.read'], 'duration' => 900, ] ); - $this->assertSame(201, $dynamicKey['headers']['status-code']); - $this->assertNotEmpty($dynamicKey['body']['secret']); + $this->assertSame(201, $ephemeralKey['headers']['status-code']); + $this->assertNotEmpty($ephemeralKey['body']['secret']); - $dynamicKeySecret = $dynamicKey['body']['secret']; + $ephemeralKeySecret = $ephemeralKey['body']['secret']; - $dynamicHeaders = [ + $ephemeralHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $dynamicKeySecret, + 'x-appwrite-key' => $ephemeralKeySecret, ]; // Step 2: Create a project user using console headers. @@ -60,37 +60,37 @@ class KeysIntegrationTest extends Scope $consoleHeaders, [ 'userId' => ID::unique(), - 'email' => 'dynamic_key_' . \uniqid() . '@localhost.test', + 'email' => 'ephemeral_key_' . \uniqid() . '@localhost.test', 'password' => 'password1234', - 'name' => 'Dynamic Key Test User', + 'name' => 'Ephemeral Key Test User', ] ); $this->assertSame(201, $user['headers']['status-code']); $userId = $user['body']['$id']; - // Step 3: Dynamic key can list users. + // Step 3: Ephemeral key can list users. $list = $this->client->call( Client::METHOD_GET, '/users', - $dynamicHeaders + $ephemeralHeaders ); $this->assertSame(200, $list['headers']['status-code']); $this->assertGreaterThanOrEqual(1, $list['body']['total']); - // Step 4: Dynamic key can get the specific user. + // Step 4: Ephemeral key can get the specific user. $get = $this->client->call( Client::METHOD_GET, '/users/' . $userId, - $dynamicHeaders + $ephemeralHeaders ); $this->assertSame(200, $get['headers']['status-code']); $this->assertSame($userId, $get['body']['$id']); - // Step 5: Dynamic key cannot create users (missing users.write scope). + // Step 5: Ephemeral key cannot create users (missing users.write scope). $createAttempt = $this->client->call( Client::METHOD_POST, '/users', - $dynamicHeaders, + $ephemeralHeaders, [ 'userId' => ID::unique(), 'email' => 'should_fail_' . \uniqid() . '@localhost.test', diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 71f6675561..42fd190172 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -2038,7 +2038,7 @@ class SitesCustomServerTest extends Scope 'previewAuthDisabled' => true, ]); $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey, ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertStringContainsString("Hello Appwrite", $response['body']); @@ -2046,7 +2046,7 @@ class SitesCustomServerTest extends Scope $this->assertGreaterThan($contentLength, $response['headers']['content-length']); $response = $proxyClient->call(Client::METHOD_GET, '/non-existing-path', followRedirects: false, headers: [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey, ]); $this->assertEquals(404, $response['headers']['status-code']); $this->assertStringContainsString("Page not found", $response['body']); @@ -2882,7 +2882,7 @@ class SitesCustomServerTest extends Scope ]); $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey, ]); $this->assertEquals(400, $response['headers']['status-code']); $deployment = $this->getDeployment($siteId, $deploymentId); @@ -2924,7 +2924,7 @@ class SitesCustomServerTest extends Scope // deployment is still building error page $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey, ]); $this->assertEquals(400, $response['headers']['status-code']); $this->assertStringContainsString("Deployment is still building", $response['body']); @@ -2939,7 +2939,7 @@ class SitesCustomServerTest extends Scope // deployment failed error page $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [ - 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey, + 'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey, ]); $this->assertEquals(400, $response['headers']['status-code']); $this->assertStringContainsString("Deployment build failed", $response['body']); diff --git a/tests/resources/functions/dynamic-api-key/index.js b/tests/resources/functions/ephemeral-api-key/index.js similarity index 100% rename from tests/resources/functions/dynamic-api-key/index.js rename to tests/resources/functions/ephemeral-api-key/index.js diff --git a/tests/resources/functions/dynamic-api-key/package-lock.json b/tests/resources/functions/ephemeral-api-key/package-lock.json similarity index 93% rename from tests/resources/functions/dynamic-api-key/package-lock.json rename to tests/resources/functions/ephemeral-api-key/package-lock.json index 2d86fe18d3..3756c13c0c 100644 --- a/tests/resources/functions/dynamic-api-key/package-lock.json +++ b/tests/resources/functions/ephemeral-api-key/package-lock.json @@ -1,11 +1,11 @@ { - "name": "dynamic-api-key", + "name": "ephemeral-api-key", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "dynamic-api-key", + "name": "ephemeral-api-key", "version": "1.0.0", "license": "ISC", "dependencies": { diff --git a/tests/resources/functions/dynamic-api-key/package.json b/tests/resources/functions/ephemeral-api-key/package.json similarity index 89% rename from tests/resources/functions/dynamic-api-key/package.json rename to tests/resources/functions/ephemeral-api-key/package.json index 19b8158131..35abec4874 100644 --- a/tests/resources/functions/dynamic-api-key/package.json +++ b/tests/resources/functions/ephemeral-api-key/package.json @@ -1,5 +1,5 @@ { - "name": "dynamic-api-key", + "name": "ephemeral-api-key", "version": "1.0.0", "main": "index.js", "scripts": { diff --git a/tests/resources/functions/dynamic-api-key/setup.sh b/tests/resources/functions/ephemeral-api-key/setup.sh similarity index 100% rename from tests/resources/functions/dynamic-api-key/setup.sh rename to tests/resources/functions/ephemeral-api-key/setup.sh diff --git a/tests/unit/Auth/KeyTest.php b/tests/unit/Auth/KeyTest.php index 58fe3113e1..bcdb46180f 100644 --- a/tests/unit/Auth/KeyTest.php +++ b/tests/unit/Auth/KeyTest.php @@ -14,7 +14,7 @@ class KeyTest extends TestCase { public function testDecode(): void { - // Decode dynamic key + // Decode ephemeral key $projectId = 'test'; $usage = false; $scopes = [ @@ -36,12 +36,12 @@ class KeyTest extends TestCase $this->assertEquals($projectId, $decoded->getProjectId()); $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); - $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); + $this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType()); $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); - $this->assertEquals('Dynamic Key', $decoded->getName()); + $this->assertEquals('Ephemeral Key', $decoded->getName()); - // Decode dynamic key with extras + // Decode ephemeral key with extras $extra = [ 'disabledMetrics' => ['metric123'], 'hostnameOverride' => true, @@ -60,10 +60,10 @@ class KeyTest extends TestCase $this->assertEquals($projectId, $decoded->getProjectId()); $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); - $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); + $this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType()); $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); - $this->assertEquals('Dynamic Key', $decoded->getName()); + $this->assertEquals('Ephemeral Key', $decoded->getName()); $this->assertEquals(['metric123'], $decoded->getDisabledMetrics()); $this->assertEquals(true, $decoded->getHostnameOverride()); $this->assertEquals(true, $decoded->isBannerDisabled()); @@ -71,8 +71,8 @@ class KeyTest extends TestCase $this->assertEquals(true, $decoded->isPreviewAuthDisabled()); $this->assertEquals(true, $decoded->isDeploymentStatusIgnored()); - // Decode invalid dynamic key - $invalidKey = API_KEY_DYNAMIC . '_invalid_jwt_token'; + // Decode invalid ephemeral key + $invalidKey = API_KEY_EPHEMERAL . '_invalid_jwt_token'; $decoded = Key::decode( project: new Document(['$id' => $projectId]), team: new Document(), @@ -82,12 +82,12 @@ class KeyTest extends TestCase $this->assertEquals($projectId, $decoded->getProjectId()); $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); - $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); + $this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType()); $this->assertEquals(User::ROLE_GUESTS, $decoded->getRole()); $this->assertEquals($guestRoleScopes, $decoded->getScopes()); $this->assertEquals('UNKNOWN', $decoded->getName()); - // Decode expired dynamic key + // Decode expired ephemeral key $expiredKey = self::generateKey($projectId, $usage, $scopes, maxAge: 1, timestamp: time() - 60); \sleep(2); $decoded = Key::decode( @@ -99,7 +99,7 @@ class KeyTest extends TestCase $this->assertEquals($projectId, $decoded->getProjectId()); $this->assertEquals('', $decoded->getTeamId()); $this->assertEquals('', $decoded->getUserId()); - $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); + $this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType()); $this->assertEquals(User::ROLE_GUESTS, $decoded->getRole()); $this->assertEquals($guestRoleScopes, $decoded->getScopes()); $this->assertEquals('UNKNOWN', $decoded->getName()); @@ -363,6 +363,6 @@ class KeyTest extends TestCase 'scopes' => $scopes, ], $extra)); - return API_KEY_DYNAMIC . '_' . $apiKey; + return API_KEY_EPHEMERAL . '_' . $apiKey; } } From 05f2d2b9cf87aadcc249202ddb02d9e4a8ae6639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 19:29:37 +0200 Subject: [PATCH 352/402] Fix tests --- src/Appwrite/Utopia/Request/Filters/V24.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Appwrite/Utopia/Request/Filters/V24.php b/src/Appwrite/Utopia/Request/Filters/V24.php index 2809c6f2c6..f62c1f8c0b 100644 --- a/src/Appwrite/Utopia/Request/Filters/V24.php +++ b/src/Appwrite/Utopia/Request/Filters/V24.php @@ -11,6 +11,7 @@ class V24 extends Filter { switch ($model) { case 'project.createStandardKey': + $content = $this->fillKeyId($content); $content = $this->parseKeyScopes($content); break; } @@ -18,6 +19,12 @@ class V24 extends Filter return $content; } + protected function fillKeyId(array $content): array + { + $content['keyId'] = $content['keyId'] ?? 'unique()'; + return $content; + } + protected function parseKeyScopes(array $content): array { if (!\is_array($content['scopes'] ?? null)) { From a58ea1123b1b9734a79c7de78cab4dbb34583262 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 13:21:17 +0530 Subject: [PATCH 353/402] chore: bump docker-base to 1.2.0 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7cb007c188..5e9f125de3 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist \ `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi` -FROM appwrite/base:1.0.1 AS base +FROM appwrite/base:1.2.0 AS base LABEL maintainer="team@appwrite.io" From 86123c9e93ed198d1cb760ec5bd5c1b35a8a4ba3 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 13:27:04 +0530 Subject: [PATCH 354/402] fix: update PHP extension path for xdebug cleanup in production --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5e9f125de3..94747797ff 100755 --- a/Dockerfile +++ b/Dockerfile @@ -100,7 +100,7 @@ RUN mkdir -p /etc/letsencrypt/live/ && chmod -Rf 755 /etc/letsencrypt/live/ FROM base AS production RUN rm -rf /usr/src/code/app/config/specs && \ - rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20240924/xdebug.so && \ + rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20250925/xdebug.so && \ find /usr -name '*.a' -delete 2>/dev/null || true && \ find /usr -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true && \ find /usr -name '*.pyc' -delete 2>/dev/null || true From e75fc5b8598ab03c0cc0829b3f156953e5184fb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 29 Apr 2026 10:08:31 +0200 Subject: [PATCH 355/402] Add list scopes endpoint for Console --- app/init/models.php | 4 ++ .../console/list-oauth2-providers.md | 1 - docs/references/console/variables.md | 1 - .../Console/Http/OAuth2Providers/XList.php | 2 +- .../Modules/Console/Http/Scopes/Key/XList.php | 67 +++++++++++++++++++ .../Modules/Console/Http/Variables/Get.php | 2 +- .../Modules/Console/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 2 + .../Utopia/Response/Model/ConsoleKeyScope.php | 37 ++++++++++ .../Response/Model/ConsoleKeyScopeList.php | 37 ++++++++++ .../Console/ConsoleConsoleClientTest.php | 43 ++++++++++++ .../Console/ConsoleCustomServerTest.php | 18 +++++ 12 files changed, 212 insertions(+), 4 deletions(-) delete mode 100644 docs/references/console/list-oauth2-providers.md delete mode 100644 docs/references/console/variables.md create mode 100644 src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php create mode 100644 src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php create mode 100644 src/Appwrite/Utopia/Response/Model/ConsoleKeyScopeList.php diff --git a/app/init/models.php b/app/init/models.php index 56f24ddc2c..9530b4b98b 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -56,6 +56,8 @@ use Appwrite\Utopia\Response\Model\ColumnString; use Appwrite\Utopia\Response\Model\ColumnText; use Appwrite\Utopia\Response\Model\ColumnURL; use Appwrite\Utopia\Response\Model\ColumnVarchar; +use Appwrite\Utopia\Response\Model\ConsoleKeyScope; +use Appwrite\Utopia\Response\Model\ConsoleKeyScopeList; use Appwrite\Utopia\Response\Model\ConsoleOAuth2Provider; use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderList; use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderParameter; @@ -488,6 +490,8 @@ Response::setModel(new ConsoleVariables()); Response::setModel(new ConsoleOAuth2ProviderParameter()); Response::setModel(new ConsoleOAuth2Provider()); Response::setModel(new ConsoleOAuth2ProviderList()); +Response::setModel(new ConsoleKeyScope()); +Response::setModel(new ConsoleKeyScopeList()); Response::setModel(new MFAChallenge()); Response::setModel(new MFARecoveryCodes()); Response::setModel(new MFAType()); diff --git a/docs/references/console/list-oauth2-providers.md b/docs/references/console/list-oauth2-providers.md deleted file mode 100644 index d813296031..0000000000 --- a/docs/references/console/list-oauth2-providers.md +++ /dev/null @@ -1 +0,0 @@ -List all OAuth2 providers supported by the Appwrite server, along with the parameters required to configure each provider. The response excludes mock providers but includes sandbox providers. diff --git a/docs/references/console/variables.md b/docs/references/console/variables.md deleted file mode 100644 index ddfa2b9b72..0000000000 --- a/docs/references/console/variables.md +++ /dev/null @@ -1 +0,0 @@ -Get all Environment Variables that are relevant for the console. \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php index 574f7a5f6a..79a36643a1 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php +++ b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php @@ -34,7 +34,7 @@ class XList extends Action namespace: 'console', group: 'console', name: 'listOAuth2Providers', - description: '/docs/references/console/list-oauth2-providers.md', + description: 'List all OAuth2 providers supported by the Appwrite server, along with the parameters required to configure each provider. The response excludes mock providers but includes sandbox providers.', auth: [AuthType::ADMIN], responses: [ new SDKResponse( diff --git a/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php new file mode 100644 index 0000000000..255a7583bb --- /dev/null +++ b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php @@ -0,0 +1,67 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/console/scopes/key') + ->desc('List key scopes') + ->groups(['api']) + ->label('scope', 'public') + ->label('sdk', new Method( + namespace: 'console', + group: 'console', + name: 'listKeyScopes', + description: 'List all scopes available for project API keys, along with a description for each scope.', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_CONSOLE_KEY_SCOPE_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->inject('response') + ->callback($this->action(...)); + } + + public function action(Response $response): void + { + $scopesConfig = Config::getParam('projectScopes', []); + + $scopes = []; + foreach ($scopesConfig as $scopeId => $scope) { + $scopes[] = new Document([ + '$id' => $scopeId, + 'description' => $scope['description'] ?? '', + ]); + } + + $response->dynamic(new Document([ + 'total' => \count($scopes), + 'scopes' => $scopes, + ]), Response::MODEL_CONSOLE_KEY_SCOPE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php b/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php index 8368b272f1..d39049a409 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php @@ -36,7 +36,7 @@ class Get extends Action namespace: 'console', group: 'console', name: 'variables', - description: '/docs/references/console/variables.md', + description: 'Get all Environment Variables that are relevant for the console.', auth: [AuthType::ADMIN], responses: [ new SDKResponse( diff --git a/src/Appwrite/Platform/Modules/Console/Services/Http.php b/src/Appwrite/Platform/Modules/Console/Services/Http.php index 77029af0f9..2540ae8e01 100644 --- a/src/Appwrite/Platform/Modules/Console/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Console/Services/Http.php @@ -15,6 +15,7 @@ use Appwrite\Platform\Modules\Console\Http\Redirects\Recover\Get as RedirectReco use Appwrite\Platform\Modules\Console\Http\Redirects\Register\Get as RedirectRegister; use Appwrite\Platform\Modules\Console\Http\Redirects\Root\Get as RedirectRoot; use Appwrite\Platform\Modules\Console\Http\Resources\Get as GetResourceAvailability; +use Appwrite\Platform\Modules\Console\Http\Scopes\Key\XList as ListKeyScopes; use Appwrite\Platform\Modules\Console\Http\Variables\Get as GetVariables; use Utopia\Platform\Service; @@ -30,6 +31,7 @@ class Http extends Service $this->addAction(GetVariables::getName(), new GetVariables()); $this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers()); + $this->addAction(ListKeyScopes::getName(), new ListKeyScopes()); $this->addAction(CreateAssistantQuery::getName(), new CreateAssistantQuery()); $this->addAction(GetResourceAvailability::getName(), new GetResourceAvailability()); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index b6c0fcc1ab..899cdc086a 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -335,6 +335,8 @@ class Response extends SwooleResponse public const MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER = 'consoleOAuth2ProviderParameter'; public const MODEL_CONSOLE_OAUTH2_PROVIDER = 'consoleOAuth2Provider'; public const MODEL_CONSOLE_OAUTH2_PROVIDER_LIST = 'consoleOAuth2ProviderList'; + public const MODEL_CONSOLE_KEY_SCOPE = 'consoleKeyScope'; + public const MODEL_CONSOLE_KEY_SCOPE_LIST = 'consoleKeyScopeList'; // Deprecated public const MODEL_PERMISSIONS = 'permissions'; diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php new file mode 100644 index 0000000000..4932707d21 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php @@ -0,0 +1,37 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Scope ID.', + 'default' => '', + 'example' => 'users.read', + ]) + ->addRule('description', [ + 'type' => self::TYPE_STRING, + 'description' => 'Scope description.', + 'default' => '', + 'example' => 'Access to read your project\'s users', + ]) + ; + } + + public function getName(): string + { + return 'Console Key Scope'; + } + + public function getType(): string + { + return Response::MODEL_CONSOLE_KEY_SCOPE; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleKeyScopeList.php b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScopeList.php new file mode 100644 index 0000000000..aadf3afa63 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScopeList.php @@ -0,0 +1,37 @@ +addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of key scopes exposed by the server.', + 'default' => 0, + 'example' => 5, + ]) + ->addRule('scopes', [ + 'type' => Response::MODEL_CONSOLE_KEY_SCOPE, + 'description' => 'List of key scopes, each with its ID and description.', + 'default' => [], + 'array' => true, + ]) + ; + } + + public function getName(): string + { + return 'Console Key Scopes List'; + } + + public function getType(): string + { + return Response::MODEL_CONSOLE_KEY_SCOPE_LIST; + } +} diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php index 3b3232cda3..e4566837e9 100644 --- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php +++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php @@ -128,4 +128,47 @@ class ConsoleConsoleClientTest extends Scope // Sandbox providers (e.g. paypalSandbox) are included $this->assertContains('paypalSandbox', $providerIds); } + + public function testListKeyScopes(): void + { + $response = $this->client->call(Client::METHOD_GET, '/console/scopes/key', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['total']); + $this->assertIsArray($response['body']['scopes']); + $this->assertGreaterThan(0, $response['body']['total']); + $this->assertEquals($response['body']['total'], \count($response['body']['scopes'])); + + $scopeIds = \array_column($response['body']['scopes'], '$id'); + + // Well-known scopes must be present + $this->assertContains('users.read', $scopeIds); + $this->assertContains('users.write', $scopeIds); + $this->assertContains('functions.read', $scopeIds); + $this->assertContains('functions.write', $scopeIds); + + // Every scope has the expected shape + foreach ($response['body']['scopes'] as $scope) { + $this->assertArrayHasKey('$id', $scope); + $this->assertIsString($scope['$id']); + $this->assertNotEmpty($scope['$id']); + $this->assertArrayHasKey('description', $scope); + $this->assertIsString($scope['description']); + $this->assertNotEmpty($scope['description']); + } + + // A specific scope has the expected description + $usersRead = null; + foreach ($response['body']['scopes'] as $scope) { + if ($scope['$id'] === 'users.read') { + $usersRead = $scope; + break; + } + } + $this->assertNotNull($usersRead); + $this->assertEquals('Access to read your project\'s users', $usersRead['description']); + } } diff --git a/tests/e2e/Services/Console/ConsoleCustomServerTest.php b/tests/e2e/Services/Console/ConsoleCustomServerTest.php index d3c64ae039..0c914fade7 100644 --- a/tests/e2e/Services/Console/ConsoleCustomServerTest.php +++ b/tests/e2e/Services/Console/ConsoleCustomServerTest.php @@ -43,4 +43,22 @@ class ConsoleCustomServerTest extends Scope $this->assertContains('github', $providerIds); $this->assertNotContains('mock', $providerIds); } + + public function testListKeyScopes(): void + { + // Public endpoint: must succeed without admin authentication. Drop the + // headers from getHeaders() and only pass project + content-type. + $response = $this->client->call(Client::METHOD_GET, '/console/scopes/key', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['total']); + $this->assertIsArray($response['body']['scopes']); + $this->assertGreaterThan(0, $response['body']['total']); + + $scopeIds = \array_column($response['body']['scopes'], '$id'); + $this->assertContains('users.read', $scopeIds); + } } From 9d7df345901314cdbbb5da64b8f2c2d832633903 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 14:29:37 +0530 Subject: [PATCH 356/402] fix: clean up php 8.5 runtime deprecations --- Dockerfile | 8 +- src/Appwrite/Auth/OAuth2.php | 2 - .../Http/Installer/Certificate/Get.php | 1 - .../Modules/Console/Http/Assistant/Create.php | 2 - src/Appwrite/Platform/Workers/Webhooks.php | 80 +++++++++---------- src/Executor/Executor.php | 3 - tests/e2e/Client.php | 2 - .../e2e/Services/Functions/FunctionsBase.php | 1 - .../Services/Migrations/MigrationsBase.php | 1 + tests/e2e/Services/Sites/SitesBase.php | 1 - 10 files changed, 46 insertions(+), 55 deletions(-) diff --git a/Dockerfile b/Dockerfile index 94747797ff..f6852553d7 100755 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,11 @@ ENV _APP_VERSION=$VERSION \ _APP_HOME=https://appwrite.io RUN \ + apk add --update --no-cache git && \ + if [ "$DEBUG" != "true" ]; then \ + rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \ + rm -f /usr/local/lib/php/extensions/no-debug-non-zts-*/xdebug.so; \ + fi && \ if [ "$DEBUG" == "true" ]; then \ apk add boost boost-dev; \ fi @@ -100,7 +105,8 @@ RUN mkdir -p /etc/letsencrypt/live/ && chmod -Rf 755 /etc/letsencrypt/live/ FROM base AS production RUN rm -rf /usr/src/code/app/config/specs && \ - rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20250925/xdebug.so && \ + rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini && \ + rm -f /usr/local/lib/php/extensions/no-debug-non-zts-*/xdebug.so && \ find /usr -name '*.a' -delete 2>/dev/null || true && \ find /usr -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true && \ find /usr -name '*.pyc' -delete 2>/dev/null || true diff --git a/src/Appwrite/Auth/OAuth2.php b/src/Appwrite/Auth/OAuth2.php index a8a2d175b5..958b28ed18 100644 --- a/src/Appwrite/Auth/OAuth2.php +++ b/src/Appwrite/Auth/OAuth2.php @@ -206,8 +206,6 @@ abstract class OAuth2 $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - \curl_close($ch); - if ($code >= 400) { throw new Exception($response, $code); } diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php b/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php index ab0037f4b2..876dc00215 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php @@ -62,7 +62,6 @@ class Get extends Action curl_setopt_array($ch, $options); curl_exec($ch); $errno = curl_errno($ch); - curl_close($ch); return $errno === 0; } diff --git a/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php b/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php index 554456b041..8953f682d5 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php @@ -85,8 +85,6 @@ class Create extends Action curl_exec($ch); - curl_close($ch); - $response->chunk('', true); } } diff --git a/src/Appwrite/Platform/Workers/Webhooks.php b/src/Appwrite/Platform/Workers/Webhooks.php index 5b0497dbea..a7f4595966 100644 --- a/src/Appwrite/Platform/Workers/Webhooks.php +++ b/src/Appwrite/Platform/Workers/Webhooks.php @@ -106,51 +106,47 @@ class Webhooks extends Action $httpPass = $webhook->getAttribute('httpPass'); $ch = \curl_init($webhook->getAttribute('url')); - try { - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); - \curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); - \curl_setopt($ch, CURLOPT_HEADER, 0); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_TIMEOUT, 15); - \curl_setopt($ch, CURLOPT_MAXFILESIZE, self::MAX_FILE_SIZE); - \curl_setopt($ch, CURLOPT_USERAGENT, \sprintf( - APP_USERAGENT, - System::getEnv('_APP_VERSION', 'UNKNOWN'), - System::getEnv('_APP_EMAIL_SECURITY', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS', APP_EMAIL_SECURITY)) - )); - \curl_setopt( - $ch, - CURLOPT_HTTPHEADER, - [ - 'Content-Type: application/json', - 'Content-Length: ' . \strlen($payload), - 'X-' . APP_NAME . '-Webhook-Id: ' . $webhook->getId(), - 'X-' . APP_NAME . '-Webhook-Events: ' . implode(',', $events), - 'X-' . APP_NAME . '-Webhook-Name: ' . $webhook->getAttribute('name', ''), - 'X-' . APP_NAME . '-Webhook-User-Id: ' . $user->getId(), - 'X-' . APP_NAME . '-Webhook-Project-Id: ' . $project->getId(), - 'X-' . APP_NAME . '-Webhook-Signature: ' . $signature, - ] - ); - \curl_setopt($ch, CURLOPT_MAXREDIRS, 5); + \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); + \curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + \curl_setopt($ch, CURLOPT_HEADER, 0); + \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, CURLOPT_TIMEOUT, 15); + \curl_setopt($ch, CURLOPT_MAXFILESIZE, self::MAX_FILE_SIZE); + \curl_setopt($ch, CURLOPT_USERAGENT, \sprintf( + APP_USERAGENT, + System::getEnv('_APP_VERSION', 'UNKNOWN'), + System::getEnv('_APP_EMAIL_SECURITY', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS', APP_EMAIL_SECURITY)) + )); + \curl_setopt( + $ch, + CURLOPT_HTTPHEADER, + [ + 'Content-Type: application/json', + 'Content-Length: ' . \strlen($payload), + 'X-' . APP_NAME . '-Webhook-Id: ' . $webhook->getId(), + 'X-' . APP_NAME . '-Webhook-Events: ' . implode(',', $events), + 'X-' . APP_NAME . '-Webhook-Name: ' . $webhook->getAttribute('name', ''), + 'X-' . APP_NAME . '-Webhook-User-Id: ' . $user->getId(), + 'X-' . APP_NAME . '-Webhook-Project-Id: ' . $project->getId(), + 'X-' . APP_NAME . '-Webhook-Signature: ' . $signature, + ] + ); + \curl_setopt($ch, CURLOPT_MAXREDIRS, 5); - if (!$webhook->getAttribute('security', true)) { - \curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); - \curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - } - - if (!empty($httpUser) && !empty($httpPass)) { - \curl_setopt($ch, CURLOPT_USERPWD, "$httpUser:$httpPass"); - \curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); - } - - $responseBody = \curl_exec($ch); - $curlError = \curl_error($ch); - $statusCode = \curl_getinfo($ch, CURLINFO_RESPONSE_CODE); - } finally { - \curl_close($ch); + if (!$webhook->getAttribute('security', true)) { + \curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + \curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); } + if (!empty($httpUser) && !empty($httpPass)) { + \curl_setopt($ch, CURLOPT_USERPWD, "$httpUser:$httpPass"); + \curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); + } + + $responseBody = \curl_exec($ch); + $curlError = \curl_error($ch); + $statusCode = \curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + if (!empty($curlError) || $statusCode >= 400) { $dbForPlatform->increaseDocumentAttribute('webhooks', $webhook->getId(), 'attempts', 1); $webhook = $dbForPlatform->getDocument('webhooks', $webhook->getId()); diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index a4f1ae44cd..eb74867c9c 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -378,7 +378,6 @@ class Executor $responseBody = curl_exec($ch); if (isset($callback)) { - curl_close($ch); return []; } @@ -418,8 +417,6 @@ class Executor throw new Exception($curlErrorMessage . ' with status code ' . $responseStatus, $responseStatus); } - curl_close($ch); - $responseHeaders['status-code'] = $responseStatus; return [ diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index 4358058fe3..6965a87d73 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -294,8 +294,6 @@ class Client throw new Exception(curl_error($ch) . ' with status code ' . $responseStatus, $responseStatus); } - curl_close($ch); - $responseHeaders['status-code'] = $responseStatus; if ($responseStatus === 500) { diff --git a/tests/e2e/Services/Functions/FunctionsBase.php b/tests/e2e/Services/Functions/FunctionsBase.php index 42976cda84..458359bbe9 100644 --- a/tests/e2e/Services/Functions/FunctionsBase.php +++ b/tests/e2e/Services/Functions/FunctionsBase.php @@ -352,7 +352,6 @@ trait FunctionsBase $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); if ($httpCode === 200) { $commitData = json_decode($response, true); diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 069dc9cfbb..4346e5a5fa 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1303,6 +1303,7 @@ trait MigrationsBase $mimeType = match ($csvFileName) { default => 'text/csv', + 'missing-column.csv', 'missing-row.csv' => 'text/plain', // invalid csv structure, falls back to plain text! }; diff --git a/tests/e2e/Services/Sites/SitesBase.php b/tests/e2e/Services/Sites/SitesBase.php index c3377faad8..7b9c5e86b0 100644 --- a/tests/e2e/Services/Sites/SitesBase.php +++ b/tests/e2e/Services/Sites/SitesBase.php @@ -350,7 +350,6 @@ trait SitesBase $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); if ($httpCode === 200) { $commitData = json_decode($response, true); From ec3aa2b54f0fc14365051ce6c203f6a11d54f533 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 15:09:39 +0530 Subject: [PATCH 357/402] ci: share docker image via GHCR instead of upload-artifact The build job uploads the appwrite-dev image as an actions artifact (~hundreds of MB), and 30+ E2E test jobs all pull it concurrently with actions/download-artifact. GitHub Actions' artifact storage struggles with that many parallel downloads and intermittently fails with BlobNotFound or 'Artifact download failed after 5 retries'. Push the built image to ghcr.io//appwrite-dev: in the build job and pull from GHCR in each test job. GHCR handles parallel image fetches without throttling. Mirrors appwrite-labs/cloud#3906. --- .github/workflows/ci.yml | 150 ++++++++++++++++++++++++--------------- 1 file changed, 93 insertions(+), 57 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e521ac3771..8cc3b3e113 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ concurrency: env: COMPOSE_FILE: docker-compose.yml IMAGE: appwrite-dev + REGISTRY_IMAGE: ghcr.io/${{ github.repository }}/appwrite-dev K6_VERSION: '0.53.0' on: @@ -19,6 +20,10 @@ on: type: string default: '' +permissions: + contents: read + packages: write + jobs: dependencies: name: Checks / Dependencies @@ -258,32 +263,30 @@ jobs: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - - name: Build Appwrite + - name: Build and push Appwrite uses: docker/build-push-action@v6 with: context: . - push: false - tags: ${{ env.IMAGE }} - load: true + push: true + tags: ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max - outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar target: development build-args: | DEBUG=false TESTING=true VERSION=dev - - name: Upload Docker Image - uses: actions/upload-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp/${{ env.IMAGE }}.tar - retention-days: 1 - unit: name: Tests / Unit runs-on: ubuntu-latest @@ -291,26 +294,32 @@ jobs: permissions: contents: read pull-requests: write + packages: read steps: - name: checkout uses: actions/checkout@v6 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Docker Image + run: | + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + - name: Load and Start Appwrite timeout-minutes: 5 run: | - docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable docker compose up -d --quiet-pull --wait @@ -338,26 +347,32 @@ jobs: permissions: contents: read pull-requests: write + packages: read steps: - name: checkout uses: actions/checkout@v6 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Docker Image + run: | + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + - name: Load and Start Appwrite timeout-minutes: 5 run: | - docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable docker compose up -d --quiet-pull --wait @@ -396,6 +411,7 @@ jobs: permissions: contents: read pull-requests: write + packages: read strategy: fail-fast: false matrix: @@ -450,16 +466,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Set environment run: | echo "_APP_OPTIONS_ROUTER_PROTECTION=enabled" >> $GITHUB_ENV - + if [ "${{ matrix.database }}" = "MariaDB" ]; then echo "COMPOSE_PROFILES=mariadb" >> $GITHUB_ENV echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV @@ -483,6 +493,18 @@ jobs: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Docker Image + run: | + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + - name: Load and Start Appwrite timeout-minutes: 5 env: @@ -491,7 +513,6 @@ jobs: _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} run: | - docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable docker compose up -d --quiet-pull --wait @@ -545,6 +566,7 @@ jobs: permissions: contents: read pull-requests: write + packages: read strategy: fail-fast: false matrix: @@ -555,18 +577,24 @@ jobs: with: fetch-depth: 1 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Docker Image + run: | + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + - name: Load and Start Appwrite timeout-minutes: 5 env: @@ -575,7 +603,6 @@ jobs: _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} run: | - docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable docker compose up -d --quiet-pull --wait @@ -606,6 +633,7 @@ jobs: permissions: contents: read pull-requests: write + packages: read strategy: fail-fast: false matrix: @@ -614,18 +642,24 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Docker Image + run: | + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + - name: Load and Start Appwrite timeout-minutes: 5 env: @@ -633,7 +667,6 @@ jobs: _APP_DATABASE_DOCUMENTSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'documentsdb_db_main' || '' }} _APP_DATABASE_VECTORSDB_SHARED_TABLES: ${{ matrix.mode != 'dedicated' && 'vectorsdb_db_main' || '' }} run: | - docker load --input /tmp/${{ env.IMAGE }}.tar docker compose pull --quiet --ignore-buildable docker compose up -d --quiet-pull --wait @@ -675,28 +708,31 @@ jobs: contents: read issues: write pull-requests: write + packages: read steps: - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 1 - - name: Download Docker Image - uses: actions/download-artifact@v7 - with: - name: ${{ env.IMAGE }} - path: /tmp - - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Load Appwrite image + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull Appwrite image run: | - docker load --input /tmp/${{ env.IMAGE }}.tar - docker tag ${{ env.IMAGE }} ${{ env.IMAGE }}:after + docker pull ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }} + docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}:after - name: Setup k6 uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 From 701f557755046e934ef2082f3e39179c02deb8fc Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 15:23:05 +0530 Subject: [PATCH 358/402] ci: clean up GHCR CI image after pipeline finishes Every CI run pushes ghcr.io//appwrite-dev: and nothing removes it. On an active repo with many PRs the GHCR storage grows without bound. Add a cleanup job that runs after all consumer jobs complete (always, even if some fail) and deletes the SHA-tagged package version via the Packages API. Addresses Greptile feedback on appwrite/appwrite#12176. --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cc3b3e113..3c644dbec5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -871,3 +871,29 @@ jobs: - name: Fail benchmark if: always() && steps.benchmark_after.outcome != 'success' run: exit 1 + + cleanup: + name: Cleanup GHCR Image + if: ${{ always() && github.event_name == 'pull_request' }} + needs: [build, unit, e2e_general, e2e_service, e2e_abuse, e2e_screenshots, benchmark] + runs-on: ubuntu-latest + permissions: + packages: write + steps: + - name: Delete CI image from GHCR + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + package_path="${GITHUB_REPOSITORY#*/}/appwrite-dev" + encoded_path="$(printf '%s' "$package_path" | jq -Rr @uri)" + version_id=$(gh api -H "Accept: application/vnd.github+json" \ + "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions" \ + --jq ".[] | select(.metadata.container.tags | index(\"${GITHUB_SHA}\")) | .id") + if [ -n "$version_id" ]; then + gh api --method DELETE -H "Accept: application/vnd.github+json" \ + "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions/${version_id}" + echo "Deleted ${package_path}:${GITHUB_SHA} (version ${version_id})" + else + echo "No GHCR version found for SHA ${GITHUB_SHA}" + fi From 444739685962fcabc0bc596d87f42699aa25630e Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 15:37:59 +0530 Subject: [PATCH 359/402] Update base image to 1.2.1 --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index f6852553d7..1922a0d2b9 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist \ `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi` -FROM appwrite/base:1.2.0 AS base +FROM appwrite/base:1.2.1 AS base LABEL maintainer="team@appwrite.io" @@ -24,7 +24,6 @@ ENV _APP_VERSION=$VERSION \ _APP_HOME=https://appwrite.io RUN \ - apk add --update --no-cache git && \ if [ "$DEBUG" != "true" ]; then \ rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \ rm -f /usr/local/lib/php/extensions/no-debug-non-zts-*/xdebug.so; \ From d13e6d75f0bddabd28b73e4456bd476e2e2441e9 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 15:58:59 +0530 Subject: [PATCH 360/402] Fix Trivy SARIF categories on nightly scan --- .github/workflows/nightly.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5cbec8f867..2b56a7e1d6 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -24,9 +24,10 @@ jobs: ignore-unfixed: 'false' severity: 'CRITICAL,HIGH' - name: Upload Docker Image Scan Results - uses: github/codeql-action/upload-sarif@v2 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: 'trivy-image-results.sarif' + category: 'trivy-image' scan-code: name: Scan Code @@ -42,6 +43,7 @@ jobs: output: 'trivy-fs-results.sarif' severity: 'CRITICAL,HIGH' - name: Upload Code Scan Results - uses: github/codeql-action/upload-sarif@v2 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: 'trivy-fs-results.sarif' + category: 'trivy-source' From 360d08f0873aebdc4d1b9db7e8d22f6be8bbcf4c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 16:01:15 +0530 Subject: [PATCH 361/402] Preserve CI image for job retries --- .github/workflows/ci.yml | 26 ----------------------- .github/workflows/cleanup-cache.yml | 32 ++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c644dbec5..8cc3b3e113 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -871,29 +871,3 @@ jobs: - name: Fail benchmark if: always() && steps.benchmark_after.outcome != 'success' run: exit 1 - - cleanup: - name: Cleanup GHCR Image - if: ${{ always() && github.event_name == 'pull_request' }} - needs: [build, unit, e2e_general, e2e_service, e2e_abuse, e2e_screenshots, benchmark] - runs-on: ubuntu-latest - permissions: - packages: write - steps: - - name: Delete CI image from GHCR - continue-on-error: true - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - package_path="${GITHUB_REPOSITORY#*/}/appwrite-dev" - encoded_path="$(printf '%s' "$package_path" | jq -Rr @uri)" - version_id=$(gh api -H "Accept: application/vnd.github+json" \ - "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions" \ - --jq ".[] | select(.metadata.container.tags | index(\"${GITHUB_SHA}\")) | .id") - if [ -n "$version_id" ]; then - gh api --method DELETE -H "Accept: application/vnd.github+json" \ - "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions/${version_id}" - echo "Deleted ${package_path}:${GITHUB_SHA} (version ${version_id})" - else - echo "No GHCR version found for SHA ${GITHUB_SHA}" - fi diff --git a/.github/workflows/cleanup-cache.yml b/.github/workflows/cleanup-cache.yml index 8f9f05a38c..4b6b13d35d 100644 --- a/.github/workflows/cleanup-cache.yml +++ b/.github/workflows/cleanup-cache.yml @@ -5,6 +5,11 @@ on: types: - closed +permissions: + actions: write + contents: read + packages: write + jobs: cleanup: runs-on: ubuntu-latest @@ -36,4 +41,29 @@ jobs: done done env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Cleanup GHCR image + continue-on-error: true + run: | + package_path="${GITHUB_REPOSITORY#*/}/appwrite-dev" + encoded_path="$(printf '%s' "$package_path" | jq -Rr @uri)" + + gh api --paginate "/repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }}/commits" --jq '.[].sha' | while read -r sha; do + version_ids=$(gh api --paginate -H "Accept: application/vnd.github+json" \ + "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions" \ + --jq ".[] | select(.metadata.container.tags | index(\"${sha}\")) | .id") + + if [ -z "$version_ids" ]; then + echo "No GHCR version found for SHA ${sha}" + continue + fi + + echo "$version_ids" | while read -r version_id; do + gh api --method DELETE -H "Accept: application/vnd.github+json" \ + "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${encoded_path}/versions/${version_id}" + echo "Deleted ${package_path}:${sha} (version ${version_id})" + done + done + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From cccafeff0c640f0a07fb3e2d26c925a9e160b42f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 16:02:41 +0530 Subject: [PATCH 362/402] Add nightly SARIF upload guards --- .github/workflows/nightly.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2b56a7e1d6..c4289678bb 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -25,6 +25,7 @@ jobs: severity: 'CRITICAL,HIGH' - name: Upload Docker Image Scan Results uses: github/codeql-action/upload-sarif@v4 + if: always() && hashFiles('trivy-image-results.sarif') != '' with: sarif_file: 'trivy-image-results.sarif' category: 'trivy-image' @@ -44,6 +45,7 @@ jobs: severity: 'CRITICAL,HIGH' - name: Upload Code Scan Results uses: github/codeql-action/upload-sarif@v4 + if: always() && hashFiles('trivy-fs-results.sarif') != '' with: sarif_file: 'trivy-fs-results.sarif' category: 'trivy-source' From bae61e8a05c351ad3cd0897be04327e8fd393d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 29 Apr 2026 13:13:13 +0200 Subject: [PATCH 363/402] Improve developer experience of keys endpoints --- .../Project/Keys/{Standard => }/Create.php | 13 ++++++------ .../Http/Project/Keys/Ephemeral/Create.php | 2 +- .../Modules/Project/Services/Http.php | 4 ++-- tests/e2e/Services/Project/KeysBase.php | 21 +++++++++++++++---- 4 files changed, 26 insertions(+), 14 deletions(-) rename src/Appwrite/Platform/Modules/Project/Http/Project/Keys/{Standard => }/Create.php (90%) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php similarity index 90% rename from src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php rename to src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php index 67bdcc09a6..eebc0a7067 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Standard/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) - ->setHttpPath('/v1/project/keys/standard') - ->httpAlias('/v1/project/keys') + ->setHttpPath('/v1/project/keys') ->httpAlias('/v1/projects/:projectId/keys') - ->desc('Create standard project key') + ->desc('Create project key') ->groups(['api', 'project']) ->label('scope', 'keys.write') ->label('event', 'keys.[keyId].create') @@ -49,9 +48,9 @@ class Create extends Base ->label('sdk', new Method( namespace: 'project', group: 'keys', - name: 'createStandardKey', + name: 'createKey', description: <<param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false) - ->param('duration', 900, new Range(1, 3600), 'Time in seconds before ephemeral key expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) + ->param('duration', null, new Range(1, 3600), 'Time in seconds before ephemeral key expires. Default duration is 900 seconds, and maximum is 3600 seconds.', optional: false) ->inject('response') ->inject('queueForEvents') ->inject('project') diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 2c6ea29c7a..609de96530 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -5,10 +5,10 @@ namespace Appwrite\Platform\Modules\Project\Services; use Appwrite\Platform\Modules\Project\Http\Init; use Appwrite\Platform\Modules\Project\Http\Project\AuthMethods\Update as UpdateAuthMethod; use Appwrite\Platform\Modules\Project\Http\Project\Delete as DeleteProject; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Ephemeral\Create as CreateEphemeralKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey; -use Appwrite\Platform\Modules\Project\Http\Project\Keys\Standard\Create as CreateStandardKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Update as UpdateKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\XList as ListKeys; use Appwrite\Platform\Modules\Project\Http\Project\Labels\Update as UpdateProjectLabels; @@ -131,7 +131,7 @@ class Http extends Service $this->addAction(UpdateVariable::getName(), new UpdateVariable()); // Keys - $this->addAction(CreateStandardKey::getName(), new CreateStandardKey()); + $this->addAction(CreateKey::getName(), new CreateKey()); $this->addAction(CreateEphemeralKey::getName(), new CreateEphemeralKey()); $this->addAction(ListKeys::getName(), new ListKeys()); $this->addAction(GetKey::getName(), new GetKey()); diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php index cd50f67c14..c8687d9964 100644 --- a/tests/e2e/Services/Project/KeysBase.php +++ b/tests/e2e/Services/Project/KeysBase.php @@ -245,8 +245,11 @@ trait KeysBase public function testCreateEphemeralKey(): void { + $duration = 900; + $key = $this->createEphemeralKey( ['users.read', 'users.write'], + $duration, ); $this->assertSame(201, $key['headers']['status-code']); @@ -271,12 +274,11 @@ trait KeysBase $this->assertNotEmpty($payload['projectId']); $this->assertSame(['users.read', 'users.write'], $payload['scopes']); - // Verify default duration (900 seconds) $expireDt = new \DateTime($key['body']['expire']); $now = new \DateTime(); $diff = $expireDt->getTimestamp() - $now->getTimestamp(); - $this->assertGreaterThanOrEqual(890, $diff); - $this->assertLessThanOrEqual(910, $diff); + $this->assertGreaterThanOrEqual($duration - 10, $diff); + $this->assertLessThanOrEqual($duration + 10, $diff); } public function testCreateEphemeralKeyWithDuration(): void @@ -302,6 +304,7 @@ trait KeysBase { $key = $this->createEphemeralKey( [], + 900, ); $this->assertSame(201, $key['headers']['status-code']); @@ -312,17 +315,27 @@ trait KeysBase { $response = $this->createEphemeralKey( ['users.read'], - null, + 900, false ); $this->assertSame(401, $response['headers']['status-code']); } + public function testCreateEphemeralKeyMissingDuration(): void + { + $response = $this->createEphemeralKey( + ['users.read'], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + public function testCreateEphemeralKeyInvalidScope(): void { $response = $this->createEphemeralKey( ['invalid.scope'], + 900, ); $this->assertSame(400, $response['headers']['status-code']); From aaf91f381618bdf3b4bea47a7e16bee47dfe074f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 29 Apr 2026 13:52:13 +0200 Subject: [PATCH 364/402] Improve scopes quality --- app/config/roles.php | 10 +- app/config/scopes/project.php | 448 +++++++++++------- app/controllers/api/users.php | 8 +- .../Modules/Console/Http/Scopes/Key/XList.php | 10 +- .../Functions/Http/Executions/Create.php | 2 +- .../Functions/Http/Executions/Delete.php | 2 +- .../Modules/Functions/Http/Executions/Get.php | 2 +- .../Functions/Http/Executions/XList.php | 2 +- .../Utopia/Response/Model/ConsoleKeyScope.php | 12 + tests/benchmarks/bulk-operations/utils.js | 4 +- tests/benchmarks/http.js | 4 +- tests/e2e/Scopes/ProjectCustom.php | 4 +- .../Projects/Schedules/SchedulesBase.php | 4 +- 13 files changed, 307 insertions(+), 205 deletions(-) diff --git a/app/config/roles.php b/app/config/roles.php index d653b4857c..8fba27e503 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -21,8 +21,8 @@ $member = [ 'projects.read', 'locale.read', 'avatars.read', - 'execution.read', - 'execution.write', + 'executions.read', + 'executions.write', 'targets.read', 'targets.write', 'subscribers.write', @@ -81,8 +81,8 @@ $admins = [ 'sites.write', 'log.read', 'log.write', - 'execution.read', - 'execution.write', + 'executions.read', + 'executions.write', 'rules.read', 'rules.write', 'migrations.read', @@ -123,7 +123,7 @@ return [ 'files.write', 'locale.read', 'avatars.read', - 'execution.write', + 'executions.write', ], ], User::ROLE_USERS => [ diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 947cd863f8..64eb1836b5 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -1,239 +1,327 @@ [ - 'description' => 'Access to create, update, and delete user sessions', - ], - 'users.read' => [ - 'description' => 'Access to read your project\'s users', - ], - 'users.write' => [ - 'description' => 'Access to create, update, and delete your project\'s users', - ], - 'teams.read' => [ - 'description' => 'Access to read your project\'s teams', - ], - 'teams.write' => [ - 'description' => 'Access to create, update, and delete your project\'s teams', - ], - 'databases.read' => [ - 'description' => 'Access to read your project\'s databases', - ], - 'databases.write' => [ - 'description' => 'Access to create, update, and delete your project\'s databases', - ], - 'collections.read' => [ - 'description' => 'Access to read your project\'s database collections', - ], - 'collections.write' => [ - 'description' => 'Access to create, update, and delete your project\'s database collections', - ], - 'tables.read' => [ - 'description' => 'Access to read your project\'s database tables', - ], - 'tables.write' => [ - 'description' => 'Access to create, update, and delete your project\'s database tables', - ], - 'attributes.read' => [ - 'description' => 'Access to read your project\'s database collection\'s attributes', - ], - 'attributes.write' => [ - 'description' => 'Access to create, update, and delete your project\'s database collection\'s attributes', - ], - 'columns.read' => [ - 'description' => 'Access to read your project\'s database table\'s columns', - ], - 'columns.write' => [ - 'description' => 'Access to create, update, and delete your project\'s database table\'s columns', - ], - 'indexes.read' => [ - 'description' => 'Access to read your project\'s database table\'s indexes', - ], - 'indexes.write' => [ - 'description' => 'Access to create, update, and delete your project\'s database table\'s indexes', - ], - 'documents.read' => [ - 'description' => 'Access to read your project\'s database documents', - ], - 'documents.write' => [ - 'description' => 'Access to create, update, and delete your project\'s database documents', - ], - 'rows.read' => [ - 'description' => 'Access to read your project\'s database rows', - ], - 'rows.write' => [ - 'description' => 'Access to create, update, and delete your project\'s database rows', - ], - 'files.read' => [ - 'description' => 'Access to read your project\'s storage files and preview images', - ], - 'files.write' => [ - 'description' => 'Access to create, update, and delete your project\'s storage files', - ], - 'buckets.read' => [ - 'description' => 'Access to read your project\'s storage buckets', - ], - 'buckets.write' => [ - 'description' => 'Access to create, update, and delete your project\'s storage buckets', - ], - 'functions.read' => [ - 'description' => 'Access to read your project\'s functions and code deployments', - ], - 'functions.write' => [ - 'description' => 'Access to create, update, and delete your project\'s functions and code deployments', - ], - 'sites.read' => [ - 'description' => 'Access to read your project\'s sites and deployments', - ], - 'sites.write' => [ - 'description' => 'Access to create, update, and delete your project\'s sites and deployments', - ], - 'log.read' => [ - 'description' => 'Access to read your site\'s logs', - ], - 'log.write' => [ - 'description' => 'Access to update, and delete your site\'s logs', - ], - 'execution.read' => [ - 'description' => 'Access to read your project\'s execution logs', - ], - 'execution.write' => [ - 'description' => 'Access to execute your project\'s functions', - ], - 'locale.read' => [ - 'description' => 'Access to access your project\'s Locale service', - ], - 'avatars.read' => [ - 'description' => 'Access to access your project\'s Avatars service', - ], - 'health.read' => [ - 'description' => 'Access to read your project\'s health status', - ], - 'providers.read' => [ - 'description' => 'Access to read your project\'s providers', - ], - 'providers.write' => [ - 'description' => 'Access to create, update, and delete your project\'s providers', - ], - 'messages.read' => [ - 'description' => 'Access to read your project\'s messages', - ], - 'messages.write' => [ - 'description' => 'Access to create, update, and delete your project\'s messages', - ], - 'topics.read' => [ - 'description' => 'Access to read your project\'s topics', - ], - 'topics.write' => [ - 'description' => 'Access to create, update, and delete your project\'s topics', - ], - 'subscribers.read' => [ - 'description' => 'Access to read your project\'s subscribers', - ], - 'subscribers.write' => [ - 'description' => 'Access to create, update, and delete your project\'s subscribers', - ], - 'targets.read' => [ - 'description' => 'Access to read your project\'s targets', - ], - 'targets.write' => [ - 'description' => 'Access to create, update, and delete your project\'s targets', - ], - 'rules.read' => [ - 'description' => 'Access to read your project\'s proxy rules', - ], - 'rules.write' => [ - 'description' => 'Access to create, update, and delete your project\'s proxy rules', - ], - 'schedules.read' => [ - 'description' => 'Access to read your project\'s schedules', - ], - 'schedules.write' => [ - 'description' => 'Access to create, update, and delete your project\'s schedules', - ], - 'migrations.read' => [ - 'description' => 'Access to read your project\'s migrations', - ], - 'migrations.write' => [ - 'description' => 'Access to create, update, and delete your project\'s migrations.', - ], - 'vcs.read' => [ - 'description' => 'Access to read your project\'s VCS repositories', - ], - 'vcs.write' => [ - 'description' => 'Access to create, update, and delete your project\'s VCS repositories', - ], - 'assistant.read' => [ - 'description' => 'Access to read the Assistant service', - ], - 'tokens.read' => [ - 'description' => 'Access to read your project\'s tokens', - ], - 'tokens.write' => [ - 'description' => 'Access to create, update, and delete your project\'s tokens', - ], - "webhooks.read" => [ - "description" => - "Access to read project\'s webhooks", - ], - "webhooks.write" => [ - "description" => - "Access to create, update, and delete project\'s webhooks", - ], +// List of publicly visible scopes +return [ + // Project "project.read" => [ "description" => "Access to read project\'s information", + "category" => "Project", ], "project.write" => [ "description" => "Access to update project\'s information", + "category" => "Project", ], "keys.read" => [ "description" => "Access to read project\'s keys", + "category" => "Project", ], "keys.write" => [ "description" => "Access to create, update, and delete project\'s keys", + "category" => "Project", ], "platforms.read" => [ "description" => "Access to read project\'s platforms", + "category" => "Project", ], "platforms.write" => [ "description" => "Access to create, update, and delete project\'s platforms", + "category" => "Project", ], "mocks.read" => [ "description" => "Access to read project\'s mocks", + "category" => "Project", ], "mocks.write" => [ "description" => "Access to create, update, and delete project\'s mocks", + "category" => "Project", ], "policies.read" => [ "description" => "Access to read project\'s policies", + "category" => "Project", ], "policies.write" => [ "description" => "Access to update project\'s policies", + "category" => "Project", ], "templates.read" => [ "description" => "Access to read project\'s templates", + "category" => "Project", ], "templates.write" => [ "description" => "Access to create, update, and delete project\'s templates", + "category" => "Project", ], "oauth2.read" => [ "description" => "Access to read project\'s OAuth2 configuration", + "category" => "Project", ], "oauth2.write" => [ "description" => "Access to update project\'s OAuth2 configuration", + "category" => "Project", ], + + // Auth + 'users.read' => [ + 'description' => 'Access to read users', + 'category' => 'Auth', + ], + 'users.write' => [ + 'description' => 'Access to create, update, and delete users', + 'category' => 'Auth', + ], + 'sessions.read' => [ + 'description' => 'Access to read user sessions', + 'category' => 'Auth', + ], + 'sessions.write' => [ + 'description' => 'Access to create, update, and delete user sessions', + 'category' => 'Auth', + ], + 'teams.read' => [ + 'description' => 'Access to read teams', + 'category' => 'Auth', + ], + 'teams.write' => [ + 'description' => 'Access to create, update, and delete teams', + 'category' => 'Auth', + ], + + // Databases + 'databases.read' => [ + 'description' => 'Access to read databases', + 'category' => 'Databases', + ], + 'databases.write' => [ + 'description' => 'Access to create, update, and delete databases', + 'category' => 'Databases', + ], + 'tables.read' => [ + 'description' => 'Access to read database tables', + 'category' => 'Databases', + ], + 'tables.write' => [ + 'description' => 'Access to create, update, and delete database tables', + 'category' => 'Databases', + ], + 'columns.read' => [ + 'description' => 'Access to read database table columns', + 'category' => 'Databases', + ], + 'columns.write' => [ + 'description' => 'Access to create, update, and delete database table columns', + 'category' => 'Databases', + ], + 'indexes.read' => [ + 'description' => 'Access to read database table indexes', + 'category' => 'Databases', + ], + 'indexes.write' => [ + 'description' => 'Access to create, update, and delete database table indexes', + 'category' => 'Databases', + ], + 'rows.read' => [ + 'description' => 'Access to read database table rows', + 'category' => 'Databases', + ], + 'rows.write' => [ + 'description' => 'Access to create, update, and delete database table rows', + 'category' => 'Databases', + ], + 'collections.read' => [ + 'description' => 'Access to read database collections', + 'category' => 'Databases', + 'deprecated' => true, + ], + 'collections.write' => [ + 'description' => 'Access to create, update, and delete database collections', + 'category' => 'Databases', + 'deprecated' => true, + ], + 'attributes.read' => [ + 'description' => 'Access to read database collection attributes', + 'category' => 'Databases', + 'deprecated' => true, + ], + 'attributes.write' => [ + 'description' => 'Access to create, update, and delete database collection attributes', + 'category' => 'Databases', + 'deprecated' => true, + ], + 'documents.read' => [ + 'description' => 'Access to read database collection documents', + 'category' => 'Databases', + 'deprecated' => true, + ], + 'documents.write' => [ + 'description' => 'Access to create, update, and delete database collection\ documents', + 'category' => 'Databases', + 'deprecated' => true, + ], + + // Storage + 'buckets.read' => [ + 'description' => 'Access to read storage buckets', + 'category' => 'Storage', + ], + 'buckets.write' => [ + 'description' => 'Access to create, update, and delete storage buckets', + 'category' => 'Storage', + ], + 'files.read' => [ + 'description' => 'Access to read storage files and preview images', + 'category' => 'Storage', + ], + 'files.write' => [ + 'description' => 'Access to create, update, and delete storage files', + 'category' => 'Storage', + ], + 'tokens.read' => [ + 'description' => 'Access to read storage file tokens', + 'category' => 'Storage', + ], + 'tokens.write' => [ + 'description' => 'Access to create, update, and delete storage file tokens', + 'category' => 'Storage', + ], + + // Functions + 'functions.read' => [ + 'description' => 'Access to read functions and deployments', + 'category' => 'Functions', + ], + 'functions.write' => [ + 'description' => 'Access to create, update, and delete functions and deployments', + 'category' => 'Functions', + ], + 'executions.read' => [ + 'description' => 'Access to read function executions', + 'category' => 'Functions', + ], + 'executions.write' => [ + 'description' => 'Access to create function executions', + 'category' => 'Functions', + ], + + // Sites + 'sites.read' => [ + 'description' => 'Access to read sites and deployments', + 'category' => 'Sites', + ], + 'sites.write' => [ + 'description' => 'Access to create, update, and delete sites and deployments', + 'category' => 'Sites', + ], + 'log.read' => [ + 'description' => 'Access to read site logs', + 'category' => 'Sites', + ], + 'log.write' => [ + 'description' => 'Access to update, and delete site logs', + 'category' => 'Sites', + ], + + // Messaging + 'providers.read' => [ + 'description' => 'Access to read messaging providers', + 'category' => 'Messaging', + ], + 'providers.write' => [ + 'description' => 'Access to create, update, and delete messaging providers', + 'category' => 'Messaging', + ], + 'topics.read' => [ + 'description' => 'Access to read messaging topics', + 'category' => 'Messaging', + ], + 'topics.write' => [ + 'description' => 'Access to create, update, and delete messaging topics', + 'category' => 'Messaging', + ], + 'subscribers.read' => [ + 'description' => 'Access to read messaging subscribers', + 'category' => 'Messaging', + ], + 'subscribers.write' => [ + 'description' => 'Access to create, update, and delete messaging subscribers', + 'category' => 'Messaging', + ], + 'targets.read' => [ + 'description' => 'Access to read messaging targets', + 'category' => 'Messaging', + ], + 'targets.write' => [ + 'description' => 'Access to create, update, and delete messaging targets', + 'category' => 'Messaging', + ], + 'messages.read' => [ + 'description' => 'Access to read messaging messages', + 'category' => 'Messaging', + ], + 'messages.write' => [ + 'description' => 'Access to create, update, and delete messaging messages', + 'category' => 'Messaging', + ], + + // Proxy + 'rules.read' => [ + 'description' => 'Access to read proxy rules', + 'category' => 'Proxy', + ], + 'rules.write' => [ + 'description' => 'Access to create, update, and delete proxy rules', + 'category' => 'Proxy', + ], + + // TODO: VCS + + // Other + "webhooks.read" => [ + "description" => + "Access to read webhooks", + 'category' => 'Other', + ], + "webhooks.write" => [ + "description" => + "Access to create, update, and delete webhooks", + 'category' => 'Other', + ], + 'locale.read' => [ + 'description' => 'Access to use Locale service', + 'category' => 'Other', + ], + 'avatars.read' => [ + 'description' => 'Access to use Avatars service', + 'category' => 'Other', + ], + 'health.read' => [ + 'description' => 'Access to use Health service', + 'category' => 'Other', + ], + 'assistant.read' => [ + 'description' => 'Access to use Assistant service', + 'category' => 'Other', + ], + 'migrations.read' => [ + 'description' => 'Access to read migrations', + 'category' => 'Other', + ], + 'migrations.write' => [ + 'description' => 'Access to create, update, and delete migrations.', + 'category' => 'Other', + ], + // TODO: Figure out schedules.read, schedules.write ]; diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 1346812668..abcecac396 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -856,7 +856,7 @@ Http::get('/v1/users/:userId/targets/:targetId') Http::get('/v1/users/:userId/sessions') ->desc('List user sessions') ->groups(['api', 'users']) - ->label('scope', 'users.read') + ->label('scope', ['users.read', 'sessions.read']) ->label('sdk', new Method( namespace: 'users', group: 'sessions', @@ -2314,7 +2314,7 @@ Http::post('/v1/users/:userId/sessions') ->desc('Create session') ->groups(['api', 'users']) ->label('event', 'users.[userId].sessions.[sessionId].create') - ->label('scope', 'users.write') + ->label('scope', ['users.write', 'sessions.write']) ->label('audits.event', 'session.create') ->label('audits.resource', 'user/{request.userId}') ->label('usage.metric', 'sessions.{scope}.requests.create') @@ -2470,7 +2470,7 @@ Http::delete('/v1/users/:userId/sessions/:sessionId') ->desc('Delete user session') ->groups(['api', 'users']) ->label('event', 'users.[userId].sessions.[sessionId].delete') - ->label('scope', 'users.write') + ->label('scope', ['users.write', 'sessions.write']) ->label('audits.event', 'session.delete') ->label('audits.resource', 'user/{request.userId}') ->label('sdk', new Method( @@ -2521,7 +2521,7 @@ Http::delete('/v1/users/:userId/sessions') ->desc('Delete user sessions') ->groups(['api', 'users']) ->label('event', 'users.[userId].sessions.delete') - ->label('scope', 'users.write') + ->label('scope', ['users.write', 'sessions.write']) ->label('audits.event', 'session.delete') ->label('audits.resource', 'user/{user.$id}') ->label('sdk', new Method( diff --git a/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php index 255a7583bb..d951e93886 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Scopes/Key/XList.php @@ -18,21 +18,21 @@ class XList extends Action public static function getName(): string { - return 'listKeyScopes'; + return 'listConsoleProjectScopes'; } public function __construct() { $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/console/scopes/key') - ->desc('List key scopes') + ->setHttpPath('/v1/console/scopes/project') + ->desc('List project scopes') ->groups(['api']) ->label('scope', 'public') ->label('sdk', new Method( namespace: 'console', group: 'console', - name: 'listKeyScopes', + name: 'listProjectScopes', description: 'List all scopes available for project API keys, along with a description for each scope.', auth: [AuthType::ADMIN], responses: [ @@ -56,6 +56,8 @@ class XList extends Action $scopes[] = new Document([ '$id' => $scopeId, 'description' => $scope['description'] ?? '', + 'category' => $scope['category'] ?? '', + 'deprecated' => $scope['deprecated'] ?? false, ]); } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 4bf2fbc48f..9f15cf9d1e 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -60,7 +60,7 @@ class Create extends Base ->setHttpPath('/v1/functions/:functionId/executions') ->desc('Create execution') ->groups(['api', 'functions']) - ->label('scope', 'execution.write') + ->label('scope', ['executions.write', 'execution.write']) ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) ->label('event', 'functions.[functionId].executions.[executionId].create') ->label('sdk', new Method( diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php index 21ec3c66ce..9ecb5c0bf0 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Delete.php @@ -35,7 +35,7 @@ class Delete extends Base ->setHttpPath('/v1/functions/:functionId/executions/:executionId') ->desc('Delete execution') ->groups(['api', 'functions']) - ->label('scope', 'execution.write') + ->label('scope', ['executions.write', 'execution.write']) ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) ->label('event', 'functions.[functionId].executions.[executionId].delete') ->label('audits.event', 'executions.delete') diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php index aec9d56543..0a9dd01b7e 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php @@ -31,7 +31,7 @@ class Get extends Base ->setHttpPath('/v1/functions/:functionId/executions/:executionId') ->desc('Get execution') ->groups(['api', 'functions']) - ->label('scope', 'execution.read') + ->label('scope', ['executions.read', 'execution.read']) ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) ->label('sdk', new Method( namespace: 'functions', diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php index b12980b222..6ad2a5ae55 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php @@ -39,7 +39,7 @@ class XList extends Base ->setHttpPath('/v1/functions/:functionId/executions') ->desc('List executions') ->groups(['api', 'functions']) - ->label('scope', 'execution.read') + ->label('scope', ['executions.read', 'execution.read']) ->label('resourceType', RESOURCE_TYPE_FUNCTIONS) ->label('sdk', new Method( namespace: 'functions', diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php index 4932707d21..224d114271 100644 --- a/src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php +++ b/src/Appwrite/Utopia/Response/Model/ConsoleKeyScope.php @@ -22,6 +22,18 @@ class ConsoleKeyScope extends Model 'default' => '', 'example' => 'Access to read your project\'s users', ]) + ->addRule('category', [ + 'type' => self::TYPE_STRING, + 'description' => 'Scope category.', + 'default' => '', + 'example' => 'Auth', + ]) + ->addRule('deprecated', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Scope is deprecated.', + 'default' => false, + 'example' => true, + ]) ; } diff --git a/tests/benchmarks/bulk-operations/utils.js b/tests/benchmarks/bulk-operations/utils.js index dc8dcac569..5b8bbc6c67 100644 --- a/tests/benchmarks/bulk-operations/utils.js +++ b/tests/benchmarks/bulk-operations/utils.js @@ -197,8 +197,8 @@ const SCOPES = [ "buckets.write", "functions.read", "functions.write", - "execution.read", - "execution.write", + "executions.read", + "executions.write", "targets.read", "targets.write", "providers.read", diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 6466ffd361..f7bb54024d 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -75,8 +75,8 @@ const API_SCOPES = [ 'functions.write', 'log.read', 'log.write', - 'execution.read', - 'execution.write', + 'executions.read', + 'executions.write', 'locale.read', 'avatars.read', 'rules.read', diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 31d85524af..3071ddfa2a 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -137,8 +137,8 @@ trait ProjectCustom 'functions.write', 'sites.read', 'sites.write', - 'execution.read', - 'execution.write', + 'executions.read', + 'executions.write', 'log.read', 'log.write', 'locale.read', diff --git a/tests/e2e/Services/Projects/Schedules/SchedulesBase.php b/tests/e2e/Services/Projects/Schedules/SchedulesBase.php index 681e39b662..4baaca4e5b 100644 --- a/tests/e2e/Services/Projects/Schedules/SchedulesBase.php +++ b/tests/e2e/Services/Projects/Schedules/SchedulesBase.php @@ -62,8 +62,8 @@ trait SchedulesBase 'scopes' => [ 'functions.read', 'functions.write', - 'execution.read', - 'execution.write', + 'executions.read', + 'executions.write', 'messages.read', 'messages.write', ], From e010bf25d5a09a8bdb5b330134e70c3f9e28f7ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 29 Apr 2026 13:57:16 +0200 Subject: [PATCH 365/402] Fix formatting --- app/config/scopes/project.php | 44 +++++++++++++++++------------------ 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 64eb1836b5..aa6752967d 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -11,67 +11,67 @@ return [ "project.write" => [ "description" => "Access to update project\'s information", - "category" => "Project", + "category" => "Project", ], "keys.read" => [ "description" => "Access to read project\'s keys", - "category" => "Project", + "category" => "Project", ], "keys.write" => [ "description" => "Access to create, update, and delete project\'s keys", - "category" => "Project", + "category" => "Project", ], "platforms.read" => [ "description" => "Access to read project\'s platforms", - "category" => "Project", + "category" => "Project", ], "platforms.write" => [ "description" => "Access to create, update, and delete project\'s platforms", - "category" => "Project", + "category" => "Project", ], "mocks.read" => [ "description" => "Access to read project\'s mocks", - "category" => "Project", + "category" => "Project", ], "mocks.write" => [ "description" => "Access to create, update, and delete project\'s mocks", - "category" => "Project", + "category" => "Project", ], "policies.read" => [ "description" => "Access to read project\'s policies", - "category" => "Project", + "category" => "Project", ], "policies.write" => [ "description" => "Access to update project\'s policies", - "category" => "Project", + "category" => "Project", ], "templates.read" => [ "description" => "Access to read project\'s templates", - "category" => "Project", + "category" => "Project", ], "templates.write" => [ "description" => "Access to create, update, and delete project\'s templates", - "category" => "Project", + "category" => "Project", ], "oauth2.read" => [ "description" => "Access to read project\'s OAuth2 configuration", - "category" => "Project", + "category" => "Project", ], "oauth2.write" => [ "description" => "Access to update project\'s OAuth2 configuration", - "category" => "Project", + "category" => "Project", ], // Auth @@ -99,7 +99,7 @@ return [ 'description' => 'Access to create, update, and delete teams', 'category' => 'Auth', ], - + // Databases 'databases.read' => [ 'description' => 'Access to read databases', @@ -197,7 +197,7 @@ return [ 'description' => 'Access to create, update, and delete storage file tokens', 'category' => 'Storage', ], - + // Functions 'functions.read' => [ 'description' => 'Access to read functions and deployments', @@ -215,7 +215,7 @@ return [ 'description' => 'Access to create function executions', 'category' => 'Functions', ], - + // Sites 'sites.read' => [ 'description' => 'Access to read sites and deployments', @@ -233,7 +233,7 @@ return [ 'description' => 'Access to update, and delete site logs', 'category' => 'Sites', ], - + // Messaging 'providers.read' => [ 'description' => 'Access to read messaging providers', @@ -275,7 +275,7 @@ return [ 'description' => 'Access to create, update, and delete messaging messages', 'category' => 'Messaging', ], - + // Proxy 'rules.read' => [ 'description' => 'Access to read proxy rules', @@ -285,19 +285,19 @@ return [ 'description' => 'Access to create, update, and delete proxy rules', 'category' => 'Proxy', ], - + // TODO: VCS - + // Other "webhooks.read" => [ "description" => "Access to read webhooks", - 'category' => 'Other', + 'category' => 'Other', ], "webhooks.write" => [ "description" => "Access to create, update, and delete webhooks", - 'category' => 'Other', + 'category' => 'Other', ], 'locale.read' => [ 'description' => 'Access to use Locale service', From b3e3b2a330b8f1180d6f524c8d26068b637a148d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 29 Apr 2026 14:00:14 +0200 Subject: [PATCH 366/402] Fix missing index scopes --- .../Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php | 2 +- .../Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php | 2 +- .../Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php | 2 +- .../Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php index e683aafba1..d377bed184 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php @@ -37,7 +37,7 @@ class Create extends IndexCreate ->desc('Create index') ->groups(['api', 'database']) ->label('event', 'databases.[databaseId].tables.[tableId].indexes.[indexId].create') - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'indexes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('audits.event', 'index.create') ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php index 7750408e29..ca7e4fc2da 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Delete.php @@ -36,7 +36,7 @@ class Delete extends IndexDelete ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/indexes/:key') ->desc('Delete index') ->groups(['api', 'database']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'indexes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].indexes.[indexId].update') ->label('audits.event', 'index.delete') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php index 8f721abf0e..9918bcb2b8 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Get.php @@ -32,7 +32,7 @@ class Get extends IndexGet ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/indexes/:key') ->desc('Get index') ->groups(['api', 'database']) - ->label('scope', ['tables.read', 'collections.read']) + ->label('scope', ['tables.read', 'collections.read', 'indexes.read']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('sdk', new Method( namespace: $this->getSDKNamespace(), diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php index ff1e736c31..5fe3be4c05 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/XList.php @@ -33,7 +33,7 @@ class XList extends IndexXList ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/indexes') ->desc('List indexes') ->groups(['api', 'database']) - ->label('scope', ['tables.read', 'collections.read']) + ->label('scope', ['tables.read', 'collections.read', 'indexes.read']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('sdk', new Method( namespace: $this->getSDKNamespace(), From 4d86e670068c4dc4b63596a2ffa6ecc84080d09f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 29 Apr 2026 14:03:44 +0200 Subject: [PATCH 367/402] Fix missing scopes for tables --- app/config/scopes/project.php | 14 +------------- .../TablesDB/Tables/Columns/Boolean/Create.php | 2 +- .../TablesDB/Tables/Columns/Boolean/Update.php | 2 +- .../TablesDB/Tables/Columns/Datetime/Create.php | 2 +- .../TablesDB/Tables/Columns/Datetime/Update.php | 2 +- .../Http/TablesDB/Tables/Columns/Delete.php | 2 +- .../Http/TablesDB/Tables/Columns/Email/Create.php | 2 +- .../Http/TablesDB/Tables/Columns/Email/Update.php | 2 +- .../Http/TablesDB/Tables/Columns/Enum/Create.php | 2 +- .../Http/TablesDB/Tables/Columns/Enum/Update.php | 2 +- .../Http/TablesDB/Tables/Columns/Float/Create.php | 2 +- .../Http/TablesDB/Tables/Columns/Float/Update.php | 2 +- .../Databases/Http/TablesDB/Tables/Columns/Get.php | 2 +- .../Http/TablesDB/Tables/Columns/IP/Create.php | 2 +- .../Http/TablesDB/Tables/Columns/IP/Update.php | 2 +- .../TablesDB/Tables/Columns/Integer/Create.php | 2 +- .../TablesDB/Tables/Columns/Integer/Update.php | 2 +- .../Http/TablesDB/Tables/Columns/Line/Create.php | 2 +- .../Http/TablesDB/Tables/Columns/Line/Update.php | 2 +- .../TablesDB/Tables/Columns/Longtext/Create.php | 2 +- .../TablesDB/Tables/Columns/Longtext/Update.php | 2 +- .../TablesDB/Tables/Columns/Mediumtext/Create.php | 2 +- .../TablesDB/Tables/Columns/Mediumtext/Update.php | 2 +- .../Http/TablesDB/Tables/Columns/Point/Create.php | 2 +- .../Http/TablesDB/Tables/Columns/Point/Update.php | 2 +- .../TablesDB/Tables/Columns/Polygon/Create.php | 2 +- .../TablesDB/Tables/Columns/Polygon/Update.php | 2 +- .../Tables/Columns/Relationship/Create.php | 2 +- .../Tables/Columns/Relationship/Update.php | 2 +- .../Http/TablesDB/Tables/Columns/String/Create.php | 2 +- .../Http/TablesDB/Tables/Columns/String/Update.php | 2 +- .../Http/TablesDB/Tables/Columns/Text/Create.php | 2 +- .../Http/TablesDB/Tables/Columns/Text/Update.php | 2 +- .../Http/TablesDB/Tables/Columns/URL/Create.php | 2 +- .../Http/TablesDB/Tables/Columns/URL/Update.php | 2 +- .../TablesDB/Tables/Columns/Varchar/Create.php | 2 +- .../TablesDB/Tables/Columns/Varchar/Update.php | 2 +- .../Http/TablesDB/Tables/Columns/XList.php | 2 +- 38 files changed, 38 insertions(+), 50 deletions(-) diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index aa6752967d..c9c8786f38 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -276,18 +276,6 @@ return [ 'category' => 'Messaging', ], - // Proxy - 'rules.read' => [ - 'description' => 'Access to read proxy rules', - 'category' => 'Proxy', - ], - 'rules.write' => [ - 'description' => 'Access to create, update, and delete proxy rules', - 'category' => 'Proxy', - ], - - // TODO: VCS - // Other "webhooks.read" => [ "description" => @@ -323,5 +311,5 @@ return [ 'description' => 'Access to create, update, and delete migrations.', 'category' => 'Other', ], - // TODO: Figure out schedules.read, schedules.write + // TODO: Figure out schedules.read, schedules.write. Remove, likely ]; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php index ddfb023d25..10cd65bc98 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Create.php @@ -34,7 +34,7 @@ class Create extends BooleanCreate ->desc('Create boolean column') ->groups(['api', 'database', 'schema']) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('audits.event', 'column.create') ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Update.php index c808021796..1e0fe04bdc 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Boolean/Update.php @@ -34,7 +34,7 @@ class Update extends BooleanUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/boolean/:key') ->desc('Update boolean column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Create.php index 0698002f61..64e73e310e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Create.php @@ -34,7 +34,7 @@ class Create extends DatetimeCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/datetime') ->desc('Create datetime column') ->groups(['api', 'database']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Update.php index 035893f33f..44c1a06da8 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Datetime/Update.php @@ -35,7 +35,7 @@ class Update extends DatetimeUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/datetime/:key') ->desc('Update dateTime column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Delete.php index 81e71df07a..f4d606637d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Delete.php @@ -33,7 +33,7 @@ class Delete extends AttributesDelete ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key') ->desc('Delete column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.delete') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php index b0e81ed6b7..d0b2ed3e4b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Create.php @@ -34,7 +34,7 @@ class Create extends EmailCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/email') ->desc('Create email column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php index d1278376c1..c116d8c5b1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Email/Update.php @@ -35,7 +35,7 @@ class Update extends EmailUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/email/:key') ->desc('Update email column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php index 9aeb9b2d4b..e58ae115fc 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Create.php @@ -35,7 +35,7 @@ class Create extends EnumCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/enum') ->desc('Create enum column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php index 43503ee8ed..208fa9c8cf 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Enum/Update.php @@ -36,7 +36,7 @@ class Update extends EnumUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/enum/:key') ->desc('Update enum column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php index 0dd0ef39e1..b8e81820aa 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Create.php @@ -34,7 +34,7 @@ class Create extends FloatCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/float') ->desc('Create float column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php index 716923cc63..9ab61e642b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Float/Update.php @@ -35,7 +35,7 @@ class Update extends FloatUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/float/:key') ->desc('Update float column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php index 0fe5fa062a..b0ef9e8a85 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Get.php @@ -42,7 +42,7 @@ class Get extends AttributesGet ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key') ->desc('Get column') ->groups(['api', 'database']) - ->label('scope', ['tables.read', 'collections.read']) + ->label('scope', ['tables.read', 'collections.read', 'columns.read', 'attributes.read']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('sdk', new Method( namespace: $this->getSDKNamespace(), diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php index c359feaab4..c2faec9aeb 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Create.php @@ -34,7 +34,7 @@ class Create extends IPCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/ip') ->desc('Create IP address column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php index 0c7cc6644b..dcc4160580 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/IP/Update.php @@ -35,7 +35,7 @@ class Update extends IPUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/ip/:key') ->desc('Update IP address column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php index bbb1710866..1a965c19dc 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Create.php @@ -34,7 +34,7 @@ class Create extends IntegerCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/integer') ->desc('Create integer column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php index a9348f51e0..58dea7c848 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Integer/Update.php @@ -35,7 +35,7 @@ class Update extends IntegerUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/integer/:key') ->desc('Update integer column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php index fb2c4fd1a8..c2f480d5d0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Create.php @@ -35,7 +35,7 @@ class Create extends LineCreate ->desc('Create line column') ->groups(['api', 'database', 'schema']) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('audits.event', 'column.create') ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php index 564b743a2a..e2e8c59121 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Line/Update.php @@ -35,7 +35,7 @@ class Update extends LineUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/line/:key') ->desc('Update line column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php index da9471f37c..8e2dbd911d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Create.php @@ -33,7 +33,7 @@ class Create extends LongtextCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/longtext') ->desc('Create longtext column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php index fe93530cfb..9b90b745a2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Longtext/Update.php @@ -34,7 +34,7 @@ class Update extends LongtextUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/longtext/:key') ->desc('Update longtext column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php index 585856cab9..f0b8099f02 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Create.php @@ -33,7 +33,7 @@ class Create extends MediumtextCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/mediumtext') ->desc('Create mediumtext column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php index 733159d1d4..03009da25c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Mediumtext/Update.php @@ -34,7 +34,7 @@ class Update extends MediumtextUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/mediumtext/:key') ->desc('Update mediumtext column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php index 9736e33158..138ee482c3 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Create.php @@ -35,7 +35,7 @@ class Create extends PointCreate ->desc('Create point column') ->groups(['api', 'database', 'schema']) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('audits.event', 'column.create') ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php index f104b170bd..66fb451a1f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Point/Update.php @@ -35,7 +35,7 @@ class Update extends PointUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/point/:key') ->desc('Update point column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php index 177399396c..a03a34f310 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Create.php @@ -35,7 +35,7 @@ class Create extends PolygonCreate ->desc('Create polygon column') ->groups(['api', 'database', 'schema']) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('audits.event', 'column.create') ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php index e66e19a7b9..7a2fd8a5de 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Polygon/Update.php @@ -35,7 +35,7 @@ class Update extends PolygonUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/polygon/:key') ->desc('Update polygon column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php index 84ee3e6863..87544926fe 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Create.php @@ -34,7 +34,7 @@ class Create extends RelationshipCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/relationship') ->desc('Create relationship column') ->groups(['api', 'database']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php index da5c8ca477..47884eda80 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Relationship/Update.php @@ -34,7 +34,7 @@ class Update extends RelationshipUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/:key/relationship') ->desc('Update relationship column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php index 122c8625f9..17f60f61c1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Create.php @@ -37,7 +37,7 @@ class Create extends StringCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/string') ->desc('Create string column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php index 0974a44d5d..2ec806d4fe 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/String/Update.php @@ -37,7 +37,7 @@ class Update extends StringUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/string/:key') ->desc('Update string column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php index 2c68431d8c..a8fde7d271 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Create.php @@ -33,7 +33,7 @@ class Create extends TextCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/text') ->desc('Create text column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php index 599c93988d..4c1477fb9e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Text/Update.php @@ -34,7 +34,7 @@ class Update extends TextUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/text/:key') ->desc('Update text column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php index 0b386c23f6..19b33594b7 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Create.php @@ -34,7 +34,7 @@ class Create extends URLCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/url') ->desc('Create URL column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php index df6117ea77..d680389d9e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/URL/Update.php @@ -35,7 +35,7 @@ class Update extends URLUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/url/:key') ->desc('Update URL column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php index 0ee04f5f63..7595f16c45 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Create.php @@ -35,7 +35,7 @@ class Create extends VarcharCreate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/varchar') ->desc('Create varchar column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create') ->label('audits.event', 'column.create') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php index 2b8eb9fbd7..dd170a0a19 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/Varchar/Update.php @@ -36,7 +36,7 @@ class Update extends VarcharUpdate ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/varchar/:key') ->desc('Update varchar column') ->groups(['api', 'database', 'schema']) - ->label('scope', ['tables.write', 'collections.write']) + ->label('scope', ['tables.write', 'collections.write', 'columns.write', 'attributes.write']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].update') ->label('audits.event', 'column.update') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php index b38edf6218..56c436a13e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Columns/XList.php @@ -33,7 +33,7 @@ class XList extends AttributesXList ->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns') ->desc('List columns') ->groups(['api', 'database']) - ->label('scope', ['tables.read', 'collections.read']) + ->label('scope', ['tables.read', 'collections.read', 'columns.read', 'attributes.read']) ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('sdk', new Method( namespace: $this->getSDKNamespace(), From e1b8f5bf98bf30319714b9374723e1e3901076a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 29 Apr 2026 14:04:54 +0200 Subject: [PATCH 368/402] review improvements --- app/config/scopes/project.php | 2 +- .../Modules/Project/Http/Project/Keys/Ephemeral/Create.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index c9c8786f38..934a08b9ac 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -167,7 +167,7 @@ return [ 'deprecated' => true, ], 'documents.write' => [ - 'description' => 'Access to create, update, and delete database collection\ documents', + 'description' => 'Access to create, update, and delete database collection documents', 'category' => 'Databases', 'deprecated' => true, ], diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Ephemeral/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Ephemeral/Create.php index 1d4b625343..7fdefca218 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Ephemeral/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Ephemeral/Create.php @@ -59,7 +59,7 @@ class Create extends Base ], )) ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false) - ->param('duration', null, new Range(1, 3600), 'Time in seconds before ephemeral key expires. Default duration is 900 seconds, and maximum is 3600 seconds.', optional: false) + ->param('duration', null, new Range(1, 3600), 'Time in seconds before ephemeral key expires. Maximum duration is 3600 seconds.', optional: false) ->inject('response') ->inject('queueForEvents') ->inject('project') From 32ebfc6cb8838743d718387d496a66071b3ec20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 29 Apr 2026 14:14:49 +0200 Subject: [PATCH 369/402] Fix backwards compatibility --- app/config/scopes/project.php | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 934a08b9ac..63b946f74f 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -311,5 +311,30 @@ return [ 'description' => 'Access to create, update, and delete migrations.', 'category' => 'Other', ], - // TODO: Figure out schedules.read, schedules.write. Remove, likely + + // TODO: Figure out where to move those + 'schedules.read' => [ + 'description' => 'Access to read schedules.', + 'category' => 'Other', + ], + 'schedules.write' => [ + 'description' => 'Access to create, update, and delete schedules.', + 'category' => 'Other', + ], + 'vcs.read' => [ + 'description' => 'Access to read resources under VCS service.', + 'category' => 'Other', + ], + 'vcs.write' => [ + 'description' => 'Access to create, update, and delete resources under VCS service.', + 'category' => 'Other', + ], + 'rules.read' => [ + 'description' => 'Access to read proxy rules.', + 'category' => 'Other', + ], + 'rules.write' => [ + 'description' => 'Access to create, update, and delete proxy rules.', + 'category' => 'Other', + ], ]; From 794d8eac5b57e0d496917138aed0da18543dbb8c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 29 Apr 2026 17:55:06 +0530 Subject: [PATCH 370/402] Fix project delete platform cleanup ordering --- src/Appwrite/Platform/Workers/Deletes.php | 102 +++++++++++----------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 8f5397f630..23ea6934ab 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -633,6 +633,57 @@ class Deletes extends Action $dsn = new DSN('mysql://' . $document->getAttribute('database', 'console')); } + // Delete Platforms + $this->deleteByGroup('platforms', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + + // Delete project and function rules + $this->deleteByGroup('rules', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) { + $this->deleteRule($dbForPlatform, $document, $certificates); + }); + + // Delete Keys + $this->deleteByGroup('keys', [ + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + + // Delete Webhooks + $this->deleteByGroup('webhooks', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + + // Delete VCS Installations + $this->deleteByGroup('installations', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + + // Delete VCS Repositories + $this->deleteByGroup('repositories', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + + // Delete VCS comments + $this->deleteByGroup('vcsComments', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + + // Delete Schedules + $this->deleteByGroup('schedules', [ + Query::equal('projectId', [$projectId]), + Query::orderAsc() + ], $dbForPlatform); + /** * @var Database $dbForProject */ @@ -694,57 +745,6 @@ class Deletes extends Action $databasesToClean )); - // Delete Platforms - $this->deleteByGroup('platforms', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); - - // Delete project and function rules - $this->deleteByGroup('rules', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) { - $this->deleteRule($dbForPlatform, $document, $certificates); - }); - - // Delete Keys - $this->deleteByGroup('keys', [ - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); - - // Delete Webhooks - $this->deleteByGroup('webhooks', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); - - // Delete VCS Installations - $this->deleteByGroup('installations', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); - - // Delete VCS Repositories - $this->deleteByGroup('repositories', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); - - // Delete VCS comments - $this->deleteByGroup('vcsComments', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); - - // Delete Schedules - $this->deleteByGroup('schedules', [ - Query::equal('projectId', [$projectId]), - Query::orderAsc() - ], $dbForPlatform); - // Delete metadata table if ($projectTables) { batch(array_map( From 36486ccc934e914b01c457ece547d1733444dbf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 29 Apr 2026 14:41:19 +0200 Subject: [PATCH 371/402] Fix tests --- .../Services/Console/ConsoleConsoleClientTest.php | 6 ++++-- .../Services/Console/ConsoleCustomServerTest.php | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php index e4566837e9..8235ebb7bc 100644 --- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php +++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php @@ -131,7 +131,7 @@ class ConsoleConsoleClientTest extends Scope public function testListKeyScopes(): void { - $response = $this->client->call(Client::METHOD_GET, '/console/scopes/key', array_merge([ + $response = $this->client->call(Client::METHOD_GET, '/console/scopes/project', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); @@ -158,6 +158,8 @@ class ConsoleConsoleClientTest extends Scope $this->assertArrayHasKey('description', $scope); $this->assertIsString($scope['description']); $this->assertNotEmpty($scope['description']); + $this->assertArrayHasKey('deprecated', $scope); + $this->assertIsBool($scope['deprecated']); } // A specific scope has the expected description @@ -169,6 +171,6 @@ class ConsoleConsoleClientTest extends Scope } } $this->assertNotNull($usersRead); - $this->assertEquals('Access to read your project\'s users', $usersRead['description']); + $this->assertEquals('Access to read users', $usersRead['description']); } } diff --git a/tests/e2e/Services/Console/ConsoleCustomServerTest.php b/tests/e2e/Services/Console/ConsoleCustomServerTest.php index 0c914fade7..f06011843f 100644 --- a/tests/e2e/Services/Console/ConsoleCustomServerTest.php +++ b/tests/e2e/Services/Console/ConsoleCustomServerTest.php @@ -48,7 +48,7 @@ class ConsoleCustomServerTest extends Scope { // Public endpoint: must succeed without admin authentication. Drop the // headers from getHeaders() and only pass project + content-type. - $response = $this->client->call(Client::METHOD_GET, '/console/scopes/key', [ + $response = $this->client->call(Client::METHOD_GET, '/console/scopes/project', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]); @@ -60,5 +60,18 @@ class ConsoleCustomServerTest extends Scope $scopeIds = \array_column($response['body']['scopes'], '$id'); $this->assertContains('users.read', $scopeIds); + + $usersRead = null; + foreach ($response['body']['scopes'] as $scope) { + if ($scope['$id'] === 'users.read') { + $usersRead = $scope; + break; + } + } + $this->assertNotNull($usersRead); + $this->assertIsString($usersRead['description']); + $this->assertNotEmpty($usersRead['description']); + $this->assertArrayHasKey('deprecated', $usersRead); + $this->assertIsBool($usersRead['deprecated']); } } From 7a9a2899ff32c785ffaa9583037128d28c696955 Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 29 Apr 2026 15:41:56 +0300 Subject: [PATCH 372/402] setGlobalCollections --- app/cli.php | 24 ++++++++++++++++++- app/init/resources.php | 10 +++++++- app/init/resources/request.php | 35 ++++++++++++++++++++++++++- app/init/worker/message.php | 44 +++++++++++++++++++++++++++++++++- app/realtime.php | 6 +++++ 5 files changed, 115 insertions(+), 4 deletions(-) diff --git a/app/cli.php b/app/cli.php index a6267fa341..dd7c172cab 100644 --- a/app/cli.php +++ b/app/cli.php @@ -157,12 +157,19 @@ $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform, } if (isset($databases[$dsn->getHost()])) { + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $projectCollections = $collections['projects'] ?? []; + $projectsGlobalCollections = array_keys($projectCollections); + $projectsGlobalCollections[] = 'audit'; + $database = $databases[$dsn->getHost()]; $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); if (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) + ->setGlobalCollections($projectsGlobalCollections) ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { @@ -182,9 +189,16 @@ $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform, $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); if (\in_array($dsn->getHost(), $sharedTables)) { + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $projectCollections = $collections['projects'] ?? []; + $projectsGlobalCollections = array_keys($projectCollections); + $projectsGlobalCollections[] = 'audit'; + $database ->setSharedTables(true) ->setTenant($project->getSequence()) + ->setGlobalCollections($projectsGlobalCollections) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -225,7 +239,15 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization // set tenant if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant($project->getSequence()); + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $logsCollections = $collections['logs'] ?? []; + $logsCollections = array_keys($logsCollections); + + $database + ->setTenant($project->getSequence()) + ->setGlobalCollections($logsCollections) + ; } return $database; diff --git a/app/init/resources.php b/app/init/resources.php index 29506bfc9c..12438c3fae 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -169,7 +169,15 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization // set tenant if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant($project->getSequence()); + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $logsCollections = $collections['logs'] ?? []; + $logsCollections = array_keys($logsCollections); + + $database + ->setTenant($project->getSequence()) + ->setGlobalCollections($logsCollections) + ; } return $database; diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 1aa53b7403..ec2b24566f 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -204,9 +204,16 @@ return function (Container $container): void { $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); if (\in_array($dsn->getHost(), $sharedTables)) { + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $projectCollections = $collections['projects'] ?? []; + $projectsGlobalCollections = array_keys($projectCollections); + $projectsGlobalCollections[] = 'audit'; + $database ->setSharedTables(true) ->setTenant($project->getSequence()) + ->setGlobalCollections($projectsGlobalCollections) ->setNamespace($dsn->getParam('namespace')); } else { $database @@ -235,7 +242,15 @@ return function (Container $container): void { ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant($project->getSequence()); + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $logsCollections = $collections['logs'] ?? []; + $logsCollections = array_keys($logsCollections); + + $database + ->setTenant($project->getSequence()) + ->setGlobalCollections($logsCollections) + ; } return $database; @@ -690,8 +705,15 @@ return function (Container $container): void { $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); if (\in_array($dsn->getHost(), $sharedTables)) { + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $projectCollections = $collections['projects'] ?? []; + $projectsGlobalCollections = array_keys($projectCollections); + $projectsGlobalCollections[] = 'audit'; + $database ->setSharedTables(true) + ->setGlobalCollections($projectsGlobalCollections) ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { @@ -1338,6 +1360,17 @@ return function (Container $container): void { $database->setTimeout($timeout); } + if ($database->getSharedTables() && $database->getTenant() !== null) { + //Do we need to set it for DOCUMENTSDB/VECTORSDB??? + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $projectCollections = $collections['projects'] ?? []; + $projectsGlobalCollections = array_keys($projectCollections); + $projectsGlobalCollections[] = 'audit'; + + $database->setGlobalCollections($projectsGlobalCollections); + } + // Register database event listeners for usage stats collection $documentsMetric = METRIC_DOCUMENTS; $databaseIdDocumentsMetric = METRIC_DATABASE_ID_DOCUMENTS; diff --git a/app/init/worker/message.php b/app/init/worker/message.php index dfe6af9bd9..a9c356bcec 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -14,6 +14,7 @@ use Appwrite\Utopia\Database\Documents\User; use Utopia\Audit\Adapter\Database as AdapterDatabase; use Utopia\Audit\Audit as UtopiaAudit; use Utopia\Cache\Cache; +use Utopia\Config\Config; use Utopia\Console; use Utopia\Database\Adapter\Pool as DatabasePool; use Utopia\Database\Database; @@ -90,8 +91,15 @@ return function (Container $container): void { $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); if (\in_array($dsn->getHost(), $sharedTables)) { + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $projectCollections = $collections['projects'] ?? []; + $projectsGlobalCollections = array_keys($projectCollections); + $projectsGlobalCollections[] = 'audit'; + $database ->setSharedTables(true) + ->setGlobalCollections($projectsGlobalCollections) ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { @@ -130,8 +138,15 @@ return function (Container $container): void { $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); if (\in_array($dsn->getHost(), $sharedTables)) { + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $projectCollections = $collections['projects'] ?? []; + $projectsGlobalCollections = array_keys($projectCollections); + $projectsGlobalCollections[] = 'audit'; + $database ->setSharedTables(true) + ->setGlobalCollections($projectsGlobalCollections) ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { @@ -152,8 +167,15 @@ return function (Container $container): void { $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); if (\in_array($dsn->getHost(), $sharedTables)) { + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $projectCollections = $collections['projects'] ?? []; + $projectsGlobalCollections = array_keys($projectCollections); + $projectsGlobalCollections[] = 'audit'; + $database ->setSharedTables(true) + ->setGlobalCollections($projectsGlobalCollections) ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { @@ -243,6 +265,18 @@ return function (Container $container): void { } $database->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER); + + if ($database->getSharedTables() && $database->getTenant() !== null){ + // Do we need to set for DOCUMENTSDB/VECTORSDB??? + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $projectCollections = $collections['projects'] ?? []; + $projectsGlobalCollections = array_keys($projectCollections); + $projectsGlobalCollections[] = 'audit'; + + $database->setGlobalCollections($projectsGlobalCollections); + } + return $database; }; }, ['cache', 'register', 'project', 'authorization']); @@ -269,7 +303,15 @@ return function (Container $container): void { ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER); if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { - $database->setTenant($project->getSequence()); + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $logsCollections = $collections['logs'] ?? []; + $logsCollections = array_keys($logsCollections); + + $database + ->setTenant($project->getSequence()) + ->setGlobalCollections($logsCollections) + ; } return $database; diff --git a/app/realtime.php b/app/realtime.php index 352903d942..826d751b14 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -130,8 +130,14 @@ if (!function_exists('getProjectDB')) { $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); if (\in_array($dsn->getHost(), $sharedTables)) { + $collections = Config::getParam('collections', []); + $projectCollections = $collections['projects'] ?? []; + $projectsGlobalCollections = array_keys($projectCollections); + $projectsGlobalCollections[] = 'audit'; + $database ->setSharedTables(true) + ->setGlobalCollections($projectsGlobalCollections) ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { From 8eed06678b857cfbacc41a07728f94034e5d277c Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 29 Apr 2026 15:54:05 +0300 Subject: [PATCH 373/402] formatting --- app/init/resources/request.php | 20 +++++++++----------- app/init/worker/message.php | 22 +++++++++++----------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/app/init/resources/request.php b/app/init/resources/request.php index ec2b24566f..f118fde7ee 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -1314,6 +1314,13 @@ return function (Container $container): void { $database = new Database($adapter, $cache); $sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''))); + //Do we need to set it for DOCUMENTSDB/VECTORSDB??? + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $projectCollections = $collections['projects'] ?? []; + $projectsGlobalCollections = array_keys($projectCollections); + $projectsGlobalCollections[] = 'audit'; + $database ->setDatabase(APP_DATABASE) ->setAuthorization($authorization) @@ -1336,6 +1343,7 @@ return function (Container $container): void { if (\in_array($databaseHost, $dbTypeSharedTables)) { $database ->setSharedTables(true) + ->setGlobalCollections($projectsGlobalCollections) ->setTenant($project->getSequence()) ->setNamespace($databaseDSN->getParam('namespace')); } else { @@ -1347,6 +1355,7 @@ return function (Container $container): void { } elseif (\in_array($dsn->getHost(), $sharedTables)) { $database ->setSharedTables(true) + ->setGlobalCollections($projectsGlobalCollections) ->setTenant($project->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { @@ -1360,17 +1369,6 @@ return function (Container $container): void { $database->setTimeout($timeout); } - if ($database->getSharedTables() && $database->getTenant() !== null) { - //Do we need to set it for DOCUMENTSDB/VECTORSDB??? - /** @var array $collections */ - $collections = Config::getParam('collections', []); - $projectCollections = $collections['projects'] ?? []; - $projectsGlobalCollections = array_keys($projectCollections); - $projectsGlobalCollections[] = 'audit'; - - $database->setGlobalCollections($projectsGlobalCollections); - } - // Register database event listeners for usage stats collection $documentsMetric = METRIC_DOCUMENTS; $databaseIdDocumentsMetric = METRIC_DATABASE_ID_DOCUMENTS; diff --git a/app/init/worker/message.php b/app/init/worker/message.php index a9c356bcec..555784437a 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -232,6 +232,15 @@ return function (Container $container): void { $sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''))); + // Do we need to set for DOCUMENTSDB/VECTORSDB???????? + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $projectCollections = $collections['projects'] ?? []; + $projectsGlobalCollections = array_keys($projectCollections); + $projectsGlobalCollections[] = 'audit'; + + $database->setGlobalCollections($projectsGlobalCollections); + // For separate pools (documentsdb/vectorsdb), check their own shared tables config. // If not configured, use dedicated mode to avoid cross-engine tenant type mismatches. if ($databaseHost !== $dsn->getHost()) { @@ -244,6 +253,7 @@ return function (Container $container): void { if (\in_array($databaseHost, $dbTypeSharedTables)) { $database ->setSharedTables(true) + ->setGlobalCollections($projectsGlobalCollections) ->setTenant($projectDocument->getSequence()) ->setNamespace($databaseDSN->getParam('namespace')); } else { @@ -255,6 +265,7 @@ return function (Container $container): void { } elseif (\in_array($dsn->getHost(), $sharedTables, true)) { $database ->setSharedTables(true) + ->setGlobalCollections($projectsGlobalCollections) ->setTenant($projectDocument->getSequence()) ->setNamespace($dsn->getParam('namespace')); } else { @@ -266,17 +277,6 @@ return function (Container $container): void { $database->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER); - if ($database->getSharedTables() && $database->getTenant() !== null){ - // Do we need to set for DOCUMENTSDB/VECTORSDB??? - /** @var array $collections */ - $collections = Config::getParam('collections', []); - $projectCollections = $collections['projects'] ?? []; - $projectsGlobalCollections = array_keys($projectCollections); - $projectsGlobalCollections[] = 'audit'; - - $database->setGlobalCollections($projectsGlobalCollections); - } - return $database; }; }, ['cache', 'register', 'project', 'authorization']); From 18b976967258ffad4c971ad2e2010006d9bba17d Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 29 Apr 2026 16:14:21 +0300 Subject: [PATCH 374/402] lock file --- composer.lock | 60 +++++++++++++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/composer.lock b/composer.lock index 2cf57b95a3..c19bd1dfec 100644 --- a/composer.lock +++ b/composer.lock @@ -3351,16 +3351,16 @@ }, { "name": "utopia-php/abuse", - "version": "1.2.2", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/utopia-php/abuse.git", - "reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152" + "reference": "53f4274939353522ba331f55bcff6e6011ffc56c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/abuse/zipball/20bee84fd14dbe81d50ecabf1ffd81cceca06152", - "reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152", + "url": "https://api.github.com/repos/utopia-php/abuse/zipball/53f4274939353522ba331f55bcff6e6011ffc56c", + "reference": "53f4274939353522ba331f55bcff6e6011ffc56c", "shasum": "" }, "require": { @@ -3397,9 +3397,9 @@ ], "support": { "issues": "https://github.com/utopia-php/abuse/issues", - "source": "https://github.com/utopia-php/abuse/tree/1.2.2" + "source": "https://github.com/utopia-php/abuse/tree/1.2.3" }, - "time": "2026-02-02T10:43:10+00:00" + "time": "2026-04-29T11:19:08+00:00" }, { "name": "utopia-php/agents", @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.22", + "version": "5.4.1", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7" + "reference": "688d9422b5ff42ac2ecc29397d94891cfd772e93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/d765945da6b3141852014b2f96ecf1fe7e3d6ba7", - "reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7", + "url": "https://api.github.com/repos/utopia-php/database/zipball/688d9422b5ff42ac2ecc29397d94891cfd772e93", + "reference": "688d9422b5ff42ac2ecc29397d94891cfd772e93", "shasum": "" }, "require": { @@ -3903,9 +3903,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.22" + "source": "https://github.com/utopia-php/database/tree/5.4.1" }, - "time": "2026-04-20T07:12:46+00:00" + "time": "2026-04-29T07:32:59+00:00" }, { "name": "utopia-php/detector", @@ -4062,16 +4062,16 @@ }, { "name": "utopia-php/domains", - "version": "1.0.5", + "version": "1.0.6", "source": { "type": "git", "url": "https://github.com/utopia-php/domains.git", - "reference": "0edf6bb2b07f30db849a267027077bf5abb994c6" + "reference": "c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/domains/zipball/0edf6bb2b07f30db849a267027077bf5abb994c6", - "reference": "0edf6bb2b07f30db849a267027077bf5abb994c6", + "url": "https://api.github.com/repos/utopia-php/domains/zipball/c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6", + "reference": "c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6", "shasum": "" }, "require": { @@ -4118,9 +4118,9 @@ ], "support": { "issues": "https://github.com/utopia-php/domains/issues", - "source": "https://github.com/utopia-php/domains/tree/1.0.5" + "source": "https://github.com/utopia-php/domains/tree/1.0.6" }, - "time": "2026-03-03T09:20:50+00:00" + "time": "2026-04-29T11:08:10+00:00" }, { "name": "utopia-php/dsn", @@ -5466,16 +5466,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.24.0", + "version": "1.25.1", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "6d4d26659bc7a1c347c1d4d8dae3b77b5562e0cb" + "reference": "f21a556b9acdbf75bbdcdc90a078af641646eade" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/6d4d26659bc7a1c347c1d4d8dae3b77b5562e0cb", - "reference": "6d4d26659bc7a1c347c1d4d8dae3b77b5562e0cb", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/f21a556b9acdbf75bbdcdc90a078af641646eade", + "reference": "f21a556b9acdbf75bbdcdc90a078af641646eade", "shasum": "" }, "require": { @@ -5511,9 +5511,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.24.0" + "source": "https://github.com/appwrite/sdk-generator/tree/1.25.1" }, - "time": "2026-04-24T12:50:05+00:00" + "time": "2026-04-28T11:12:22+00:00" }, { "name": "brianium/paratest", @@ -6222,11 +6222,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.51", + "version": "2.1.53", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc3b523c45e714c70de2ac5113b958223b55dc59", - "reference": "dc3b523c45e714c70de2ac5113b958223b55dc59", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ef67586798c003274797b288a68b221e4270dca7", + "reference": "ef67586798c003274797b288a68b221e4270dca7", "shasum": "" }, "require": { @@ -6271,7 +6271,7 @@ "type": "github" } ], - "time": "2026-04-21T18:22:01+00:00" + "time": "2026-04-28T16:09:00+00:00" }, { "name": "phpunit/php-code-coverage", @@ -8445,7 +8445,7 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -8466,5 +8466,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } From 9d3255f5cd0ceb8e654e7abec95e7c7bb9aed563 Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 29 Apr 2026 17:08:14 +0300 Subject: [PATCH 375/402] Update lock --- composer.lock | 72 +++++++++++++++++++++++++-------------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/composer.lock b/composer.lock index 50b6355c46..3edbc39614 100644 --- a/composer.lock +++ b/composer.lock @@ -3351,16 +3351,16 @@ }, { "name": "utopia-php/abuse", - "version": "1.2.2", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/utopia-php/abuse.git", - "reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152" + "reference": "53f4274939353522ba331f55bcff6e6011ffc56c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/abuse/zipball/20bee84fd14dbe81d50ecabf1ffd81cceca06152", - "reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152", + "url": "https://api.github.com/repos/utopia-php/abuse/zipball/53f4274939353522ba331f55bcff6e6011ffc56c", + "reference": "53f4274939353522ba331f55bcff6e6011ffc56c", "shasum": "" }, "require": { @@ -3397,9 +3397,9 @@ ], "support": { "issues": "https://github.com/utopia-php/abuse/issues", - "source": "https://github.com/utopia-php/abuse/tree/1.2.2" + "source": "https://github.com/utopia-php/abuse/tree/1.2.3" }, - "time": "2026-02-02T10:43:10+00:00" + "time": "2026-04-29T11:19:08+00:00" }, { "name": "utopia-php/agents", @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.22", + "version": "5.4.1", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7" + "reference": "688d9422b5ff42ac2ecc29397d94891cfd772e93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/d765945da6b3141852014b2f96ecf1fe7e3d6ba7", - "reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7", + "url": "https://api.github.com/repos/utopia-php/database/zipball/688d9422b5ff42ac2ecc29397d94891cfd772e93", + "reference": "688d9422b5ff42ac2ecc29397d94891cfd772e93", "shasum": "" }, "require": { @@ -3903,9 +3903,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.22" + "source": "https://github.com/utopia-php/database/tree/5.4.1" }, - "time": "2026-04-20T07:12:46+00:00" + "time": "2026-04-29T07:32:59+00:00" }, { "name": "utopia-php/detector", @@ -4062,16 +4062,16 @@ }, { "name": "utopia-php/domains", - "version": "1.0.5", + "version": "1.0.6", "source": { "type": "git", "url": "https://github.com/utopia-php/domains.git", - "reference": "0edf6bb2b07f30db849a267027077bf5abb994c6" + "reference": "c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/domains/zipball/0edf6bb2b07f30db849a267027077bf5abb994c6", - "reference": "0edf6bb2b07f30db849a267027077bf5abb994c6", + "url": "https://api.github.com/repos/utopia-php/domains/zipball/c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6", + "reference": "c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6", "shasum": "" }, "require": { @@ -4118,9 +4118,9 @@ ], "support": { "issues": "https://github.com/utopia-php/domains/issues", - "source": "https://github.com/utopia-php/domains/tree/1.0.5" + "source": "https://github.com/utopia-php/domains/tree/1.0.6" }, - "time": "2026-03-03T09:20:50+00:00" + "time": "2026-04-29T11:08:10+00:00" }, { "name": "utopia-php/dsn", @@ -4530,16 +4530,16 @@ }, { "name": "utopia-php/migration", - "version": "1.9.4", + "version": "1.9.5", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "969dc9477ea962f16da9254facdbd8944cf13477" + "reference": "952a4dfe232702f80e45c35129466a8d8cb4c599" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/969dc9477ea962f16da9254facdbd8944cf13477", - "reference": "969dc9477ea962f16da9254facdbd8944cf13477", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/952a4dfe232702f80e45c35129466a8d8cb4c599", + "reference": "952a4dfe232702f80e45c35129466a8d8cb4c599", "shasum": "" }, "require": { @@ -4579,9 +4579,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.9.4" + "source": "https://github.com/utopia-php/migration/tree/1.9.5" }, - "time": "2026-04-27T12:42:51+00:00" + "time": "2026-04-29T11:19:13+00:00" }, { "name": "utopia-php/mongo", @@ -5020,16 +5020,16 @@ }, { "name": "utopia-php/storage", - "version": "2.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/utopia-php/storage.git", - "reference": "52d1f89a47165ef0d3deff63043cda182175adfb" + "reference": "8a2e3a86fd01aaed675884146665308c2122264e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/storage/zipball/52d1f89a47165ef0d3deff63043cda182175adfb", - "reference": "52d1f89a47165ef0d3deff63043cda182175adfb", + "url": "https://api.github.com/repos/utopia-php/storage/zipball/8a2e3a86fd01aaed675884146665308c2122264e", + "reference": "8a2e3a86fd01aaed675884146665308c2122264e", "shasum": "" }, "require": { @@ -5066,9 +5066,9 @@ ], "support": { "issues": "https://github.com/utopia-php/storage/issues", - "source": "https://github.com/utopia-php/storage/tree/2.0.0" + "source": "https://github.com/utopia-php/storage/tree/2.0.1" }, - "time": "2026-04-27T11:39:32+00:00" + "time": "2026-04-29T09:05:48+00:00" }, { "name": "utopia-php/system", @@ -6221,11 +6221,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.52", + "version": "2.1.54", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/08a34f8db7ca4daabff74a474fe13c0e56e2b4e5", - "reference": "08a34f8db7ca4daabff74a474fe13c0e56e2b4e5", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", "shasum": "" }, "require": { @@ -6270,7 +6270,7 @@ "type": "github" } ], - "time": "2026-04-28T12:17:53+00:00" + "time": "2026-04-29T13:31:09+00:00" }, { "name": "phpunit/php-code-coverage", @@ -8444,7 +8444,7 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -8465,5 +8465,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } From d099167d18dab7cad8fc646fcb154d709df94d3a Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 29 Apr 2026 17:09:25 +0300 Subject: [PATCH 376/402] Shared env --- .env | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.env b/.env index 3dc7afe34a..4a6a3ac344 100644 --- a/.env +++ b/.env @@ -47,6 +47,8 @@ _APP_DB_SCHEMA=appwrite _APP_DB_USER=user _APP_DB_PASS=password _APP_DB_ROOT_PASS=rootsecretpassword +_APP_DATABASE_SHARED_TABLES= +_APP_DATABASE_SHARED_NAMESPACE= _APP_DB_ADAPTER_DOCUMENTSDB=mongodb _APP_DB_HOST_DOCUMENTSDB=mongodb _APP_DB_PORT_DOCUMENTSDB=27017 From 4050b9ded1e66b937282b80a268d6407be4f442b Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 30 Apr 2026 09:28:22 +0530 Subject: [PATCH 377/402] Continue project cleanup after resource failures --- src/Appwrite/Platform/Workers/Deletes.php | 185 +++++++++++++++------- 1 file changed, 128 insertions(+), 57 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 23ea6934ab..a5fe352b07 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -634,55 +634,87 @@ class Deletes extends Action } // Delete Platforms - $this->deleteByGroup('platforms', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); + try { + $this->deleteByGroup('platforms', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + } catch (Throwable $th) { + Console::error('Failed to delete platforms: ' . $th->getMessage()); + } // Delete project and function rules - $this->deleteByGroup('rules', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) { - $this->deleteRule($dbForPlatform, $document, $certificates); - }); + try { + $this->deleteByGroup('rules', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) { + $this->deleteRule($dbForPlatform, $document, $certificates); + }); + } catch (Throwable $th) { + Console::error('Failed to delete rules: ' . $th->getMessage()); + } // Delete Keys - $this->deleteByGroup('keys', [ - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); + try { + $this->deleteByGroup('keys', [ + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + } catch (Throwable $th) { + Console::error('Failed to delete keys: ' . $th->getMessage()); + } // Delete Webhooks - $this->deleteByGroup('webhooks', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); + try { + $this->deleteByGroup('webhooks', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + } catch (Throwable $th) { + Console::error('Failed to delete webhooks: ' . $th->getMessage()); + } // Delete VCS Installations - $this->deleteByGroup('installations', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); + try { + $this->deleteByGroup('installations', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + } catch (Throwable $th) { + Console::error('Failed to delete installations: ' . $th->getMessage()); + } // Delete VCS Repositories - $this->deleteByGroup('repositories', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); + try { + $this->deleteByGroup('repositories', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + } catch (Throwable $th) { + Console::error('Failed to delete repositories: ' . $th->getMessage()); + } // Delete VCS comments - $this->deleteByGroup('vcsComments', [ - Query::equal('projectInternalId', [$projectInternalId]), - Query::orderAsc() - ], $dbForPlatform); + try { + $this->deleteByGroup('vcsComments', [ + Query::equal('projectInternalId', [$projectInternalId]), + Query::orderAsc() + ], $dbForPlatform); + } catch (Throwable $th) { + Console::error('Failed to delete VCS comments: ' . $th->getMessage()); + } // Delete Schedules - $this->deleteByGroup('schedules', [ - Query::equal('projectId', [$projectId]), - Query::orderAsc() - ], $dbForPlatform); + try { + $this->deleteByGroup('schedules', [ + Query::equal('projectId', [$projectId]), + Query::orderAsc() + ], $dbForPlatform); + } catch (Throwable $th) { + Console::error('Failed to delete schedules: ' . $th->getMessage()); + } /** * @var Database $dbForProject @@ -736,24 +768,35 @@ class Deletes extends Action }; batch(array_map( - fn ($databaseDoc) => fn () => $this->cleanDatabase( - $databaseDoc, - $executionActionPerDatabase, - $projectTables, - $projectCollectionIds - ), + fn ($databaseDoc) => function () use ($databaseDoc, $executionActionPerDatabase, $projectTables, $projectCollectionIds) { + try { + $this->cleanDatabase( + $databaseDoc, + $executionActionPerDatabase, + $projectTables, + $projectCollectionIds + ); + } catch (Throwable $th) { + Console::error('Failed to delete database ' . $databaseDoc->getAttribute('database') . ': ' . $th->getMessage()); + } + }, $databasesToClean )); // Delete metadata table if ($projectTables) { batch(array_map( - fn ($databaseDoc) => fn () => - $executionActionPerDatabase( - $databaseDoc, - fn (Database $dbForDatabases) => - $dbForDatabases->deleteCollection(Database::METADATA) - ), + fn ($databaseDoc) => function () use ($databaseDoc, $executionActionPerDatabase) { + try { + $executionActionPerDatabase( + $databaseDoc, + fn (Database $dbForDatabases) => + $dbForDatabases->deleteCollection(Database::METADATA) + ); + } catch (Throwable $th) { + Console::error('Failed to delete metadata table for database ' . $databaseDoc->getAttribute('database') . ': ' . $th->getMessage()); + } + }, $databasesToClean )); } else { @@ -764,19 +807,47 @@ class Deletes extends Action $queries[] = Query::orderAsc(); - $this->deleteByGroup( - Database::METADATA, - $queries, - $dbForProject - ); + try { + $this->deleteByGroup( + Database::METADATA, + $queries, + $dbForProject + ); + } catch (Throwable $th) { + Console::error('Failed to delete metadata documents: ' . $th->getMessage()); + } } // Delete all storage directories - $deviceForFiles->delete($deviceForFiles->getRoot(), true); - $deviceForSites->delete($deviceForSites->getRoot(), true); - $deviceForFunctions->delete($deviceForFunctions->getRoot(), true); - $deviceForBuilds->delete($deviceForBuilds->getRoot(), true); - $deviceForCache->delete($deviceForCache->getRoot(), true); + try { + $deviceForFiles->delete($deviceForFiles->getRoot(), true); + } catch (Throwable $th) { + Console::error('Failed to delete files storage directory: ' . $th->getMessage()); + } + + try { + $deviceForSites->delete($deviceForSites->getRoot(), true); + } catch (Throwable $th) { + Console::error('Failed to delete sites storage directory: ' . $th->getMessage()); + } + + try { + $deviceForFunctions->delete($deviceForFunctions->getRoot(), true); + } catch (Throwable $th) { + Console::error('Failed to delete functions storage directory: ' . $th->getMessage()); + } + + try { + $deviceForBuilds->delete($deviceForBuilds->getRoot(), true); + } catch (Throwable $th) { + Console::error('Failed to delete builds storage directory: ' . $th->getMessage()); + } + + try { + $deviceForCache->delete($deviceForCache->getRoot(), true); + } catch (Throwable $th) { + Console::error('Failed to delete cache storage directory: ' . $th->getMessage()); + } } finally { $dbForProject->enableValidation(); From 337d47b1d9f5609aa6721f6903c57c4fec9fd4f6 Mon Sep 17 00:00:00 2001 From: frubio Date: Thu, 30 Apr 2026 09:33:08 +0200 Subject: [PATCH 378/402] fix(locale): add Spanish session alert translations --- app/config/locale/translations/es.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/config/locale/translations/es.json b/app/config/locale/translations/es.json index 21a406b418..1bbc8062be 100644 --- a/app/config/locale/translations/es.json +++ b/app/config/locale/translations/es.json @@ -28,6 +28,16 @@ "emails.invitation.thanks": "Gracias.,", "emails.invitation.buttonText": "Aceptar invitación a {{team}}", "emails.invitation.signature": "El equipo de {{project}}", + "emails.sessionAlert.subject": "Alerta de seguridad: nueva sesión en tu cuenta de {{project}}", + "emails.sessionAlert.preview": "Nuevo inicio de sesión detectado en {{project}} a las {{time}} UTC.", + "emails.sessionAlert.hello": "Hola {{user}},", + "emails.sessionAlert.body": "Se ha creado una nueva sesión en tu cuenta de {{b}}{{project}}{{/b}}, {{b}}el {{date}} de {{year}} a las {{time}} UTC{{/b}}.\nEstos son los detalles de la nueva sesión:", + "emails.sessionAlert.listDevice": "Dispositivo: {{b}}{{device}}{{/b}}", + "emails.sessionAlert.listIpAddress": "Dirección IP: {{b}}{{ipAddress}}{{/b}}", + "emails.sessionAlert.listCountry": "País: {{b}}{{country}}{{/b}}", + "emails.sessionAlert.footer": "Si has sido tú, no tienes que hacer nada más.\nSi no has iniciado esta sesión o sospechas actividad no autorizada, protege tu cuenta.", + "emails.sessionAlert.thanks": "Gracias,", + "emails.sessionAlert.signature": "El equipo de {{project}}", "locale.country.unknown": "Desconocido", "countries.af": "Afganistán", "countries.ao": "Angola", From c0bba74eee628b3811a29f8e984ce377b2d3d481 Mon Sep 17 00:00:00 2001 From: fogelito Date: Thu, 30 Apr 2026 10:36:12 +0300 Subject: [PATCH 379/402] set setGlobalCollections logs --- app/cli.php | 16 +++++++--------- app/init/resources.php | 16 +++++++--------- app/init/resources/request.php | 17 +++++++---------- app/init/worker/message.php | 17 +++++++---------- 4 files changed, 28 insertions(+), 38 deletions(-) diff --git a/app/cli.php b/app/cli.php index dd7c172cab..ada155c4dc 100644 --- a/app/cli.php +++ b/app/cli.php @@ -226,6 +226,11 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization return $database; } + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $logsCollections = $collections['logs'] ?? []; + $logsCollections = array_keys($logsCollections); + $adapter = new DatabasePool($pools->get('logs')); $database = new Database($adapter, $cache); @@ -234,20 +239,13 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization ->setAuthorization($authorization) ->setSharedTables(true) ->setNamespace('logsV1') + ->setGlobalCollections($logsCollections) ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_TASK) ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); // set tenant if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { - /** @var array $collections */ - $collections = Config::getParam('collections', []); - $logsCollections = $collections['logs'] ?? []; - $logsCollections = array_keys($logsCollections); - - $database - ->setTenant($project->getSequence()) - ->setGlobalCollections($logsCollections) - ; + $database->setTenant($project->getSequence()); } return $database; diff --git a/app/init/resources.php b/app/init/resources.php index 12438c3fae..96457294de 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -159,25 +159,23 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $adapter = new DatabasePool($pools->get('logs')); $database = new Database($adapter, $cache); + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $logsCollections = $collections['logs'] ?? []; + $logsCollections = array_keys($logsCollections); + $database ->setDatabase(APP_DATABASE) ->setAuthorization($authorization) ->setSharedTables(true) + ->setGlobalCollections($logsCollections) ->setNamespace('logsV1') ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API) ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); // set tenant if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { - /** @var array $collections */ - $collections = Config::getParam('collections', []); - $logsCollections = $collections['logs'] ?? []; - $logsCollections = array_keys($logsCollections); - - $database - ->setTenant($project->getSequence()) - ->setGlobalCollections($logsCollections) - ; + $database->setTenant($project->getSequence()); } return $database; diff --git a/app/init/resources/request.php b/app/init/resources/request.php index f118fde7ee..70d691370d 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -230,6 +230,11 @@ return function (Container $container): void { $adapter = null; return function (?Document $project = null) use ($pools, $cache, $authorization, &$adapter) { + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $logsCollections = $collections['logs'] ?? []; + $logsCollections = array_keys($logsCollections); + $adapter ??= new DatabasePool($pools->get('logs')); $database = new Database($adapter, $cache); @@ -237,20 +242,13 @@ return function (Container $container): void { ->setDatabase(APP_DATABASE) ->setAuthorization($authorization) ->setSharedTables(true) + ->setGlobalCollections($logsCollections) ->setNamespace('logsV1') ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API) ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { - /** @var array $collections */ - $collections = Config::getParam('collections', []); - $logsCollections = $collections['logs'] ?? []; - $logsCollections = array_keys($logsCollections); - - $database - ->setTenant($project->getSequence()) - ->setGlobalCollections($logsCollections) - ; + $database->setTenant($project->getSequence()); } return $database; @@ -1314,7 +1312,6 @@ return function (Container $container): void { $database = new Database($adapter, $cache); $sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''))); - //Do we need to set it for DOCUMENTSDB/VECTORSDB??? /** @var array $collections */ $collections = Config::getParam('collections', []); $projectCollections = $collections['projects'] ?? []; diff --git a/app/init/worker/message.php b/app/init/worker/message.php index 555784437a..08900c9bee 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -232,7 +232,6 @@ return function (Container $container): void { $sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''))); - // Do we need to set for DOCUMENTSDB/VECTORSDB???????? /** @var array $collections */ $collections = Config::getParam('collections', []); $projectCollections = $collections['projects'] ?? []; @@ -291,6 +290,11 @@ return function (Container $container): void { return $database; } + /** @var array $collections */ + $collections = Config::getParam('collections', []); + $logsCollections = $collections['logs'] ?? []; + $logsCollections = array_keys($logsCollections); + $adapter = new DatabasePool($pools->get('logs')); $database = new Database($adapter, $cache); @@ -298,20 +302,13 @@ return function (Container $container): void { ->setDatabase(APP_DATABASE) ->setAuthorization($authorization) ->setSharedTables(true) + ->setGlobalCollections($logsCollections) ->setNamespace('logsV1') ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER) ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER); if ($project !== null && !$project->isEmpty() && $project->getId() !== 'console') { - /** @var array $collections */ - $collections = Config::getParam('collections', []); - $logsCollections = $collections['logs'] ?? []; - $logsCollections = array_keys($logsCollections); - - $database - ->setTenant($project->getSequence()) - ->setGlobalCollections($logsCollections) - ; + $database->setTenant($project->getSequence()); } return $database; From d98bd8c9723e969a686f372d638ab4e3adfa8964 Mon Sep 17 00:00:00 2001 From: fogelito Date: Thu, 30 Apr 2026 10:44:21 +0300 Subject: [PATCH 380/402] Remove line --- app/init/worker/message.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/init/worker/message.php b/app/init/worker/message.php index 08900c9bee..17796fadcd 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -275,7 +275,6 @@ return function (Container $container): void { } $database->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER); - return $database; }; }, ['cache', 'register', 'project', 'authorization']); From b73ba68bfb13c6fdae4714f3363796ab41046469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 30 Apr 2026 10:21:38 +0200 Subject: [PATCH 381/402] Fix oauth order; Fix apple secreting too much --- .../Console/Http/OAuth2Providers/XList.php | 6 ++-- .../Http/Project/OAuth2/Apple/Update.php | 5 ++-- .../Console/ConsoleConsoleClientTest.php | 2 ++ tests/e2e/Services/Project/OAuth2Base.php | 30 ++++++++----------- 4 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php index 79a36643a1..e253292ca9 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php +++ b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php @@ -54,9 +54,9 @@ class XList extends Action $actions = OAuth2Base::getProviderActions(); $providers = []; - foreach ($actions as $providerId => $updateClass) { - $config = $providersConfig[$providerId] ?? null; - if ($config === null) { + foreach ($providersConfig as $providerId => $config) { + $updateClass = $actions[$providerId] ?? null; + if ($updateClass === null) { continue; } if (!($config['enabled'] ?? false)) { diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php index 08fc7dbf6b..dc9eede2b4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Apple/Update.php @@ -146,13 +146,14 @@ class Update extends Base { $providerId = static::getProviderId(); $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $storedSecret = $this->decodeStoredSecret($project); return new Document([ '$id' => $providerId, 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', - 'keyId' => '', - 'teamId' => '', + 'keyId' => $storedSecret['keyID'] ?? '', + 'teamId' => $storedSecret['teamID'] ?? '', 'p8File' => '', ]); } diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php index 8235ebb7bc..c111b744c3 100644 --- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php +++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php @@ -56,6 +56,8 @@ class ConsoleConsoleClientTest extends Scope $this->assertEquals($response['body']['total'], \count($response['body']['oAuth2Providers'])); $providerIds = \array_column($response['body']['oAuth2Providers'], '$id'); + $this->assertEquals('amazon', $providerIds[0]); + $this->assertEquals('zoom', $providerIds[\count($providerIds) - 1]); // Well-known providers must be present $this->assertContains('github', $providerIds); diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php index 8345bfab0a..9ff3830ec5 100644 --- a/tests/e2e/Services/Project/OAuth2Base.php +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -478,9 +478,8 @@ trait OAuth2Base $this->assertSame(200, $response['headers']['status-code']); $this->assertSame('apple', $response['body']['$id']); $this->assertSame('ip.appwrite.app.web', $response['body']['serviceId']); - // keyId / teamId / p8File are write-only — PATCH response must not echo them back. - $this->assertSame('', $response['body']['keyId']); - $this->assertSame('', $response['body']['teamId']); + $this->assertSame('P4000000N8', $response['body']['keyId']); + $this->assertSame('D4000000R6', $response['body']['teamId']); $this->assertSame('', $response['body']['p8File']); $this->assertSame(false, $response['body']['enabled']); @@ -511,12 +510,10 @@ trait OAuth2Base ]); $this->assertSame(200, $response['headers']['status-code']); - // serviceId is the (non-secret) clientId; keyId/teamId are write-only - // and must not surface in the response. Persistence of the merged - // values is verified separately via the enable-after-merge tests. $this->assertSame('ip.appwrite.app.seed', $response['body']['serviceId']); - $this->assertSame('', $response['body']['keyId']); - $this->assertSame('', $response['body']['teamId']); + $this->assertSame('KEYUPDATED', $response['body']['keyId']); + $this->assertSame('TEAMSEED01', $response['body']['teamId']); + $this->assertSame('', $response['body']['p8File']); // Cleanup $this->updateOAuth2('apple', [ @@ -546,9 +543,9 @@ trait OAuth2Base 'teamId' => 'TEAMROTATED', ]); $this->assertSame(200, $teamOnly['headers']['status-code']); - // teamId is write-only; verify only the non-secret serviceId echo. - // The actual merge is validated by the enable-after-merge call below. - $this->assertSame('', $teamOnly['body']['teamId']); + $this->assertSame('TEAMROTATED', $teamOnly['body']['teamId']); + $this->assertSame('KEYMERGE01', $teamOnly['body']['keyId']); + $this->assertSame('', $teamOnly['body']['p8File']); $this->assertSame('ip.appwrite.app.merge', $teamOnly['body']['serviceId']); // Patch only `serviceId` — keyId/teamId/p8File live in the JSON blob @@ -669,9 +666,8 @@ trait OAuth2Base $this->assertSame(200, $response['headers']['status-code']); $this->assertSame('ip.appwrite.app.read', $response['body']['serviceId']); - // All three secret-bearing fields must be hidden on read. - $this->assertSame('', $response['body']['keyId']); - $this->assertSame('', $response['body']['teamId']); + $this->assertSame('KEYREAD', $response['body']['keyId']); + $this->assertSame('TEAMREAD', $response['body']['teamId']); $this->assertSame('', $response['body']['p8File']); // Cleanup @@ -699,13 +695,13 @@ trait OAuth2Base $this->assertSame(200, $update['headers']['status-code']); $this->assertTrue($update['body']['enabled']); - // GET must hide all three secret-bearing fields while keeping serviceId. + // GET must hide p8File while keeping the non-secret fields. $get = $this->getOAuth2Provider('apple'); $this->assertSame(200, $get['headers']['status-code']); $this->assertTrue($get['body']['enabled']); $this->assertSame('ip.appwrite.app.enable', $get['body']['serviceId']); - $this->assertSame('', $get['body']['keyId']); - $this->assertSame('', $get['body']['teamId']); + $this->assertSame('ENABLEKEY', $get['body']['keyId']); + $this->assertSame('ENABLETEAM', $get['body']['teamId']); $this->assertSame('', $get['body']['p8File']); // Cleanup From 62b7d5558faa9512f4ea24365c6a6290fbf7773c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 30 Apr 2026 12:36:45 +0200 Subject: [PATCH 382/402] Mark params nonrequired --- src/Appwrite/Auth/OAuth2/Authentik.php | 7 +++++++ src/Appwrite/Auth/OAuth2/FusionAuth.php | 7 +++++++ src/Appwrite/Auth/OAuth2/Keycloak.php | 11 +++++++++++ src/Appwrite/Auth/OAuth2/Microsoft.php | 11 +++++++++-- .../Project/Http/Project/OAuth2/Authentik/Update.php | 6 +++--- .../Project/Http/Project/OAuth2/FusionAuth/Update.php | 6 +++--- .../Project/Http/Project/OAuth2/GitHub/Update.php | 2 +- .../Project/Http/Project/OAuth2/Keycloak/Update.php | 10 +++++----- .../Project/Http/Project/OAuth2/Microsoft/Update.php | 6 +++--- .../e2e/Services/Console/ConsoleConsoleClientTest.php | 2 +- 10 files changed, 50 insertions(+), 18 deletions(-) diff --git a/src/Appwrite/Auth/OAuth2/Authentik.php b/src/Appwrite/Auth/OAuth2/Authentik.php index 5d2445088b..aa4b126ae8 100644 --- a/src/Appwrite/Auth/OAuth2/Authentik.php +++ b/src/Appwrite/Auth/OAuth2/Authentik.php @@ -37,6 +37,13 @@ class Authentik extends OAuth2 return 'authentik'; } + public function verifyCredentials(): void + { + if (empty($this->getAuthentikDomain())) { + throw new \Exception('Authentik endpoint is required.'); + } + } + /** * @return string */ diff --git a/src/Appwrite/Auth/OAuth2/FusionAuth.php b/src/Appwrite/Auth/OAuth2/FusionAuth.php index 415be4c6ad..fa8b45dc72 100644 --- a/src/Appwrite/Auth/OAuth2/FusionAuth.php +++ b/src/Appwrite/Auth/OAuth2/FusionAuth.php @@ -37,6 +37,13 @@ class FusionAuth extends OAuth2 return 'fusionauth'; } + public function verifyCredentials(): void + { + if (empty($this->getFusionAuthDomain())) { + throw new \Exception('FusionAuth endpoint is required.'); + } + } + /** * @return string */ diff --git a/src/Appwrite/Auth/OAuth2/Keycloak.php b/src/Appwrite/Auth/OAuth2/Keycloak.php index 05e007eb7d..b53b08e2d9 100644 --- a/src/Appwrite/Auth/OAuth2/Keycloak.php +++ b/src/Appwrite/Auth/OAuth2/Keycloak.php @@ -37,6 +37,17 @@ class Keycloak extends OAuth2 return 'keycloak'; } + public function verifyCredentials(): void + { + if (empty($this->getKeycloakDomain())) { + throw new \Exception('Keycloak endpoint is required.'); + } + + if (empty($this->getKeycloakRealm())) { + throw new \Exception('Keycloak realm name is required.'); + } + } + /** * @return string */ diff --git a/src/Appwrite/Auth/OAuth2/Microsoft.php b/src/Appwrite/Auth/OAuth2/Microsoft.php index bc05843b37..19966ec1ac 100644 --- a/src/Appwrite/Auth/OAuth2/Microsoft.php +++ b/src/Appwrite/Auth/OAuth2/Microsoft.php @@ -36,6 +36,13 @@ class Microsoft extends OAuth2 return 'microsoft'; } + public function verifyCredentials(): void + { + if (empty($this->getTenantID())) { + throw new \Exception('Microsoft tenant is required.'); + } + } + /** * @return string */ @@ -201,7 +208,7 @@ class Microsoft extends OAuth2 } /** - * Extracts the Tenant Id from the JSON stored in appSecret. Defaults to 'common' as a fallback + * Extracts the Tenant Id from the JSON stored in appSecret. * * @return string */ @@ -209,6 +216,6 @@ class Microsoft extends OAuth2 { $secret = $this->getAppSecret(); - return $secret['tenantID'] ?? 'common'; + return $secret['tenantID'] ?? ''; } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php index d5d465c3d4..683f8cca92 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php @@ -105,7 +105,7 @@ class Update extends Base )) ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) - ->param('endpoint', '', new Text(256, 1), 'Domain of Authentik instance. For example: example.authentik.com', optional: false) + ->param('endpoint', '', new Text(256, 1), 'Domain of Authentik instance. For example: example.authentik.com', optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) ->inject('response') ->inject('dbForPlatform') @@ -151,7 +151,7 @@ class Update extends Base // The secret is stored as JSON `{"clientSecret": "...", "authentikDomain": "..."}` // to match the shape Authentik's OAuth2 adapter expects (getAuthentikDomain()). - // The `endpoint` param is required on every call, so it's always written. + // The `endpoint` param is optional; if omitted, the existing stored endpoint is preserved. // `clientSecret` is optional; if omitted, the existing stored secret is preserved. $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; $existing = []; @@ -160,7 +160,7 @@ class Update extends Base } $encodedSecret = \json_encode([ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), - 'authentikDomain' => $endpoint, + 'authentikDomain' => $endpoint !== '' ? $endpoint : ($existing['authentikDomain'] ?? ''), ]); $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php index 25f81e1459..e0846621dc 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php @@ -105,7 +105,7 @@ class Update extends Base )) ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) - ->param('endpoint', '', new Text(256, 1), 'Domain of FusionAuth instance. For example: example.fusionauth.io', optional: false) + ->param('endpoint', '', new Text(256, 1), 'Domain of FusionAuth instance. For example: example.fusionauth.io', optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) ->inject('response') ->inject('dbForPlatform') @@ -151,7 +151,7 @@ class Update extends Base // The secret is stored as JSON `{"clientSecret": "...", "fusionAuthDomain": "..."}` // to match the shape FusionAuth's OAuth2 adapter expects (getFusionAuthDomain()). - // The `endpoint` param is required on every call, so it's always written. + // The `endpoint` param is optional; if omitted, the existing stored endpoint is preserved. // `clientSecret` is optional; if omitted, the existing stored secret is preserved. $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; $existing = []; @@ -160,7 +160,7 @@ class Update extends Base } $encodedSecret = \json_encode([ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), - 'fusionAuthDomain' => $endpoint, + 'fusionAuthDomain' => $endpoint !== '' ? $endpoint : ($existing['fusionAuthDomain'] ?? ''), ]); $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php index 3b6f89db06..7c680e5141 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php @@ -35,7 +35,7 @@ class Update extends Base public static function getClientIdName(): string { - return 'OAuth 2 app Client ID, or App ID'; + return 'OAuth2 app Client ID, or App ID'; } public static function getClientIdExample(): string diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php index 797875cab2..121c6115b4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php @@ -111,8 +111,8 @@ class Update extends Base )) ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) - ->param('endpoint', '', new Text(256, 1), 'Domain of Keycloak instance. For example: keycloak.example.com', optional: false) - ->param('realmName', '', new Text(256, 1), 'Keycloak realm name. For example: appwrite-realm', optional: false) + ->param('endpoint', '', new Text(256, 1), 'Domain of Keycloak instance. For example: keycloak.example.com', optional: true) + ->param('realmName', '', new Text(256, 1), 'Keycloak realm name. For example: appwrite-realm', optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) ->inject('response') ->inject('dbForPlatform') @@ -161,7 +161,7 @@ class Update extends Base // The secret is stored as JSON `{"clientSecret": "...", "keycloakDomain": "...", "keycloakRealm": "..."}` // to match the shape Keycloak's OAuth2 adapter expects (getKeycloakDomain(), getKeycloakRealm()). - // The `endpoint` and `realmName` params are required on every call, so they're always written. + // The `endpoint` and `realmName` params are optional; if omitted, existing stored values are preserved. // `clientSecret` is optional; if omitted, the existing stored secret is preserved. $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; $existing = []; @@ -170,8 +170,8 @@ class Update extends Base } $encodedSecret = \json_encode([ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), - 'keycloakDomain' => $endpoint, - 'keycloakRealm' => $realmName, + 'keycloakDomain' => $endpoint !== '' ? $endpoint : ($existing['keycloakDomain'] ?? ''), + 'keycloakRealm' => $realmName !== '' ? $realmName : ($existing['keycloakRealm'] ?? ''), ]); $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php index 0690ee333a..a4db4c1aee 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php @@ -115,7 +115,7 @@ class Update extends Base )) ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) - ->param('tenant', '', new Text(256, 1), 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID. For example: common', optional: false) + ->param('tenant', '', new Text(256, 1), 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID. For example: common', true) ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) ->inject('response') ->inject('dbForPlatform') @@ -161,7 +161,7 @@ class Update extends Base // The secret is stored as JSON `{"clientSecret": "...", "tenantID": "..."}` // to match the shape Microsoft's OAuth2 adapter expects (getTenantID()). - // The `tenant` param is required on every call, so it's always written. + // The `tenant` param is optional; if omitted, the existing stored tenant is preserved. // `applicationSecret` is optional; if omitted, the existing stored secret is preserved. $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; $existing = []; @@ -170,7 +170,7 @@ class Update extends Base } $encodedSecret = \json_encode([ 'clientSecret' => $applicationSecret ?? ($existing['clientSecret'] ?? ''), - 'tenantID' => $tenant, + 'tenantID' => $tenant !== '' ? $tenant : ($existing['tenantID'] ?? ''), ]); $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled); diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php index c111b744c3..c8f921f2ec 100644 --- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php +++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php @@ -101,7 +101,7 @@ class ConsoleConsoleClientTest extends Scope $this->assertCount(2, $github['parameters']); $clientId = $github['parameters'][0]; $this->assertEquals('clientId', $clientId['$id']); - $this->assertEquals('OAuth 2 app Client ID, or App ID', $clientId['name']); + $this->assertEquals('OAuth2 app Client ID, or App ID', $clientId['name']); $this->assertEquals('e4d87900000000540733', $clientId['example']); $this->assertEquals('Example of wrong value: 370006', $clientId['hint']); $clientSecret = $github['parameters'][1]; From 8785aa987768847d59eaf6c8ed7f38add4671e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 30 Apr 2026 12:41:55 +0200 Subject: [PATCH 383/402] Fix nullable implementation oauth --- .../Project/Http/Project/OAuth2/Authentik/Update.php | 6 +++--- .../Http/Project/OAuth2/FusionAuth/Update.php | 6 +++--- .../Project/Http/Project/OAuth2/Keycloak/Update.php | 12 ++++++------ .../Project/Http/Project/OAuth2/Microsoft/Update.php | 6 +++--- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php index 683f8cca92..af6b12618a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php @@ -105,7 +105,7 @@ class Update extends Base )) ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) - ->param('endpoint', '', new Text(256, 1), 'Domain of Authentik instance. For example: example.authentik.com', optional: true) + ->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of Authentik instance. For example: example.authentik.com', optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) ->inject('response') ->inject('dbForPlatform') @@ -138,7 +138,7 @@ class Update extends Base public function handle( ?string $clientId, ?string $clientSecret, - string $endpoint, + ?string $endpoint, ?bool $enabled, Response $response, Database $dbForPlatform, @@ -160,7 +160,7 @@ class Update extends Base } $encodedSecret = \json_encode([ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), - 'authentikDomain' => $endpoint !== '' ? $endpoint : ($existing['authentikDomain'] ?? ''), + 'authentikDomain' => $endpoint ?? ($existing['authentikDomain'] ?? ''), ]); $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php index e0846621dc..3cdf0eeb89 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php @@ -105,7 +105,7 @@ class Update extends Base )) ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) - ->param('endpoint', '', new Text(256, 1), 'Domain of FusionAuth instance. For example: example.fusionauth.io', optional: true) + ->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of FusionAuth instance. For example: example.fusionauth.io', optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) ->inject('response') ->inject('dbForPlatform') @@ -138,7 +138,7 @@ class Update extends Base public function handle( ?string $clientId, ?string $clientSecret, - string $endpoint, + ?string $endpoint, ?bool $enabled, Response $response, Database $dbForPlatform, @@ -160,7 +160,7 @@ class Update extends Base } $encodedSecret = \json_encode([ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), - 'fusionAuthDomain' => $endpoint !== '' ? $endpoint : ($existing['fusionAuthDomain'] ?? ''), + 'fusionAuthDomain' => $endpoint ?? ($existing['fusionAuthDomain'] ?? ''), ]); $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php index 121c6115b4..aa41e8a5e9 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php @@ -111,8 +111,8 @@ class Update extends Base )) ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) - ->param('endpoint', '', new Text(256, 1), 'Domain of Keycloak instance. For example: keycloak.example.com', optional: true) - ->param('realmName', '', new Text(256, 1), 'Keycloak realm name. For example: appwrite-realm', optional: true) + ->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of Keycloak instance. For example: keycloak.example.com', optional: true) + ->param('realmName', null, new Nullable(new Text(256, 0)), 'Keycloak realm name. For example: appwrite-realm', optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) ->inject('response') ->inject('dbForPlatform') @@ -147,8 +147,8 @@ class Update extends Base public function handle( ?string $clientId, ?string $clientSecret, - string $endpoint, - string $realmName, + ?string $endpoint, + ?string $realmName, ?bool $enabled, Response $response, Database $dbForPlatform, @@ -170,8 +170,8 @@ class Update extends Base } $encodedSecret = \json_encode([ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), - 'keycloakDomain' => $endpoint !== '' ? $endpoint : ($existing['keycloakDomain'] ?? ''), - 'keycloakRealm' => $realmName !== '' ? $realmName : ($existing['keycloakRealm'] ?? ''), + 'keycloakDomain' => $endpoint ?? ($existing['keycloakDomain'] ?? ''), + 'keycloakRealm' => $realmName ?? ($existing['keycloakRealm'] ?? ''), ]); $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php index a4db4c1aee..811819a05c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php @@ -115,7 +115,7 @@ class Update extends Base )) ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true) - ->param('tenant', '', new Text(256, 1), 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID. For example: common', true) + ->param('tenant', null, new Nullable(new Text(256, 0)), 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID. For example: common', true) ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true) ->inject('response') ->inject('dbForPlatform') @@ -148,7 +148,7 @@ class Update extends Base public function handle( ?string $applicationId, ?string $applicationSecret, - string $tenant, + ?string $tenant, ?bool $enabled, Response $response, Database $dbForPlatform, @@ -170,7 +170,7 @@ class Update extends Base } $encodedSecret = \json_encode([ 'clientSecret' => $applicationSecret ?? ($existing['clientSecret'] ?? ''), - 'tenantID' => $tenant !== '' ? $tenant : ($existing['tenantID'] ?? ''), + 'tenantID' => $tenant ?? ($existing['tenantID'] ?? ''), ]); $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled); From 71300383b2cb7aadb1c20fa3fc40e08b7b9b7d68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 30 Apr 2026 12:48:59 +0200 Subject: [PATCH 384/402] Update tests --- tests/e2e/Services/Project/OAuth2Base.php | 202 ++++++++++++---------- 1 file changed, 110 insertions(+), 92 deletions(-) diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php index 9ff3830ec5..5451435c3c 100644 --- a/tests/e2e/Services/Project/OAuth2Base.php +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -872,30 +872,36 @@ trait OAuth2Base } // ========================================================================= - // Update Authentik (clientId + clientSecret + REQUIRED endpoint) + // Update Authentik (clientId + clientSecret + optional endpoint) // ========================================================================= - public function testUpdateOAuth2AuthentikRequiresEndpoint(): void + public function testUpdateOAuth2AuthentikAllowsOmittedEndpointWhenDisabled(): void { - // The `endpoint` param is required (Text(min=1)); omitting → 400. $response = $this->updateOAuth2('authentik', [ 'clientId' => 'whatever', 'clientSecret' => 'whatever', + 'enabled' => false, ]); - $this->assertSame(400, $response['headers']['status-code']); - $this->assertSame('general_argument_invalid', $response['body']['type']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('authentik', $response['body']['$id']); + + // Cleanup + $this->updateOAuth2('authentik', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); } - public function testUpdateOAuth2AuthentikEmptyEndpointRejected(): void + public function testUpdateOAuth2AuthentikEmptyEndpointRejectedWhenEnabling(): void { - // The `endpoint` validator is Text(min=1). Sending `''` must be - // rejected the same way as omitting — the validator should treat the - // empty-string degenerate case as a missing required field. $response = $this->updateOAuth2('authentik', [ 'clientId' => 'whatever', 'clientSecret' => 'whatever', 'endpoint' => '', + 'enabled' => true, ]); $this->assertSame(400, $response['headers']['status-code']); @@ -920,15 +926,14 @@ trait OAuth2Base $this->updateOAuth2('authentik', [ 'clientId' => '', 'clientSecret' => '', - 'endpoint' => 'cleanup.authentik.com', + 'endpoint' => '', 'enabled' => false, ]); } public function testUpdateOAuth2AuthentikPartialPreservesSecret(): void { - // Authentik's `endpoint` is required on every call, so we always - // re-send it. The `clientSecret` lives in the JSON blob and must + // The `clientSecret` and `endpoint` live in the JSON blob and must // survive when omitted on a subsequent call that only changes clientId. $this->updateOAuth2('authentik', [ 'clientId' => 'authentik-merge-client', @@ -939,27 +944,24 @@ trait OAuth2Base $response = $this->updateOAuth2('authentik', [ 'clientId' => 'authentik-rotated-client', - 'endpoint' => 'merge.authentik.com', ]); $this->assertSame(200, $response['headers']['status-code']); $this->assertSame('authentik-rotated-client', $response['body']['clientId']); $this->assertSame('merge.authentik.com', $response['body']['endpoint']); // Confirm clientSecret survived the omitted-field merge by enabling - // — Authentik has no verifyCredentials() hook, so non-empty stored - // secret is enough. `endpoint` must be re-sent (required on enable too). + // without re-sending endpoint. $enable = $this->updateOAuth2('authentik', [ - 'endpoint' => 'merge.authentik.com', 'enabled' => true, ]); $this->assertSame(200, $enable['headers']['status-code']); $this->assertTrue($enable['body']['enabled']); - // Cleanup — endpoint is required, use a placeholder. + // Cleanup $this->updateOAuth2('authentik', [ 'clientId' => '', 'clientSecret' => '', - 'endpoint' => 'cleanup.authentik.com', + 'endpoint' => '', 'enabled' => false, ]); } @@ -984,40 +986,46 @@ trait OAuth2Base $this->assertSame('enable.authentik.com', $get['body']['endpoint']); $this->assertSame('', $get['body']['clientSecret']); - // Cleanup — endpoint is required (Text(min=1)) so use a placeholder. + // Cleanup $this->updateOAuth2('authentik', [ 'clientId' => '', 'clientSecret' => '', - 'endpoint' => 'cleanup.authentik.com', + 'endpoint' => '', 'enabled' => false, ]); } // ========================================================================= - // Update FusionAuth (clientId + clientSecret + REQUIRED endpoint) + // Update FusionAuth (clientId + clientSecret + optional endpoint) // ========================================================================= - public function testUpdateOAuth2FusionAuthRequiresEndpoint(): void + public function testUpdateOAuth2FusionAuthAllowsOmittedEndpointWhenDisabled(): void { - // The `endpoint` param is required (Text(min=1)); omitting → 400. $response = $this->updateOAuth2('fusionauth', [ 'clientId' => 'whatever', 'clientSecret' => 'whatever', + 'enabled' => false, ]); - $this->assertSame(400, $response['headers']['status-code']); - $this->assertSame('general_argument_invalid', $response['body']['type']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('fusionauth', $response['body']['$id']); + + // Cleanup + $this->updateOAuth2('fusionauth', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); } - public function testUpdateOAuth2FusionAuthEmptyEndpointRejected(): void + public function testUpdateOAuth2FusionAuthEmptyEndpointRejectedWhenEnabling(): void { - // The `endpoint` validator is Text(min=1). Sending `''` must be - // rejected the same way as omitting — the validator should treat the - // empty-string degenerate case as a missing required field. $response = $this->updateOAuth2('fusionauth', [ 'clientId' => 'whatever', 'clientSecret' => 'whatever', 'endpoint' => '', + 'enabled' => true, ]); $this->assertSame(400, $response['headers']['status-code']); @@ -1042,15 +1050,14 @@ trait OAuth2Base $this->updateOAuth2('fusionauth', [ 'clientId' => '', 'clientSecret' => '', - 'endpoint' => 'cleanup.fusionauth.io', + 'endpoint' => '', 'enabled' => false, ]); } public function testUpdateOAuth2FusionAuthPartialPreservesSecret(): void { - // FusionAuth's `endpoint` is required on every call, so we always - // re-send it. The `clientSecret` lives in the JSON blob and must + // The `clientSecret` and `endpoint` live in the JSON blob and must // survive when omitted on a subsequent call that only changes clientId. $this->updateOAuth2('fusionauth', [ 'clientId' => 'fusionauth-merge-client', @@ -1061,27 +1068,24 @@ trait OAuth2Base $response = $this->updateOAuth2('fusionauth', [ 'clientId' => 'fusionauth-rotated-client', - 'endpoint' => 'merge.fusionauth.io', ]); $this->assertSame(200, $response['headers']['status-code']); $this->assertSame('fusionauth-rotated-client', $response['body']['clientId']); $this->assertSame('merge.fusionauth.io', $response['body']['endpoint']); // Confirm clientSecret survived the omitted-field merge by enabling - // — FusionAuth has no verifyCredentials() hook, so non-empty stored - // secret is enough. `endpoint` must be re-sent (required on enable too). + // without re-sending endpoint. $enable = $this->updateOAuth2('fusionauth', [ - 'endpoint' => 'merge.fusionauth.io', 'enabled' => true, ]); $this->assertSame(200, $enable['headers']['status-code']); $this->assertTrue($enable['body']['enabled']); - // Cleanup — endpoint is required, use a placeholder. + // Cleanup $this->updateOAuth2('fusionauth', [ 'clientId' => '', 'clientSecret' => '', - 'endpoint' => 'cleanup.fusionauth.io', + 'endpoint' => '', 'enabled' => false, ]); } @@ -1106,70 +1110,85 @@ trait OAuth2Base $this->assertSame('enable.fusionauth.io', $get['body']['endpoint']); $this->assertSame('', $get['body']['clientSecret']); - // Cleanup — endpoint is required (Text(min=1)) so use a placeholder. + // Cleanup $this->updateOAuth2('fusionauth', [ 'clientId' => '', 'clientSecret' => '', - 'endpoint' => 'cleanup.fusionauth.io', + 'endpoint' => '', 'enabled' => false, ]); } // ========================================================================= - // Update Keycloak (clientId + clientSecret + REQUIRED endpoint + REQUIRED realmName) + // Update Keycloak (clientId + clientSecret + optional endpoint + optional realmName) // ========================================================================= - public function testUpdateOAuth2KeycloakRequiresEndpoint(): void + public function testUpdateOAuth2KeycloakAllowsOmittedEndpointWhenDisabled(): void { - // The `endpoint` param is required (Text(min=1)); omitting → 400. $response = $this->updateOAuth2('keycloak', [ 'clientId' => 'whatever', 'clientSecret' => 'whatever', 'realmName' => 'appwrite-realm', + 'enabled' => false, ]); - $this->assertSame(400, $response['headers']['status-code']); - $this->assertSame('general_argument_invalid', $response['body']['type']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('keycloak', $response['body']['$id']); + + // Cleanup + $this->updateOAuth2('keycloak', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'realmName' => '', + 'enabled' => false, + ]); } - public function testUpdateOAuth2KeycloakEmptyEndpointRejected(): void + public function testUpdateOAuth2KeycloakEmptyEndpointRejectedWhenEnabling(): void { - // The `endpoint` validator is Text(min=1). Sending `''` must be - // rejected the same way as omitting — the validator should treat the - // empty-string degenerate case as a missing required field. $response = $this->updateOAuth2('keycloak', [ 'clientId' => 'whatever', 'clientSecret' => 'whatever', 'endpoint' => '', 'realmName' => 'appwrite-realm', + 'enabled' => true, ]); $this->assertSame(400, $response['headers']['status-code']); $this->assertSame('general_argument_invalid', $response['body']['type']); } - public function testUpdateOAuth2KeycloakRequiresRealmName(): void + public function testUpdateOAuth2KeycloakAllowsOmittedRealmNameWhenDisabled(): void { - // The `realmName` param is required (Text(min=1)); omitting → 400. $response = $this->updateOAuth2('keycloak', [ 'clientId' => 'whatever', 'clientSecret' => 'whatever', 'endpoint' => 'keycloak.example.com', + 'enabled' => false, ]); - $this->assertSame(400, $response['headers']['status-code']); - $this->assertSame('general_argument_invalid', $response['body']['type']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('keycloak', $response['body']['$id']); + + // Cleanup + $this->updateOAuth2('keycloak', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'realmName' => '', + 'enabled' => false, + ]); } - public function testUpdateOAuth2KeycloakEmptyRealmNameRejected(): void + public function testUpdateOAuth2KeycloakEmptyRealmNameRejectedWhenEnabling(): void { - // The `realmName` validator is Text(min=1). Sending `''` must be - // rejected the same way as omitting. $response = $this->updateOAuth2('keycloak', [ 'clientId' => 'whatever', 'clientSecret' => 'whatever', 'endpoint' => 'keycloak.example.com', 'realmName' => '', + 'enabled' => true, ]); $this->assertSame(400, $response['headers']['status-code']); @@ -1196,16 +1215,15 @@ trait OAuth2Base $this->updateOAuth2('keycloak', [ 'clientId' => '', 'clientSecret' => '', - 'endpoint' => 'cleanup.keycloak.com', - 'realmName' => 'cleanup-realm', + 'endpoint' => '', + 'realmName' => '', 'enabled' => false, ]); } public function testUpdateOAuth2KeycloakPartialPreservesSecret(): void { - // Keycloak's `endpoint` and `realmName` are required on every call, - // so we always re-send them. The `clientSecret` lives in the JSON + // The `clientSecret`, `endpoint`, and `realmName` live in the JSON // blob and must survive when omitted on a subsequent call that only // changes clientId. $this->updateOAuth2('keycloak', [ @@ -1218,8 +1236,6 @@ trait OAuth2Base $response = $this->updateOAuth2('keycloak', [ 'clientId' => 'keycloak-rotated-client', - 'endpoint' => 'merge.keycloak.com', - 'realmName' => 'merge-realm', ]); $this->assertSame(200, $response['headers']['status-code']); $this->assertSame('keycloak-rotated-client', $response['body']['clientId']); @@ -1227,23 +1243,19 @@ trait OAuth2Base $this->assertSame('merge-realm', $response['body']['realmName']); // Confirm clientSecret survived the omitted-field merge by enabling - // — Keycloak has no verifyCredentials() hook, so non-empty stored - // secret is enough. `endpoint`/`realmName` must be re-sent (required - // on enable too). + // without re-sending endpoint or realmName. $enable = $this->updateOAuth2('keycloak', [ - 'endpoint' => 'merge.keycloak.com', - 'realmName' => 'merge-realm', 'enabled' => true, ]); $this->assertSame(200, $enable['headers']['status-code']); $this->assertTrue($enable['body']['enabled']); - // Cleanup — endpoint and realmName are required, use placeholders. + // Cleanup $this->updateOAuth2('keycloak', [ 'clientId' => '', 'clientSecret' => '', - 'endpoint' => 'cleanup.keycloak.com', - 'realmName' => 'cleanup-realm', + 'endpoint' => '', + 'realmName' => '', 'enabled' => false, ]); } @@ -1270,40 +1282,47 @@ trait OAuth2Base $this->assertSame('enable-realm', $get['body']['realmName']); $this->assertSame('', $get['body']['clientSecret']); - // Cleanup — endpoint and realmName are required (Text(min=1)) so use placeholders. + // Cleanup $this->updateOAuth2('keycloak', [ 'clientId' => '', 'clientSecret' => '', - 'endpoint' => 'cleanup.keycloak.com', - 'realmName' => 'cleanup-realm', + 'endpoint' => '', + 'realmName' => '', 'enabled' => false, ]); } // ========================================================================= - // Update Microsoft (applicationId + applicationSecret + REQUIRED tenant) + // Update Microsoft (applicationId + applicationSecret + optional tenant) // ========================================================================= - public function testUpdateOAuth2MicrosoftRequiresTenant(): void + public function testUpdateOAuth2MicrosoftAllowsOmittedTenantWhenDisabled(): void { $response = $this->updateOAuth2('microsoft', [ 'applicationId' => 'whatever', 'applicationSecret' => 'whatever', + 'enabled' => false, ]); - $this->assertSame(400, $response['headers']['status-code']); - $this->assertSame('general_argument_invalid', $response['body']['type']); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('microsoft', $response['body']['$id']); + + // Cleanup + $this->updateOAuth2('microsoft', [ + 'applicationId' => '', + 'applicationSecret' => '', + 'tenant' => '', + 'enabled' => false, + ]); } - public function testUpdateOAuth2MicrosoftEmptyTenantRejected(): void + public function testUpdateOAuth2MicrosoftEmptyTenantRejectedWhenEnabling(): void { - // The `tenant` validator is Text(min=1). Sending `''` must be rejected - // the same way as omitting — the validator should treat the empty - // string as a missing required field. $response = $this->updateOAuth2('microsoft', [ 'applicationId' => 'whatever', 'applicationSecret' => 'whatever', 'tenant' => '', + 'enabled' => true, ]); $this->assertSame(400, $response['headers']['status-code']); @@ -1331,7 +1350,7 @@ trait OAuth2Base $this->updateOAuth2('microsoft', [ 'applicationId' => '', 'applicationSecret' => '', - 'tenant' => 'common', + 'tenant' => '', 'enabled' => false, ]); } @@ -1346,23 +1365,21 @@ trait OAuth2Base 'enabled' => false, ]); - // Patch with only `tenant` (it's required on every call) and a new - // applicationId, leaving applicationSecret omitted. The stored secret - // must not be wiped. + // Patch with only a new applicationId, leaving applicationSecret and + // tenant omitted. The stored JSON values must not be wiped. $response = $this->updateOAuth2('microsoft', [ 'applicationId' => 'updated-app-id', - 'tenant' => 'organizations', ]); $this->assertSame(200, $response['headers']['status-code']); $this->assertSame('updated-app-id', $response['body']['applicationId']); - $this->assertSame('organizations', $response['body']['tenant']); + $this->assertSame('common', $response['body']['tenant']); // Cleanup $this->updateOAuth2('microsoft', [ 'applicationId' => '', 'applicationSecret' => '', - 'tenant' => 'common', + 'tenant' => '', 'enabled' => false, ]); } @@ -1387,11 +1404,11 @@ trait OAuth2Base $this->assertSame('common', $get['body']['tenant']); $this->assertSame('', $get['body']['applicationSecret']); - // Cleanup — tenant is required (Text(min=1)) so use a placeholder. + // Cleanup $this->updateOAuth2('microsoft', [ 'applicationId' => '', 'applicationSecret' => '', - 'tenant' => 'common', + 'tenant' => '', 'enabled' => false, ]); } @@ -2401,8 +2418,9 @@ trait OAuth2Base // // Ensures each provider's Update endpoint is wired up correctly: routing, // provider class, response model and `$id`. Custom-shaped providers - // (apple, auth0, authentik, gitlab, microsoft, oidc, okta, dropbox) and - // sandboxes (paypalSandbox, tradeshiftSandbox) have dedicated tests above. + // (apple, auth0, authentik, fusionauth, gitlab, keycloak, microsoft, oidc, + // okta, dropbox) and sandboxes (paypalSandbox, tradeshiftSandbox) have + // dedicated tests above. // Github is excluded because its `verifyCredentials()` hook is exercised // separately. // ========================================================================= From 65f926b4c58c75c4b0704901462b19646e74d09e Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 1 May 2026 18:55:13 +0530 Subject: [PATCH 385/402] Update appwrite runtimes to 0.20 --- composer.json | 2 +- composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 683da6f21b..735955d980 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,7 @@ "ext-openssl": "*", "ext-zlib": "*", "ext-sockets": "*", - "appwrite/php-runtimes": "0.19.*", + "appwrite/php-runtimes": "0.20.*", "appwrite/php-clamav": "2.0.*", "utopia-php/abuse": "1.2.*", "utopia-php/agents": "1.2.*", diff --git a/composer.lock b/composer.lock index 3edbc39614..2889069e67 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4bee36b21a57e754d2b3417e72dc9599", + "content-hash": "bd45829c252971301370d62300be106d", "packages": [ { "name": "adhocore/jwt", @@ -161,16 +161,16 @@ }, { "name": "appwrite/php-runtimes", - "version": "0.19.5", + "version": "0.20.0", "source": { "type": "git", "url": "https://github.com/appwrite/runtimes.git", - "reference": "aa2f7760cd0493c0880209b92df812c9386b3546" + "reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/runtimes/zipball/aa2f7760cd0493c0880209b92df812c9386b3546", - "reference": "aa2f7760cd0493c0880209b92df812c9386b3546", + "url": "https://api.github.com/repos/appwrite/runtimes/zipball/7d9b7f4eef5c0a142a60907b06de2219d025c5c3", + "reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3", "shasum": "" }, "require": { @@ -210,9 +210,9 @@ ], "support": { "issues": "https://github.com/appwrite/runtimes/issues", - "source": "https://github.com/appwrite/runtimes/tree/0.19.5" + "source": "https://github.com/appwrite/runtimes/tree/0.20.0" }, - "time": "2026-04-01T01:39:23+00:00" + "time": "2026-05-01T07:47:07+00:00" }, { "name": "brick/math", From 413930a15edd9f630ee29483964073a585bc148a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 2 May 2026 12:10:03 +0530 Subject: [PATCH 386/402] Add Rust starter function template runtime --- app/config/templates/function.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/config/templates/function.php b/app/config/templates/function.php index df3a569705..c6ac446509 100644 --- a/app/config/templates/function.php +++ b/app/config/templates/function.php @@ -79,12 +79,13 @@ return [ ...getRuntimes($templateRuntimes['DENO'], 'deno cache src/main.ts', 'src/main.ts', 'deno/starter', $allowList), ...getRuntimes($templateRuntimes['BUN'], 'bun install', 'src/main.ts', 'bun/starter', $allowList), ...getRuntimes($templateRuntimes['RUBY'], 'bundle install', 'lib/main.rb', 'ruby/starter', $allowList), + ...getRuntimes($templateRuntimes['RUST'], '', 'main.rs', 'rust/starter', $allowList), ], - 'instructions' => 'For documentation and instructions check out file.', + 'instructions' => 'For documentation and instructions check out the templates repository.', 'vcsProvider' => 'github', 'providerRepositoryId' => 'templates', 'providerOwner' => 'appwrite', - 'providerVersion' => '0.2.*', + 'providerVersion' => '0.3.*', 'variables' => [], 'scopes' => ['users.read'] ], From 7f27851dab09b52f5abba84af1bdff009d936215 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sun, 3 May 2026 15:15:27 +0100 Subject: [PATCH 387/402] chore: bump base image to 1.3.1 Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1922a0d2b9..5f43b1527f 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist \ `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi` -FROM appwrite/base:1.2.1 AS base +FROM appwrite/base:1.3.1 AS base LABEL maintainer="team@appwrite.io" From 4e20e382d21647c44f6d41445467d7768477c67c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 3 May 2026 19:49:13 +0200 Subject: [PATCH 388/402] Add deprecated function scopes --- app/config/scopes/project.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 63b946f74f..3aaf5469de 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -210,10 +210,20 @@ return [ 'executions.read' => [ 'description' => 'Access to read function executions', 'category' => 'Functions', + 'deprecated' => true, ], 'executions.write' => [ 'description' => 'Access to create function executions', 'category' => 'Functions', + 'deprecated' => true, + ], + 'execution.read' => [ + 'description' => 'Access to read function executions. This scope is deprecated for consistency purposes, and replaced by `executions.read`.', + 'category' => 'Functions', + ], + 'execution.write' => [ + 'description' => 'Access to create function executions. This scope is deprecated for consistency purposes, and replaced by `executions.write`.', + 'category' => 'Functions', ], // Sites From 6051b8150c5bf671db43ab93b6f6e7bec0f18e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 3 May 2026 19:51:10 +0200 Subject: [PATCH 389/402] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Matej Bačo --- app/config/scopes/project.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 3aaf5469de..716caa3b8e 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -210,7 +210,6 @@ return [ 'executions.read' => [ 'description' => 'Access to read function executions', 'category' => 'Functions', - 'deprecated' => true, ], 'executions.write' => [ 'description' => 'Access to create function executions', From 8f68a59a797e9297b9229bc19c318502b6ef7893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 3 May 2026 19:51:56 +0200 Subject: [PATCH 390/402] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Matej Bačo --- app/config/scopes/project.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 716caa3b8e..a048920de9 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -214,15 +214,16 @@ return [ 'executions.write' => [ 'description' => 'Access to create function executions', 'category' => 'Functions', - 'deprecated' => true, ], 'execution.read' => [ 'description' => 'Access to read function executions. This scope is deprecated for consistency purposes, and replaced by `executions.read`.', 'category' => 'Functions', + 'deprecated' => true, ], 'execution.write' => [ 'description' => 'Access to create function executions. This scope is deprecated for consistency purposes, and replaced by `executions.write`.', 'category' => 'Functions', + 'deprecated' => true, ], // Sites From 92eceba2181134d19263ec90fdd6bddd5237c8f2 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sun, 3 May 2026 19:21:00 +0100 Subject: [PATCH 391/402] chore: bump base image to 1.4.1 Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5f43b1527f..9a61635415 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist \ `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi` -FROM appwrite/base:1.3.1 AS base +FROM appwrite/base:1.4.1 AS base LABEL maintainer="team@appwrite.io" From f5a7cfd2ea0135d1b01a01fd60d378bd81acba8c Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 4 May 2026 12:22:48 +0530 Subject: [PATCH 392/402] fix: resolve query syntax errors and improve error handling in Request class --- src/Appwrite/Utopia/Request.php | 65 ++++++----- .../e2e/Services/Databases/DatabasesBase.php | 45 ++++++++ .../Utopia/Request/Filters/ThrowingFilter.php | 24 +++++ tests/unit/Utopia/RequestTest.php | 101 ++++++++++++++++++ 4 files changed, 207 insertions(+), 28 deletions(-) create mode 100644 tests/unit/Utopia/Request/Filters/ThrowingFilter.php diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 3004392f76..945a3d2bf6 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -51,38 +51,47 @@ class Request extends UtopiaRequest if (!\is_array($methods)) { $id = $methods->getNamespace() . '.' . $methods->getMethodName(); + } else { + $matched = null; + foreach ($methods as $method) { + /** @var Method|null $method */ + if ($method === null) { + continue; + } + + // Find the method that matches the parameters passed + $methodParamNames = \array_map(fn ($param) => $param->getName(), $method->getParameters()); + $invalidParams = \array_diff(\array_keys($parameters), $methodParamNames); + + // No params defined, or all params are valid + if (empty($methodParamNames) || empty($invalidParams)) { + $matched = $method; + break; + } + } + + $id = $matched !== null + ? $matched->getNamespace() . '.' . $matched->getMethodName() + : 'unknown.unknown'; + } + + try { foreach ($this->getFilters() as $filter) { $parameters = $filter->parse($parameters, $id); } - $this->filteredParams = $parameters; - return $parameters; - } - - $matched = null; - foreach ($methods as $method) { - /** @var Method|null $method */ - if ($method === null) { - continue; + } catch (\Throwable $e) { + // 4xx filter throws are user-input errors that the action layer + // revalidates and reports. Cache the raw, pre-filter parameters + // so a subsequent getParams() — e.g. when the framework builds + // arguments for an error hook — returns without re-running + // filters. Otherwise the second throw gets wrapped as + // "Error handler had an error: ..." (HTTP 500), masking the + // intended 400. + $code = $e->getCode(); + if (\is_int($code) && $code >= 400 && $code < 500) { + $this->filteredParams = parent::getParams(); } - - // Find the method that matches the parameters passed - $methodParamNames = \array_map(fn ($param) => $param->getName(), $method->getParameters()); - $invalidParams = \array_diff(\array_keys($parameters), $methodParamNames); - - // No params defined, or all params are valid - if (empty($methodParamNames) || empty($invalidParams)) { - $matched = $method; - break; - } - } - - $id = $matched !== null - ? $matched->getNamespace() . '.' . $matched->getMethodName() - : 'unknown.unknown'; - - // Apply filters - foreach ($this->getFilters() as $filter) { - $parameters = $filter->parse($parameters, $id); + throw $e; } $this->filteredParams = $parameters; diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index e3efe3bbd9..315f40475a 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -4339,6 +4339,51 @@ trait DatabasesBase // $this->assertEquals('Invalid query: Cannot query search on attribute "actors" because it is an array.', $documents['body']['message']); } + public function testDocumentsListInvalidQuerySyntax(): void + { + $data = $this->setupDocuments(); + $databaseId = $data['databaseId']; + + // Each entry in `queries` must be a JSON-encoded object (e.g. + // `{"method":"limit","values":[5]}`). Anything else — including the + // legacy SDK shorthand `limit(5)`, a hand-rolled non-JSON string, or a + // JSON value that isn't an object — must be rejected by Query::parse + // with the same `Invalid query: Syntax error` message so callers get + // a consistent 400. + $invalidQueries = [ + 'legacy shorthand' => 'limit(5)', + 'plain string' => 'not-json', + 'json non-object' => '"limit"', + 'malformed json' => '{"method":"limit","values":[5}', + 'unquoted attribute' => '{method:"limit",values:[5]}', + ]; + + foreach ($invalidQueries as $label => $rawQuery) { + $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [$rawQuery], + ]); + + $this->assertEquals(400, $documents['headers']['status-code'], "Expected 400 for [$label]: $rawQuery"); + $this->assertEquals('Invalid query: Syntax error', $documents['body']['message'], "Wrong error for [$label]: $rawQuery"); + } + + // Sanity check: the JSON form the SDK actually emits still works. + $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::limit(5)->toString(), + ], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertLessThanOrEqual(5, count($documents['body'][$this->getRecordResource()])); + } + public function testUpdateDocument(): void { $data = $this->setupDocuments(); diff --git a/tests/unit/Utopia/Request/Filters/ThrowingFilter.php b/tests/unit/Utopia/Request/Filters/ThrowingFilter.php new file mode 100644 index 0000000000..8e02b92e39 --- /dev/null +++ b/tests/unit/Utopia/Request/Filters/ThrowingFilter.php @@ -0,0 +1,24 @@ +calls++; + throw new \Exception($this->reason, $this->code); + } +} diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php index 57ebae6d1e..d6f911da8d 100644 --- a/tests/unit/Utopia/RequestTest.php +++ b/tests/unit/Utopia/RequestTest.php @@ -5,10 +5,12 @@ namespace Tests\Unit\Utopia; use Appwrite\SDK\Method; use Appwrite\SDK\Parameter; use Appwrite\Utopia\Request; +use Appwrite\Utopia\Request\Filter; use PHPUnit\Framework\TestCase; use Swoole\Http\Request as SwooleRequest; use Tests\Unit\Utopia\Request\Filters\First; use Tests\Unit\Utopia\Request\Filters\Second; +use Tests\Unit\Utopia\Request\Filters\ThrowingFilter; use Utopia\Http\Route; class RequestTest extends TestCase @@ -192,6 +194,105 @@ class RequestTest extends TestCase $this->assertSame('fallback', $request->getHeader('referer', 'fallback')); } + public function testGetParamsCachesRawParamsWhenFilterThrows4xx(): void + { + // Regression: when a request filter throws a 4xx exception during + // Request::getParams() (e.g. RequestV20 rejecting an unparseable + // queries[]), the framework's error path calls getParams() again to + // build error-hook arguments. Without caching, that second call + // re-runs the filter and re-throws, which the framework wraps as + // "Error handler had an error: ..." (HTTP 500), masking the intended + // 400. This test pins that behavior: the first call throws (so the + // action's argument resolution aborts), but the second call returns + // the raw, pre-filter params without re-invoking filters. + $filter = new ThrowingFilter(400, 'invalid input'); + + $this->setupSingleMethodRoute($filter); + $this->request->setQueryString(['foo' => 'bar']); + + $threw = false; + try { + $this->request->getParams(); + } catch (\Throwable $e) { + $threw = true; + $this->assertSame(400, $e->getCode()); + $this->assertSame('invalid input', $e->getMessage()); + } + $this->assertTrue($threw, 'First getParams() call must rethrow the filter exception.'); + $this->assertSame(1, $filter->calls, 'Filter ran once on the first call.'); + + // Second call: framework's error hook arg resolution. Must return raw + // params without re-invoking the filter. + $params = $this->request->getParams(); + $this->assertSame(['foo' => 'bar'], $params); + $this->assertSame(1, $filter->calls, 'Filter must not run again after a cached 4xx failure.'); + } + + public function testGetParamsDoesNotCacheRawParamsForServerError(): void + { + // 5xx filter throws indicate genuine server-side problems, not + // user-input mistakes. They must keep rethrowing on every call so + // the framework's normal error handling sees the failure each time + // — caching raw params would silently swallow real bugs. + $filter = new ThrowingFilter(500, 'boom'); + + $this->setupSingleMethodRoute($filter); + $this->request->setQueryString(['foo' => 'bar']); + + for ($attempt = 1; $attempt <= 2; $attempt++) { + $threw = false; + try { + $this->request->getParams(); + } catch (\Throwable $e) { + $threw = true; + $this->assertSame(500, $e->getCode()); + } + $this->assertTrue($threw, "Call #$attempt must rethrow."); + $this->assertSame($attempt, $filter->calls, "Filter must run on call #$attempt."); + } + } + + public function testGetParamsDoesNotCacheRawParamsForUncodedException(): void + { + // \Exception with the default code of 0 is treated as "unknown" and + // must propagate every call — same reasoning as 5xx. + $filter = new ThrowingFilter(0, 'unknown'); + + $this->setupSingleMethodRoute($filter); + $this->request->setQueryString(['foo' => 'bar']); + + for ($attempt = 1; $attempt <= 2; $attempt++) { + $threw = false; + try { + $this->request->getParams(); + } catch (\Throwable) { + $threw = true; + } + $this->assertTrue($threw, "Call #$attempt must rethrow."); + $this->assertSame($attempt, $filter->calls, "Filter must run on call #$attempt."); + } + } + + /** + * Helper to attach a route with a single SDK method and one filter. + */ + private function setupSingleMethodRoute(Filter $filter): void + { + $route = new Route(Request::METHOD_GET, '/single'); + $route->label('sdk', new Method( + namespace: 'namespace', + group: 'group', + name: 'method', + description: 'description', + auth: [], + responses: [], + )); + + $this->request->addHeader('EXAMPLE', 'VALUE'); + $this->request->setRoute($route); + $this->request->addFilter($filter); + } + /** * Helper to attach a route with multiple SDK methods to the request. */ From f27172018ff73c74928834a7361cde9e9d506b60 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 4 May 2026 06:56:56 +0000 Subject: [PATCH 393/402] chore: bump utopia-php/audit to 2.2.2 --- composer.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.lock b/composer.lock index 2889069e67..9f34d2dbe1 100644 --- a/composer.lock +++ b/composer.lock @@ -3502,16 +3502,16 @@ }, { "name": "utopia-php/audit", - "version": "2.2.1", + "version": "2.2.2", "source": { "type": "git", "url": "https://github.com/utopia-php/audit.git", - "reference": "e3e2d6ad5c7f6377d9237df296a12eb7943892fd" + "reference": "90886c202e7983999e6b6a8201004d5ab61d4b57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/audit/zipball/e3e2d6ad5c7f6377d9237df296a12eb7943892fd", - "reference": "e3e2d6ad5c7f6377d9237df296a12eb7943892fd", + "url": "https://api.github.com/repos/utopia-php/audit/zipball/90886c202e7983999e6b6a8201004d5ab61d4b57", + "reference": "90886c202e7983999e6b6a8201004d5ab61d4b57", "shasum": "" }, "require": { @@ -3545,9 +3545,9 @@ ], "support": { "issues": "https://github.com/utopia-php/audit/issues", - "source": "https://github.com/utopia-php/audit/tree/2.2.1" + "source": "https://github.com/utopia-php/audit/tree/2.2.2" }, - "time": "2026-02-02T10:39:25+00:00" + "time": "2026-05-04T06:48:58+00:00" }, { "name": "utopia-php/auth", @@ -8444,7 +8444,7 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -8465,5 +8465,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } From c32294743ac428769fff11948f4a3c510ba3fa1d Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 4 May 2026 12:31:50 +0530 Subject: [PATCH 394/402] reverted e2e --- src/Appwrite/Utopia/Request.php | 2 +- .../e2e/Services/Databases/DatabasesBase.php | 45 ------------------- 2 files changed, 1 insertion(+), 46 deletions(-) diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 945a3d2bf6..123b13cc8b 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -89,7 +89,7 @@ class Request extends UtopiaRequest // intended 400. $code = $e->getCode(); if (\is_int($code) && $code >= 400 && $code < 500) { - $this->filteredParams = parent::getParams(); + $this->filteredParams = $parameters; } throw $e; } diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 315f40475a..e3efe3bbd9 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -4339,51 +4339,6 @@ trait DatabasesBase // $this->assertEquals('Invalid query: Cannot query search on attribute "actors" because it is an array.', $documents['body']['message']); } - public function testDocumentsListInvalidQuerySyntax(): void - { - $data = $this->setupDocuments(); - $databaseId = $data['databaseId']; - - // Each entry in `queries` must be a JSON-encoded object (e.g. - // `{"method":"limit","values":[5]}`). Anything else — including the - // legacy SDK shorthand `limit(5)`, a hand-rolled non-JSON string, or a - // JSON value that isn't an object — must be rejected by Query::parse - // with the same `Invalid query: Syntax error` message so callers get - // a consistent 400. - $invalidQueries = [ - 'legacy shorthand' => 'limit(5)', - 'plain string' => 'not-json', - 'json non-object' => '"limit"', - 'malformed json' => '{"method":"limit","values":[5}', - 'unquoted attribute' => '{method:"limit",values:[5]}', - ]; - - foreach ($invalidQueries as $label => $rawQuery) { - $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'queries' => [$rawQuery], - ]); - - $this->assertEquals(400, $documents['headers']['status-code'], "Expected 400 for [$label]: $rawQuery"); - $this->assertEquals('Invalid query: Syntax error', $documents['body']['message'], "Wrong error for [$label]: $rawQuery"); - } - - // Sanity check: the JSON form the SDK actually emits still works. - $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'queries' => [ - Query::limit(5)->toString(), - ], - ]); - - $this->assertEquals(200, $documents['headers']['status-code']); - $this->assertLessThanOrEqual(5, count($documents['body'][$this->getRecordResource()])); - } - public function testUpdateDocument(): void { $data = $this->setupDocuments(); From 1fc3a8803ca1e393ac7cc89cad6280c4107fe9c4 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 4 May 2026 12:45:32 +0530 Subject: [PATCH 395/402] multiline comment --- src/Appwrite/Utopia/Request.php | 16 +++++++++------- tests/unit/Utopia/RequestTest.php | 20 +++++++++++--------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 123b13cc8b..24803eeaa7 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -80,13 +80,15 @@ class Request extends UtopiaRequest $parameters = $filter->parse($parameters, $id); } } catch (\Throwable $e) { - // 4xx filter throws are user-input errors that the action layer - // revalidates and reports. Cache the raw, pre-filter parameters - // so a subsequent getParams() — e.g. when the framework builds - // arguments for an error hook — returns without re-running - // filters. Otherwise the second throw gets wrapped as - // "Error handler had an error: ..." (HTTP 500), masking the - // intended 400. + /* + * 4xx filter throws are user-input errors that the action layer + * revalidates and reports. Cache the raw, pre-filter parameters + * so a subsequent getParams() — e.g. when the framework builds + * arguments for an error hook — returns without re-running + * filters. Otherwise the second throw gets wrapped as + * "Error handler had an error: ..." (HTTP 500), masking the + * intended 400. + */ $code = $e->getCode(); if (\is_int($code) && $code >= 400 && $code < 500) { $this->filteredParams = $parameters; diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php index d6f911da8d..a242d74f46 100644 --- a/tests/unit/Utopia/RequestTest.php +++ b/tests/unit/Utopia/RequestTest.php @@ -196,15 +196,17 @@ class RequestTest extends TestCase public function testGetParamsCachesRawParamsWhenFilterThrows4xx(): void { - // Regression: when a request filter throws a 4xx exception during - // Request::getParams() (e.g. RequestV20 rejecting an unparseable - // queries[]), the framework's error path calls getParams() again to - // build error-hook arguments. Without caching, that second call - // re-runs the filter and re-throws, which the framework wraps as - // "Error handler had an error: ..." (HTTP 500), masking the intended - // 400. This test pins that behavior: the first call throws (so the - // action's argument resolution aborts), but the second call returns - // the raw, pre-filter params without re-invoking filters. + /* + * Regression: when a request filter throws a 4xx exception during + * Request::getParams() (e.g. RequestV20 rejecting an unparseable + * queries[]), the framework's error path calls getParams() again to + * build error-hook arguments. Without caching, that second call + * re-runs the filter and re-throws, which the framework wraps as + * "Error handler had an error: ..." (HTTP 500), masking the intended + * 400. This test pins that behavior: the first call throws (so the + * action's argument resolution aborts), but the second call returns + * the raw, pre-filter params without re-invoking filters. + */ $filter = new ThrowingFilter(400, 'invalid input'); $this->setupSingleMethodRoute($filter); From 737c85822d6b5ad95cb5885d73fc1c00172d3a9d Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 4 May 2026 12:46:44 +0530 Subject: [PATCH 396/402] multi line comment --- tests/unit/Utopia/RequestTest.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/unit/Utopia/RequestTest.php b/tests/unit/Utopia/RequestTest.php index a242d74f46..2247ff71f1 100644 --- a/tests/unit/Utopia/RequestTest.php +++ b/tests/unit/Utopia/RequestTest.php @@ -232,10 +232,12 @@ class RequestTest extends TestCase public function testGetParamsDoesNotCacheRawParamsForServerError(): void { - // 5xx filter throws indicate genuine server-side problems, not - // user-input mistakes. They must keep rethrowing on every call so - // the framework's normal error handling sees the failure each time - // — caching raw params would silently swallow real bugs. + /* + * 5xx filter throws indicate genuine server-side problems, not + * user-input mistakes. They must keep rethrowing on every call so + * the framework's normal error handling sees the failure each time + * — caching raw params would silently swallow real bugs. + */ $filter = new ThrowingFilter(500, 'boom'); $this->setupSingleMethodRoute($filter); From 1e9b364f5858db0036b692bcd6e58d3230394e8b Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 4 May 2026 16:50:38 +0530 Subject: [PATCH 397/402] feat(specs): include union of auth headers in every platform's securityDefinitions Each platform spec previously declared only the auth headers referenced by endpoints in that platform's surface. The unified web SDK now exposes a single Client class with cross-platform auth factories (fromSession, fromCookie, fromDevKey, etc.), which means an SDK generator targeting one platform still needs setter generation for headers that other platforms expose. The web SDK currently papers over this with a webClientHeaders augmentation in the SDK generator. Add the missing securityDefinitions entries directly so every platform spec carries the union, removing the need for SDK-side augmentation: - client: + Cookie (for SSR cookie forwarding) - server: + Cookie, DevKey - console: + Session, DevKey Per-endpoint security requirements continue to be filtered per platform as before; only the securityDefinitions registry expands to declare schemes that exist API-wide. SDKs that iterate the registry to emit auth setters now produce the union without per-SDK fallback code. --- src/Appwrite/Platform/Tasks/Specs.php | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index 82020b05b1..92f1f3d3f5 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -163,6 +163,12 @@ class Specs extends Action 'description' => 'Your secret dev API key', 'in' => 'header', ], + 'Cookie' => [ + 'type' => 'apiKey', + 'name' => 'Cookie', + 'description' => 'The user cookie to authenticate with. Used by SDKs that forward an incoming Cookie header in server-side runtimes.', + 'in' => 'header', + ], 'ImpersonateUserId' => [ 'type' => 'apiKey', 'name' => 'X-Appwrite-Impersonate-User-Id', @@ -219,6 +225,18 @@ class Specs extends Action 'description' => 'The user agent string of the client that made the request', 'in' => 'header', ], + 'DevKey' => [ + 'type' => 'apiKey', + 'name' => 'X-Appwrite-Dev-Key', + 'description' => 'Your secret dev API key', + 'in' => 'header', + ], + 'Cookie' => [ + 'type' => 'apiKey', + 'name' => 'Cookie', + 'description' => 'The user cookie to authenticate with. Used by SDKs that forward an incoming Cookie header in server-side runtimes.', + 'in' => 'header', + ], 'ImpersonateUserId' => [ 'type' => 'apiKey', 'name' => 'X-Appwrite-Impersonate-User-Id', @@ -275,6 +293,18 @@ class Specs extends Action 'description' => 'The user cookie to authenticate with', 'in' => 'header', ], + 'Session' => [ + 'type' => 'apiKey', + 'name' => 'X-Appwrite-Session', + 'description' => 'The user session to authenticate with', + 'in' => 'header', + ], + 'DevKey' => [ + 'type' => 'apiKey', + 'name' => 'X-Appwrite-Dev-Key', + 'description' => 'Your secret dev API key', + 'in' => 'header', + ], 'ImpersonateUserId' => [ 'type' => 'apiKey', 'name' => 'X-Appwrite-Impersonate-User-Id', From e81ae6774576afebfae8cc114bfa5eaf26937e16 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 4 May 2026 16:53:22 +0530 Subject: [PATCH 398/402] fix(specs): align console Cookie description with client/server --- src/Appwrite/Platform/Tasks/Specs.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index 92f1f3d3f5..c8120bd017 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -290,7 +290,7 @@ class Specs extends Action 'Cookie' => [ 'type' => 'apiKey', 'name' => 'Cookie', - 'description' => 'The user cookie to authenticate with', + 'description' => 'The user cookie to authenticate with. Used by SDKs that forward an incoming Cookie header in server-side runtimes.', 'in' => 'header', ], 'Session' => [ From 1a410c43d79ec837e54c48d4d34753c61e16fbef Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 5 May 2026 10:54:01 +0530 Subject: [PATCH 399/402] fix: update graphql php dependency --- composer.json | 2 +- composer.lock | 23 ++++++++++++----------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/composer.json b/composer.json index 735955d980..236e3aa83d 100644 --- a/composer.json +++ b/composer.json @@ -93,7 +93,7 @@ "chillerlan/php-qrcode": "4.3.*", "adhocore/jwt": "1.1.*", "spomky-labs/otphp": "11.*", - "webonyx/graphql-php": "15.31.*", + "webonyx/graphql-php": "15.32.*", "league/csv": "9.14.*", "enshrined/svg-sanitize": "0.22.*" }, diff --git a/composer.lock b/composer.lock index 9f34d2dbe1..a155f3f68b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bd45829c252971301370d62300be106d", + "content-hash": "2af4d953af2a624be8bf2f89ea27336c", "packages": [ { "name": "adhocore/jwt", @@ -5384,16 +5384,16 @@ }, { "name": "webonyx/graphql-php", - "version": "v15.31.5", + "version": "v15.32.3", "source": { "type": "git", "url": "https://github.com/webonyx/graphql-php.git", - "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9" + "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/089c4ef7e112df85788cfe06596278a8f99f4aa9", - "reference": "089c4ef7e112df85788cfe06596278a8f99f4aa9", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/993bf0bea17f870412ad8a90f60c41cb8d5f1145", + "reference": "993bf0bea17f870412ad8a90f60c41cb8d5f1145", "shasum": "" }, "require": { @@ -5402,16 +5402,16 @@ "php": "^7.4 || ^8" }, "require-dev": { - "amphp/amp": "^2.6", - "amphp/http-server": "^2.1", + "amphp/amp": "^2.6 || ^3", + "amphp/http-server": "^2.1 || ^3", "dms/phpunit-arraysubset-asserts": "dev-master", "ergebnis/composer-normalize": "^2.28", - "friendsofphp/php-cs-fixer": "3.94.2", + "friendsofphp/php-cs-fixer": "3.95.1", "mll-lab/php-cs-fixer-config": "5.13.0", "nyholm/psr7": "^1.5", "phpbench/phpbench": "^1.2", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "2.1.46", + "phpstan/phpstan": "2.1.51", "phpstan/phpstan-phpunit": "2.0.16", "phpstan/phpstan-strict-rules": "2.0.10", "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", @@ -5425,6 +5425,7 @@ "ticketswap/phpstan-error-formatter": "1.3.0" }, "suggest": { + "amphp/amp": "To leverage async resolving on AMPHP platform (v3 with AmpFutureAdapter, v2 with AmpPromiseAdapter)", "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", "psr/http-message": "To use standard GraphQL server", "react/promise": "To leverage async resolving on React PHP platform" @@ -5447,7 +5448,7 @@ ], "support": { "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v15.31.5" + "source": "https://github.com/webonyx/graphql-php/tree/v15.32.3" }, "funding": [ { @@ -5459,7 +5460,7 @@ "type": "open_collective" } ], - "time": "2026-04-11T18:06:15+00:00" + "time": "2026-04-24T13:49:35+00:00" } ], "packages-dev": [ From b0220292a706c8256c0c61c6a8d506f37cd97ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 5 May 2026 12:58:06 +0200 Subject: [PATCH 400/402] Rename policies to prevent double scope usease --- app/config/roles.php | 4 ++-- app/config/scopes/project.php | 14 +++++++++++++- .../Modules/Project/Http/Project/Policies/Get.php | 2 +- .../Project/Http/Project/Policies/XList.php | 2 +- src/Appwrite/Platform/Workers/Migrations.php | 4 ++-- tests/e2e/Scopes/ProjectCustom.php | 4 ++-- 6 files changed, 21 insertions(+), 9 deletions(-) diff --git a/app/config/roles.php b/app/config/roles.php index 8fba27e503..04175ac1d5 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -59,8 +59,8 @@ $admins = [ 'oauth2.write', 'mocks.read', 'mocks.write', - 'policies.read', - 'policies.write', + 'project.policies.read', + 'project.policies.write', 'templates.read', 'templates.write', 'projects.write', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index a048920de9..6e019e7b93 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -44,11 +44,23 @@ return [ "category" => "Project", ], "policies.read" => [ + "description" => + "Access to read project\'s policies. Replaced by \'project.policies.read\' for more granular control.", + "category" => "Project", + 'deprecated' => true, + ], + "policies.write" => [ + "description" => + "Access to update project\'s policies. Replaces by \'project.policies.read\' for more granular control.", + "category" => "Project", + 'deprecated' => true, + ], + "project.policies.read" => [ "description" => "Access to read project\'s policies", "category" => "Project", ], - "policies.write" => [ + "project.policies.write" => [ "description" => "Access to update project\'s policies", "category" => "Project", diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php index 3ffe30f1fa..21342332d9 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php @@ -27,7 +27,7 @@ class Get extends Action ->setHttpPath('/v1/project/policies/:policyId') ->desc('Get project policy') ->groups(['api', 'project']) - ->label('scope', 'policies.read') + ->label('scope', ['policies.read', 'project.policies.read']) ->label('sdk', new Method( namespace: 'project', group: 'policies', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php index 893b28fef2..3020fa79dd 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php @@ -33,7 +33,7 @@ class XList extends Action ->setHttpPath('/v1/project/policies') ->desc('List project policies') ->groups(['api', 'project']) - ->label('scope', 'policies.read') + ->label('scope', ['policies.read', 'project.policies.read']) ->label('sdk', new Method( namespace: 'project', group: 'policies', diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index c25a83c231..3fd86baea9 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -380,8 +380,8 @@ class Migrations extends Action 'oauth2.write', 'mocks.read', 'mocks.write', - 'policies.read', - 'policies.write', + 'project.policies.read', + 'project.policies.write', 'templates.read', 'templates.write', ]; diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 3071ddfa2a..99219ebf99 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -173,8 +173,8 @@ trait ProjectCustom 'oauth2.write', 'mocks.read', 'mocks.write', - 'policies.read', - 'policies.write', + 'project.policies.read', + 'project.policies.write', 'templates.read', 'templates.write', ], From 32d30dfd9d1e9a823611296859afa5168b3a0639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 5 May 2026 13:06:57 +0200 Subject: [PATCH 401/402] Fix copy --- app/config/scopes/project.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 6e019e7b93..7a61524b87 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -45,13 +45,13 @@ return [ ], "policies.read" => [ "description" => - "Access to read project\'s policies. Replaced by \'project.policies.read\' for more granular control.", + "Access to read project\'s policies. Replaced by \'project.policies.read\' for more granular control", "category" => "Project", 'deprecated' => true, ], "policies.write" => [ "description" => - "Access to update project\'s policies. Replaces by \'project.policies.read\' for more granular control.", + "Access to update project\'s policies. Replaces by \'project.policies.write\' for more granular control", "category" => "Project", 'deprecated' => true, ], From fef4cbf3b04006f7f96879e6a6c2118bad20953f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 5 May 2026 13:37:51 +0200 Subject: [PATCH 402/402] Fix missing new scope --- .../Project/Http/Project/Policies/MembershipPrivacy/Update.php | 2 +- .../Project/Http/Project/Policies/PasswordDictionary/Update.php | 2 +- .../Project/Http/Project/Policies/PasswordHistory/Update.php | 2 +- .../Http/Project/Policies/PasswordPersonalData/Update.php | 2 +- .../Project/Http/Project/Policies/SessionAlert/Update.php | 2 +- .../Project/Http/Project/Policies/SessionDuration/Update.php | 2 +- .../Http/Project/Policies/SessionInvalidation/Update.php | 2 +- .../Project/Http/Project/Policies/SessionLimit/Update.php | 2 +- .../Modules/Project/Http/Project/Policies/UserLimit/Update.php | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php index c947ff225a..41a6168b07 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php @@ -31,7 +31,7 @@ class Update extends Action ->httpAlias('/v1/projects/:projectId/auth/memberships-privacy') ->desc('Update membership privacy policy') ->groups(['api', 'project']) - ->label('scope', 'policies.write') + ->label('scope', ['policies.write', 'project.policies.write']) ->label('event', 'projects.[projectId].policies.[policy].update') ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php index e2c678abb6..d7ee99fbfe 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php @@ -31,7 +31,7 @@ class Update extends Action ->httpAlias('/v1/projects/:projectId/auth/password-dictionary') ->desc('Update password dictionary policy') ->groups(['api', 'project']) - ->label('scope', 'policies.write') + ->label('scope', ['policies.write', 'project.policies.write']) ->label('event', 'projects.[projectId].policies.[policy].update') ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php index a8ae81caff..84861a19e1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php @@ -32,7 +32,7 @@ class Update extends Action ->httpAlias('/v1/projects/:projectId/auth/password-history') ->desc('Update password history policy') ->groups(['api', 'project']) - ->label('scope', 'policies.write') + ->label('scope', ['policies.write', 'project.policies.write']) ->label('event', 'projects.[projectId].policies.[policy].update') ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php index 9db7cf0549..435f00fc39 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php @@ -31,7 +31,7 @@ class Update extends Action ->httpAlias('/v1/projects/:projectId/auth/personal-data') ->desc('Update password personal data policy') ->groups(['api', 'project']) - ->label('scope', 'policies.write') + ->label('scope', ['policies.write', 'project.policies.write']) ->label('event', 'projects.[projectId].policies.[policy].update') ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php index 22b7a44b04..79653d46ad 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php @@ -31,7 +31,7 @@ class Update extends Action ->httpAlias('/v1/projects/:projectId/auth/session-alerts') ->desc('Update session alert policy') ->groups(['api', 'project']) - ->label('scope', 'policies.write') + ->label('scope', ['policies.write', 'project.policies.write']) ->label('event', 'projects.[projectId].policies.[policy].update') ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php index ba72c93a6f..0a7f33218a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php @@ -31,7 +31,7 @@ class Update extends Action ->httpAlias('/v1/projects/:projectId/auth/duration') ->desc('Update session duration policy') ->groups(['api', 'project']) - ->label('scope', 'policies.write') + ->label('scope', ['policies.write', 'project.policies.write']) ->label('event', 'projects.[projectId].policies.[policy].update') ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php index 8f8a959959..a1feb67346 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php @@ -31,7 +31,7 @@ class Update extends Action ->httpAlias('/v1/projects/:projectId/auth/session-invalidation') ->desc('Update session invalidation policy') ->groups(['api', 'project']) - ->label('scope', 'policies.write') + ->label('scope', ['policies.write', 'project.policies.write']) ->label('event', 'projects.[projectId].policies.[policy].update') ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php index 382ed6f0d9..936a541249 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php @@ -32,7 +32,7 @@ class Update extends Action ->httpAlias('/v1/projects/:projectId/auth/max-sessions') ->desc('Update session limit policy') ->groups(['api', 'project']) - ->label('scope', 'policies.write') + ->label('scope', ['policies.write', 'project.policies.write']) ->label('event', 'projects.[projectId].policies.[policy].update') ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php index 9129b81250..2b7e704853 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php @@ -32,7 +32,7 @@ class Update extends Action ->httpAlias('/v1/projects/:projectId/auth/limit') ->desc('Update user limit policy') ->groups(['api', 'project']) - ->label('scope', 'policies.write') + ->label('scope', ['policies.write', 'project.policies.write']) ->label('event', 'projects.[projectId].policies.[policy].update') ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}')