From 4396a0ef7e373b719adc42bc78bc15d4540c76eb Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 28 Aug 2025 19:06:18 +0530 Subject: [PATCH 01/31] added processing of migration resource stats --- src/Appwrite/Platform/Workers/Migrations.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 649f62609b..01203aa394 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -25,6 +25,10 @@ use Utopia\Migration\Destination; use Utopia\Migration\Destinations\Appwrite as DestinationAppwrite; use Utopia\Migration\Destinations\CSV as DestinationCSV; use Utopia\Migration\Exception as MigrationException; +use Utopia\Migration\Resource; +use Utopia\Migration\Resources\Database\Database as ResourceDatabase; +use Utopia\Migration\Resources\Database\Row as ResourceRow; +use Utopia\Migration\Resources\Database\Table as ResourceTable; use Utopia\Migration\Source; use Utopia\Migration\Sources\Appwrite as SourceAppwrite; use Utopia\Migration\Sources\CSV; @@ -52,6 +56,7 @@ class Migrations extends Action */ protected array $sourceReport = []; + private string $source; /** * @var callable|null */ @@ -368,6 +373,7 @@ class Migrations extends Action $destination ); + $aggregatedResources = []; /** Start Transfer */ if (empty($source->getErrors())) { $migration->setAttribute('stage', 'migrating'); @@ -375,10 +381,14 @@ class Migrations extends Action $transfer->run( $migration->getAttribute('resources'), - function () use ($migration, $transfer, $project, $queueForRealtime) { + function ($resources) use ($migration, $transfer, $project, $queueForRealtime, &$aggregatedResources) { $migration->setAttribute('resourceData', json_encode($transfer->getCache())); $migration->setAttribute('statusCounters', json_encode($transfer->getStatusCounters())); - $this->updateMigrationDocument($migration, $project, $queueForRealtime); + + if (!empty($resources)) { + $aggregatedResources[] = $resources; + } + $this->updateMigrationDocument($migration, $projectDocument, $queueForRealtime); }, $migration->getAttribute('resourceId'), $migration->getAttribute('resourceType') From b517e502a94bec8cb90bd3f0b25373dc207c63ac Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 28 Aug 2025 19:10:00 +0530 Subject: [PATCH 02/31] lint From 532ec364d2c6996e65bed2a4aade7b890352c25c Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 28 Aug 2025 19:13:24 +0530 Subject: [PATCH 03/31] updated migration version From bf841442017a15af94e1ad74432ac57edd49d602 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 28 Aug 2025 19:14:14 +0530 Subject: [PATCH 04/31] updated reset of the stats usage worker to reset metrics --- src/Appwrite/Event/StatsUsage.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Appwrite/Event/StatsUsage.php b/src/Appwrite/Event/StatsUsage.php index 47ba5a3ea0..a944d70c94 100644 --- a/src/Appwrite/Event/StatsUsage.php +++ b/src/Appwrite/Event/StatsUsage.php @@ -86,4 +86,11 @@ class StatsUsage extends Event }), ]; } + + public function reset(): Event + { + $this->metrics = []; + parent::reset(); + return $this; + } } From 799776353207f109c86784c2e52d58e1c72b5afb Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 28 Aug 2025 19:21:11 +0530 Subject: [PATCH 05/31] linting --- src/Appwrite/Platform/Workers/Migrations.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 01203aa394..9585ee198e 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -388,7 +388,7 @@ class Migrations extends Action if (!empty($resources)) { $aggregatedResources[] = $resources; } - $this->updateMigrationDocument($migration, $projectDocument, $queueForRealtime); + $this->updateMigrationDocument($migration, $project, $queueForRealtime); }, $migration->getAttribute('resourceId'), $migration->getAttribute('resourceType') From 6c38c566d9a64f038aad4093ea734e1c5265ffaa Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 28 Aug 2025 19:59:48 +0530 Subject: [PATCH 06/31] optimised the memory usage by resource aggregation --- src/Appwrite/Platform/Workers/Migrations.php | 29 +++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 9585ee198e..e453625456 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -386,7 +386,34 @@ class Migrations extends Action $migration->setAttribute('statusCounters', json_encode($transfer->getStatusCounters())); if (!empty($resources)) { - $aggregatedResources[] = $resources; + /** + * @var Resource $resource + */ + $resource = $resources[0]; + $count = count($resources); + $databaseId = null; + $tableId = null; + switch ($resource->getName()) { + case ResourceTable::getName(): + /** @var ResourceTable $resource */ + $databaseId = $resource->getDatabase()->getSequence(); + break; + case ResourceRow::getName(): + /** @var ResourceRow $resource */ + $table = $resource->getTable(); + $databaseId = $table->getDatabase()->getSequence(); + $tableId = $table->getSequence(); + break; + default: + break; + } + $aggregatedResources[] = [ + "name" => $resource->getName(), + "count" => $count, + "databaseId" => $databaseId, + "tableId" => $tableId + ]; + } $this->updateMigrationDocument($migration, $project, $queueForRealtime); }, From a3574032e3ed496192208edded98a14ebf2bf35e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 29 Aug 2025 02:48:31 +1200 Subject: [PATCH 07/31] Update src/Appwrite/Platform/Workers/Migrations.php --- src/Appwrite/Platform/Workers/Migrations.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index e453625456..8601012d00 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -408,10 +408,10 @@ class Migrations extends Action break; } $aggregatedResources[] = [ - "name" => $resource->getName(), - "count" => $count, - "databaseId" => $databaseId, - "tableId" => $tableId + 'name' => $resource->getName(), + 'count' => $count, + 'databaseId' => $databaseId, + 'tableId' => $tableId ]; } From 96532b9725b571e57f25ad4a33c2afa437f7f775 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 6 Feb 2026 18:47:22 +0530 Subject: [PATCH 08/31] linting --- src/Appwrite/Platform/Workers/Migrations.php | 68 ++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 8601012d00..cfacc0f450 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -5,6 +5,7 @@ namespace Appwrite\Platform\Workers; use Ahc\Jwt\JWT; use Appwrite\Event\Mail; use Appwrite\Event\Realtime; +use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\Template\Template; use Utopia\CLI\Console; @@ -83,6 +84,7 @@ class Migrations extends Action ->inject('deviceForMigrations') ->inject('deviceForFiles') ->inject('queueForMails') + ->inject('queueForStatsUsage') ->inject('plan') ->inject('authorization') ->callback($this->action(...)); @@ -101,6 +103,7 @@ class Migrations extends Action Device $deviceForMigrations, Device $deviceForFiles, Mail $queueForMails, + StatsUsage $queueForStatsUsage, array $plan, Authorization $authorization, ): void { @@ -144,6 +147,7 @@ class Migrations extends Action $migration, $queueForRealtime, $queueForMails, + $queueForStatsUsage, $platform, $authorization ); @@ -329,6 +333,7 @@ class Migrations extends Action Document $migration, Realtime $queueForRealtime, Mail $queueForMails, + StatsUsage $queueForStatsUsage, array $platform, Authorization $authorization, ): void { @@ -480,6 +485,16 @@ class Migrations extends Action } if ($migration->getAttribute('status', '') === 'completed') { + foreach ($aggregatedResources as $resource) { + $this->processMigrationResourceStats( + $resource, + $queueForStatsUsage, + $project, + $migration->getAttribute('source'), + $authorization, + $migration->getAttribute('resourceId') + ); + } $destination?->success(); $source?->success(); @@ -774,4 +789,57 @@ class Migrations extends Action return $errors; } + + private function processMigrationResourceStats(array $resources, StatsUsage $queueForStatsUsage, Document $projectDocument, string $source, Authorization $authorization, ?string $resourceId) + { + $resourceName = $resources['name']; + $count = $resources['count']; + $databaseInternalId = $resources['databaseId']; + $tableInternalId = $resources['tableId']; + + if ($source === CSV::getName()) { + [$databaseId, $tableId] = explode(':', $resourceId); + $database = $authorization->skip(fn () => $this->dbForProject->getDocument('databases', $databaseId)); + $table = $authorization->skip(fn () => $this->dbForProject->getDocument('database_' . $database->getSequence(), $tableId)); + $databaseInternalId = (int) $database->getSequence(); + $tableInternalId = (int) $table->getSequence(); + } + + switch ($resourceName) { + case ResourceDatabase::getName(): + $queueForStatsUsage->addMetric(METRIC_DATABASES, $count); + break; + + case ResourceTable::getName(): + $queueForStatsUsage + ->addMetric(METRIC_COLLECTIONS, $count) + ->addMetric( + str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_COLLECTIONS), + $count + ); + break; + + case ResourceRow::getName(): + $queueForStatsUsage + ->addMetric( + str_replace( + ['{databaseInternalId}','{collectionInternalId}'], + [$databaseInternalId, $tableInternalId], + METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS + ), + $count + ) + ->addMetric( + str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_DOCUMENTS), + $count + ); + break; + + default: + break; + } + + $queueForStatsUsage->setProject($projectDocument)->trigger(); + $queueForStatsUsage->reset(); + } } From 7faf14769f3b1c3ce2ff8c4531fe0876b9062e74 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 6 Feb 2026 19:10:00 +0530 Subject: [PATCH 09/31] added metric documents --- src/Appwrite/Platform/Workers/Migrations.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index cfacc0f450..e559dc0787 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -832,7 +832,8 @@ class Migrations extends Action ->addMetric( str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_DOCUMENTS), $count - ); + ) + ->addMetric(METRIC_DOCUMENTS, $count); break; default: From 36c87d109a4d9a7d5f79056a38fd938197c4fe7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 13:45:18 +0100 Subject: [PATCH 10/31] Fix rule oauth flow --- app/controllers/api/account.php | 20 ++++++++++++++++++-- src/Appwrite/Network/Validator/Origin.php | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index bc84ae7ef6..8d819d429d 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -64,7 +64,6 @@ use Utopia\Http; use Utopia\Locale\Locale; use Utopia\Storage\Validator\FileName; use Utopia\System\System; -use Utopia\Validator; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; @@ -1469,13 +1468,14 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->inject('devKey') ->inject('user') ->inject('dbForProject') + ->inject('dbForPlatform') ->inject('geodb') ->inject('queueForEvents') ->inject('store') ->inject('proofForPassword') ->inject('proofForToken') ->inject('authorization') - ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) { + ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Redirect $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) { $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); @@ -1512,6 +1512,22 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $state = $defaultState; } + // Allow redirect to rule URL if related to project + $rules = $authorization->skip(fn () => $dbForPlatform->find('rules', [ + Query::equal('domain', [ + parse_url($state['success'], PHP_URL_HOST), + parse_url($state['failure'], PHP_URL_HOST) + ]), + Query::equal('projectIntenralId', [$project->getSequence()]), + Query::limit(2) + ])); + + foreach ($rules as $rule) { + $allowedHostnames = $redirectValidator->getAllowedHostnames(); + $allowedHostnames[] = $rule['domain']; + $redirectValidator->setAllowedHostnames($allowedHostnames); + } + if ($devKey->isEmpty() && !$redirectValidator->isValid($state['success'])) { throw new Exception(Exception::PROJECT_INVALID_SUCCESS_URL); } diff --git a/src/Appwrite/Network/Validator/Origin.php b/src/Appwrite/Network/Validator/Origin.php index 02d5d8e83d..2f76aa2f86 100644 --- a/src/Appwrite/Network/Validator/Origin.php +++ b/src/Appwrite/Network/Validator/Origin.php @@ -22,6 +22,27 @@ class Origin extends Validator { } + public function setAllowedHostnames(array $allowedHostnames): self + { + $this->allowedHostnames = $allowedHostnames; + return $this; + } + + public function setAllowedSchemes(array $allowedSchemes): self + { + $this->allowedSchemes = $allowedSchemes; + return $this; + } + + public function getAllowedHostnames(): array + { + return $this->allowedHostnames; + } + + public function getAllowedSchemes(): array + { + return $this->allowedSchemes; + } /** * Check if Origin is valid. From 074ffad82624fffabec966075fce86e6cbb96287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 13:46:15 +0100 Subject: [PATCH 11/31] Improve origin unit tests --- tests/unit/Network/Validators/OriginTest.php | 56 ++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/unit/Network/Validators/OriginTest.php b/tests/unit/Network/Validators/OriginTest.php index a4c235f755..aa3ab65e5a 100644 --- a/tests/unit/Network/Validators/OriginTest.php +++ b/tests/unit/Network/Validators/OriginTest.php @@ -74,4 +74,60 @@ class OriginTest extends TestCase $this->assertEquals(false, $validator->isValid('random-scheme://localhost')); $this->assertEquals('Invalid Scheme. The scheme used (random-scheme) in the Origin (random-scheme://localhost) is not supported. If you are using a custom scheme, please change it to `appwrite-callback-`', $validator->getDescription()); } + + public function testGetAllowedHostnames(): void + { + $validator = new Origin( + allowedHostnames: ['appwrite.io', 'localhost'], + allowedSchemes: ['exp'] + ); + + $this->assertEquals(['appwrite.io', 'localhost'], $validator->getAllowedHostnames()); + } + + public function testGetAllowedSchemes(): void + { + $validator = new Origin( + allowedHostnames: ['appwrite.io'], + allowedSchemes: ['exp', 'appwrite-callback-123'] + ); + + $this->assertEquals(['exp', 'appwrite-callback-123'], $validator->getAllowedSchemes()); + } + + public function testSetAllowedHostnames(): void + { + $validator = new Origin( + allowedHostnames: ['appwrite.io'], + allowedSchemes: ['exp'] + ); + + $this->assertEquals(true, $validator->isValid('https://appwrite.io')); + $this->assertEquals(false, $validator->isValid('https://example.com')); + + $result = $validator->setAllowedHostnames(['example.com']); + + $this->assertSame($validator, $result); + $this->assertEquals(['example.com'], $validator->getAllowedHostnames()); + $this->assertEquals(true, $validator->isValid('https://example.com')); + $this->assertEquals(false, $validator->isValid('https://appwrite.io')); + } + + public function testSetAllowedSchemes(): void + { + $validator = new Origin( + allowedHostnames: ['appwrite.io'], + allowedSchemes: ['exp'] + ); + + $this->assertEquals(true, $validator->isValid('exp://')); + $this->assertEquals(false, $validator->isValid('appwrite-callback-456://')); + + $result = $validator->setAllowedSchemes(['appwrite-callback-456']); + + $this->assertSame($validator, $result); + $this->assertEquals(['appwrite-callback-456'], $validator->getAllowedSchemes()); + $this->assertEquals(true, $validator->isValid('appwrite-callback-456://')); + $this->assertEquals(false, $validator->isValid('exp://')); + } } From 525b929e54b668227b94b00d60574ca820ef9bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 13:57:19 +0100 Subject: [PATCH 12/31] Fix implementation, add tests --- app/controllers/api/account.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 8d819d429d..afb45dbfb9 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1518,7 +1518,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') parse_url($state['success'], PHP_URL_HOST), parse_url($state['failure'], PHP_URL_HOST) ]), - Query::equal('projectIntenralId', [$project->getSequence()]), + Query::equal('projectInternalId', [$project->getSequence()]), Query::limit(2) ])); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e2e5621662..abdcbcee24 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5190,6 +5190,25 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://domain-without-rule.com' ], followRedirects: false); $this->assertEquals(400, $response['headers']['status-code']); + + // Also ensure final step blocks unknown redirect URL + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'referer' => 'https://' . $domain, + 'origin' => '', + 'referer' => 'https://mockserver.com', + ], [ + 'code' => 'any-code', + 'state' => \json_encode([ + 'success' => 'https://domain-without-rule.com', + 'failure' => 'https://domain-without-rule.com' + ]), + 'error' => '', + 'errorDescription' => '', + ], followRedirects: false); + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertStringContainsString('project_invalid_success_url', $response['body']); // Ensure rule's domain can be redirect URL $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [ @@ -5202,6 +5221,25 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://' . $domain ], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); + + // Also ensure final step allows redirect URL + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'referer' => 'https://' . $domain, + 'origin' => '', + 'referer' => 'https://mockserver.com', + ], [ + 'code' => 'any-code', + 'state' => \json_encode([ + 'success' => 'https://' . $domain, + 'failure' => 'https://' . $domain + ]), + 'error' => '', + 'errorDescription' => '', + ], followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringContainsString('https://' . $domain, $response['headers']['location']); // Ensure unknown domain cannot be redirect URL $response = $this->client->call(Client::METHOD_POST, '/account/sessions/magic-url', [ From 615aff07143feb09903e350f6c37b02ec146fe3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 14:34:05 +0100 Subject: [PATCH 13/31] Allow custom ID for API keys --- app/controllers/api/projects.php | 6 ++- tests/e2e/Scopes/ProjectCustom.php | 2 + .../Projects/ProjectsConsoleClientTest.php | 44 ++++++++++++++++++- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index d61340cfff..23ec2f51c4 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -14,6 +14,7 @@ use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; +use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Response; use PHPMailer\PHPMailer\PHPMailer; use Utopia\Config\Config; @@ -1094,12 +1095,13 @@ Http::post('/v1/projects/:projectId/keys') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('keyId', '', new CustomId(), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') ->param('scopes', null, new Nullable(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.') ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -1108,7 +1110,7 @@ Http::post('/v1/projects/:projectId/keys') } $key = new Document([ - '$id' => ID::unique(), + '$id' => $keyId, '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 52c53016d6..1859d551a4 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -60,6 +60,7 @@ trait ProjectCustom 'cookie' => 'a_session_console=' . $this->getRoot()['session'], 'x-appwrite-project' => 'console', ], [ + 'keyId' => ID::unique(), 'name' => 'Demo Project Key', 'scopes' => [ 'users.read', @@ -194,6 +195,7 @@ trait ProjectCustom 'cookie' => 'a_session_console=' . $this->getRoot()['session'], 'x-appwrite-project' => 'console', ], [ + 'keyId' => ID::unique(), 'name' => 'Demo Project Key', 'scopes' => $scopes, ]); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e2e5621662..d12ad35d9c 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -2774,6 +2774,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['functions.read', 'teams.write'], ]); @@ -3123,6 +3124,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['teams.read', 'teams.write'], ]); @@ -3137,6 +3139,36 @@ class ProjectsConsoleClientTest extends Scope $this->assertEmpty($response['body']['sdks']); $this->assertArrayHasKey('accessedAt', $response['body']); $this->assertEmpty($response['body']['accessedAt']); + + /** + * Test for SUCCESS without key ID + */ + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Key Custom', + 'scopes' => ['teams.read', 'teams.write'], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + + /** + * Test for SUCCESS with custom ID + */ + $customKeyId = 'key-with-custom-id'; + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'keyId' => $customKeyId, + 'name' => 'Key Custom', + 'scopes' => ['teams.read', 'teams.write'], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals($customKeyId, $response['body']['$id']); $data = array_merge($data, [ 'keyId' => $response['body']['$id'], @@ -3150,6 +3182,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['unknown'], ]); @@ -3174,7 +3207,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(1, $response['body']['total']); + $this->assertEquals(3, $response['body']['total']); /** * Test for FAILURE @@ -3200,7 +3233,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($keyId, $response['body']['$id']); - $this->assertEquals('Key Test', $response['body']['name']); + $this->assertEquals('Key Custom', $response['body']['name']); $this->assertContains('teams.read', $response['body']['scopes']); $this->assertContains('teams.write', $response['body']['scopes']); $this->assertCount(2, $response['body']['scopes']); @@ -3240,6 +3273,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['users.write'], 'expire' => DateTime::addSeconds(new \DateTime(), 3600), @@ -3260,6 +3294,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['health.read'], 'expire' => null, @@ -3282,6 +3317,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['health.read'], 'expire' => DateTime::addSeconds(new \DateTime(), -3600), @@ -3323,6 +3359,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['teams.read'], 'expire' => DateTime::addSeconds(new \DateTime(), 3600), @@ -3355,6 +3392,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['health.read'], 'expire' => DateTime::addSeconds(new \DateTime(), 3600), @@ -4364,6 +4402,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['users.read', 'users.write'], ]); @@ -4384,6 +4423,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ + 'keyId' => ID::unique(), 'name' => 'Key Test', 'scopes' => ['users.read', 'users.write'], ]); From 9b762dde40baf629937ab792bd1e0ff6179e53ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 14:34:36 +0100 Subject: [PATCH 14/31] formatting fix --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index d12ad35d9c..25a29f9844 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3139,7 +3139,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEmpty($response['body']['sdks']); $this->assertArrayHasKey('accessedAt', $response['body']); $this->assertEmpty($response['body']['accessedAt']); - + /** * Test for SUCCESS without key ID */ From 40ab50ec9de23fd28c48c9bdf1784fab83f84445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 14:34:52 +0100 Subject: [PATCH 15/31] formatting fix --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index abdcbcee24..c406632a2d 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5190,7 +5190,7 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://domain-without-rule.com' ], followRedirects: false); $this->assertEquals(400, $response['headers']['status-code']); - + // Also ensure final step blocks unknown redirect URL $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ 'content-type' => 'application/json', @@ -5221,7 +5221,7 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://' . $domain ], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); - + // Also ensure final step allows redirect URL $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ 'content-type' => 'application/json', @@ -5232,8 +5232,8 @@ class ProjectsConsoleClientTest extends Scope ], [ 'code' => 'any-code', 'state' => \json_encode([ - 'success' => 'https://' . $domain, - 'failure' => 'https://' . $domain + 'success' => 'https://' . $domain, + 'failure' => 'https://' . $domain ]), 'error' => '', 'errorDescription' => '', From 96e85c0bab0c0d9719913cf90a750e572e338370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 14:35:47 +0100 Subject: [PATCH 16/31] AI pr review --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index c406632a2d..74d1aa9580 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5195,7 +5195,6 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, - 'referer' => 'https://' . $domain, 'origin' => '', 'referer' => 'https://mockserver.com', ], [ @@ -5226,7 +5225,6 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, - 'referer' => 'https://' . $domain, 'origin' => '', 'referer' => 'https://mockserver.com', ], [ From 6df5556473c333c0e89e833adb026058b850bd2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 14:58:36 +0100 Subject: [PATCH 17/31] Implement queries param to list api keys --- app/controllers/api/projects.php | 48 ++++++- .../Database/Validator/Queries/Keys.php | 18 +++ .../Projects/ProjectsConsoleClientTest.php | 136 ++++++++++++++++++ 3 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/Keys.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index d61340cfff..4ccb0be2ad 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -29,6 +29,8 @@ use Utopia\Domains\Validator\PublicDomain; use Utopia\Http; use Utopia\Locale\Locale; use Utopia\System\System; +use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Validator\Query\Cursor; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Hostname; @@ -1152,10 +1154,12 @@ Http::get('/v1/projects/:projectId/keys') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('queries', [], new \Appwrite\Utopia\Database\Validator\Queries\Keys(), '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 on the following attributes: ' . implode(', ', \Appwrite\Utopia\Database\Validator\Queries\Keys::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('dbForPlatform') - ->action(function (string $projectId, bool $includeTotal, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, array $queries, string $search, bool $includeTotal, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -1163,15 +1167,45 @@ Http::get('/v1/projects/:projectId/keys') throw new Exception(Exception::PROJECT_NOT_FOUND); } - $keys = $dbForPlatform->find('keys', [ - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - Query::limit(5000), - ]); + 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); + } + + $queries[] = Query::equal('resourceType', ['projects']); + $queries[] = Query::equal('resourceInternalId', [$project->getSequence()]); + + $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()); + } + + $keyId = $cursor->getValue(); + $cursorDocument = $dbForPlatform->getDocument('keys', $keyId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + $keys = $dbForPlatform->find('keys', $queries); $response->dynamic(new Document([ 'keys' => $keys, - 'total' => $includeTotal ? count($keys) : 0, + 'total' => $includeTotal ? $dbForPlatform->count('keys', $filterQueries, APP_LIMIT_COUNT) : 0, ]), Response::MODEL_KEY_LIST); }); diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Keys.php b/src/Appwrite/Utopia/Database/Validator/Queries/Keys.php new file mode 100644 index 0000000000..4bae12765c --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Keys.php @@ -0,0 +1,18 @@ +client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Key Test 2', + 'scopes' => ['users.read'], + 'expire' => $expireDate, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $key2Id = $response['body']['$id']; + + /** List all keys (no queries) */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), []); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(2, $response['body']['total']); + $this->assertCount(2, $response['body']['keys']); + + /** List keys with limit */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::limit(1)->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['keys']); + $this->assertEquals(2, $response['body']['total']); + + /** List keys with offset */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::offset(1)->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['keys']); + $this->assertEquals(2, $response['body']['total']); + + /** List keys with cursor after */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::cursorAfter(new Document(['$id' => $data['keyId']]))->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['keys']); + $this->assertEquals($key2Id, $response['body']['keys'][0]['$id']); + + /** List keys filtering by expire (lessThan now — should match none) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::lessThan('expire', (new \DateTime())->format('Y-m-d H:i:s'))->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(0, $response['body']['total']); + + /** List keys filtering by expire (greaterThan now — should match the key with expiry) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::greaterThan('expire', (new \DateTime())->format('Y-m-d H:i:s'))->toString(), + ] + ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals(1, $response['body']['total']); + $this->assertCount(1, $response['body']['keys']); + $this->assertEquals('Key Test 2', $response['body']['keys'][0]['name']); + + /** List keys with orderDesc */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::orderDesc('')->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(2, $response['body']['keys']); + $this->assertEquals('Key Test 2', $response['body']['keys'][0]['name']); + $this->assertEquals('Key Test', $response['body']['keys'][1]['name']); + + /** List keys with total disabled */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'total' => false, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(2, $response['body']['keys']); + $this->assertEquals(0, $response['body']['total']); /** * Test for FAILURE */ + /** Test invalid query attribute */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('secret', ['test'])->toString(), + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + /** Test invalid cursor */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::cursorAfter(new Document(['$id' => 'invalid']))->toString(), + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + return $data; } From 7bf5f2d36074989d30c0f999f1cd0572fe014b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 15:55:35 +0100 Subject: [PATCH 18/31] Fix bug 5xx error --- .env | 2 +- app/controllers/api/account.php | 30 +++++++++++++++++------------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/.env b/.env index 7ac8fc25ef..c190e29c0b 100644 --- a/.env +++ b/.env @@ -18,7 +18,7 @@ _APP_EMAIL_SECURITY=security@appwrite.io _APP_EMAIL_CERTIFICATES=certificates@appwrite.io _APP_SYSTEM_RESPONSE_FORMAT= _APP_CUSTOM_DOMAIN_DENY_LIST= -_APP_OPTIONS_ABUSE=disabled +_APP_OPTIONS_ABUSE=enabled _APP_OPTIONS_ROUTER_PROTECTION=disabled _APP_OPTIONS_FORCE_HTTPS=disabled _APP_OPTIONS_ROUTER_FORCE_HTTPS=disabled diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index afb45dbfb9..1e2bb5aee0 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -64,6 +64,7 @@ use Utopia\Http; use Utopia\Locale\Locale; use Utopia\Storage\Validator\FileName; use Utopia\System\System; +use Utopia\Validator; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; @@ -1475,7 +1476,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->inject('proofForPassword') ->inject('proofForToken') ->inject('authorization') - ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Redirect $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) { + ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) { $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); @@ -1513,19 +1514,22 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } // Allow redirect to rule URL if related to project - $rules = $authorization->skip(fn () => $dbForPlatform->find('rules', [ - Query::equal('domain', [ - parse_url($state['success'], PHP_URL_HOST), - parse_url($state['failure'], PHP_URL_HOST) - ]), - Query::equal('projectInternalId', [$project->getSequence()]), - Query::limit(2) - ])); + //Check if $redirectValidator is instance of Redirect class + if ($redirectValidator instanceof Redirect) { + $rules = $authorization->skip(fn () => $dbForPlatform->find('rules', [ + Query::equal('domain', [ + parse_url($state['success'], PHP_URL_HOST), + parse_url($state['failure'], PHP_URL_HOST) + ]), + Query::equal('projectInternalId', [$project->getSequence()]), + Query::limit(2) + ])); - foreach ($rules as $rule) { - $allowedHostnames = $redirectValidator->getAllowedHostnames(); - $allowedHostnames[] = $rule['domain']; - $redirectValidator->setAllowedHostnames($allowedHostnames); + foreach ($rules as $rule) { + $allowedHostnames = $redirectValidator->getAllowedHostnames(); + $allowedHostnames[] = $rule['domain']; + $redirectValidator->setAllowedHostnames($allowedHostnames); + } } if ($devKey->isEmpty() && !$redirectValidator->isValid($state['success'])) { From 3dc69ba62abb6acc4b2b58722071173bc037fd75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 15:55:49 +0100 Subject: [PATCH 19/31] Revert unwanted push --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index c190e29c0b..7ac8fc25ef 100644 --- a/.env +++ b/.env @@ -18,7 +18,7 @@ _APP_EMAIL_SECURITY=security@appwrite.io _APP_EMAIL_CERTIFICATES=certificates@appwrite.io _APP_SYSTEM_RESPONSE_FORMAT= _APP_CUSTOM_DOMAIN_DENY_LIST= -_APP_OPTIONS_ABUSE=enabled +_APP_OPTIONS_ABUSE=disabled _APP_OPTIONS_ROUTER_PROTECTION=disabled _APP_OPTIONS_FORCE_HTTPS=disabled _APP_OPTIONS_ROUTER_FORCE_HTTPS=disabled From e666dc9504e70313cceb06d78762e24556d22c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 16:42:14 +0100 Subject: [PATCH 20/31] AI review fixes --- app/controllers/api/projects.php | 4 +++- .../Projects/ProjectsConsoleClientTest.php | 14 +++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 23ec2f51c4..4fa40bcbc1 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1095,13 +1095,15 @@ Http::post('/v1/projects/:projectId/keys') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('keyId', '', new CustomId(), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + // TODO: When migrating to Platform API, mark keyId required for consistency + ->param('keyId', 'unique()', new CustomId(), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', true) ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') ->param('scopes', null, new Nullable(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.') ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('dbForPlatform') ->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) { + $keyId = $keyId == 'unique()' ? ID::unique() : $keyId; $project = $dbForPlatform->getDocument('projects', $projectId); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 25a29f9844..de67438b91 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3167,8 +3167,20 @@ class ProjectsConsoleClientTest extends Scope 'scopes' => ['teams.read', 'teams.write'], ]); + /** + * Test for SUCCESS with magic string ID + */ + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'keyId' => 'unique()', + 'name' => 'Key Custom', + 'scopes' => ['teams.read', 'teams.write'], + ]); + $this->assertEquals(201, $response['headers']['status-code']); - $this->assertEquals($customKeyId, $response['body']['$id']); + $this->assertNotEmpty($response['body']['$id']); $data = array_merge($data, [ 'keyId' => $response['body']['$id'], From c0f5fa90cb6eb977355720d0c07c7337502a507b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 16:53:18 +0100 Subject: [PATCH 21/31] Fix AI review --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index de67438b91..2fa6c67461 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3167,6 +3167,9 @@ class ProjectsConsoleClientTest extends Scope 'scopes' => ['teams.read', 'teams.write'], ]); + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertSame($customKeyId, $response['body']['$id']); + /** * Test for SUCCESS with magic string ID */ @@ -3181,6 +3184,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); + $this->assertNotSame('unique()', $response['body']['$id']); $data = array_merge($data, [ 'keyId' => $response['body']['$id'], @@ -3219,7 +3223,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(3, $response['body']['total']); + $this->assertEquals(4, $response['body']['total']); /** * Test for FAILURE From 29915ddd3b9b4fb258d0425bca60ac03ec7e0c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 16:58:44 +0100 Subject: [PATCH 22/31] Manual fixes of agent code --- app/controllers/api/projects.php | 14 +-- .../Projects/ProjectsConsoleClientTest.php | 103 ++++++++++++++++++ 2 files changed, 108 insertions(+), 9 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 4ccb0be2ad..ad842f4ec9 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -14,23 +14,24 @@ 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\Exception\Query as QueryException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; use Utopia\Database\Validator\Datetime as DatetimeValidator; +use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Domains\Validator\PublicDomain; use Utopia\Http; use Utopia\Locale\Locale; use Utopia\System\System; -use Utopia\Database\Exception\Query as QueryException; -use Utopia\Database\Validator\Query\Cursor; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Hostname; @@ -1154,12 +1155,11 @@ Http::get('/v1/projects/:projectId/keys') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('queries', [], new \Appwrite\Utopia\Database\Validator\Queries\Keys(), '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 on the following attributes: ' . implode(', ', \Appwrite\Utopia\Database\Validator\Queries\Keys::ALLOWED_ATTRIBUTES), true) - ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('queries', [], new Keys(), '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 on the following attributes: ' . implode(', ', Keys::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('response') ->inject('dbForPlatform') - ->action(function (string $projectId, array $queries, string $search, bool $includeTotal, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, array $queries, bool $includeTotal, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -1173,10 +1173,6 @@ Http::get('/v1/projects/:projectId/keys') throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - if (!empty($search)) { - $queries[] = Query::search('search', $search); - } - $queries[] = Query::equal('resourceType', ['projects']); $queries[] = Query::equal('resourceInternalId', [$project->getSequence()]); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index c80b706c43..d2f24a6795 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3261,6 +3261,109 @@ class ProjectsConsoleClientTest extends Scope $this->assertCount(1, $response['body']['keys']); $this->assertEquals('Key Test 2', $response['body']['keys'][0]['name']); + /** List keys filtering by name (equal — exact match) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('name', ['Key Test'])->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(1, $response['body']['total']); + $this->assertCount(1, $response['body']['keys']); + $this->assertEquals('Key Test', $response['body']['keys'][0]['name']); + + /** List keys filtering by name (equal — multiple values) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('name', ['Key Test', 'Key Test 2'])->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(2, $response['body']['total']); + $this->assertCount(2, $response['body']['keys']); + + /** List keys filtering by name (equal — no match) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('name', ['Non Existent Key'])->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(0, $response['body']['total']); + $this->assertCount(0, $response['body']['keys']); + + /** List keys filtering by scopes (contains — match key with teams.read) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('scopes', ['teams.read'])->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(1, $response['body']['total']); + $this->assertCount(1, $response['body']['keys']); + $this->assertEquals('Key Test', $response['body']['keys'][0]['name']); + + /** List keys filtering by scopes (contains — match key with users.read) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('scopes', ['users.read'])->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(1, $response['body']['total']); + $this->assertCount(1, $response['body']['keys']); + $this->assertEquals('Key Test 2', $response['body']['keys'][0]['name']); + + /** List keys filtering by scopes (contains — no match) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::contains('scopes', ['databases.read'])->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(0, $response['body']['total']); + $this->assertCount(0, $response['body']['keys']); + + /** List keys filtering by name and scopes combined */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('name', ['Key Test'])->toString(), + Query::contains('scopes', ['teams.read'])->toString(), + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(1, $response['body']['total']); + $this->assertCount(1, $response['body']['keys']); + $this->assertEquals('Key Test', $response['body']['keys'][0]['name']); + /** List keys with orderDesc */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', From a263afeff107dcda768c0703ff8a163e756bed6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 17:10:00 +0100 Subject: [PATCH 23/31] AI quality fixes --- app/controllers/api/account.php | 2 +- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 1e2bb5aee0..17515fe949 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1527,7 +1527,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') foreach ($rules as $rule) { $allowedHostnames = $redirectValidator->getAllowedHostnames(); - $allowedHostnames[] = $rule['domain']; + $allowedHostnames[] = $rule->getAttribute('domain', ''); $redirectValidator->setAllowedHostnames($allowedHostnames); } } diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 74d1aa9580..5280509967 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5204,7 +5204,7 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://domain-without-rule.com' ]), 'error' => '', - 'errorDescription' => '', + 'error_description' => '', ], followRedirects: false); $this->assertEquals(400, $response['headers']['status-code']); $this->assertStringContainsString('project_invalid_success_url', $response['body']); @@ -5234,7 +5234,7 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://' . $domain ]), 'error' => '', - 'errorDescription' => '', + 'error_deescription' => '', ], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); $this->assertStringContainsString('https://' . $domain, $response['headers']['location']); From dafa97879c8dd644fb58a628c917910e56a92ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 17:12:30 +0100 Subject: [PATCH 24/31] AI review fixes --- app/controllers/api/projects.php | 5 ++++- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index ad842f4ec9..8451ff2ebd 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1186,7 +1186,10 @@ Http::get('/v1/projects/:projectId/keys') } $keyId = $cursor->getValue(); - $cursorDocument = $dbForPlatform->getDocument('keys', $keyId); + $cursorDocument = $dbForPlatform->getDocument('keys', $keyId, [ + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$project->getSequence()]), + ]); if ($cursorDocument->isEmpty()) { throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found."); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index d2f24a6795..fe4c8b2762 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3370,7 +3370,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'queries' => [ - Query::orderDesc('')->toString(), + Query::orderDesc('$createdAt')->toString(), ] ]); From a87263a571293502a0893e82c906c40b962340e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 20:07:33 +0100 Subject: [PATCH 25/31] Fix failing test --- app/controllers/api/projects.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 8451ff2ebd..ad842f4ec9 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1186,10 +1186,7 @@ Http::get('/v1/projects/:projectId/keys') } $keyId = $cursor->getValue(); - $cursorDocument = $dbForPlatform->getDocument('keys', $keyId, [ - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - ]); + $cursorDocument = $dbForPlatform->getDocument('keys', $keyId); if ($cursorDocument->isEmpty()) { throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found."); From b4329183853a4cc43e3911cc42263be07408e8d2 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:24:57 +0000 Subject: [PATCH 26/31] feat: worker executions --- Dockerfile | 1 + app/controllers/general.php | 35 +++-- app/init/resources.php | 4 + app/worker.php | 5 + bin/worker-executions | 3 + docker-compose.yml | 31 +++++ src/Appwrite/Event/Event.php | 3 + src/Appwrite/Event/Execution.php | 56 ++++++++ src/Appwrite/Event/Func.php | 2 - src/Appwrite/Platform/Services/Workers.php | 2 + src/Appwrite/Platform/Workers/Executions.php | 52 +++++++ src/Appwrite/Platform/Workers/Functions.php | 130 +++++++----------- .../Functions/FunctionsCustomServerTest.php | 5 +- tests/resources/docker/docker-compose.yml | 21 +++ tests/unit/Docker/ComposeTest.php | 2 +- 15 files changed, 247 insertions(+), 105 deletions(-) create mode 100755 bin/worker-executions create mode 100644 src/Appwrite/Event/Execution.php create mode 100644 src/Appwrite/Platform/Workers/Executions.php diff --git a/Dockerfile b/Dockerfile index e848b6f0b5..6faaf9ed2b 100755 --- a/Dockerfile +++ b/Dockerfile @@ -81,6 +81,7 @@ RUN chmod +x /usr/local/bin/doctor && \ chmod +x /usr/local/bin/worker-certificates && \ chmod +x /usr/local/bin/worker-databases && \ chmod +x /usr/local/bin/worker-deletes && \ + chmod +x /usr/local/bin/worker-executions && \ chmod +x /usr/local/bin/worker-functions && \ chmod +x /usr/local/bin/worker-mails && \ chmod +x /usr/local/bin/worker-messaging && \ diff --git a/app/controllers/general.php b/app/controllers/general.php index d894135886..10e9537680 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -8,7 +8,7 @@ use Appwrite\Auth\Key; use Appwrite\Event\Certificate; use Appwrite\Event\Delete as DeleteEvent; use Appwrite\Event\Event; -use Appwrite\Event\Func; +use Appwrite\Event\Execution; use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Network\Cors; @@ -60,7 +60,7 @@ Config::setParam('domainVerification', false); Config::setParam('cookieDomain', 'localhost'); 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, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount) +function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Authorization $authorization, ?Key $apiKey, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { $host = $request->getHostname() ?? ''; if (!empty($previewHostname)) { @@ -696,14 +696,11 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S throw $th; } } finally { - if ($type === 'function') { - $queueForFunctions - ->setType(Func::TYPE_ASYNC_WRITE) + if ($type === 'function' || $type === 'site') { + $queueForExecutions ->setExecution($execution) ->setProject($project) ->trigger(); - } elseif ($type === 'site') { // TODO: Move it to logs worker later - $dbForProject->createDocument('executions', $execution); } } @@ -877,7 +874,7 @@ Http::init() ->inject('geodb') ->inject('queueForStatsUsage') ->inject('queueForEvents') - ->inject('queueForFunctions') + ->inject('queueForExecutions') ->inject('executor') ->inject('platform') ->inject('isResourceBlocked') @@ -888,7 +885,7 @@ Http::init() ->inject('authorization') ->inject('queueForDeletes') ->inject('executionsRetentionCount') - ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Func $queueForFunctions, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { + ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Execution $queueForExecutions, Executor $executor, array $platform, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { /* * Appwrite Router */ @@ -896,7 +893,7 @@ Http::init() $platformHostnames = $platform['hostnames'] ?? []; // Only run Router when external domain if (!\in_array($hostname, $platformHostnames) || !empty($previewHostname)) { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { $utopia->getRoute()?->label('router', true); } } @@ -1175,7 +1172,7 @@ Http::options() ->inject('getProjectDB') ->inject('queueForEvents') ->inject('queueForStatsUsage') - ->inject('queueForFunctions') + ->inject('queueForExecutions') ->inject('executor') ->inject('geodb') ->inject('isResourceBlocked') @@ -1188,14 +1185,14 @@ Http::options() ->inject('authorization') ->inject('queueForDeletes') ->inject('executionsRetentionCount') - ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { + ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, Document $project, Document $devKey, ?Key $apiKey, Cors $cors, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { /* * Appwrite Router */ $platformHostnames = $platform['hostnames'] ?? []; // Only run Router when external domain if (!in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { $utopia->getRoute()?->label('router', true); } } @@ -1571,7 +1568,7 @@ Http::get('/robots.txt') ->inject('getProjectDB') ->inject('queueForEvents') ->inject('queueForStatsUsage') - ->inject('queueForFunctions') + ->inject('queueForExecutions') ->inject('executor') ->inject('geodb') ->inject('isResourceBlocked') @@ -1581,13 +1578,13 @@ Http::get('/robots.txt') ->inject('authorization') ->inject('queueForDeletes') ->inject('executionsRetentionCount') - ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { + ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { $platformHostnames = $platform['hostnames'] ?? []; if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) { $template = new View(__DIR__ . '/../views/general/robots.phtml'); $response->text($template->render(false)); } else { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { $utopia->getRoute()?->label('router', true); } } @@ -1606,7 +1603,7 @@ Http::get('/humans.txt') ->inject('getProjectDB') ->inject('queueForEvents') ->inject('queueForStatsUsage') - ->inject('queueForFunctions') + ->inject('queueForExecutions') ->inject('executor') ->inject('geodb') ->inject('isResourceBlocked') @@ -1616,13 +1613,13 @@ Http::get('/humans.txt') ->inject('authorization') ->inject('queueForDeletes') ->inject('executionsRetentionCount') - ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { + ->action(function (Http $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Database $dbForPlatform, callable $getProjectDB, Event $queueForEvents, StatsUsage $queueForStatsUsage, Execution $queueForExecutions, Executor $executor, Reader $geodb, callable $isResourceBlocked, array $platform, string $previewHostname, ?Key $apiKey, Authorization $authorization, DeleteEvent $queueForDeletes, int $executionsRetentionCount) { $platformHostnames = $platform['hostnames'] ?? []; if (in_array($request->getHostname(), $platformHostnames) || !empty($previewHostname)) { $template = new View(__DIR__ . '/../views/general/humans.phtml'); $response->text($template->render(false)); } else { - if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForFunctions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { + if (router($utopia, $dbForPlatform, $getProjectDB, $swooleRequest, $request, $response, $log, $queueForEvents, $queueForStatsUsage, $queueForExecutions, $executor, $geodb, $isResourceBlocked, $platform, $previewHostname, $authorization, $apiKey, $queueForDeletes, $executionsRetentionCount)) { $utopia->getRoute()?->label('router', true); } } diff --git a/app/init/resources.php b/app/init/resources.php index 8f78df1573..a031292ca7 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -10,6 +10,7 @@ use Appwrite\Event\Certificate; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; use Appwrite\Event\Event; +use Appwrite\Event\Execution; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; @@ -159,6 +160,9 @@ Http::setResource('queueForAudits', function (Publisher $publisher) { Http::setResource('queueForFunctions', function (Publisher $publisher) { return new Func($publisher); }, ['publisher']); +Http::setResource('queueForExecutions', function (Publisher $publisher) { + return new Execution($publisher); +}, ['publisher']); Http::setResource('eventProcessor', function () { return new EventProcessor(); }, []); diff --git a/app/worker.php b/app/worker.php index d0094222a7..49635b0381 100644 --- a/app/worker.php +++ b/app/worker.php @@ -9,6 +9,7 @@ use Appwrite\Event\Certificate; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; use Appwrite\Event\Event; +use Appwrite\Event\Execution; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; @@ -358,6 +359,10 @@ Server::setResource('queueForFunctions', function (Publisher $publisher) { return new Func($publisher); }, ['publisher']); +Server::setResource('queueForExecutions', function (Publisher $publisher) { + return new Execution($publisher); +}, ['publisher']); + Server::setResource('queueForRealtime', function () { return new Realtime(); }, []); diff --git a/bin/worker-executions b/bin/worker-executions new file mode 100755 index 0000000000..6789fb8da8 --- /dev/null +++ b/bin/worker-executions @@ -0,0 +1,3 @@ +#!/bin/sh + +exec php /usr/src/code/app/worker.php executions "$@" diff --git a/docker-compose.yml b/docker-compose.yml index 37305055e5..a1f5ca77c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -641,6 +641,37 @@ services: - _APP_LOGGING_CONFIG - _APP_DATABASE_SHARED_TABLES + appwrite-worker-executions: + entrypoint: worker-executions + <<: *x-logging + container_name: appwrite-worker-executions + image: appwrite-dev + networks: + - appwrite + volumes: + - ./app:/usr/src/code/app + - ./src:/usr/src/code/src + depends_on: + redis: + condition: service_started + mariadb: + condition: service_started + environment: + - _APP_ENV + - _APP_WORKER_PER_CORE + - _APP_POOL_ADAPTER + - _APP_REDIS_HOST + - _APP_REDIS_PORT + - _APP_REDIS_USER + - _APP_REDIS_PASS + - _APP_DB_HOST + - _APP_DB_PORT + - _APP_DB_SCHEMA + - _APP_DB_USER + - _APP_DB_PASS + - _APP_LOGGING_CONFIG + - _APP_DATABASE_SHARED_TABLES + appwrite-worker-functions: entrypoint: worker-functions <<: *x-logging diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index 6db001398f..6f00d0cd0e 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -46,6 +46,9 @@ class Event public const MESSAGING_QUEUE_NAME = 'v1-messaging'; public const MESSAGING_CLASS_NAME = 'MessagingV1'; + public const EXECUTIONS_QUEUE_NAME = 'v1-executions'; + public const EXECUTIONS_CLASS_NAME = 'ExecutionsV1'; + public const MIGRATIONS_QUEUE_NAME = 'v1-migrations'; public const MIGRATIONS_CLASS_NAME = 'MigrationsV1'; diff --git a/src/Appwrite/Event/Execution.php b/src/Appwrite/Event/Execution.php new file mode 100644 index 0000000000..398025565c --- /dev/null +++ b/src/Appwrite/Event/Execution.php @@ -0,0 +1,56 @@ +setQueue(Event::EXECUTIONS_QUEUE_NAME) + ->setClass(Event::EXECUTIONS_CLASS_NAME); + } + + /** + * Sets execution document for the execution event. + * + * @param Document $execution + * @return self + */ + public function setExecution(Document $execution): self + { + $this->execution = $execution; + + return $this; + } + + /** + * Returns set execution document for the execution event. + * + * @return null|Document + */ + public function getExecution(): ?Document + { + return $this->execution; + } + + /** + * Prepare payload for the execution event. + * + * @return array + */ + protected function preparePayload(): array + { + return [ + 'project' => $this->project, + 'execution' => $this->execution, + ]; + } +} diff --git a/src/Appwrite/Event/Func.php b/src/Appwrite/Event/Func.php index 2f7f8e3c5c..7790437e1c 100644 --- a/src/Appwrite/Event/Func.php +++ b/src/Appwrite/Event/Func.php @@ -9,8 +9,6 @@ use Utopia\System\System; class Func extends Event { - public const TYPE_ASYNC_WRITE = 'async_write'; - protected string $jwt = ''; protected string $type = ''; protected string $body = ''; diff --git a/src/Appwrite/Platform/Services/Workers.php b/src/Appwrite/Platform/Services/Workers.php index f2cbeb390a..fa77725f7a 100644 --- a/src/Appwrite/Platform/Services/Workers.php +++ b/src/Appwrite/Platform/Services/Workers.php @@ -5,6 +5,7 @@ namespace Appwrite\Platform\Services; use Appwrite\Platform\Workers\Audits; use Appwrite\Platform\Workers\Certificates; use Appwrite\Platform\Workers\Deletes; +use Appwrite\Platform\Workers\Executions; use Appwrite\Platform\Workers\Functions; use Appwrite\Platform\Workers\Mails; use Appwrite\Platform\Workers\Messaging; @@ -23,6 +24,7 @@ class Workers extends Service ->addAction(Audits::getName(), new Audits()) ->addAction(Certificates::getName(), new Certificates()) ->addAction(Deletes::getName(), new Deletes()) + ->addAction(Executions::getName(), new Executions()) ->addAction(Functions::getName(), new Functions()) ->addAction(Mails::getName(), new Mails()) ->addAction(Messaging::getName(), new Messaging()) diff --git a/src/Appwrite/Platform/Workers/Executions.php b/src/Appwrite/Platform/Workers/Executions.php new file mode 100644 index 0000000000..300a84162c --- /dev/null +++ b/src/Appwrite/Platform/Workers/Executions.php @@ -0,0 +1,52 @@ +desc('Executions worker') + ->groups(['executions']) + ->inject('message') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action( + Message $message, + Database $dbForProject, + ): void { + $payload = $message->getPayload() ?? []; + + if (empty($payload)) { + throw new Exception('Missing payload'); + } + + $execution = new Document($payload['execution'] ?? []); + + if ($execution->isEmpty()) { + throw new Exception('Missing execution'); + } + + if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check + $dbForProject->upsertDocument('executions', $execution); + } + } +} diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 98e846aa4d..b5577c5668 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -4,6 +4,7 @@ namespace Appwrite\Platform\Workers; use Ahc\Jwt\JWT; use Appwrite\Event\Event; +use Appwrite\Event\Execution as ExecutionEvent; use Appwrite\Event\Func; use Appwrite\Event\Realtime; use Appwrite\Event\StatsUsage; @@ -16,8 +17,6 @@ use Utopia\CLI\Console; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Exception\Conflict; -use Utopia\Database\Exception\Structure; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -50,6 +49,7 @@ class Functions extends Action ->inject('queueForRealtime') ->inject('queueForEvents') ->inject('queueForStatsUsage') + ->inject('queueForExecutions') ->inject('log') ->inject('executor') ->inject('isResourceBlocked') @@ -65,6 +65,7 @@ class Functions extends Action Realtime $queueForRealtime, Event $queueForEvents, StatsUsage $queueForStatsUsage, + ExecutionEvent $queueForExecutions, Log $log, Executor $executor, callable $isResourceBlocked @@ -77,15 +78,6 @@ class Functions extends Action $type = $payload['type'] ?? ''; - // Short-term solution to offhand write operation from API container - if ($type === Func::TYPE_ASYNC_WRITE) { - $execution = new Document($payload['execution'] ?? []); - if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check - $dbForProject->createDocument('executions', $execution); - } - return; - } - $events = $payload['events'] ?? []; $data = $payload['body'] ?? ''; $eventData = $payload['payload'] ?? ''; @@ -166,6 +158,7 @@ class Functions extends Action queueForRealtime: $queueForRealtime, queueForStatsUsage: $queueForStatsUsage, queueForEvents: $queueForEvents, + queueForExecutions: $queueForExecutions, project: $project, function: $function, executor: $executor, @@ -210,6 +203,7 @@ class Functions extends Action queueForRealtime: $queueForRealtime, queueForStatsUsage: $queueForStatsUsage, queueForEvents: $queueForEvents, + queueForExecutions: $queueForExecutions, project: $project, function: $function, executor: $executor, @@ -236,6 +230,7 @@ class Functions extends Action queueForRealtime: $queueForRealtime, queueForStatsUsage: $queueForStatsUsage, queueForEvents: $queueForEvents, + queueForExecutions: $queueForExecutions, project: $project, function: $function, executor: $executor, @@ -268,7 +263,8 @@ class Functions extends Action */ private function fail( string $message, - Database $dbForProject, + Document $project, + ExecutionEvent $queueForExecutions, Document $function, string $trigger, string $path, @@ -311,13 +307,10 @@ class Functions extends Action 'duration' => 0.0, ]); - if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check - $execution = $dbForProject->createDocument('executions', $execution); - - if ($execution->isEmpty()) { - throw new Exception('Failed to create execution'); - } - } + $queueForExecutions + ->setExecution($execution) + ->setProject($project) + ->trigger(); } /** @@ -341,9 +334,6 @@ class Functions extends Action * @param string|null $eventData * @param string|null $executionId * @return void - * @throws Structure - * @throws \Utopia\Database\Exception - * @throws Conflict */ private function execute( Log $log, @@ -353,6 +343,7 @@ class Functions extends Action Realtime $queueForRealtime, StatsUsage $queueForStatsUsage, Event $queueForEvents, + ExecutionEvent $queueForExecutions, Document $project, Document $function, Executor $executor, @@ -380,19 +371,19 @@ class Functions extends Action if ($deployment->getAttribute('resourceId') !== $functionId) { $errorMessage = 'The execution could not be completed because a corresponding deployment was not found. A function deployment needs to be created before it can be executed. Please create a deployment for your function and try again.'; - $this->fail($errorMessage, $dbForProject, $function, $trigger, $path, $method, $user, $jwt, $event); + $this->fail($errorMessage, $project, $queueForExecutions, $function, $trigger, $path, $method, $user, $jwt, $event); return; } if ($deployment->isEmpty()) { $errorMessage = 'The execution could not be completed because a corresponding deployment was not found. A function deployment needs to be created before it can be executed. Please create a deployment for your function and try again.'; - $this->fail($errorMessage, $dbForProject, $function, $trigger, $path, $method, $user, $jwt, $event); + $this->fail($errorMessage, $project, $queueForExecutions, $function, $trigger, $path, $method, $user, $jwt, $event); return; } if ($deployment->getAttribute('status') !== 'ready') { $errorMessage = 'The execution could not be completed because the build is not ready. Please wait for the build to complete and try again.'; - $this->fail($errorMessage, $dbForProject, $function, $trigger, $path, $method, $user, $jwt, $event); + $this->fail($errorMessage, $project, $queueForExecutions, $function, $trigger, $path, $method, $user, $jwt, $event); return; } @@ -423,60 +414,38 @@ class Functions extends Action $headers['x-appwrite-continent-code'] = ''; $headers['x-appwrite-continent-eu'] = 'false'; - /** Create execution or update execution status */ - $execution = $dbForProject->getDocument('executions', $executionId ?? ''); - if ($execution->isEmpty()) { + /** Create or update execution to processing status */ + if (empty($executionId)) { $executionId = ID::unique(); - $headers['x-appwrite-execution-id'] = $executionId; - $headersFiltered = []; - foreach ($headers as $key => $value) { - if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_REQUEST)) { - $headersFiltered[] = [ 'name' => $key, 'value' => $value ]; - } - } + } + $headers['x-appwrite-execution-id'] = $executionId; - $execution = new Document([ - '$id' => $executionId, - '$permissions' => $user->isEmpty() ? [] : [Permission::read(Role::user($user->getId()))], - 'resourceInternalId' => $function->getSequence(), - 'resourceId' => $function->getId(), - 'resourceType' => 'functions', - 'deploymentInternalId' => $deployment->getSequence(), - 'deploymentId' => $deployment->getId(), - 'trigger' => $trigger, - 'status' => 'processing', - 'responseStatusCode' => 0, - 'responseHeaders' => [], - 'requestPath' => $path, - 'requestMethod' => $method, - 'requestHeaders' => $headersFiltered, - 'errors' => '', - 'logs' => '', - 'duration' => 0.0, - ]); - - if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check - $execution = $dbForProject->createDocument('executions', $execution); - - // TODO: @Meldiron Trigger executions.create event here - - if ($execution->isEmpty()) { - throw new Exception('Failed to create or read execution'); - } + $headersFiltered = []; + foreach ($headers as $key => $value) { + if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_REQUEST)) { + $headersFiltered[] = [ 'name' => $key, 'value' => $value ]; } } - if ($execution->getAttribute('status') !== 'processing') { - $execution->setAttribute('status', 'processing'); - - if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check - try { - $execution = $dbForProject->updateDocument('executions', $executionId, $execution); - } catch (\Throwable $e) { - $log->addExtra('updateError', $e->getMessage()); - } - } - } + $execution = new Document([ + '$id' => $executionId, + '$permissions' => $user->isEmpty() ? [] : [Permission::read(Role::user($user->getId()))], + 'resourceInternalId' => $function->getSequence(), + 'resourceId' => $function->getId(), + 'resourceType' => 'functions', + 'deploymentInternalId' => $deployment->getSequence(), + 'deploymentId' => $deployment->getId(), + 'trigger' => $trigger, + 'status' => 'processing', + 'responseStatusCode' => 0, + 'responseHeaders' => [], + 'requestPath' => $path, + 'requestMethod' => $method, + 'requestHeaders' => $headersFiltered, + 'errors' => '', + 'logs' => '', + 'duration' => 0.0, + ]); $durationStart = \microtime(true); @@ -618,14 +587,11 @@ class Functions extends Action $error = $th->getMessage(); $errorCode = $th->getCode(); } finally { - /** Update execution status */ - if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check - try { - $execution = $dbForProject->updateDocument('executions', $executionId, $execution); - } catch (\Throwable $e) { - $log->addExtra('updateError', $e->getMessage()); - } - } + /** Persist final execution status */ + $queueForExecutions + ->setExecution($execution) + ->setProject($project) + ->trigger(); /** Trigger usage queue */ $queueForStatsUsage diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 35bdf90347..f7261adfc9 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -1751,7 +1751,10 @@ class FunctionsCustomServerTest extends Scope $this->assertEventually(function () use ($functionId, $userId) { $executions = $this->listExecutions($functionId); - $lastExecution = $executions['body']['executions'][0]; + $this->assertEquals(200, $executions['headers']['status-code']); + $executionsList = $executions['body']['executions'] ?? []; + $this->assertNotEmpty($executionsList); + $lastExecution = $executionsList[0]; $this->assertEquals('completed', $lastExecution['status']); $this->assertEquals(204, $lastExecution['responseStatusCode']); diff --git a/tests/resources/docker/docker-compose.yml b/tests/resources/docker/docker-compose.yml index c648b2f4c3..8530df0db6 100644 --- a/tests/resources/docker/docker-compose.yml +++ b/tests/resources/docker/docker-compose.yml @@ -212,6 +212,27 @@ services: - _APP_DB_USER - _APP_DB_PASS + appwrite-worker-executions: + entrypoint: worker-executions + container_name: appwrite-worker-executions + build: + context: . + restart: unless-stopped + networks: + - appwrite + depends_on: + - redis + - mariadb + environment: + - _APP_ENV + - _APP_REDIS_HOST + - _APP_REDIS_PORT + - _APP_DB_HOST + - _APP_DB_PORT + - _APP_DB_SCHEMA + - _APP_DB_USER + - _APP_DB_PASS + appwrite-worker-functions: entrypoint: worker-functions container_name: appwrite-worker-functions diff --git a/tests/unit/Docker/ComposeTest.php b/tests/unit/Docker/ComposeTest.php index 74779230ac..e63d877baf 100644 --- a/tests/unit/Docker/ComposeTest.php +++ b/tests/unit/Docker/ComposeTest.php @@ -23,7 +23,7 @@ class ComposeTest extends TestCase public function testServices(): void { - $this->assertCount(15, $this->object->getServices()); + $this->assertCount(16, $this->object->getServices()); $this->assertEquals('appwrite', $this->object->getService('appwrite')->getContainerName()); $this->assertEquals('', $this->object->getService('appwrite')->getImageVersion()); $this->assertEquals('3.6', $this->object->getService('traefik')->getImageVersion()); From 0621a32aa685b6bf9bdd02619022513cb8f3fd73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 10 Feb 2026 11:43:22 +0100 Subject: [PATCH 27/31] Fix tests; speed up tests; fix 5xx error --- app/config/errors.php | 5 ++ app/controllers/api/projects.php | 9 +- src/Appwrite/Extend/Exception.php | 1 + .../Projects/ProjectsConsoleClientTest.php | 90 +++++++++---------- 4 files changed, 58 insertions(+), 47 deletions(-) diff --git a/app/config/errors.php b/app/config/errors.php index 62affd8101..16fad57de1 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1139,6 +1139,11 @@ return [ 'description' => 'Key with the requested ID could not be found.', 'code' => 404, ], + Exception::KEY_ALREADY_EXISTS => [ + 'name' => Exception::KEY_ALREADY_EXISTS, + 'description' => 'Key with the same ID already exists. Try again with a different ID.', + 'code' => 409, + ], Exception::PLATFORM_NOT_FOUND => [ 'name' => Exception::PLATFORM_NOT_FOUND, 'description' => 'Platform with the requested ID could not be found.', diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 95997fd7dc..33dda5d9b9 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -14,13 +14,14 @@ 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\Database\Validator\CustomId; +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\Exception\Duplicate; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; @@ -1132,7 +1133,11 @@ Http::post('/v1/projects/:projectId/keys') 'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)), ]); - $key = $dbForPlatform->createDocument('keys', $key); + try { + $key = $dbForPlatform->createDocument('keys', $key); + } catch (Duplicate) { + throw new Exception(Exception::KEY_ALREADY_EXISTS); + } $dbForPlatform->purgeCachedDocument('projects', $project->getId()); diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 2bc7021b31..d02f3902a3 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -318,6 +318,7 @@ class Exception extends \Exception /** Keys */ public const string KEY_NOT_FOUND = 'key_not_found'; + public const string KEY_ALREADY_EXISTS = 'key_already_exists'; /** Variables */ public const string VARIABLE_NOT_FOUND = 'variable_not_found'; diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index fce5c5b91c..2266c2fe72 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1168,7 +1168,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'duration' => 60, // Set session duration to 1 minute + 'duration' => 10, // Set session duration to 10 seconds ]); $this->assertEquals(200, $response['headers']['status-code']); @@ -1177,7 +1177,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertArrayHasKey('platforms', $response['body']); $this->assertArrayHasKey('webhooks', $response['body']); $this->assertArrayHasKey('keys', $response['body']); - $this->assertEquals(60, $response['body']['authDuration']); + $this->assertEquals(10, $response['body']['authDuration']); $projectId = $response['body']['$id']; @@ -1218,44 +1218,30 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); - // Check session doesn't expire too soon. - sleep(30); + // Eventually session expires, within 15 seconds (10+variance) + $this->assertEventually(function () use ($projectId, $sessionCookie) { + // Get User + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'Cookie' => $sessionCookie, + ])); - // Get User - $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'Cookie' => $sessionCookie, - ])); + $this->assertEquals(401, $response['headers']['status-code']); + }, timeoutMs: 15 * 1000); - $this->assertEquals(200, $response['headers']['status-code']); - - // Wait just over a minute - sleep(35); - - // Get User - $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'Cookie' => $sessionCookie, - ])); - - $this->assertEquals(401, $response['headers']['status-code']); - - // Set session duration to 15s + // Set session duration to 10min $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'duration' => 15, // seconds + 'duration' => 600, // seconds ]); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(15, $response['body']['authDuration']); - - // Wait 20 seconds, ensure non-valid session - \sleep(20); + $this->assertEquals(600, $response['body']['authDuration']); + // Ensure.. Something? $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, @@ -3157,7 +3143,7 @@ class ProjectsConsoleClientTest extends Scope /** * Test for SUCCESS with custom ID */ - $customKeyId = 'key-with-custom-id'; + $customKeyId = \uniqid() . 'custom-id'; $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -3170,6 +3156,20 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertSame($customKeyId, $response['body']['$id']); + /** + * Test for FAILURE with custom ID + */ + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'keyId' => $customKeyId, + 'name' => 'Key Custom', + 'scopes' => ['teams.read', 'teams.write'], + ]); + + $this->assertEquals(409, $response['headers']['status-code']); + /** * Test for SUCCESS with magic string ID */ @@ -3237,8 +3237,8 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders()), []); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(2, $response['body']['total']); - $this->assertCount(2, $response['body']['keys']); + $this->assertEquals(5, $response['body']['total']); + $this->assertCount(5, $response['body']['keys']); /** List keys with limit */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ @@ -3252,7 +3252,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertCount(1, $response['body']['keys']); - $this->assertEquals(2, $response['body']['total']); + $this->assertEquals(5, $response['body']['total']); /** List keys with offset */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ @@ -3265,8 +3265,8 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertCount(1, $response['body']['keys']); - $this->assertEquals(2, $response['body']['total']); + $this->assertCount(4, $response['body']['keys']); + $this->assertEquals(5, $response['body']['total']); /** List keys with cursor after */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ @@ -3280,6 +3280,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertCount(1, $response['body']['keys']); + $this->assertEquals(5, $response['body']['total']); $this->assertEquals($key2Id, $response['body']['keys'][0]['$id']); /** List keys filtering by expire (lessThan now — should match none) */ @@ -3308,7 +3309,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals(1, $response['body']['total']); $this->assertCount(1, $response['body']['keys']); - $this->assertEquals('Key Test 2', $response['body']['keys'][0]['name']); /** List keys filtering by name (equal — exact match) */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ @@ -3364,9 +3364,8 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(1, $response['body']['total']); - $this->assertCount(1, $response['body']['keys']); - $this->assertEquals('Key Test', $response['body']['keys'][0]['name']); + $this->assertEquals(4, $response['body']['total']); + $this->assertCount(4, $response['body']['keys']); /** List keys filtering by scopes (contains — match key with users.read) */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ @@ -3381,7 +3380,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals(1, $response['body']['total']); $this->assertCount(1, $response['body']['keys']); - $this->assertEquals('Key Test 2', $response['body']['keys'][0]['name']); /** List keys filtering by scopes (contains — no match) */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ @@ -3424,9 +3422,8 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertCount(2, $response['body']['keys']); - $this->assertEquals('Key Test 2', $response['body']['keys'][0]['name']); - $this->assertEquals('Key Test', $response['body']['keys'][1]['name']); + $this->assertCount(5, $response['body']['keys']); + $this->assertGreaterThan($response['body']['keys'][1]['$createdAt'], $response['body']['keys'][0]['$createdAt']); /** List keys with total disabled */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/keys', array_merge([ @@ -3434,10 +3431,13 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'total' => false, + 'queries' => [ + Query::limit(1)->toString() + ] ]); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertCount(2, $response['body']['keys']); + $this->assertCount(1, $response['body']['keys']); $this->assertEquals(0, $response['body']['total']); /** From e2071bd5dd68c3782f852eadde994ef9bad7b4cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 10 Feb 2026 11:48:29 +0100 Subject: [PATCH 28/31] add backwards compatibility --- app/controllers/api/projects.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 33dda5d9b9..2cc3510f6f 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1182,6 +1182,11 @@ Http::get('/v1/projects/:projectId/keys') throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } + // Backwards compatibility + if (\count(Query::getByType($queries, [Query::TYPE_LIMIT])) === 0) { + $queries[] = Query::limit(5000); + } + $queries[] = Query::equal('resourceType', ['projects']); $queries[] = Query::equal('resourceInternalId', [$project->getSequence()]); From 4df093402337857de2a963ec5565c57b5a26f01a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 10 Feb 2026 12:01:38 +0100 Subject: [PATCH 29/31] Comment fix --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 2266c2fe72..7a9557bdae 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1241,7 +1241,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals(600, $response['body']['authDuration']); - // Ensure.. Something? + // Ensure session is still expired (new duration only affects new sessions) $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, From 1822ede19eb53ecfb1fe0d76104384427546a834 Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:21:48 +0530 Subject: [PATCH 30/31] Fix GitHub error file path (#11289) * Fix error file path in GitHub APIs * const --- app/init/constants.php | 2 ++ src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/Get.php | 2 +- src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/init/constants.php b/app/init/constants.php index 0ee5271d7f..f084359068 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -5,6 +5,8 @@ use Appwrite\Platform\Modules\Compute\Specification; const APP_NAME = 'Appwrite'; const APP_DOMAIN = 'appwrite.io'; +const APP_VIEWS_DIR = __DIR__ . '/../views'; + // Email const APP_EMAIL_TEAM = 'team@localhost.test'; // Default email address const APP_EMAIL_SECURITY = ''; // Default security email address diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/Get.php index 5db6cb6e43..6188c14b5d 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/Get.php @@ -31,7 +31,7 @@ class Get extends Action ->desc('Create GitHub app installation') ->groups(['api', 'vcs']) ->label('scope', 'vcs.read') - ->label('error', __DIR__ . '/../../views/general/error.phtml') + ->label('error', APP_VIEWS_DIR . '/general/error.phtml') ->label('sdk', new Method( namespace: 'vcs', group: 'installations', 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 535f26e0cd..1212c06a72 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Callback/Get.php @@ -35,7 +35,7 @@ class Get extends Action ->desc('Get installation and authorization from GitHub app') ->groups(['api', 'vcs']) ->label('scope', 'public') - ->label('error', __DIR__ . '/../../views/general/error.phtml') + ->label('error', APP_VIEWS_DIR . '/general/error.phtml') ->param('installation_id', '', new Text(256, 0), 'GitHub installation ID', true) ->param('setup_action', '', new Text(256, 0), 'GitHub setup action type', true) ->param('state', '', new Text(2048), 'GitHub state. Contains info sent when starting authorization flow.', true) From 161b971f536ea4191596fa9ad38a0839f32e51ed Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 11 Feb 2026 10:11:04 +0530 Subject: [PATCH 31/31] Fix oauth redirect custom scheme state --- app/controllers/api/account.php | 30 +++++++----- .../Projects/ProjectsConsoleClientTest.php | 49 +++++++++++++++++++ 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 17515fe949..69fa23fe50 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1514,21 +1514,25 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } // Allow redirect to rule URL if related to project - //Check if $redirectValidator is instance of Redirect class + // Check if $redirectValidator is instance of Redirect class if ($redirectValidator instanceof Redirect) { - $rules = $authorization->skip(fn () => $dbForPlatform->find('rules', [ - Query::equal('domain', [ - parse_url($state['success'], PHP_URL_HOST), - parse_url($state['failure'], PHP_URL_HOST) - ]), - Query::equal('projectInternalId', [$project->getSequence()]), - Query::limit(2) - ])); + $domains = \array_filter([ + parse_url($state['success'], PHP_URL_HOST) ?? '', + parse_url($state['failure'], PHP_URL_HOST) ?? '' + ], fn ($domain) => \is_string($domain) && $domain !== ''); - foreach ($rules as $rule) { - $allowedHostnames = $redirectValidator->getAllowedHostnames(); - $allowedHostnames[] = $rule->getAttribute('domain', ''); - $redirectValidator->setAllowedHostnames($allowedHostnames); + if (!empty($domains)) { + $rules = $authorization->skip(fn () => $dbForPlatform->find('rules', [ + Query::equal('domain', \array_values(\array_unique($domains))), + Query::equal('projectInternalId', [$project->getSequence()]), + Query::limit(2) + ])); + + foreach ($rules as $rule) { + $allowedHostnames = $redirectValidator->getAllowedHostnames(); + $allowedHostnames[] = $rule->getAttribute('domain', ''); + $redirectValidator->setAllowedHostnames($allowedHostnames); + } } } diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 7a9557bdae..1d15f10971 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5561,6 +5561,55 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); } + public function testOAuthRedirectWithCustomSchemeState(): void + { + // Prepare project + $projectId = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'testOAuthRedirectWithCustomSchemeState', + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $provider = 'mock'; + $appId = '1'; + $secret = '123456'; + + // Prepare OAuth provider + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/oauth2', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'provider' => $provider, + 'appId' => $appId, + 'secret' => $secret, + 'enabled' => true, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $scheme = 'appwrite-callback-' . $projectId; + $state = \json_encode([ + 'success' => $scheme . ':///', + 'failure' => $scheme . ':///' + ]); + + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'origin' => '', + 'referer' => '', + ], [ + 'code' => 'any-code', + 'state' => $state, + 'error' => 'access_denied', + 'error_description' => 'test', + ], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith($scheme . '://', $response['headers']['location']); + $this->assertStringContainsString('error=', $response['headers']['location']); + } + /** * @group abuseEnabled */